Python智能旅游推荐系统毕设全攻略:表结构、协同过滤与避坑指南
简介这是一套基于Python与Django开发的智能旅游推荐系统完整毕业设计项目面向需要完成毕业设计、课程设计或期末大作业的Python学习者功能涵盖旅游路线智能推荐、景点管理、用户交互等模块采用Vue前端与Django后端配合前后端代码齐全系统界面美观、操作简便。资源包共797个文件、约20.19MB包含164个JavaScript、53个Vue/HTML/CSS前端资源45个Python及40个pyc后端代码2个SQL数据库脚本及图片媒体文件并附安装与运行脚本结构清晰可直接部署。目前已有227人浏览学习项目经严格调试可运行附加Navicat建库脚本与一键启动工具适合参考完整系统源码、快速搭建旅游推荐类应用的中级Python开发者。1. 智能旅游推荐系统不只是算法一张评分表把“Python毕设”的所有模块串起来拿到“基于Python的智能旅游推荐系统”这个毕业设计标题时很多同学第一反应是“我要写协同过滤”然后去搜一堆算法代码最后发现和网页、数据库、用户登录根本对不上。实际上这个项目是一个典型的 Web 推荐系统黑匣子前端负责展示后端用 Flask/Django 接收请求数据库存用户和景点推荐引擎读数据计算再写回结果。真正决定能不能跑通、能不能过答辩的是表结构怎么设计、推荐算法怎么和数据库增删改查联动、以及冷启动阶段怎么兜底。这篇笔记按我平时带毕设项目的顺序从建表讲到算法再到缓存和踩坑给你一条能照着复现的完整路径。适合正在做这个题、又不想把“智能”只停留在 PPT 上的同学。2. 先建模再谈算法用三张表把用户、景点、行为串成闭环2.1 为什么推荐系统毕设最常死在表结构上我见过太多人拿到题目后第一件事就是搜“Python 协同过滤源码”找到一个能算相似度的脚本然后尝试把它硬塞进项目里。结果往往是算法单独跑能出数一接 Web 就崩因为推荐引擎需要的数据根本没有被存取。推荐系统不是一个孤立算法它是一条完整的数据链注册登录写用户表景点管理写景点表用户点击、评分、收藏写行为表。这三类数据缺一个协同过滤就跑不动。最常见的翻车现象是有人把用户的评分记录存成 JSON 字段一行塞进 MySQL美其名曰“灵活”。真到了算用户相似度的时候又要用 pandas 反复解析绕一大圈。正确的做法是先画数据流注册 - user 表景点信息 - scenic 表用户打分/收藏 - rating/favorite 表。三张表建好之后推荐算法就变成了一个简单的过程从库里读数据算结果再把结果写回缓存表。另外提醒一句网上能搜到的“免费python源码大全”里旅游推荐系统类项目十有八九是半成品要么数据库脚本只给了 SQLite 示例文件要么直接连了个远程数据库。我一般会建议你从本地 MySQL 起步把脚本里的建表语句读懂再迁到 SQLite 也容易。数据库选型不是这个项目的核心亮点但表结构设计必须清晰否则后面算法、页面、答辩全会被拖下水。2.2 最小可跑的建表 SQLMySQL 5.7 兼容的六张表下面这套建表语句是我自己常用的骨架兼容 MySQL 5.7 和 8.0也在 SQLite 上改过。推荐系统需要的用户、景点、行为、相似用户缓存、推荐结果缓存都覆盖了。CREATE DATABASE IF NOT EXISTS travel_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE travel_recommend; CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, city VARCHAR(50) DEFAULT , preferences VARCHAR(255) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE scenic ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, category VARCHAR(50) NOT NULL, tags VARCHAR(255) DEFAULT , description TEXT, image_url VARCHAR(255) DEFAULT , score DECIMAL(3,2) DEFAULT 0, price_level TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_city (city) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE rating ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, scenic_id INT NOT NULL, score DECIMAL(2,1) NOT NULL, comment VARCHAR(500) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_scenic (user_id, scenic_id), KEY idx_scenic (scenic_id), CONSTRAINT fk_rating_user FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, CONSTRAINT fk_rating_scenic FOREIGN KEY (scenic_id) REFERENCES scenic (id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE favorite ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, scenic_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_fav_scenic (user_id, scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE similar_user_cache ( user_id INT NOT NULL, similar_user_id INT NOT NULL, similarity DECIMAL(5,4) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, similar_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE rec_cache ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, scenic_id INT NOT NULL, recommend_score DECIMAL(5,4) NOT NULL, reason VARCHAR(255) DEFAULT , expired_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_expired (user_id, expired_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个参数要特别注意。第一所有表都用utf8mb4它才能完整存下中文和 emoji。如果你拿到的原项目用utf8后面插入“北京”可能没问题但万一景点描述里带了个冷门符号页面就出问号。第二rating表设置UNIQUE KEY (user_id, scenic_id)这是为了避免同一用户对同一景点出现两条评分记录算法算相似度时被重复数据带偏。第三score用DECIMAL(2,1)而不是FLOAT浮点存储会出现 0.30000000000000004 这类问题用十进制更稳。第四similar_user_cache和rec_cache是缓存表一个缓存用户相似度一个缓存最终推荐结果。没有这两张表每次刷新推荐页都要全量跑矩阵演示时卡几秒体验很不好。2.3 用 Flask-SQLAlchemy 把表映射成模型别手写几十行原生 SQL建表 SQL 只是第一步Python 项目里操作数据库我习惯用 Flask-SQLAlchemy而不是手写SELECT * FROM rating再自己拼字典。ORM 的优点是字段和对象一一对应代码可读性好写毕设论文时也容易解释。from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(255), nullableFalse) city db.Column(db.String(50), default) preferences db.Column(db.String(255), default) created_at db.Column(db.DateTime, defaultdatetime.utcnow) ratings db.relationship(Rating, backrefuser, lazydynamic) favorites db.relationship(Favorite, backrefuser, lazydynamic) class Scenic(db.Model): __tablename__ scenic id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(100), nullableFalse) city db.Column(db.String(50), nullableFalse) category db.Column(db.String(50), nullableFalse) tags db.Column(db.String(255), default) description db.Column(db.Text) image_url db.Column(db.String(255), default) score db.Column(db.Numeric(3, 2), default0) price_level db.Column(db.SmallInteger, default1) ratings db.relationship(Rating, backrefscenic, lazydynamic) class Rating(db.Model): __tablename__ rating id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id), nullableFalse) scenic_id db.Column(db.Integer, db.ForeignKey(scenic.id), nullableFalse) score db.Column(db.Numeric(2, 1), nullableFalse) comment db.Column(db.String(500), default) created_at db.Column(db.DateTime, defaultdatetime.utcnow) __table_args__ ( db.UniqueConstraint(user_id, scenic_id, nameuk_user_scenic), ) class Favorite(db.Model): __tablename__ favorite id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id), nullableFalse) scenic_id db.Column(db.Integer, db.ForeignKey(scenic.id), nullableFalse) created_at db.Column(db.DateTime, defaultdatetime.utcnow)写完 model 之后在 Flask 入口文件里配置数据库连接串app.config[SQLALCHEMY_DATABASE_URI] ( mysqlpymysql://root:your_passwordlocalhost/travel_recommend?charsetutf8mb4 ) app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db.init_app(app)这部分的逻辑是ORM 中的Float会被映射成 MySQL 的 float但前面建表用的是DECIMAL所以字段类型要保持一致优先用db.Numeric。lazydynamic表示user.ratings返回的是一个查询对象而不是立即把所有数据加载到内存在后续做协同过滤时你能用user.ratings.all()按需取数避免一次查出全表。这里有个隐藏坑后面会重点讲从 MySQL 读出来的Numeric字段是Decimal对象直接塞进float()计算没问题但如果你拿它当列表下标就会报错需要养成转换习惯。3. 推荐引擎的三段式从基于内容到协同过滤再到混合降级3.1 基于内容推荐先做景点标签向量再用余弦相似度找同类基于内容推荐的思想很简单如果用户喜欢“故宫”而“天坛”也有“历史古迹、古建筑、世界遗产”这些标签那么两个景点的向量夹角就很小余弦相似度接近 1。这个算法不需要其他用户的行为数据所以新注册用户只要填了偏好标签就能拿到推荐适合做冷启动。我这里用最朴素的方式实现景点的tags字段存逗号分隔的标签像历史古迹,古建筑,摄影。先把所有出现的标签收集成一个词表再把每个景点转成 0/1 向量最后用 scikit-learn 算余弦相似度。import numpy as np from sklearn.metrics.pairwise import cosine_similarity def build_tag_vocab(scenic_list): 从所有景点 tags 中收集词表返回 {标签: 下标} vocab set() for scenic in scenic_list: for tag in scenic.tags.split(,): tag tag.strip() if tag: vocab.add(tag) return {tag: idx for idx, tag in enumerate(sorted(vocab))} def scenic_to_vector(scenic, vocab): 把单个景点转成词表长度的 0/1 向量 vec np.zeros(len(vocab)) for tag in scenic.tags.split(,): tag tag.strip() if tag in vocab: vec[vocab[tag]] 1.0 return vec def content_recommend(user_pref_tags, scenic_list, vocab, seen_ids, top_n10, min_sim0.1): pref_vec np.zeros(len(vocab)) for tag in user_pref_tags.split(,): tag tag.strip() if tag in vocab: pref_vec[vocab[tag]] 1.0 scored [] for scenic in scenic_list: if scenic.id in seen_ids: continue vec scenic_to_vector(scenic, vocab) sim float(cosine_similarity([pref_vec], [vec])[0][0]) if sim min_sim: scored.append((scenic, sim)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_n]这个函数的逻辑分四步先把用户偏好字符串也变成向量然后遍历所有景点跳过已经去过的再计算相似度最后按相似度排序取前 N 个。min_sim是一道闸门设得太低比如 0.01推荐结果会混入完全不相关的景点设得太高比如 0.5向量只有 0/1 时几乎拿不到结果。对 10 个标签以内的数据集我一般用 0.1。这里还有个小技巧景点相似度矩阵是可以提前算好缓存的毕竟景点的tags很少变。你可以在项目初始化时用“python构建邻接矩阵”的思路把景点两两相似度算好存在similar_scenic_cache表里推荐时直接查表不用每次请求都跑一遍向量化。3.2 基于用户的协同过滤用 Pearson 相关系数找“品味相近的人”协同过滤是旅游推荐系统毕设里最常写的算法也是答辩老师最爱问的部分。思路是找到和当前用户评分行为最像的一群用户再把他们喜欢而你没看过的景点推荐给你。假设我们有三个用户 A、B、CA 和 B 都给“故宫”打了 5 分给“天坛”打了 4 分那么 A 和 B 的口味高度相似。实现时先构建用户-景点评分矩阵再算用户之间的 Pearson 相关系数最后做加权平均。import pandas as pd from scipy.stats import pearsonr def build_rating_matrix(db_ratings): 把 rating 表记录转成 user_id x scenic_id 的透视表 df pd.DataFrame( [(r.user_id, r.scenic_id, float(r.score)) for r in db_ratings], columns[user_id, scenic_id, score] ) return df.pivot_table(indexuser_id, columnsscenic_id, valuesscore) def user_similarity(uid1, uid2, matrix, min_support2): 计算两个用户的 Pearson 相关系数共同评分项不足则返回 0 ratings_1 matrix.loc[uid1].dropna() ratings_2 matrix.loc[uid2].dropna() common ratings_1.index.intersection(ratings_2.index) if len(common) min_support: return 0.0 a ratings_1[common].values b ratings_2[common].values if a.std() 0 or b.std() 0: return 0.0 corr, _ pearsonr(a, b) if np.isnan(corr): return 0.0 return corr def predict_score(uid, scenic_id, matrix, k10): 预测用户对某个未评分景点的分数 sim_scores [] for other_uid in matrix.index: if other_uid uid: continue if pd.isna(matrix.loc[other_uid, scenic_id]): continue sim user_similarity(uid, other_uid, matrix) if sim 0: sim_scores.append((sim, other_uid)) sim_scores.sort(keylambda x: x[0], reverseTrue) neighbors sim_scores[:k] if not neighbors: return 0.0 numerator sum(sim * matrix.loc[other_uid, scenic_id] for sim, other_uid in neighbors) denominator sum(sim for sim, _ in neighbors) return numerator / denominator if denominator ! 0 else 0.0这里的核心参数是min_support和k。min_support2表示两个用户至少要有 2 个共同评分的景点否则相关系数没有统计意义如果设成 1任何两个用户只要共同评了一个景点就会被算成“高度相似”推荐结果会很乱。k10是最近邻数量取值太小容易受单个用户影响太大又会把低相似度用户也算进去10 到 20 对毕业设计的数据量是够用的。一个非常隐蔽的坑是当用户评分向量方差为 0 时比如一个人给所有景点都打了 5 分pearsonr会返回 NaN。代码里必须提前用a.std() 0做拦截否则这个 NaN 会在后续排序中把整组结果污染掉。这就是为什么在文章开头说推荐系统是个黑匣子很多边界条件不写看起来算法没问题实际运行就是出不来数。3.3 混合推荐与冷启动降级alpha 加权和热门兜底基于内容和协同过滤各有各的短板。新用户没有任何评分记录协同过滤矩阵里根本没有他的行老用户虽然行为丰富但如果景点标签写得粗糙内容推荐也会失灵。所以我一般做两层混合第一层是冷启动判断第二层是加权融合。def hybrid_recommend(user, top_n10, alpha0.6): # 冷启动用户还没有任何评分行为时直接用热门榜兜底 if user.ratings.count() 0: hot_scenics Scenic.query.order_by(Scenic.score.desc()).limit(top_n).all() return [(s, float(s.score)) for s in hot_scenics] seen_ids {r.scenic_id for r in user.ratings.all()} vocab build_tag_vocab(Scenic.query.all()) content_items content_recommend(user.preferences, Scenic.query.all(), vocab, seen_ids, top_ntop_n * 3) ratings Rating.query.all() matrix build_rating_matrix(ratings) cf_scores {} for scenic in Scenic.query.all(): if scenic.id in seen_ids: continue pred predict_score(user.id, scenic.id, matrix, k10) if pred 0: cf_scores[scenic.id] pred merged {} for scenic, sim in content_items: merged[scenic.id] alpha * sim for scenic_id, pred in cf_scores.items(): merged[scenic_id] merged.get(scenic_id, 0) (1 - alpha) * (pred / 5.0) ranked sorted(merged.items(), keylambda x: x[1], reverseTrue) return ranked[:top_n]混合公式里的alpha是内容推荐的权重我一般设 0.6。如果你的评分数据很密集协同过滤更可靠可以把alpha调到 0.4如果用户偏好标签填得很准确就调高到 0.7。要注意CF 预测分数是 1 到 5 分内容相似度是 0 到 1直接相加会把相似度淹没掉所以要先把 CF 分数除以 5 归一化。冷启动兜底还有一个更朴素的写法直接按scenic.score倒序再加上WHERE city user.city做本地化过滤。这个逻辑简单但答辩效果好老师问“新用户怎么推荐”你可以回答先用城市和热门标签过滤再等用户产生评分后切到混合算法。这就是推荐系统里的降级策略属于必须写进论文的点。4. 数据库增删改查与推荐结果联动从评分写入到缓存列表4.1 评分/收藏接口一个路由完成 insert 和 update推荐系统的数据源来自用户行为所以必须先把评分和收藏的接口写扎实。这里不需要复杂的前端只需要一个能处理 POST 请求的 Flask 路由用户在景点详情页打分前端fetch或表单提交过来。from flask import request, redirect, session, url_for app.route(/api/rate, methods[POST]) def api_rate(): user_id session.get(user_id) if not user_id: return redirect(url_for(login)) scenic_id int(request.form.get(scenic_id)) score float(request.form.get(score)) if score 1 or score 5: return 评分必须在 1 到 5 之间, 400 rating Rating.query.filter_by( user_iduser_id, scenic_idscenic_id ).first() if rating is None: rating Rating(user_iduser_id, scenic_idscenic_id, scorescore) db.session.add(rating) else: rating.score score # 重要评分变更后旧推荐结果已经失效必须清掉 RecCache.query.filter_by(user_iduser_id).delete() db.session.commit() return ok这段代码覆盖了数据库增删改查里的增和改先查有没有记录没有就 insert有就 update。要注意的是最后必须db.session.commit()否则 SQLAlchemy 只是把对象标记为待处理没有真正写库。我见过很多同学在 Flask 里忘了这行重启项目后数据全没了还以为是 MySQL 崩了。RecCache.query.filter_by(user_iduser_id).delete()这行是血泪经验换来的。如果不删缓存用户打了 5 分之后推荐列表还是旧的演示效果会非常尴尬。删除缓存和提交事务必须在同一个 session 里完成不能分两次 commit否则会出现“评分保存了但缓存没删”的中间态。4.2 推荐结果不现算建一张推荐缓存表按用户和失效时间读取推荐算法本身不慢慢的是每次请求都要重新构建评分矩阵、算所有用户相似度。如果 50 个用户 500 条评分单次计算可能几十毫秒感觉不出来但到答辩演示那天电脑开了十几个程序内存一吃紧页面转圈几秒钟老师就会皱眉。所以在第 2 章建了rec_cache表推荐结果算一次存一小时。from datetime import datetime, timedelta def get_recommendations(user_id, top_n10): now datetime.now() cached RecCache.query.filter( RecCache.user_id user_id, RecCache.expired_at now ).order_by(RecCache.recommend_score.desc()).limit(top_n).all() if len(cached) top_n: return cached user User.query.get(user_id) results hybrid_recommend(user, top_ntop_n * 2) # 先清掉旧的避免记录堆积 RecCache.query.filter_by(user_iduser_id).delete() for scenic_id, score in results: db.session.add(RecCache( user_iduser_id, scenic_idscenic_id, recommend_scorescore, expired_atnow timedelta(hours1) )) db.session.commit() return results这个函数的逻辑是“先查缓存缓存不足再重算”。expired_at是缓存失效时间我设 1 小时足够保证演示期间重复刷新页面不会卡。如果你希望评分后立刻生效不需要等过期就在评分接口里显式删除该用户的RecCache这比缩短过期时间更精准。这里有一个隐藏的并发问题如果两个请求同时发现缓存为空可能会重复计算并写入两遍。毕业设计并发量不大可以不用管但如果你想在论文里提一句“通过唯一索引避免重复缓存”就在rec_cache表加UNIQUE KEY (user_id, scenic_id)插入时就知道了。4.3 没有真实数据写一个模拟脚本生成 20 个景点和 500 条行为很多同学拿到项目压缩包后发现数据库是空的页面上一个景点都没有。这时候别急着搜索“爬虫抓取景点代码”爬 OTA 平台的数据涉及协议和版权问题毕设没必要冒这个险。正确做法是写一个脚本模拟生成一批有真实感的景点和用户行为数据。数据集不需要大20 个景点、20 个用户、500 条评分足够演示协同过滤了。import random from faker import Faker from sqlalchemy.orm import sessionmaker fake Faker(zh_CN) cities [北京, 上海, 杭州, 成都, 西安] categories [历史古迹, 自然风光, 主题乐园, 博物馆] def generate_scenics(session, count20): for i in range(count): scenic Scenic( namefake.city() fake.word(), cityrandom.choice(cities), categoryrandom.choice(categories), tags,.join(random.sample([摄影, 亲子, 美食, 历史, 徒步], k3)), descriptionfake.text(), image_urlf/static/img/scenic/{i 1}.jpg, scoreround(random.uniform(3.5, 5.0), 2), price_levelrandom.randint(1, 3) ) session.add(scenic) session.commit()在生成评分数据时有一个关键技巧不要完全随机分配要让每个用户集中评 10 个左右的景点并且有部分共同评分项。如果 500 条评分平均撒在 20 个用户和 20 个景点上矩阵会非常稀疏协同过滤算出的大多是 0你又会怀疑算法写错了。我一般会让每个用户先随机选 3 个偏好景点打 5 分再随机选 7 个打 3 到 4 分这样既稀疏又有规律。模拟脚本的另一个作用是给答辩准备截图。你可以在脚本里固定随机种子比如random.seed(42)这样每次生成的数据一致论文里截图能和演示系统对上不会出现“论文写的 A 景点系统里根本没有”这种尴尬。5. 避坑推荐系统毕设最常踩的 5 个翻车点和修复记录5.1 中文乱码从 MySQL 到页面层层都是问号现象启动项目后景点名称和管理后台里全是“???”页面显示乱码。原因MySQL 连接串没有指定字符集或者建表时用了默认的latin1。Python 字符串本身是 Unicode但写入数据库时编码转换失败中文字符就变成问号。解决三层都改。第一建库时指定DEFAULT CHARACTER SET utf8mb4对应第 2 章的建库语句第二Flask 配置里的 SQLAlchemy 连接串必须加?charsetutf8mb4例如mysqlpymysql://root:123456localhost/travel_recommend?charsetutf8mb4第三HTML 模板头部加meta charsetutf-8。这三层缺一个都可能出现乱码。排查时先到 MySQL 命令行执行SHOW CREATE TABLE scenic看表的默认字符集是不是utf8mb4这一步能快速定位问题源头。5.2 协同过滤返回空列表相似度矩阵全是 0现象某个用户明明有评分记录但点击“为我推荐”之后列表是空的后台打印相似度矩阵全是 NaN 或 0。原因评分数据太稀疏每个用户只评了两三个景点和其他用户没有共同评分项或者用户对所有景点打了同一个分数导致向量标准差为 0Pearson 相关系数无定义。解决在user_similarity里设置min_support共同评分项少于 2 直接返回 0对标准差为 0 的向量也返回 0。同时在hybrid_recommend里加降级判断如果 CF 结果为空就用基于内容的结果或热门榜补上。模拟数据脚本要保证每个用户至少 10 条评分这样协同过滤才有意义。5.3 改了评分推荐结果没变化缓存表没有失效现象用户把某个景点的评分从 1 分改成 5 分刷新推荐页列表顺序还是老样子。原因rec_cache表还保留着旧的推荐结果查询时没有判断过期时间直接返回了缓存。解决在评分接口中同步删掉该用户的缓存记录RecCache.query.filter_by(user_iduser_id).delete()再db.session.commit()。如果推荐结果还涉及最近邻用户那其他用户的缓存也可能受影响但毕设数据量小简单粗暴地只清当前用户就够。另外缓存查询条件必须带上expired_at now否则即使手动清了时间过期的旧纪录还会被查出来。5.4 换一台电脑跑不起来路径、Python 版本、依赖全部要固定现象在自己电脑上一切正常把项目打包发给同学对方运行时报ModuleNotFoundError或FileNotFoundError数据库也连不上。原因项目里用了绝对路径读数据文件比如C:/Users/xxx/Desktop/travel/init.sql或者本地 Python 是 3.8对方是 3.12依赖版本冲突或者 MySQL 密码不同连接串还写死在代码里。解决所有文件读取统一用Path(__file__).resolve().parent.parent拼出项目根目录数据库连接串写到config.py让对方按自己的环境改密码提供requirements.txt里面固定Flask2.3.3、SQLAlchemy2.0.23、pymysql1.1.0、scikit-learn1.3.2这些版本。提交前最好删掉本地__pycache__和缓存文件打包成 zip 时不要夹带自己的绝对路径。你拿到那个“智能旅游推荐系统.zip”时先看有没有requirements.txt没有就先补一个这能省下后面一整天的烦恼。5.5 第三方图片接口挂了景区图片全变裂图现象项目跑了两周后景点详情页图片突然全部变成裂图浏览器控制台报 403 或超时。原因为了省事image_url直接填了外站的图片链接。外部网站设置了防盗链或者接口地址已经失效数据库存的是死链。解决在初始化数据脚本里就把图片下载到本地static/img/scenic/目录数据库存相对路径如/static/img/scenic/1.jpg。没有现成图片时用一个纯色占位图 URL 兜底保证页面布局不塌。最后在景点管理功能里加上传接口把图片存入本地这样答辩时不会因为现场没网而翻车。这个问题属于典型的“演示当天才后悔”的事提前做好本地化就踏实了。6. 进阶给每条推荐生成一句“推荐理由”让答辩演示更可信第一版推荐系统做出来后页面就是一张景点卡片列表看不出和普通列表页有什么区别。答辩老师问“你为什么给我推荐这个景点”我只能回答“因为算法算出来相似度高”非常苍白。后来我给推荐引擎加了一个reason字段每条推荐结果都带一句话比如“因为你收藏过故宫而天坛同属历史古迹类标签有历史、摄影”。这个改动不大但演示效果立刻不一样老师会觉得系统真的在“思考”。实现思路很简单基于内容的推荐在用户已经打过高分的景点里找一个和当前推荐景点相似度最高的把这层关系写进理由。基于协同过滤的推荐把参与加权的最近邻数量写进去例如“有 8 位和您品味相近的用户喜欢这里”。代码如下def build_reason(user, scenic, matrix, top_similar_scenicNone): if top_similar_scenic is not None: tags scenic.tags.replace(,, 、) return ( f因为你喜欢「{top_similar_scenic.name}」 f而「{scenic.name}」同属{scenic.category} f标签上有{tags}的相似之处 ) sim_users [] for other_uid in matrix.index: if other_uid user.id: continue if not pd.isna(matrix.loc[other_uid, scenic.id]): sim user_similarity(user.id, other_uid, matrix) if sim 0: sim_users.append(other_uid) return f有{len(sim_users)}位和你品味相近的用户也喜欢「{scenic.name}」把这个reason存进rec_cache表后前端模板直接渲染。要注意的是理由不是随便写的它必须来自真实计算过程。比如“同属”这个关系就对应你在内容推荐里找到的相似景点如果你没有算过相似度硬写一句“系统智能推荐”会让老师追问更深。所以我习惯把这层关系反查出来宁可多写几行代码也要让每句话有出处。做完这一步整个项目的完整链路就闭环了用户注册登录 - 浏览景点 - 产生评分行为 - 数据库写入 - 推荐引擎读取 - 混合算法计算 - 结果连同理由写回缓存 - 页面展示。这也是我在多次毕业设计项目里用下来最顺手的骨架。希望你照着这条路径复现时能少踩几个坑把更多的精力放在把推荐理由写得更有人味上。希望帮到你。本文还有配套的精品资源点击获取