1. 从一句“没意思”到做一套系统:项目的真实起点
这个项目最初的动机其实挺朴素。前两年帮一个做本地旅游内容的朋友整理景区留言反馈,发现一个很有意思的现象:大量游客在纸质留言簿和线上评论里写的,并不是“门票贵”“设施差”这类可量化的评价,而是“这里的黄昏让人想哭”“风吹过来的时候突然就安静了”“台阶上的青苔拍照特别好看”这种纯主观的、感性的印象。按传统评分系统的思路,这些内容几乎无法被利用——没法打分,没法排序,也没法进统计报表,最后只能躺在数据库里吃灰。
但换个角度看,这些“印象”恰恰是景区和潜在游客最需要的东西。评分只能告诉别人“值不值得去”,印象却能告诉别人“去了之后会经历什么”。于是我就起了做一个“景点印象服务系统”的念头:用Django搭一套Web应用,让游客发布景点印象、浏览和检索他人印象,系统再按一定逻辑把这些零散的主观感受组织起来。项目不大,但覆盖了Django开发里相当核心的一圈知识点:模型关系设计、表单处理、图片上传、查询优化、缓存策略、安全防御。
这篇博文适合两类人看:一是课程设计和毕业设计想选旅游类题目、但不想再重复做“景点CRUD+评论”的同学,二是在职开发者想练一套完整Django项目、把平时零散的知识串起来的同行。我会把设计思路、表结构、核心代码和踩坑过程都写出来,你完全可以照着搭一套。
2. 技术选型:为什么是Django,而不是别的方案
2.1 选Django的三个决定性理由
这个项目用Django不是跟风,而是当时的刚需。第一个理由是后台管理几乎是白送的:景点、印象、用户、标签这些数据都需要后台维护,Django自带的admin能省掉一大半基础配置工作,我自己只要写业务逻辑。第二个理由是ORM的关联查询能力:景点和印象之间是典型的一对多关系,印象又和用户、标签存在多对多关系,Django的ORM在做这类关联查询时非常顺手,不需要手动拼几十行SQL。第三个理由是生态成熟,第三方库覆盖了分页、认证、图片处理、全文检索等需求,踩坑时能搜到大量现成方案。
中间也考虑过Flask和FastAPI。Flask足够轻,但后台、ORM、表单都要自己另拼一套,做这类中小型业务系统有点累;FastAPI的优势在异步和高性能接口,但这套系统没有特别高的并发压力,反而是开发效率和后台管理更重要,所以最终选了Django。
提示:如果你的需求是纯API服务,前端完全分离,FastAPI会是更好的选择。但当你需要一个同时包含Web页面、后台管理、数据库模型的完整服务端应用,Django的开箱即用优势非常明显。
2.2 配套组件与运行环境
整个项目的技术配套是这样的:
| 组件 | 选型 | 说明 |
|---|---|---|
| Web框架 | Django 4.2 LTS | 长期支持版本,稳定且有完整安全补丁 |
| 数据库 | PostgreSQL | 支持JSONField等类型,适合存印象标签这类半结构化数据 |
| 前端 | Bootstrap 5 + 原生JS | 不搞前后端分离,模板直出,降低整体复杂度 |
| 图片存储 | 本地Media + django-cleanup | 开发环境够用,生产可换OSS/S3 |
| 缓存 | Django Cache + Redis | 热点景点和印象列表的缓存 |
| 搜索 | PostgreSQL全文检索 | 用Django的SearchVector实现,不额外引入Elasticsearch |
没做前后端分离是刻意取舍。系统的核心使用场景是游客快速浏览和发布,服务端渲染响应快、SEO友好、开发链路短。印象的加载、筛选这类交互用原生JS加AJAX就能解决,犯不着为这个体量上Vue或React。
2.3 项目目录与模块划分
项目内部按功能拆成三个app:
spot/ # 景点模块:景点信息、景点照片、区域分类 impression/ # 印象模块:印象发布、浏览、点赞、标签 users/ # 用户模块:注册、登录、个人中心这样拆分的好处是边界清晰:游客访问景点详情页时只需加载spot和impression两个app的代码;用户认证相关的逻辑单独成块,后续要扩展邮箱验证、第三方登录时也不会牵动业务代码。印象是整个系统的核心,单独成app也方便后续独立扩展。
3. 数据模型设计:把“印象”变成一个可计算、可组织的对象
3.1 三个核心模型的关系梳理
“印象”这个词有点虚,落到数据模型上必须变成可量化的字段。我先梳理了一遍业务场景,确定核心模型之间的关系:
- 一个景点(Spot)有多个印象(Impression),一对多。
- 一个用户(User)可以发布多条印象,一对多。
- 一条印象可以被打上多个标签(Tag),标签和印象多对多。
- 一条印象可以被多个用户点赞,点赞关系需要独立表记录。
这里有个需要想清楚的点:印象的主体是用户的感性描述,景点只是印象的归属对象,所以查询的主路径应该是以印象为核心反向关联景点,而不是由景点出发罗列全部印象。为配合这一逻辑,印象表需要冗余存储景点名称的快照字段,避免列表页反复JOIN景点表。
3.2 景点模型代码
景点模型重点关注的是“给印象提供上下文”,而不是把景点的所有信息塞进去。我设计了这样的字段:
from django.db import models from django.urls import reverse class Spot(models.Model): name = models.CharField('景点名称', max_length=100, unique=True) slug = models.SlugField('URL标识', max_length=120, unique=True, blank=True) location = models.CharField('所在区域', max_length=100) description = models.TextField('景点简介', blank=True) cover_image = models.ImageField('封面图', upload_to='spots/covers/', blank=True, null=True) latitude = models.DecimalField('纬度', max_digits=9, decimal_places=6, blank=True, null=True) longitude = models.DecimalField('经度', max_digits=9, decimal_places=6, blank=True, null=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) impression_count = models.PositiveIntegerField('印象总数', default=0) avg_sentiment = models.FloatField('平均情感倾向', default=0.0) class Meta: verbose_name = '景点' verbose_name_plural = '景点' ordering = ['-created_at'] def __str__(self): return self.name def get_absolute_url(self): return reverse('spot:detail', args=[self.slug])impression_count和avg_sentiment这两个字段值得专门说一下。它们都是冗余存储——真实数据由印象表维护,这里只做汇总。列表页要显示“这个景点有多少条印象”“大家的感受偏向正面还是负面”,如果每次现查印象表再聚合,景点一多查询就慢。设计成冗余字段后,每次新增或删除印象时更新一下计数,用事务保证一致性,查询时直接取字段就行。
3.3 印象模型:核心中的核心
印象表是整个系统最需要仔细设计的部分。我的字段设计如下:
class Impression(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='impressions', verbose_name='发布者' ) spot = models.ForeignKey( Spot, on_delete=models.CASCADE, related_name='impressions', verbose_name='所属景点' ) spot_name_snapshot = models.CharField('景点名称快照', max_length=100, editable=False) title = models.CharField('印象标题', max_length=200) content = models.TextField('印象正文') mood = models.CharField('整体心境', max_length=20, choices=MOOD_CHOICES, default='calm') visit_date = models.DateField('游玩日期', null=True, blank=True) tags = models.ManyToManyField(Tag, related_name='impressions', blank=True, verbose_name='印象标签') image = models.ImageField('印象图片', upload_to='impressions/images/', blank=True, null=True) like_count = models.PositiveIntegerField('点赞数', default=0) is_featured = models.BooleanField('是否精华', default=False) sentiment_score = models.FloatField('情感得分', default=0.0, editable=False) created_at = models.DateTimeField('发布时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: verbose_name = '景点印象' verbose_name_plural = '景点印象' ordering = ['-created_at'] indexes = [ models.Index(fields=['spot', '-created_at']), models.Index(fields=['-like_count']), ] def __str__(self): return f'{self.user}: {self.title}' def save(self, *args, **kwargs): if not self.spot_name_snapshot: self.spot_name_snapshot = self.spot.name super().save(*args, **kwargs)几个关键设计的理由:
标题+正文结构。印象不只是随手一句短评,标题负责快速捕获读者注意力,正文则容纳完整的主观感受。相比微博式的短评,这种结构在列表页的展示效果和信息密度上都更优。
快照字段。spot_name_snapshot在保存时自动写入景点名,这样当景点信息被修改(甚至删除时用了SET_NULL策略)之后,历史印象依然能展示对应的景点名称。
情感得分字段。后续通过简单的关键词情感分析或人工标注给每条印象打一个-1到1之间的情感分,聚合到景点上就是avg_sentiment。不搞复杂算法,先用规则引擎跑一遍,再人工修正。
点赞数冗余。点赞是高频操作,每次点赞都去查点赞关系表做COUNT会拖慢列表页。点赞时用事务同时更新点赞表和like_count字段,展示时直接用冗余字段。
3.4 友善提示:冗余字段的时机
冗余字段不是第一版就加进去的。一开始我用的是正常的关联聚合查询,等景点和印象的数据量增长到——单景点印象超过几百条、列表接口明显变慢——才开始做冗余。建议你也这么走:先按规范设计表,性能瓶颈真实出现了再针对性地冗余,不要一开始就把表结构搞得异常复杂。
4. 核心功能实现:从发布一条印象到被其他游客看到
4.1 发布印象的完整流程与防御细节
发布印象是系统最核心的写操作,流程要处理表单验证、图片上传、标签写入、情感打分、冗余字段更新五个环节。
表单部分用的是Django ModelForm,自定义了一个ImpressionForm:
class ImpressionForm(forms.ModelForm): tags_input = forms.CharField( label='印象标签', required=False, help_text='用逗号分隔,例如:日落, 安静, 出片, 人多' ) class Meta: model = Impression fields = ['title', 'content', 'mood', 'visit_date', 'tags_input', 'image'] widgets = { 'title': forms.TextInput(attrs={'class': 'form-control', 'maxlength': 200}), 'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 6}), 'visit_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), } def save(self, commit=True): impression = super().save(commit=False) # 处理标签 raw_tags = self.cleaned_data.get('tags_input', '') tag_names = [t.strip() for t in raw_tags.split(',') if t.strip()] if commit: impression.save() tag_objs = [] for name in tag_names[:5]: tag, _ = Tag.objects.get_or_create(name=name) tag_objs.append(tag) impression.tags.set(tag_objs) return impression这里最值得讲的是标签的动态创建。get_or_create保证了同一个标签只存在一份,不会出现“日落”和“日落 ”两个几乎相同的标签。上限5个标签是产品上的考虑——实验发现标签太多反而让人选择困难,5个以内信息密度最好。
图片上传我额外做了两步处理。第一步是前端压缩,用Canvas把超过2MB的图片等比压缩到2000px以内再上传;第二步是后端校验,限制文件类型为JPEG/PNG/WebP,大小不超过5MB。在settings.py里配合配好MEDIA_ROOT和MEDIA_URL,再用django-cleanup这个库在记录删除时自动清理文件。
4.2 情感得分的轻量实现
情感得分不用机器学习,采用了一个可解释性强的关键词规则引擎。维护一份情感词表,正面词记+1分,负面词记-1分,否定词(“不”“没”“别”)把后面最近一个情感词翻转。例如“日落很美但人很多,不推荐周末来”这句话,扫描结果是“美”+1,“多”-1,“不推荐”里的“不”翻转“推荐”的动作含义,整体得分约-0.3,倾向偏中立负面。
实现上就是把规则写成一个独立模块,在保存前调用:
def compute_sentiment_score(text: str) -> float: score = 0 tokens = tokenize(text) negated = False for token in tokens: if token in ('不', '没', '别', '无', '莫'): negated = True continue if token in POSITIVE_WORDS: score += -1 if negated else 1 negated = False elif token in NEGATIVE_WORDS: score += 1 if negated else -1 negated = False return max(score / len(tokens) * 10, -1, 1)算法准确率大概七成左右,对印象这类短文本足够用。你是要完全精确的情感分析,还是只想要一个可解释的参考指标?如果是后者,规则引擎比深度学习模型更合适,因为没有GPU推理成本,代码透明、好调试、也方便对用户解释“为什么系统认为这条偏正面”。
4.3 印象列表页:排序、分页与查询优化
列表页最难的不是写代码,而是把“用户想先看什么”翻译成查询逻辑。我实现了三种排序:
- 最新发布:按
created_at倒序,适合刷新鲜感。 - 最受欢迎:按
like_count倒序,适合找共鸣。 - 精华优先:
is_featured=True排前面,其余按综合权重排。
第三种排序里的“综合权重”参考了常见内容社区的做法,用一个字段加时间的衰减公式:
from django.db.models import F, Value, FloatField from django.db.models.functions import Coalesce # 权重 = 点赞数 * 0.6 + 情感得分 * 0.2 + 时间衰减 * 0.2 hot_impressions = Impression.objects.filter(spot=spot).annotate( heat_score=( Coalesce(F('like_count'), 0) * 0.6 + Coalesce(F('sentiment_score'), 0) * 0.2 - (Now() - F('created_at')) * 0.02 ) ).order_by('-is_featured', '-heat_score')这个公式不是为了学术严谨,而是操作性优先。点赞数是核心指标,权重最大;情感得分作为内容质量的弱信号;时间衰减确保三周前的印象不会一直霸榜。实际可以按自己的数据特征调权重,让系统的排序有“手感”。
最关键的查询优化是解决N+1问题。印象列表页要显示发布者昵称和头像,如果用常规ORM写法,每页20条印象会产生20次额外的用户查询。我用select_related('user')一次性把用户数据JOIN出来:
impressions = Impression.objects.filter( spot=spot ).select_related('user').prefetch_related('tags')prefetch_related('tags')解决的是多对多关系——它单独发一条查询把所有关联标签一次性取回来,再在Python内存里做分组。这两行改动把列表页的数据库查询次数从大约1+N+M次降到3次,效果立竿见影。
4.4 景点印象热度榜与缓存策略
景点详情页顶部放一个“本景点印象热度Top5”,这是整站最重要的PV入口。热度榜的计算需要跑一次上面的heat公式,如果每次都实时算,数据库压力太大。我加了Redis缓存,缓存策略是:
- 缓存键:
spot_impression_ranking_{spot_id} - 缓存时间:5分钟,比较合适的平衡点,点赞变化不会在页面上停留太久,但数据库也不会被打爆
- 失效条件:新增一条印象、管理员设置精华、点赞数变化时清除对应景点缓存
代码实现:
from django.core.cache import cache def get_hot_impressions(spot): cache_key = f'spot_impression_ranking_{spot.id}' result = cache.get(cache_key) if result is None: result = list( Impression.objects.filter(spot=spot) .select_related('user') .annotate(heat_score=...) .order_by('-heat_score')[:5] ) cache.set(cache_key, result, 300) return result缓存的是ORM对象列表,序列化开销比较大,但5分钟内能承受。如果数据量再涨,建议缓存序列化后的字典列表而不是ORM对象,Redis内存占用会小很多。
5. 开发过程中踩过的五个坑:排查思路与解决办法
5.1 坑一:图片上传到一半就失败
印象图片上传功能上线后,测试阶段频繁出现“请求被中断”的情况。打开浏览器控制台发现,超过3MB的图片直接报413 Request Entity Too Large,Nginx默认对请求体大小有限制。
排查链路很明确:先看Nginx配置,检查client_max_body_size参数,默认是1M,大于这个的请求直接拒收。在Nginx配client_max_body_size 10m之后再测,发现图片能传了,但Django这边又报SuspiciousFileOperation。这是上传文件时图片的原始文件名有问题——某些安卓手机拍出来的文件名带中文或特殊字符,Django的文件存储对这类名字比较敏感。
两个修复方向:
- 后端在上传前用
uuid4().hex重新生成文件名,原始文件名里的中文全部不要。 - 前端压缩上限统一压到2MB以内,从源头上减小体积。
实测下来,前端压缩+UUID重命名这套组合,图片上传成功率从八五成升到了接近100%,数据库里也不会出现乱七八糟的文件名。
5.2 坑二:N+1查询拖慢了整个列表页
这个坑最隐蔽。景点详情页第一版上线时,本地开发环境数据量小,看不出问题。等灌入几千条测试数据后,页面加载变慢,用Django Debug Toolbar一查,发现首页一次请求发起了47条SQL。
问题出在列表页的select_related用得不对:我只对user做了select_related,但每一条印象要关联多个标签,标签是ManyToManyField,必须用prefetch_related。少写一个prefetch_related,Django会在模板里每次循环到impression.tags.all时发一条新查询,20条印象就是20条SQL。
加上prefetch_related('tags')之后,SQL降到3条。这里提醒一句:模板循环里出现点号调用时,天然有N+1风险,养成写完查询条件顺手加select_related/prefetch_related的习惯。
5.3 坑三:时区问题导致“昨天发布的印象跑到今天”
上线后用户反馈,凌晨时分发布的印象时间显示错位。查下来是USE_TZ = True时,Django将时间按UTC存储,渲染模板时如果没有把它转换到本地时区,就会导致展示时间比实际时间早8小时。
解决办法是在settings.py里同时设置TIME_ZONE = 'Asia/Shanghai'和USE_TZ = True,模板渲染时用{{ impression.created_at|localtime }}过滤器。其实Django在USE_TZ=True时会默认自动适应本地时区,但如果你在某些自定义的序列化器或者API视图里手动datetime.now()生成了时间,就会绕过这一层,产生错位。
建议:代码里任何涉及当前时间的操作一律用django.utils.timezone.now(),不要用Python原生的datetime.now()。
5.4 坑四:富文本里的XSS漏洞差点上线
最初我想让印象正文支持富文本编辑,引入了轻量级编辑器,允许用户写加粗、斜体、插入链接。测试时某开发者随手在正文里输入了:
<img src=x onerror=alert('xss')>保存后预览,页面真的弹出了警告框。这说明用户输入被原样注入到了HTML模板中。Django模板默认转义,但富文本编辑器生成的HTML经过|safe过滤器后,等于放弃了转义保护。
最终方案是彻底放弃富文本,回归纯文本+换行渲染。正文保存为纯文本,展示时用linebreaks过滤器把换行转成<br>,再用escape转义其余部分。XSS风险归零。如果是非做富文本不可的场景,建议引第三方Markdown渲染库,在上游做白名单过滤,不要依赖前端编辑器输出的“安全”。
5.5 坑五:点赞按钮被连点刷爆
上线第一天就有人连点了20次点赞,统计数字飞涨。点赞和取消点赞的操作是高频且竞争激烈的,不处理会出两件事:一是重复点赞,数据库里出现一条用户对同一条印象的多条点赞记录;二是点赞数统计不准确。
我的解决方式是给点赞关系表加unique_together约束:
class ImpressionLike(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) impression = models.ForeignKey(Impression, on_delete=models.CASCADE, related_name='likes') class Meta: unique_together = ('user', 'impression')然后在视图里用get_or_create加事务控制,配合数据库唯一约束,从根上杜绝重复点赞。点赞数用F()表达式原子自增,避免并发时读改写造成的丢更新:
from django.db.models import F with transaction.atomic(): like, created = ImpressionLike.objects.get_or_create( user=request.user, impression=impression ) if created: Impression.objects.filter(pk=impression.pk).update( like_count=F('like_count') + 1 ) else: like.delete() Impression.objects.filter(pk=impression.pk).update( like_count=F('like_count') - 1 )6. 重构过程中的一次关键决策:印象流的聚合展示
6.1 单一景点的印象列表改成跨景点的“印象流”
项目做到一半,我发现只做“景点详情页里的印象列表”有点浪费。游客通常不是只看一个景点,而是想看“今天适合去哪儿、那儿大家感觉怎么样”。于是增加了一个跨景点的“印象流”页面:用户登录后看到的是所有景点最近的印象,按时间线排列,每条附带所属景点的跳水名称和缩略图。
这个改动本身不大,反而是查询条件的取舍值得写出来。跨景点印象流需要同时JOIN景点表和用户表,拼SQL时很容易走向性能深渊。我的处理是分页每页20条,只查需要的字段:
feed = Impression.objects.select_related('user', 'spot').only( 'title', 'content', 'mood', 'image', 'user__nickname', 'user__avatar', 'spot__name', 'spot__slug' )[:20]only()限制只加载指定字段,避免把整行大字段(正文全文)都塞进内存。这里有个注意点:如果你在模板里访问了only()里没包含的字段,Django会再次发SQL补查,反而更慢。所以only()的字段清单一定要和模板字段提前对齐。
6.2 用标签把印象串起来
跨景点印象流加标签之后,用户点击某个标签(比如“出片”),就能看到所有被打了这个标签的跨景点印象。这个功能原本需要单独的标签搜索页,后来发现直接复用印象列表的筛选逻辑就行。
实现时把标签筛选做成一个独立查询函数:
def filter_impressions_by_tag(request, tag_slug): tag = get_object_or_404(Tag, slug=tag_slug) qs = Impression.objects.filter(tags=tag).select_related('user', 'spot') # 复用排序逻辑 ...如果标签量很大,需要加一层缓存,按标签维度缓存“最近的N条印象ID列表”,再按ID去查详情。这个方案的好处是缓存数据量小,且不会因为一条新印象出现而频繁失效。
6.3 数据量增长后的性能预警
到后期测试数据超过几万条印象时,发现三个潜在瓶颈:
- 印象流分页使用了
LIMIT/OFFSET,页码越深越慢。用游标分页(按created_at和id组合定位)替代。 like_count的排序在数据量大时会走全表排序。给排序字段建组合索引,注意查询的order_by和索引方向一致。- 图片文件都在本地磁盘,备份困难且占用空间大。生产环境建议迁移到对象存储,Django这边只需要换掉
DEFAULT_FILE_STORAGE配置,改动不大。
这些优化不在当前版本做,但提前把索引建好,数据量涨上去之后不至于马上崩。
7. 部署上线前后的几个细节:从开发机到服务器的距离
7.1 生产环境配置的差异处理
开发环境里的settings.py和生产环境差异很大,我用一个基础配置文件加一个环境变量切换的方式处理:
project/ ├── settings/ │ ├── base.py # 公共配置 │ ├── dev.py # 开发环境:DEBUG=True, SQLite │ └── prod.py # 生产环境:DEBUG=False, PostgreSQL, Redis启动命令通过环境变量指定使用哪份配置:
export DJANGO_SETTINGS_MODULE=project.settings.prod gunicorn project.wsgi:application --bind 0.0.0.0:8000 --workers 3部署用Gunicorn + Nginx,Nginx负责静态文件、媒体文件的直接服务,以及把动态请求反向代理给Gunicorn。媒体文件路径在Nginx里单独指到/var/www/media/,这个配置在Django侧不需要额外处理,只保证MEDIA_ROOT和Nginx的alias路径一致。
7.2 安全设置清单
项目上线前过了一遍安全检查,以下几项是必须配的:
DEBUG = False并配置ALLOWED_HOSTS,避免调试信息泄漏。- 启用
SECURE_SSL_REDIRECT(如果上了HTTPS),替HTTP请求做一个301跳转。 CSRF_COOKIE_SECURE = True和SESSION_COOKIE_SECURE = True,保证Cookie只走HTTPS。X_FRAME_OPTIONS = 'DENY'防止页面被恶意站点嵌入iframe。- 登录接口加
django-ratelimit限流,防止暴力破解。
这些配置大部分几分钟就能配完,但经常被忽略。Django的默认配置在安全上已经算做了很多事,只要别把DEBUG开到生产环境,问题就不大。
7.3 备份与维护的基本盘
印象数据是用户生成的,丢数据没法交代。我的备份策略是每天凌晨用pg_dump把PostgreSQL数据导出,同时把媒体目录用rsync同步到另一块磁盘。刚开始做的时候犯过一个错——只备份数据库,忘记备份图片文件,结果数据库恢复后发现图片全丢了。数据库和媒体文件必须一起备份,这种教训希望你别踩第二次。
8. 印象数据的再利用:从展示到个性化推荐
8.1 基于印象标签的景点画像
当某个景点的印象积累到一定数量后,标签的分布其实就能勾勒出这个景点的“气质”。例如某个景点高频出现“安静”“发呆”“绿荫”,另一个景点高频出现“刺激”“排队”“过山车”,我用标签频率做一个简单的画像:
def build_spot_profile(spot): tag_counts = ( ImpressionTag.objects .filter(impression__spot=spot) .values('tag__name') .annotate(count=Count('id')) .order_by('-count')[:10] ) return [{'name': item['tag__name'], 'count': item['count']} for item in tag_counts]画像是给系统用户看的——“去过的人都在用什么词形容这里”,比干巴巴的介绍更能传递氛围感。把这个放到景点详情页顶部,还有助互动率:游客看到“安静”被提到很多次,就会更倾向于写一条对应或反驳的印象,内容生态就是这样滚起来的。
8.2 给新用户的冷启动推荐
冷启动问题是推荐系统里的老大难。新用户没有行为数据,我用“当前热门印象+高频景点画像”的组合做基础推荐:新用户注册后,推荐流先展示全站最近7天热度最高的若干条印象,同时根据用户选择的偏好的标签(注册时勾选“看山”“看海”“古迹”等兴趣)过滤。这一步不依赖历史行为,只依赖注册时采集的静态标签,逻辑简单但效果不错。
代码上我把推荐封装成一个服务函数,输入用户ID,输出印象查询集:
def get_recommend_feed(user, page_size=20): user_tags = user.profile.interested_tags.all() if user_tags.exists(): qs = Impression.objects.filter(tags__in=user_tags) else: qs = Impression.objects.all() qs = qs.select_related('user', 'spot').order_by('-heat_score') return qs[:page_size]等用户有了点赞和发布行为,再逐步加入协同过滤策略。这个演进过程对新手很友好,不需要一上来就搞复杂模型。
8.3 管理后台的“精华”审核与人工运营
精华印象的判定我起先全自动算的,后来发现算法选出来的内容和人眼挑的差距很大。算法更看重点赞数和热度,但真正能打动人的印象往往是“小众而细腻”的——可能点赞只有几十个,但文字特别有画面感。于是改成半自动:算法选出热门Top50作为候选池,管理员在Django admin里人工确认哪些进入精华池。
管理员的展示列表配置成带img缩略图、正文摘要和标签列,方便快速筛选。这套人工审核机制虽然增加一点运营成本,但保证了“精选”栏目的内容质量,体验差异很明显。
最后分享一个我在项目过程中体会最深的事:景点印象系统的难点根本不在技术,而在如何把主观的、感性的“印象”转化成结构化可操作的数据。这个转化的每一步都对应着取舍——规则引擎的情感判断 vs 深度学习的精度,热度公式里的权重选择 vs 一刀切排序,人工精华审核 vs 全自动化算法。Django只是让这些想法快速落地的工具,真正决定项目走向的是你对业务的理解和判断。
如果照着这篇博文动手搭一套,建议第一版先把“发布印象 → 景点关联 → 列表展示”这条主链路跑通,再逐步加排序、缓存、标签、推荐这些进阶功能。做产品就像塑印象,先把一个完整的轮廓立起来,再一点点充实细节,效果会比一次到位好得多。