基于Flask与MySQL的校园失物招领平台:中文匹配算法实战
1. 为什么说失物招领平台是“学生信息管理系统”的天然延伸先交代下背景。这个标题看起来是“pythonflaskMySQL的学生信息管理系统设计”但真正落地的项目是一个校园失物招领智能匹配平台。我这段时间正好在帮学校信息中心做一个轻量化的失物招领系统做完之后最大的感触是失物招领平台本质上就是一个带有实时匹配逻辑的学生管理系统它管理的不只是学生档案而是学生产生的动态数据——谁丢了东西、谁捡到了东西、如何把两边对上。先说清楚这个项目的定位。整个系统基于Python的Flask框架做Web端用MySQL做数据存储核心功能有三块第一学生通过网页发布失物信息和招领信息第二系统通过中文关键词相似度匹配算法把“遗失的物品”和“招领的物品”自动关联第三将匹配结果按相似度排序推荐展示给用户。整个平台可以本地部署运行不需要复杂的服务器配置适合作为校园信息化的小型落地项目也非常适合学生练手。为什么说它是学生信息管理系统的延伸因为要完成精准匹配光有物品名称是不够的系统必须要知道发布者的身份、联系方式、丢失/捡到的时间地点这些信息和传统的“学生信息表”强相关。我实际做的时候设计的第一张表不是物品表而是用户表——它承接着整个匹配链路的源头。你可以理解为失物招领平台是给学生信息模板加上了一个“物品维度”和“匹配维度”让静态的数据活了起来。这篇内容我打算把整个项目的落地过程拆开讲从数据库设计到Flask路由实现从匹配算法到前端展示再到部署时最容易踩的坑。代码都是可以直接复制跑的适合想用Flask做完整项目的开发者做参考也适合学校信息中心想快速搭一套内部工具的运维人员抄作业。2. 数据库设计不是只建两张表那么简单2.1 核心表结构与字段说明很多人做这类系统上来就建两张表——失物表和招领表然后对着表写增删改查。真的做完你会发现问题很多匹配记录存在哪用户的多次发布怎么关联管理员怎么审核无效信息我最终的设计用了四张表结构如下用户表users存储学生的基本信息。这里我建议不要用学号做主键因为可能存在临时用户或非学生人员用自增ID更稳妥。CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) UNIQUE COMMENT 学号允许为空, username VARCHAR(50) NOT NULL COMMENT 昵称, phone VARCHAR(20) NOT NULL COMMENT 联系电话, wechat VARCHAR(50) COMMENT 微信号选填, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );失物信息表lost_items记录用户发布的寻物启事。CREATE TABLE lost_items ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(100) NOT NULL COMMENT 物品名称如黑色钱包, category VARCHAR(30) COMMENT 分类如卡证/数码/衣物, description TEXT COMMENT 详细描述补充品牌、颜色、特征, location VARCHAR(100) COMMENT 丢失地点, lost_date DATE COMMENT 丢失日期, status TINYINT DEFAULT 1 COMMENT 1寻找中 0已找到, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_category (category), CONSTRAINT fk_lost_user FOREIGN KEY (user_id) REFERENCES users(id) );招领信息表found_items记录捡到物品的学生发布的信息。字段基本对称但加了存放位置字段——捡到东西的人通常会把物品交给宿管或失物招领中心这个信息非常关键。CREATE TABLE found_items ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(100) NOT NULL, category VARCHAR(30), description TEXT, location VARCHAR(100) COMMENT 捡到地点或拾获地点, found_date DATE COMMENT 捡到日期, storage_location VARCHAR(100) COMMENT 当前存放位置如2号宿舍楼值班室, status TINYINT DEFAULT 1 COMMENT 1待认领 0已认领, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_category (category), CONSTRAINT fk_found_user FOREIGN KEY (user_id) REFERENCES users(id) );匹配记录表match_records这是整个系统实现“智能匹配”的关键。每次系统计算匹配结果后存入这张表前端直接读取推荐列表不需要每次请求都重新跑一遍算法性能压力小很多。CREATE TABLE match_records ( id INT AUTO_INCREMENT PRIMARY KEY, lost_item_id INT NOT NULL, found_item_id INT NOT NULL, similarity_score DECIMAL(5,4) COMMENT 余弦相似度得分0~1, matched_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0 COMMENT 用户是否已查看用于已读标记, KEY idx_lost (lost_item_id), KEY idx_found (found_item_id) );2.2 字段设计的几个关键心得第一个心得是标题字段必须单独拆出来不能只靠描述文本做匹配。初期我试过把title和description拼接在一起做分词结果发现描述里往往带着大量冗余词比如“本人于图书馆三楼丢失一个深蓝色联想笔记本电脑里面有很多重要论文如有捡到请联系”。这句话核心其实是“联想笔记本电脑”但分词后“图书馆”“论文”这些词会干扰相似度计算。把标题单独拿出来做高权重匹配描述只做附属加分项效果会明显更好。第二个心得是状态字段一定要有而且要在列表查询时过滤掉已完成记录。很多类似的系统上线后被人吐槽“匹配到的东西早就领走了”就是因为只匹配不过滤状态。我在写SQL查询时无论在列表还是匹配推荐里都强制带status 1条件只让“正在寻找中”和“待认领”的记录参与推荐。第三个心得是不要用外键约束关联得太死。MySQL外键在数据量大了以后会拖慢写入性能而且一旦用户被删除历史匹配记录会级联出问题。我的做法是表结构里保留user_id字段用于关联查询但不建立实质的物理外键用应用层逻辑保证数据一致性。这样在本地调试和后续扩展时候都更灵活。2.3 初始化数据的正确姿势系统第一次启动不能直接跑空库需要预置测试数据来验证匹配算法效果。我在项目里加了个init_db.py脚本一次性完成建库建表和插入示例数据。注意MySQL的建库语句要显式指定字符集否则中文会乱码CREATE DATABASE IF NOT EXISTS lost_found_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;插入示例数据时故意设置了几个“容易混淆”的案例。比如一条失物标题是“黑色钱包”的招领标题写成“黑色钱夹”用来测试分词和同义词是否有效再比如一条“苹果手机iPhone13蓝色”与“iPhone13蓝色手机壳”用来测试算法能否区分物品与配件。这些测试用例在开发阶段极其有用能直接暴露匹配算法的短板。3. Flask后端路由设计每一条接口解决一个真实操作3.1 用户注册登录Flask项目的目录我习惯按功能模块拆成蓝图Blueprint而不是把所有路由堆在一个app.py里。这个项目拆了四个蓝图auth、lost、found、match。用户注册的逻辑很简单接收用户名、电话、微信写入users表。学生用户不一定有学号我设计学号为选填项——很多学校的学生并不愿意把自己的学号填进这种轻量级系统强要求只会劝退用户。# blueprints/auth.py from flask import Blueprint, request, jsonify from db import get_db auth_bp Blueprint(auth, __name__) auth_bp.route(/api/register, methods[POST]) def register(): data request.get_json() username data.get(username, ).strip() phone data.get(phone, ).strip() wechat data.get(wechat, ).strip() student_id data.get(student_id, ).strip() or None if not username or not phone: return jsonify({code: 400, msg: 用户名和电话必填}), 400 db get_db() cursor db.cursor() cursor.execute( INSERT INTO users (student_id, username, phone, wechat) VALUES (%s, %s, %s, %s), (student_id, username, phone, wechat) ) db.commit() return jsonify({code: 0, msg: ok, data: {user_id: cursor.lastrowid}}), 201这里有个容易被新手忽略的坑cursor.lastrowid只有在执行完INSERT之后才能拿到必须在commit之前读取commit之后依然可以获取到但如果你的数据库连接是开启自动提交的这个值就不一定可靠。稳妥的做法是显式控制事务。但真正做下来会发现失物招领平台的同学大多是偶尔用一次如果每次都要手机验证码注册就太重了。我最后没做登录状态保持让用户维护一个本地id相当于匿名发布联系方式留底这在实际校园场景中更实用。3.2 发布失物与招领信息发布接口是系统的核心写入接口。前端表单提交到后端时要做两层验证第一层是必填字段不满足直接返回400第二层是长度限制title超过100字要截断或者报错description也不允许无限制输入。lost_bp.route(/api/lost, methods[POST]) def create_lost(): data request.form user_id data.get(user_id) title data.get(title, ).strip() category data.get(category, ).strip() description data.get(description, ).strip() if not user_id or not title: return jsonify({code: 400, msg: 缺少必填参数}), 400 db get_db() cursor db.cursor() cursor.execute( INSERT INTO lost_items (user_id, title, category, description, location, lost_date) VALUES (%s, %s, %s, %s, %s, %s), (user_id, title, category, description, data.get(location, ), data.get(lost_date)) ) db.commit() return jsonify({code: 0, msg: ok, data: {id: cursor.lastrowid}}), 201招领信息接口结构完全一致替换表名和字段就行。注意一点Flask接收表单数据默认按application/x-www-form-urlencoded解析如果前端用Axios发JSON格式服务端要用request.get_json()而不是request.form两种混着用会出现明明传了参数后端却拿到None的情况。3.3 列表查询与分页过滤列表查询是前端页面的主力接口。我支持按分类过滤、按关键词搜索、按时间排序并且全部做分页处理。lost_bp.route(/api/lost/list, methods[GET]) def list_lost(): page int(request.args.get(page, 1)) size int(request.args.get(size, 10)) category request.args.get(category, ) keyword request.args.get(keyword, ) offset (page - 1) * size sql SELECT * FROM lost_items WHERE status 1 params [] if category: sql AND category %s params.append(category) if keyword: sql AND (title LIKE %%%s%% OR description LIKE %%%s%%) params.extend([keyword, keyword]) sql ORDER BY created_at DESC LIMIT %s OFFSET %s params.extend([size, offset]) db get_db() cursor db.cursor() cursor.execute(sql, params) items cursor.fetchall() # 查询总数用于分页 count_sql SELECT COUNT(*) FROM lost_items WHERE status 1 # ... 省略同样的过滤逻辑 return jsonify({code: 0, data: {list: items, total: total}})不要用字符串拼接的方式去拼SQL即使内部系统也要用参数化查询。Flask的%占位符和MySQL的LIKE语句在一起很容易写错我在初次写的时候踩过坑%%转义之后再用%做通配符顺序错了就会变成查不到任何数据。上面代码里%%%s%%这个写法是实测可行的可以直接用。3.4 文件上传与路径处理发布失物招领时常常需要上传物品照片这也是本地部署时最容易出问题的环节。Flask处理文件上传不复杂但Windows和Linux的路径处理逻辑完全不同。import os from flask import current_app ALLOWED_EXTENSIONS {png, jpg, jpeg, webp} def allowed_file(filename): return . in filename and filename.rsplit(., 1)[1].lower() in ALLOWED_EXTENSIONS lost_bp.route(/api/lost/upload, methods[POST]) def upload_image(): file request.files.get(image) if not file or not allowed_file(file.filename): return jsonify({code: 400, msg: 不支持的文件类型}), 400 filename str(uuid.uuid4()) . file.filename.rsplit(., 1)[1].lower() upload_dir current_app.config[UPLOAD_FOLDER] os.makedirs(upload_dir, exist_okTrue) # 目录不存在时自动创建 file.save(os.path.join(upload_dir, filename)) return jsonify({code: 0, data: {url: /static/uploads/ filename}})这里有个关键点保存到MySQL数据库里的路径必须是Web可访问的URL路径而不是服务器的绝对路径。很多人在Windows本地测试没问题部署到Linux服务器上图片全部404就是因为存的是Windows风格路径或者存的是/home/user/project/uploads/xxx.jpg这种物理路径前端根本拼接不出来。正确做法是像我上面这样统一存/static/uploads/文件名前端直接在img标签的src里赋值即可。此外如果用的是Windows开发还建议在Flask的config里设置一个兼容性的上传目录app.config[UPLOAD_FOLDER] os.path.join( os.path.dirname(os.path.abspath(__file__)), static, uploads )os.path.join会自动处理不同操作系统下的路径分隔符比手写/或\都要稳。4. 中文关键词相似度匹配核心算法的完整实现4.1 为什么不能用英文分词工具直接处理中文开始之前先说我踩过的一个大坑。一开始我图省事直接用Python自带的split()按空格分词做相似度计算结果完全不可用。原因很简单中文句子没有天然的空格分隔一句话是一整串连续字符“黑色钱包在2号教学楼丢失”拆出来后是一整块词与词之间没有任何边界信号。后来我换成了jieba分词库情况才正常起来。jieba做中文分词是当前最常见的方案默认使用前缀词典实现高效的词图扫描再配合隐马尔可夫模型处理未登录词。对失物招领这种场景来说jieba对日常物品词汇的切分准确度已经够用了。安装方式pip install jieba示例import jieba print(jieba.lcut(黑色钱包在图书馆三楼丢失)) # 输出: [黑色, 钱包, 在, 图书馆, 三楼, 丢失]4.2 相似度计算的完整流程整个匹配算法的链路分为五步第一步用jieba对失物标题和招领标题分别做切分同时把描述文本也切分但权重降低。 第二步去除停用词。这里的停用词不只是“的、了、吗”这些虚词还包括“本人、丢失、捡到、联系、谢谢、重谢”这类在失物招领场景中无区分度的词。停用词表可以手动维护也可以基于统计词频来自动过滤。 第三步为每一条失物/招领信息构建词频向量。 第四步计算余弦相似度。 第五步设定相似度阈值超过阈值的记录写入匹配表。核心代码import jieba import math from collections import Counter # 停用词表可按需求扩充 STOP_WORDS { 本人, 的, 了, 在, 于, 是, 和, 与, 跟, 丢失, 遗失, 捡到, 拾获, 如有, 请联系, 谢谢, 看见, 希望, 能够, 可以, 一个, 这个, 那个 } def tokenize(text): 分词并去除停用词 words jieba.lcut(text.lower()) return [w.strip() for w in words if w.strip() and w.strip() not in STOP_WORDS] def cosine_similarity(text1, text2): 计算两个文本的余弦相似度 words1 tokenize(text1) words2 tokenize(text2) if not words1 or not words2: return 0.0 bow1 Counter(words1) bow2 Counter(words2) # 取所有词的并集 all_words set(bow1.keys()) | set(bow2.keys()) vec1 [bow1.get(word, 0) for word in all_words] vec2 [bow2.get(word, 0) for word in all_words] dot_product sum(a * b for a, b in zip(vec1, vec2)) norm1 math.sqrt(sum(a * a for a in vec1)) norm2 math.sqrt(sum(b * b for b in vec2)) if norm1 0 or norm2 0: return 0.0 return dot_product / (norm1 * norm2)用这个函数实测一下之前提到的案例失物标题“黑色钱包”招领标题“黑色钱夹”得分约为0.5——因为“钱包”和“钱夹”没有分到同一个词。如果我在词典里加入“钱夹”作为“钱包”的同义词后再匹配得分会更高。失物标题“丢失苹果手机iPhone13蓝色”与招领“捡到iPhone13蓝色手机”分词后共同词有“iphone13、蓝色”得分约0.5。失物“笔记本电脑联想拯救者”与招领“联想充电器”共同词只有“联想”得分不到0.2系统不会推荐。单纯余弦相似度能解决“同一个词出现”的情况但解决不了“同义词不同写法”的情况比如“钱夹”和“钱包”、“U盘”和“优盘”。要提升这个效果可以引入基于词向量的相似度插件或者更简单一点维护一个同义词扩展表在做分词匹配时把词替换成规范词再计算。4.3 同义词扩展与加权策略我在实际项目中加了一个很轻量的同义词映射字典SYNONYM_MAP { 钱夹: 钱包, 皮夹: 钱包, 优盘: u盘, U盘: u盘, USB: u盘, 移动硬盘: 硬盘, 手机: 手机, iPhone: 苹果手机, 苹果13: iphone13, 小米: 小米手机, 笔记本: 笔记本电脑, 笔电: 笔记本电脑, 校园卡: 一卡通, 饭卡: 一卡通, 水杯: 杯子, 保温杯: 杯子 }在tokenize出词之后把映射表里的词统一转换成标准词再做向量计算。这样做让“饭卡”和“校园卡”之间也能产生正匹配体验提升非常明显。加权重的话标题中的词权重系数设为2.0描述中的词权重系数设为1.0。具体的实现做法是分别对标题和描述做分词构建词汇表时同一个词如果是出现在标题里的计数时乘以2再放入向量。这部分代码稍微有点绕但效果立竿见影——只写“黑色钱包”标题的失物不会被一篇洋洋洒洒描述里提到“钱包”的招领信息挤到前面。最终的匹配流程落地在定时任务或发布时触发def run_matching(lost_item_idNone): 对新发布的失物信息执行全量匹配也可以定时全量更新 db get_db() cursor db.cursor() sql SELECT l.id, l.title, l.description, f.id, f.title, f.description FROM lost_items l, found_items f WHERE l.status 1 AND f.status 1 if lost_item_id: sql AND l.id %s cursor.execute(sql % 锁定的id参数, (lost_item_id,)) # 实际项目用参数化更好 else: cursor.execute(sql) # 注意纯字符串拼接不安全正式代码用参数化 results cursor.fetchall() for lost_id, lost_title, lost_desc, found_id, found_title, found_desc in results: title_score cosine_similarity(lost_title, found_title) desc_score cosine_similarity(lost_desc or , found_desc or ) total_score title_score * 0.8 desc_score * 0.2 if total_score 0.35: # 阈值可调默认为0.35 cursor.execute( INSERT INTO match_records (lost_item_id, found_item_id, similarity_score) VALUES (%s, %s, %s), (lost_id, found_id, round(total_score, 4)) ) db.commit()4.4 阈值设定的取舍逻辑阈值这个参数是算法里最需要实际经验的部分。定太高会导致匹配结果太少用户发了个失物两天都没有推荐定太低会导致大量低质量匹配比如手机和手机壳都被推荐出来用户看到推荐列表会觉得“这个系统根本不知道我想找什么”。我在开发环境里准备了100条测试数据分别用0.2、0.3、0.35、0.4、0.5做了五轮测试最终把默认阈值定在0.35。这个值的含义是标题和描述的加权余弦相似度至少达到0.35才进入推荐列表。如果后续系统上线积累了真实数据建议基于准确率和召回率再做调优。同时阈值要开放成配置项放在config.py里class Config: MATCH_THRESHOLD 0.35 MATCH_TITLE_WEIGHT 0.8 MATCH_DESC_WEIGHT 0.2 MATCH_TOP_LIMIT 20 # 每个失物最多匹配条数不要把算法参数写死在业务代码里否则调参的时候要改一遍功能逻辑很容易改出错。5. 前端页面与推荐展示匹配结果如何友好地呈现出来5.1 页面整体结构这个项目的前端我没有采用前后端分离方案直接使用Flask的Jinja2模板渲染加上少量原生JavaScript和CSS。对于校园内部的轻量化工具来说前后端分离反而增加部署复杂度一个nginx跑起来还要配跨域用模板渲染能省掉整个Node构建链路。页面拆成三个主要界面发布页表单字段包括物品名称、分类、描述、地点、日期、联系方式。发布前用JS做前端校验减少无效请求。广场页分tab展示失物列表和招领列表。每张卡片显示标题、描述摘要、发布日期、联系方式。支持分类筛选和关键词搜索。推荐页展示匹配结果。每条结果卡片显示“您发布的失物XXX”和“系统匹配招领XXX”中间标注相似度百分比。5.2 推荐列表的渲染逻辑推荐页面的后端路由从match_records表读取数据JOIN两边的物品表和用户表按相似度降序排列match_bp.route(/api/recommendations, methods[GET]) def recommendations(): user_id request.args.get(user_id) db get_db() cursor db.cursor() cursor.execute( SELECT l.id AS lost_id, l.title AS lost_title, l.description AS lost_desc, f.id AS found_id, f.title AS found_title, f.description AS found_desc, f.storage_location, u.username, u.phone, u.wechat, m.similarity_score, m.matched_at FROM match_records m JOIN lost_items l ON m.lost_item_id l.id JOIN found_items f ON m.found_item_id f.id JOIN users u ON f.user_id u.id WHERE l.user_id %s AND l.status 1 AND f.status 1 ORDER BY m.similarity_score DESC LIMIT 20 , (user_id,)) matches cursor.fetchall() return jsonify({code: 0, data: matches})前端渲染时把相似度分数转成百分比展示80%以上的用绿色高亮60%-80%用黄色60%以下用灰色。这样即使用户不理解“余弦相似度”是什么也能直观感知推荐结果的可靠程度。一个体验细节已经联系过对方的匹配记录要允许用户标记为“已处理”。我在匹配记录表里加了is_read字段前端每条卡片上有“标记已联系”按钮点击后更新字段状态下次刷新就不再置顶显示。这个功能很小但实际用起来会发现是用户判断“系统有没有用”的关键点。5.3 无效信息过滤的前端配合热搜词里有“无效信息过滤与匹配精度优化”这一点前端也能做一部分工作。我在发布表单里对“标题和描述重复度”做了实时检测。具体做法是当title字段的内容和description字段的前20个字符完全一致时弹出提示“请勿将描述填写为与标题完全一致的内容”因为这类信息对匹配算法是纯噪音。同样如果标题只填了“求助”、“寻物启事”这类无信息量的词前端直接拦截提交要求必须包含至少一个可能代表物品的名词。这些词表和后端停用词表保持一致确保前后端判断规则统一。6. 本地部署与调试从零跑通整个系统6.1 环境准备与依赖安装项目运行环境是Python 3.8MySQL 5.7或8.0均可。如果机器上没有安装Python和MySQL可以参考相关安装教程先把基础环境配好这里不再赘述。依赖文件requirements.txtflask2.2.5 flask-cors3.0.10 pymysql1.0.2 jieba0.42.1 cryptography39.0.2注意cryptography这个库MySQL 8.0默认认证插件是caching_sha2_passwordPyMySQL连接时会报“ssl connection error”或者“cryptography is required”之类的错误。出现这个报错不要慌先装cryptography库再重试大概率能解决。6.2 数据库连接配置与编码问题项目根目录建一个db.py统一管理数据库连接import pymysql from flask import current_app def get_db(): return pymysql.connect( hostcurrent_app.config[DB_HOST], portcurrent_app.config[DB_PORT], usercurrent_app.config[DB_USER], passwordcurrent_app.config[DB_PASSWORD], databasecurrent_app.config[DB_NAME], charsetutf8mb4, cursorclasspymysql.cursors.DictCursor )charsetutf8mb4加上建库时的utf8mb4双保险能保证中文不乱码。我在调试时遇到过这样的情况数据库建库用了utf8mb4但连接时没指定charset导致页面数据正常但接口返回乱码。排查了很久才发现是连接参数缺失这个细节写在这里大家遇到乱码问题先检查这两处。6.3 Linux服务器部署的路径坑部署到Linux服务器上时最常见的问题是上传路径不存在。Windows下Flask的os.makedirs(upload_dir, exist_okTrue)能自动创建多级目录但Linux下如果static/uploads目录不存在Nginx直接返回404而且Flask静态文件服务默认不提供uploads子目录的映射。解决办法有两个任选其一第一种在app.py里显式注册静态资源路由from flask import send_from_directory app.route(/static/uploads/path:filename) def uploaded_file(filename): return send_from_directory(current_app.config[UPLOAD_FOLDER], filename)第二种把上传目录放在项目外部然后在Nginx里单独配置一个location映射。这种方式适合生产环境让Web服务器和Nginx的职责分离。6.4 匹配算法的验证方法系统部署完成后我建议按下面的测试用例跑一遍冒烟测试确认功能链路完整测试步骤操作预期结果1注册账号A发布失物“黑色长款钱包内有校园卡”返回成功状态为寻找中2注册账号B发布招领“黑色钱包在图书馆捡到”返回成功状态为待认领3触发匹配或等待定时任务生成相似度记录4查看账号A的推荐页显示B发布的招领信息相似度较高5账号B将招领标记为已认领账号A推荐列表不再显示该信息6发布无效信息如只写“丢失东西”被前端或后端拦截无法提交如果第4步没有产生推荐优先检查匹配阈值是否过高、停用词表是否把核心名词过滤掉、分词结果是否为空。如果第6步拦截失败检查前后端的校验表是否一致。最后再分享一个调参技巧匹配阈值不要只靠代码调试把测试数据的匹配日志输出成表格人工看一遍推荐内容的准确率。我推荐你每次调整算法后都保留一组固定的测试数据做回归对比不然改一个同义词扩展表可能把原本正确的匹配结果搞坏。这个系统扩展空间其实很大后续要做的话可以考虑反向匹配——当用户发布招领信息时自动检索已有的失物信息也可以接入企业微信通知匹配成功后自动推送给双方。把基础的算法和表结构设计稳了这两个功能都只是锦上添花的事。