简介:本资源为基于Python+Django框架的旅游自主网站完整项目源码,面向希望学习Web开发的小白与进阶学习者,可作为毕业设计、课程设计、大作业或工程实训的参考方案。项目围绕旅游自主网站的设计与实现展开,涵盖前端页面与后端业务逻辑,适合需要完整项目案例来理解Django开发流程的读者。压缩包共2001个文件,约132.64MB,其中1627个jpg图片资源用于页面素材展示,83个py文件承载Django核心业务逻辑,119个js与38个css文件负责前端交互与样式,另有vue组件、svg图标、sqlite3数据库及xml、json等配置文件,结构完整、层次清晰。目前已有144人学习下载。通过该资源,读者可获取一套可直接运行的项目源码,对照目录结构梳理前后端模块划分,学习Django路由、视图与模板的协作方式,并借助图片与样式资源快速还原页面效果,为二次开发或答辩演示提供扎实基础。
1. 从零搭一个旅游自主网站:为什么我选 Python+Django 而不是别的
去年帮一个做本地周边游的朋友搭站点,需求很朴素:游客能自己查线路、自己下单、自己传攻略,后台能改价格、能看订单。听起来像标准 CRUD,但真动手时你会发现,旅游自主网站的核心难点不在页面好不好看,而在「自主」两个字——用户要能自己完成从浏览到下单的闭环,运营要能自己改内容而不用求程序员。这就是我最终选 Python+Django 的原因:它自带 Admin 后台、自带 ORM、自带用户认证,一个startapp就能把「自主」的骨架立起来。
这篇不是科普,是我把这类站点从 0 跑到能上线的一次完整复盘。适合两类人:刚学完 Python 基础、想找一个真实项目练手的;以及接了小体量旅游业务、需要快速交付一个能自己维护的站点的。我会把模型怎么设计、路由怎么拆、订单怎么防重复、部署踩了哪些坑,一条条讲清楚。你照着做,能跑出一个可用的旅游自主网站;你不想全做,也能挑订单模块或 Admin 定制那几段直接搬走。
2. 旅游自主网站的数据模型怎么设计才不返工
2.1 先想清楚「自主」到底自主在哪
很多人一上来就画页面,结果写到订单模块发现线路表少了个「可售日期」,回头改模型、改迁移、改模板,血泪经验。我的习惯是先把「谁在什么场景下改什么数据」列出来,再倒推模型。
旅游自主网站里,数据变更方只有两类:游客和运营。游客产生的是订单、评论、攻略投稿;运营维护的是线路、价格日历、库存、专题。把这两类分开,模型就不会互相污染。核心实体我一般定五个:Destination(目的地)、TourRoute(线路)、RouteSchedule(班期/价格日历)、Order(订单)、Article(攻略)。其中RouteSchedule是最容易被忽略但最关键的——旅游产品不是固定价格,是「某一天出发、某个价格、剩几个位」,把它单独建表,后面做库存扣减和价格展示才不会乱。
选型上,Django 的 ORM 对这种一对多、多对多关系支持很顺,ForeignKey加related_name就能反向查,不用手写 SQL。如果你用 Flask,这部分得自己搭 SQLAlchemy,对新手不友好。这也是我推荐django项目实战新手从这个场景切入的原因:业务复杂度刚好,不至于劝退。
2.2 五个核心模型的字段与关系
下面是我实际用的模型骨架,放在tour/models.py。字段做了精简,但关键约束都在。
# tour/models.py from django.db import models from django.contrib.auth.models import User class Destination(models.Model): name = models.CharField("目的地", max_length=50, unique=True) parent = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children" ) # 支持省-市两级 cover = models.ImageField(upload_to="dest/", blank=True) def __str__(self): return self.name class TourRoute(models.Model): title = models.CharField("线路名", max_length=120) destination = models.ForeignKey( Destination, on_delete=models.PROTECT, related_name="routes" ) days = models.PositiveSmallIntegerField("行程天数", default=1) base_price = models.DecimalField("起价", max_digits=10, decimal_places=2) is_active = models.BooleanField("上架", default=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [models.Index(fields=["destination", "is_active"])] class RouteSchedule(models.Model): route = models.ForeignKey( TourRoute, on_delete=models.CASCADE, related_name="schedules" ) depart_date = models.DateField("出发日") price = models.DecimalField("当日价", max_digits=10, decimal_places=2) stock = models.PositiveIntegerField("余位", default=0) class Meta: unique_together = ("route", "depart_date") # 同线路同日期只能有一条 ordering = ["depart_date"] class Order(models.Model): STATUS = [ ("pending", "待支付"), ("paid", "已支付"), ("canceled", "已取消"), ("done", "已完成"), ] order_no = models.CharField(max_length=32, unique=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="orders") schedule = models.ForeignKey( RouteSchedule, on_delete=models.PROTECT, related_name="orders" ) qty = models.PositiveSmallIntegerField("人数", default=1) amount = models.DecimalField(max_digits=10, decimal_places=2) status = models.CharField(max_length=10, choices=STATUS, default="pending") created_at = models.DateTimeField(auto_now_add=True) class Article(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") route = models.ForeignKey( TourRoute, null=True, blank=True, on_delete=models.SET_NULL, related_name="articles" ) title = models.CharField(max_length=150) body = models.TextField() is_approved = models.BooleanField("审核通过", default=False)逻辑说明:Destination用自关联做省市两级,避免再建一张表;TourRoute.destination用PROTECT,防止误删目的地导致线路悬空;RouteSchedule的unique_together是防重复班期的第一道闸;Order.schedule用PROTECT,订单一旦产生就不允许删班期,这是财务底线。
参数说明:max_digits=10, decimal_places=2对应最大 99999999.99,旅游客单价足够;stock用PositiveIntegerField而不是IntegerField,数据库层就挡住负数;order_no单独建唯一索引,后面生成规则再讲。
2.3 迁移与 Admin 注册:让运营当天就能改数据
模型写完,两条命令建表:
python manage.py makemigrations tour python manage.py migrate然后在tour/admin.py里把模型挂上去,运营就能在后台自己改线路和价格:
# tour/admin.py from django.contrib import admin from .models import Destination, TourRoute, RouteSchedule, Order, Article class ScheduleInline(admin.TabularInline): model = RouteSchedule extra = 1 # 默认给一行空白,方便批量加班期 @admin.register(TourRoute) class TourRouteAdmin(admin.ModelAdmin): list_display = ("title", "destination", "days", "base_price", "is_active") list_filter = ("is_active", "destination") search_fields = ("title",) inlines = [ScheduleInline] @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ("order_no", "user", "schedule", "qty", "amount", "status") list_filter = ("status",) search_fields = ("order_no",) readonly_fields = ("order_no", "created_at")ScheduleInline是关键:运营打开一条线路,直接在下面加日期和价格,不用跳页面。readonly_fields把订单号和创建时间锁死,防止手滑改坏。这套 Admin 定制做完,运营侧基本不用再找开发,这就是 Django 相比其他框架最实在的地方。
3. 用 Django 视图和模板跑通「浏览-下单」主链路
3.1 路由分层:别把所有 URL 堆在一个文件里
站点一大,urls.py就会变成一坨。我的做法是按业务拆 app:tour管线路和订单,user管登录注册,content管攻略。主urls.py只做分发:
# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), path("", include("tour.urls")), path("user/", include("user.urls")), path("content/", include("content.urls")), ]tour/urls.py里再细分:
# tour/urls.py from django.urls import path from . import views app_name = "tour" urlpatterns = [ path("", views.route_list, name="route_list"), path("route/<int:pk>/", views.route_detail, name="route_detail"), path("order/create/<int:schedule_id>/", views.order_create, name="order_create"), path("order/<str:order_no>/", views.order_detail, name="order_detail"), ]用app_name加命名空间,模板里写{% url 'tour:route_detail' route.id %},以后改路径不影响模板。这是django创建app之后最该养成的习惯。
3.2 线路列表与详情:把查询次数压下来
列表页最容易犯的错是 N+1 查询。线路列表要显示目的地名,如果不在查询时select_related,每条线路都会再查一次目的地表。
# tour/views.py from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import TourRoute, RouteSchedule def route_list(request): qs = ( TourRoute.objects .filter(is_active=True) .select_related("destination") # 一次 JOIN 拿目的地 .prefetch_related("schedules") # 预取班期,详情页复用 .order_by("-created_at") ) paginator = Paginator(qs, 12) page = paginator.get_page(request.GET.get("page")) return render(request, "tour/route_list.html", {"page": page}) def route_detail(request, pk): route = get_object_or_404( TourRoute.objects.select_related("destination"), pk=pk, is_active=True ) schedules = route.schedules.filter(stock__gt=0).order_by("depart_date") return render(request, "tour/route_detail.html", {"route": route, "schedules": schedules})逻辑说明:select_related用于外键,生成 JOIN;prefetch_related用于反向外键,单独查一次再在 Python 里拼。详情页只展示stock__gt=0的班期,卖完的自动隐藏,省得用户点了才发现没位。
参数说明:Paginator(qs, 12)每页 12 条,旅游列表卡片式布局这个数比较舒服;get_page会自动处理非法页码,比page()安全。
3.3 下单接口:库存扣减和防重复提交
下单是整个站点最容易翻车的地方。并发下两个人同时买最后一个位,处理不好就超卖。我用的是「数据库条件更新 + 事务」的保守方案,不引入 Redis 也能扛住小体量。
# tour/views.py import uuid from django.db import transaction from django.db.models import F from django.contrib.auth.decorators import login_required from django.http import JsonResponse from .models import RouteSchedule, Order @login_required def order_create(request, schedule_id): if request.method != "POST": return JsonResponse({"ok": False, "msg": "method not allowed"}, status=405) qty = int(request.POST.get("qty", 1)) if qty < 1 or qty > 10: return JsonResponse({"ok": False, "msg": "人数不合法"}) with transaction.atomic(): # 条件更新:只有余位足够时才扣减,返回受影响行数 updated = ( RouteSchedule.objects .filter(id=schedule_id, stock__gte=qty) .update(stock=F("stock") - qty) ) if not updated: return JsonResponse({"ok": False, "msg": "余位不足"}) schedule = RouteSchedule.objects.get(id=schedule_id) order = Order.objects.create( order_no=uuid.uuid4().hex[:20].upper(), user=request.user, schedule=schedule, qty=qty, amount=schedule.price * qty, ) return JsonResponse({"ok": True, "order_no": order.order_no})逻辑说明:filter(stock__gte=qty).update(...)是一条原子 SQL,数据库层面保证不会扣成负数,比「先查再判断再存」安全得多。整个操作包在transaction.atomic()里,扣库存和建订单要么都成、要么都回滚。
参数说明:qty限制 1 到 10,防止恶意刷单;order_no用 uuid 前 20 位大写,够用且不暴露自增 id;F("stock") - qty是数据库端运算,避免读改写竞态。
前端提交时加个按钮禁用,能挡掉大部分重复点击:
// static/js/order.js document.querySelector("#order-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = e.target.querySelector("button"); btn.disabled = true; // 防连点 const resp = await fetch(e.target.action, { method: "POST", body: new FormData(e.target), headers: { "X-CSRFToken": getCookie("csrftoken") }, }); const data = await resp.json(); if (data.ok) location.href = `/order/${data.order_no}/`; else { alert(data.msg); btn.disabled = false; } });X-CSRFToken必须带,否则 Django 直接 403。getCookie是通用小工具,从document.cookie里取csrftoken即可。
4. 订单状态流转与后台数据一致性排查
4.1 状态机别用 if-else 硬写
订单有 pending、paid、canceled、done 四个状态,如果到处写if status == 'pending',改需求时你会想哭。我一般把合法流转定义成一张表,集中判断:
# tour/services.py from django.db import transaction from .models import Order, RouteSchedule from django.db.models import F TRANSITIONS = { "pending": {"paid", "canceled"}, "paid": {"done", "canceled"}, "canceled": set(), "done": set(), } @transaction.atomic def change_status(order_no, to_status): order = Order.objects.select_for_update().get(order_no=order_no) if to_status not in TRANSITIONS[order.status]: raise ValueError(f"非法流转 {order.status} -> {to_status}") # 取消订单时把库存还回去 if to_status == "canceled" and order.status in ("pending", "paid"): RouteSchedule.objects.filter(id=order.schedule_id).update( stock=F("stock") + order.qty ) order.status = to_status order.save(update_fields=["status"]) return orderselect_for_update()在事务里锁住这一行,防止两个后台操作同时改同一订单。取消时归还库存,这一步漏了就会出现「订单取消了但位子没了」的玄学问题。
4.2 用 Admin 动作批量处理,别一个个点
运营经常要批量确认已支付的订单。给OrderAdmin加个 action:
# tour/admin.py @admin.action(description="标记为已完成") def mark_done(modeladmin, request, queryset): from .services import change_status ok, fail = 0, 0 for order in queryset: try: change_status(order.order_no, "done") ok += 1 except ValueError: fail += 1 modeladmin.message_user(request, f"成功 {ok} 条,跳过 {fail} 条") class OrderAdmin(admin.ModelAdmin): # ... 原有配置 actions = [mark_done]这样运营勾选一批订单,一次处理完,失败的不影响成功的。比让他们一条条点开改状态靠谱得多。
4.3 数据对不上时先查这三处
订单和库存对不上,是这类站点最常见的排查场景。我的顺序是:先看RouteSchedule.stock有没有负数(说明扣减逻辑被绕过),再看有没有canceled订单没归还库存(说明状态流转没走 service),最后看有没有同一order_no重复记录(说明防重没生效)。这三处查完,九成问题能定位。别一上来就怀疑数据库,先怀疑代码路径。
5. 部署上线前必须处理的坑与排查清单
5.1 静态文件和媒体文件别混在一起
开发时DEBUG=True,Django 帮你伺服静态文件,一上线就全 404。正确做法是collectstatic收集到统一目录,交给 Nginx 伺服;用户上传的图片走媒体目录,单独配置。
python manage.py collectstatic --noinput# nginx 片段 location /static/ { alias /srv/app/staticfiles/; } location /media/ { alias /srv/app/media/; }STATIC_ROOT和MEDIA_ROOT必须是两个不同目录,混用会导致collectstatic把用户上传的图也扫进去,越滚越大。
5.2 数据库连接和时区
生产环境用 PostgreSQL 比 SQLite 稳,settings.py里CONN_MAX_AGE设 60,复用连接。时区一定设TIME_ZONE = "Asia/Shanghai"、USE_TZ = True,否则班期日期在跨时区展示时会差一天,用户看到的出发日就错了。
5.3 避坑清单:五个我真实踩过的坑
现象:下单成功但库存没减。原因:扣减和建订单没在同一事务里,建订单失败后库存已扣。解决:全部包进transaction.atomic(),用条件更新扣减。
现象:同一用户连点两次生成两个订单。原因:前端没禁用按钮,后端没做幂等。解决:前端提交即禁用,后端可加「同用户同班期 5 秒内只允许一单」的校验。
现象:Admin 里删了线路,订单全报错。原因:外键用了CASCADE。解决:订单相关外键一律PROTECT,删之前必须先处理订单。
现象:班期日期显示成前一天。原因:USE_TZ开着但模板没做本地化。解决:模板里用{{ schedule.depart_date|date:"Y-m-d" }},并确认时区设置。
现象:图片上传后前台 404。原因:MEDIA_URL没配或 Nginx 没映射。解决:开发用static()辅助,生产配 Nginx location。
6. 让旅游自主网站真正「自主」的两个进阶技巧
第一个技巧是给运营做一个「价格日历」批量导入。旅游线路的价格经常整月整月地调,让运营在 Admin 里一天天加班期会疯。我一般写个管理命令,读 CSV 批量生成RouteSchedule:
# tour/management/commands/import_schedule.py import csv from django.core.management.base import BaseCommand from tour.models import TourRoute, RouteSchedule class Command(BaseCommand): help = "从 CSV 批量导入班期,格式:route_id,date,price,stock" def add_arguments(self, parser): parser.add_argument("csv_path") def handle(self, *args, **options): with open(options["csv_path"], encoding="utf-8") as f: for row in csv.DictReader(f): route = TourRoute.objects.get(id=row["route_id"]) RouteSchedule.objects.update_or_create( route=route, depart_date=row["date"], defaults={"price": row["price"], "stock": int(row["stock"])}, ) self.stdout.write(self.style.SUCCESS("导入完成"))update_or_create保证重复导入不会产生重复班期,运营改完 CSV 重跑一次即可。参数上route_id对应线路主键,date用YYYY-MM-DD,price和stock直接给数字。
第二个技巧是给订单加一个「超时未支付自动取消」。用 Django 的manage.py配合定时任务,每分钟扫一次超过 30 分钟还是 pending 的订单,调change_status取消并归还库存。核心查询是Order.objects.filter(status="pending", created_at__lt=timezone.now() - timedelta(minutes=30))。这一步做完,库存才真正「自主」流转,不用人工盯。
我自己的习惯是:任何涉及钱和库存的操作,先写 service 函数,再写视图,最后才写模板。视图只负责收参数和返回,业务逻辑全在 service 里,这样出问题只查一个地方。这套旅游自主网站我从模型到上线跑了两周,返工最多的地方就是一开始没把班期单独建表。希望你少走这段弯路,把时间花在内容和运营上。希望帮到你。
本文还有配套的精品资源,点击获取