☰
Flask+SQLite智能通讯录Python源码:拼音检索与判重
2026/10/29 5:03:11 网站建设 项目流程

简介:这份毕业设计资料包聚焦于用Python与人工智能技术实现智能联系人管理,面向需要完成课程设计或毕业设计的高校学生,以及希望了解AI落地的开发者。项目围绕联系人信息的添加、编辑、删除、检索与分类展开,并尝试结合自然语言处理和机器学习做查询解析与行为预测,帮助读者理解如何把智能化能力嵌入日常管理工具。压缩包共50个文件,约141.21MB,包含py源码、ui界面文件、pyc缓存、html图表页面、xml与xlsx数据、txt/dat配置、doc/pdf说明文档以及exe可执行程序等,兼顾代码研读、界面参考和免环境运行。已有614人学习,配套开发文档说明设计思路、架构与测试案例,源代码展示模块划分和业务流程,可执行程序便于快速体验,图形素材与图表页面也为二次开发和文档撰写提供参照。

1. 一个通讯录为什么要长"脑子":先看清这套 Python 源码能干什么

有个场景你可能天天遇到:手机里存着三个"张伟",想找那个做技术的,只能一个个点开看备注;同事报了个名字,你在搜索框里敲拼音首字母,结果一条都搜不出来。这套毕业设计源码做的就是这件事——用 Python 写一个联系人管理系统,录入时自动补全全拼和首字母、把号码归一化、按相似度提醒"这人可能已经存过了"、再按邮箱域名和号码段自动打标签。它不是一个只会增删改查的通讯录,而是在 CRUD 之上叠了一层轻量人工智能处理逻辑,同时配了整套开发文档说明,需求、数据库设计、接口约定都写清楚了,拿来当毕业设计底座改,比从零搭省太多事。

这套源码适合两类人:一类是要交毕业设计、需要一份结构完整又能讲清技术点的 Python 项目;另一类是想把 Flask 全链路走一遍的新手,从路由到模型到页面联调,一条线拉通。下面按"能干什么 → 怎么跑 → 怎么改"的顺序往下拆。

2. 源码地图:Flask + SQLite 这套轻组合是怎么分工的

拿到一份 Python 毕设源码,第一件事不是急着pip install,而是先看目录。目录结构决定你后面改代码改得顺不顺,也决定答辩时你能不能把"我这个项目分了几个层次"讲明白。这个项目的分层方式比较典型:入口文件薄、模型层单一职责、业务逻辑全塞进 services,页面模板只负责渲染,前端通过 JSON 接口和后台对话。

2.1 技术选型:为什么是 Flask + SQLite,而不是 Django + MySQL

这个选择不是偷懒。毕设场景有两个硬约束:一是评阅老师要在自己电脑上跑起来,二是你得在有限时间里把注意力放在"智能"那部分而不是环境配置上。Django 自带 admin、ORM、迁移一整套,功能全但概念多,一个刚上手的人光理解 app 注册和 settings 分层就要花掉两天;Flask 的核心只有一个Flask(__name__),路由、蓝图、扩展全靠自己加,反而每一行你都说得清来历。

数据库选 SQLite 更直接:整个库就是一个.db文件,拷进 U 盘就能带走,不需要装服务、配账号密码、开端口。数据量在万级以下时,SQLite 的读写性能完全够用,本地查询基本感觉不到延迟。代价是并发写入弱、不支持复杂窗口函数,但通讯录这种"读多写少、单人使用"的场景压根碰不到这个上限。

至于"人工智能"那部分,用的是pypinyin做拼音转换、jieba做中文分词、difflib做字符串相似度计算,都是纯 Python、纯本地、无需联网的库。这一点在答辩时最好主动说明:不是所有叫 AI 的功能都得跑大模型,规则引擎加经典 NLP 算法在特定任务上准确率更高、可解释性更强、响应更快。装一个几十 GB 的模型去判断"这两个名字像不像",属于用大炮打蚊子。

2.2 目录结构逐层拆:九个位置各自守着什么

下面是我按源码整理出来的骨架,实际项目的文件名可能略有差异,但职责划分基本一致:

contact_ai/ ├── app.py # 入口:创建 app、注册路由、启动服务 ├── config.py # 配置:密钥、数据库连接串、分页大小 ├── init_db.py # 建表脚本 + 演示数据灌入 ├── requirements.txt # 依赖清单 ├── models/ │ └── contact.py # 唯一的数据模型 + 拼音回填方法 ├── services/ │ ├── search_service.py # 三级检索兜底 │ ├── dedupe_service.py # 号码归一化 + 相似度判重 │ └── tag_service.py # 规则引擎自动打标签 ├── templates/ │ ├── base.html # 公共骨架 │ ├── index.html # 列表页 + 搜索框 │ └── edit.html # 新增/编辑表单 ├── static/ │ ├── css/style.css │ └── js/contact.js # 前端调接口、弹重复提醒 ├── data/ │ └── contacts.db # SQLite 数据文件(首次运行生成) ├── docs/ │ ├── 需求说明.md │ ├── 数据库设计.md │ └── 接口说明.md └── tests/ └── test_search.py # 检索用例,答辩演示"我写了测试"

这个结构的核心思想是:app.py只做装配不做业务,models只管数据长什么样,services才是真正的逻辑层。为什么要把检索、判重、打标签拆成三个独立文件而不是全写在路由里?因为这三块是最容易被老师追问的部分,独立成文件后你能单测、能替换、能单独演示。如果全塞在app.py的两百行路由函数里,改一个阈值要翻半天,别人接手也看不懂。

docs目录是这个项目比较值钱的地方。很多人交毕设只有代码没有文档,答辩时被问"你的数据库表怎么设计的"就开始现编。这里的三份文档分别对应需求边界、字段含义和接口出入参,属于可以直接抄进论文的素材。

2.3 开发文档说明里的三张表:先看字段再动代码

翻docs/数据库设计.md,核心就一张contacts表。字段分四组看:身份组(id、name)、联系方式组(phone、phone_raw、email)、扩展组(company、tags、remark)、检索辅助组(pinyin、initials)。

这里有个设计细节值得单独说:phone和phone_raw是分开存的。phone_raw保留用户原始输入,比如+86 138-0000-1111,展示时原样输出;phone存归一化之后的13800001111,用于比对和检索。如果只存一份,要么展示难看,要么比对时被分隔符和区号干扰,判重直接翻车。这种"原始值 + 标准值"双字段的写法在真实业务里非常常见,是能写进论文的小亮点。

接口文档列了四条约定,概括起来:

接口方法入参返回
/api/contactsGETq关键词、page页码分页列表 + total
/api/contactsPOST联系人 JSON,force是否强制保存新建对象或 409 重复提示
/api/contacts/<id>PUT修改字段更新后的对象
/api/contacts/<id>DELETE无{"ok": true}

注意 POST 返回 409 这个设计。它意味着后端在保存前先做了一次判重,如果发现疑似重复,不直接写库,而是把候选项甩回前端让用户决定。前端contact.js收到 409 后弹一个"发现 2 个相似联系人,是否继续保存",用户点"继续"时带上force: true重新提交。这条链路是整套系统里最能体现"智能"的地方,也是答辩可以重点讲的一段。

3. 核心模块拆解:一次"输入首字母就把人找出来"的完整链路

这一章是整篇的重头。从用户敲下zw两个字,到页面上列出三个张伟,中间经过了哪些函数、每个函数为什么这么写、参数怎么调,全部拆开。看懂这段,剩下的增删改查都是体力活。

3.1 数据层:contacts 表与拼音冗余字段的必要性

先看模型定义。注意拼音字段是在写入时算好存下来的,而不是查询时现算:

# models/contact.py from datetime import datetime from flask_sqlalchemy import SQLAlchemy from pypinyin import lazy_pinyin, Style db = SQLAlchemy() class Contact(db.Model): __tablename__ = "contacts" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(32), nullable=False, index=True) phone = db.Column(db.String(20), index=True) # 归一化后的号码,用于比对 phone_raw = db.Column(db.String(32)) # 原始输入,用于展示 email = db.Column(db.String(64)) company = db.Column(db.String(64)) tags = db.Column(db.String(128)) # 逗号分隔,毕设体量够用 pinyin = db.Column(db.String(64), index=True) # 全拼,冗余字段 initials = db.Column(db.String(16), index=True) # 首字母,冗余字段 remark = db.Column(db.Text) created_at = db.Column(db.DateTime, default=datetime.now) def fill_pinyin(self): """录入或改名时调用,同步刷新拼音冗余字段""" self.pinyin = "".join(lazy_pinyin(self.name)).lower() self.initials = "".join( lazy_pinyin(self.name, style=Style.FIRST_LETTER) ).lower() def to_dict(self): return { "id": self.id, "name": self.name, "phone": self.phone_raw or self.phone, "email": self.email, "company": self.company, "tags": self.tags.split(",") if self.tags else [], }

fill_pinyin()干的事很朴素:lazy_pinyin("张伟")返回["zhang", "wei"],拼起来就是zhangwei;加上Style.FIRST_LETTER参数后返回["z", "w"],拼起来是zw。多音字场景下lazy_pinyin默认取常用读音,比如"曾"会转成zeng而不是ceng,真要精确处理得用pypinyin的短语模式,但通讯录里这种错误率可以接受。

为什么不在查询时现算?因为 SQL 里没法直接调 Python 函数。如果把所有联系人取到内存里再逐个算拼音过滤,一万条数据就是一万次函数调用,页面肉眼可见地卡。存成字段加上索引,SQL 的LIKE 'zw%'能走索引,几十毫秒出结果。这就是典型的"用空间换时间",代价是每次改名都要记得调一次fill_pinyin(),忘了就会导致搜不到——这是个高频坑,后面避坑章节会专门讲。

to_dict()里phone_raw or phone这个写法是为了兼容老数据:早期版本只存了归一化号码,没有phone_raw,直接取会返回 None,页面上就显示空白。

3.2 检索层:拼音、分词、模糊匹配的三级兜底

检索是这个项目最值得讲的部分,因为它是"看起来简单、写起来全是细节"的典型。用户输入的可能性太多:中文全名、中文片段、全拼、首字母、手机号、公司名,甚至中英混输。一个LIKE打天下必然漏。

# services/search_service.py import re from sqlalchemy import or_ from models.contact import Contact DIGITS = re.compile(r"\D+") def normalize_keyword(kw: str) -> str: """统一小写、去空格,避免用户输 'Zhang Wei' 搜不到""" return (kw or "").strip().lower().replace(" ", "") def search_contacts(kw: str, limit: int = 200): kw = normalize_keyword(kw) if not kw: return Contact.query.order_by(Contact.created_at.desc()).limit(limit).all() # 第一级:姓名、拼音前缀、首字母前缀、号码包含,四路 OR 一起召回 conds = [ Contact.name.like(f"%{kw}%"), Contact.phone.like(f"%{kw}%"), Contact.pinyin.like(f"{kw}%"), # 前缀匹配才能吃到索引 Contact.initials.like(f"{kw}%"), ] hits = Contact.query.filter(or_(*conds)).limit(limit).all() if hits: return hits # 第二级:纯数字但前两级没命中,退化成号码任意位置模糊 if kw.isdigit(): return Contact.query.filter( Contact.phone.like(f"%{kw}%") ).limit(limit).all() # 第三级:中文长句按词切,逐词召回后取并集 try: import jieba words = [w for w in jieba.cut(kw) if len(w.strip()) >= 1] except ImportError: words = [kw] result, seen = [], set() for w in words: part = Contact.query.filter( or_(Contact.name.like(f"%{w}%"), Contact.company.like(f"%{w}%"), Contact.remark.like(f"%{w}%")) ).limit(limit).all() for c in part: if c.id not in seen: seen.add(c.id) result.append(c) return result

逐段说逻辑。normalize_keyword做两件事:去首尾空格、转小写。别小看这两行,用户从微信复制名字过来经常带空格,中文输入法下还容易混进全角空格,不处理就是"明明存在却搜不到"的玄学问题。

第一级里的pinyin.like(f"{kw}%")用的是前缀匹配而不是%kw%。原因在索引:SQLite 的 B-Tree 索引只有在前缀匹配时才能用上,%kw%会退化成全表扫描。首字母查询天然是前缀场景(用户输zw就是想找"张伟"这种以 z、w 开头的),所以前缀匹配够了。姓名的%kw%保留是因为中文名从中间搜的需求真实存在,比如搜"伟"要能出"张伟""李伟"。

第二级是补丁逻辑。用户可能只记得号码后四位1111,这时候phone LIKE '%1111%'能命中,但第一级里phone LIKE '%1111%'其实也写了——所以严格说这一级是冗余的。保留它的价值在于明确分流:数字走号码通道,不做拼音匹配,减少无效查询。

第三级是给"公司名 + 备注"这种长文本准备的。jieba.cut把"某科技公司技术部"切成若干词,逐个词去name、company、remark里找,用seen集合去重后合并。这里用了try/except ImportError,是因为jieba首次加载词典有几百毫秒延迟,如果没有安装也不能让整个搜索崩掉,降级成整串匹配。

参数上,limit=200是个经验值:一次召回超过两百条,用户根本翻不完,不如让他加关键词。分页在路由层用切片做,检索层只负责"召回候选集"。

3.3 判重合并:号码归一化 + 编辑距离的组合拳

判重比检索更容易写错。新手最常见写法是if new.name == old.name,结果"张伟"和"张伟 "(末尾空格)判不出来,+8613800001111和13800001111也判不出来。正确做法是先归一化,再算相似度:

# services/dedupe_service.py import re from difflib import SequenceMatcher from models.contact import Contact NON_DIGIT = re.compile(r"\D+") def normalize_phone(raw: str) -> str: """去掉 +86、空格、短横线、括号,统一成纯数字""" d = NON_DIGIT.sub("", raw or "") if len(d) == 13 and d.startswith("86"): d = d[2:] return d def similarity(a: str, b: str) -> float: """0~1 的相似度,1 表示完全相同""" if not a or not b: return 0.0 return SequenceMatcher(None, a.strip(), b.strip()).ratio() def find_duplicates(data: dict, threshold: float = 0.86): """保存前调用,返回疑似重复的候选列表""" new_phone = normalize_phone(data.get("phone", "")) new_name = (data.get("name") or "").strip() new_company = (data.get("company") or "").strip() hits = [] for old in Contact.query.all(): # 数据量小可全表,上万条要换方案 if new_phone and new_phone == old.phone: score = 1.0 # 号码相同,基本可以确定是同一个人 elif new_name and new_name == old.name: score = 0.95 # 同名不同号,可能是重名,也可能换号了 else: score = (similarity(new_name, old.name) * 0.6 + similarity(new_company, old.company or "") * 0.4) if score >= threshold: hits.append({"contact": old, "score": round(score, 3)}) return sorted(hits, key=lambda x: -x["score"])

normalize_phone先\D+把所有非数字字符干掉,再单独处理86前缀。注意判断条件是len(d) == 13 and d.startswith("86"),必须两个条件同时成立才裁掉——如果只判断startswith("86"),一个以 86 开头的 11 位座机号就会被误伤,这是血泪经验。

similarity用的是标准库difflib.SequenceMatcher,返回最长公共子序列的匹配比例。"张伟"和"张玮"的相似度大约是 0.5,达不到阈值,不会被判成重复——这是有意为之。名字差一个字往往就是两个人,判重要保守,宁可漏判也不能误判,因为误判会让用户合并掉不同人的记录,数据丢了就找不回来。

权重分配上,姓名 0.6、公司 0.4。为什么公司权重这么高?因为通讯录里重名率极高,"张伟"在同一个城市可能有几千个,只靠姓名判断没有意义;但如果姓名相似且公司也相似,是同一个人的概率就大幅上升。阈值 0.86 是试出来的:太高(0.95)会漏掉"张伟"和"张伟(销售)"这种带后缀的情况,太低(0.7)则会把"张伟"和"张薇"判成同一个人。

还有个隐藏的性能问题:Contact.query.all()是全表加载。五百条数据毫无压力,五千条开始有感知,五万条就直接卡死。真正的做法是在数据库里先按号码前七位或姓名首字做粗筛,只把候选集拉进内存算相似度。毕设阶段数据量小可以先这么写,但答辩时被问到"数据量大了怎么办",你得答得上来——这句话本身就是一个得分点。

3.4 智能标签:规则引擎打底,模型只做锦上添花

标签模块的名字叫"智能",实现上其实是规则。这不是偷工减料,而是在这个任务上规则就是最优解:

# services/tag_service.py import re EMAIL_RE = re.compile(r"^[\w.\-+]+@([\w\-]+\.)+[a-z]{2,}$", re.I) # 号段到运营商的映射,演示用,实际应查完整号段表 SEGMENT = {"139": "移动", "138": "移动", "186": "联通", "189": "电信"} # 公共邮箱域名,命中的说明是个人邮箱,没有企业信息价值 PUBLIC_DOMAINS = {"qq.com", "163.com", "126.com", "gmail.com", "outlook.com"} # 行业关键词,命中就打行业标签 INDUSTRY_HINTS = { "IT行业": ("科技", "信息", "网络", "软件", "数据"), "教育": ("学校", "教育", "培训", "学院"), "医疗": ("医院", "医疗", "药业"), } def auto_tags(data: dict) -> list: tags = [] email = (data.get("email") or "").strip() if EMAIL_RE.match(email): domain = email.split("@")[-1].lower() if domain not in PUBLIC_DOMAINS: tags.append("企业邮箱") phone = (data.get("phone") or "")[:3] if phone in SEGMENT: tags.append(SEGMENT[phone]) company = data.get("company") or "" for tag, words in INDUSTRY_HINTS.items(): if any(w in company for w in words): tags.append(tag) break # 只打一个行业标签,避免堆砌 return tags

逻辑很直白:邮箱域名不是公共邮箱的,大概率是公司邮箱,打"企业邮箱";号码前三位命中号段表的,打运营商标签;公司名命中行业关键词的,打行业标签。三个维度相互独立,可以叠加。

这里有两个设计取舍要讲。第一,行业标签用了break,命中第一个就停。如果不加,一家"教育科技公司"会同时打上"IT行业"和"教育"两个标签,标签一多就失去筛选意义了。

第二,规则的可维护性比准确率优先。INDUSTRY_HINTS这个字典是数据不是代码,改关键词不需要动逻辑,加一个行业就是加一行。答辩时被问"你怎么保证标签准确",可以回答:先用规则覆盖高频场景,把准确率做到 90% 以上,剩下的长尾用人工修正,同时把用户手动改过的标签回流成新规则。这个回答比"我用了深度学习"要实在得多,也更符合工程现实。

如果一定要上模型,合适的做法是拿联系人备注做文本分类,用sentence-transformers之类的库把备注转成向量,再和预设的标签描述做余弦相似度。但这条路对毕设来说是过度设计:模型几百兆、首次加载慢、结果不可解释,远不如一张关键词表来得踏实。

4. 从零跑起来:环境、依赖、初始化与页面联调

代码看懂了,接下来是把它跑起来。这一章按顺序走一遍,每一步都说明白在干什么,以及哪里容易卡住。整个过程在一台普通笔记本上大约二十分钟,前提是网络能正常拉包。

4.1 环境准备与依赖安装

Python 版本建议 3.8 以上。项目里用到的 f-string、类型注解这些语法在老版本上会报错。先建虚拟环境,别直接往系统 Python 里装:

# 进入项目根目录 cd contact_ai # 创建虚拟环境 python -m venv venv # 激活:macOS / Linux source venv/bin/activate # 激活:Windows(CMD) venv\Scripts\activate.bat

激活成功的标志是命令行前面多了个(venv)。这一步跳过的人很多,后果是包装到全局,换台机器就找不到,而且不同项目之间版本冲突。接着装依赖:

pip install -r requirements.txt

requirements.txt里是这几行:

flask flask-sqlalchemy pypinyin jieba

四个包分别对应:Web 框架、ORM、拼音转换、中文分词。这里没写死版本号,是为了兼容不同 Python 版本下的可用包;如果你要做成可复现的环境,装完之后执行pip freeze > requirements.txt把实际版本锁进去,否则过半年再装可能拿到不兼容的新版本。

jieba首次import时会构建词典缓存,大概一两秒,看到控制台卡住别急着 Ctrl+C,那是正常现象。如果公司网络连不上 PyPI,加个国内镜像源参数就行,-i后面跟镜像地址,具体地址自己搜一下,这里不贴。

4.2 配置项与数据库初始化

配置文件独立成config.py,好处是换环境只改一个地方:

# config.py import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-me") SQLALCHEMY_DATABASE_URI = ( "sqlite:///" + os.path.join(BASE_DIR, "data", "contacts.db") ) SQLALCHEMY_TRACK_MODIFICATIONS = False PAGE_SIZE = 20

SQLALCHEMY_DATABASE_URI用的是绝对路径拼接,不是写死的相对路径sqlite:///data/contacts.db。这个细节坑过很多人:相对路径是以"你执行命令时所在的目录"为基准的,从根目录启动和从app.py所在目录启动,会连到两个不同的库文件,表现出来就是"我明明存了数据怎么没了"的黑匣子现象。用BASE_DIR拼绝对路径,无论从哪里启动都指向同一个文件。

PAGE_SIZE抽出来是为了后面调分页方便,列表页一次显示二十条,改一个数字全站生效。

然后初始化数据库并灌演示数据:

python init_db.py

这个脚本干三件事:db.create_all()建表;如果表里没数据,插入十几条示例联系人;每条插入前都调一次fill_pinyin()。执行成功会在data/下生成contacts.db,文件大小几十 KB。想从头来一遍,直接删掉这个文件重新执行即可——SQLite 的好处就在这里,重置成本几乎为零,不像 MySQL 还要DROP DATABASE。

4.3 启动、接口自测与页面联调

启动服务:

python app.py

看到Running on http://127.0.0.1:5000就说明起来了,浏览器打开这个地址进列表页。这时候如果页面空白,先看控制台有没有报错,再看 Network 面板里/api/contacts返回了什么。前端调不通后端,八成是这两个原因之一:接口路径写错(少了/api前缀),或者后端返回的不是 JSON(模板渲染报错时返回的是 HTML 错误页,前端res.json()直接抛异常)。

接口可以先用命令行单独验证,不用等页面:

# 查列表 curl "http://127.0.0.1:5000/api/contacts?q=zw" # 新建一条,注意 Content-Type curl -X POST http://127.0.0.1:5000/api/contacts \ -H "Content-Type: application/json" \ -d '{"name":"张伟","phone":"+86 138-0000-1111","email":"zw@some-company.com"}'

第二条命令如果返回 409 而不是 201,说明判重逻辑生效了,返回体里会带上疑似重复的联系人和相似度分数。想强制保存,在 JSON 里加"force": true再发一次。这个"先 409 再确认"的交互流程,是演示时最容易出彩的一段,建议提前准备好两条相似数据做现场演示。

路由层的分页和创建逻辑长这样:

# app.py 片段 @app.route("/api/contacts") def list_contacts(): kw = request.args.get("q", "") page = max(int(request.args.get("page", 1)), 1) size = app.config["PAGE_SIZE"] items = search_contacts(kw) # 检索层返回候选列表 total = len(items) start = (page - 1) * size return jsonify({ "total": total, "page": page, "items": [c.to_dict() for c in items[start:start + size]], }) @app.route("/api/contacts", methods=["POST"]) def create_contact(): data = request.get_json(silent=True) or {} if not data.get("name"): return jsonify({"msg": "姓名不能为空"}), 400 dups = find_duplicates(data) if dups and not data.get("force"): return jsonify({ "code": 409, "msg": "发现疑似重复联系人", "duplicates": [ {"id": d["contact"].id, "name": d["contact"].name, "score": d["score"]} for d in dups ], }), 409 contact = Contact( name=data["name"].strip(), phone=normalize_phone(data.get("phone", "")), phone_raw=data.get("phone", ""), email=data.get("email"), company=data.get("company"), tags=",".join(data.get("tags", [])), ) contact.fill_pinyin() # 关键:不调这句就搜不到 db.session.add(contact) db.session.commit() return jsonify(contact.to_dict()), 201

request.get_json(silent=True)里的silent参数很关键:默认情况下,请求体不是合法 JSON 时 Flask 会直接抛 400 并返回一个 HTML 错误页,前端拿到的不是 JSON,解析就炸了。加上silent=True后返回 None,代码里用or {}兜底,统一走自己的错误返回格式。

max(int(...), 1)是防负数页码。用户手动改 URL 参数传page=-1,切片items[-20:0]会返回空列表,看起来像数据丢了。这类边界处理不写也能跑,但写不写体现的是工程习惯。

5. 避坑与排查:五个让新手卡到半夜的问题

下面这五条都是我在类似项目里真实遇到过或者被问到过的,按"现象 → 原因 → 解决"的顺序写,遇到了直接对号入座。

5.1 检索相关的三个坑

坑一:明明存了"张伟",搜zw一条都出不来。

现象很迷惑,因为搜"张"能出来,说明数据真的在库里。原因几乎一定是入库时没调fill_pinyin(),pinyin和initials字段是 NULL,LIKE 'zw%'匹配不上 NULL。解决办法分两步:先写个一次性修复脚本,把所有记录读出来重新fill_pinyin()后提交;再检查所有写入路径——新建、编辑、批量导入——确保每条都调了。我的习惯是把fill_pinyin()挂到 SQLAlchemy 的before_insert和before_update事件上,用钩子兜底,这样任何写入路径都不会漏。

坑二:手机号中间几位搜不到,但完整号码能搜到。

这里要区分两种情况。如果你搜的是0000,理论上phone LIKE '%0000%'能命中,出不来大概率是phone字段里存的是带分隔符的原始值,138-0000-1111里没有连续的0000。这就是双字段设计的必要性:phone存归一化的纯数字,phone_raw存原始值。检查一下入库代码,确认phone=normalize_phone(...)没漏。

坑三:jieba分词之后搜索结果反而变少了。

现象是搜"某科技公司"返回一堆不相关的人。原因是分词把长串切碎了,每个碎片单独去LIKE,碎片太短(比如单个字"某")会命中大量无关记录。解决办法是给分词结果加长度过滤,把长度小于 2 的词丢掉;或者改成"必须命中至少两个词"才算候选。这个阈值没有标准答案,拿自己的数据试,试到结果能接受为止。

5.2 运行与数据类的两个坑

坑四:重启服务后数据全没了。

第一反应是数据库坏了,实际八成是连到了不同的库文件。前面说过,相对路径sqlite:///data/contacts.db会随启动目录变化。验证方法很简单:在init_db.py里 print 一下app.config["SQLALCHEMY_DATABASE_URI"],看看路径是不是你以为的那个。换成BASE_DIR拼绝对路径就彻底解决。还有一种可能是数据在事务里没提交,db.session.add()之后忘了commit(),进程一退就丢。

坑五:页面样式错乱,或者表单提交后没反应但接口是通的。

样式问题先看static目录的路径,url_for('static', filename='css/style.css')比手写/static/css/style.css稳,前者在部署到子路径时也不会错。表单提交没反应且接口返回 200,多半是前端 JS 里preventDefault()之后没刷新列表,或者刷新了但没带分页参数,用户看到的是第一页而新数据在最后一页。调试时打开 Network 面板,看点了保存之后到底有没有发请求、请求体长什么样,比盯着代码猜要快得多。

6. 二次开发:把"能跑的毕设"改成"答辩讲得清的作品"

项目能跑只是起点,真正决定分数的是你在这套代码上加了什么、改了什么、能不能说清为什么这么改。这一章给三个方向的改造思路,以及一套改完之后的验证方法。

6.1 换库与加字段:顺序错了要推倒重来

很多人第一步就想把 SQLite 换成 MySQL,直接在config.py里改连接串,然后启动报一堆错。正确顺序是:先装驱动(pymysql),再把连接串改成mysql+pymysql://user:pass@host/db,接着建库(字符集用utf8mb4,不然中文和表情符号会丢),最后重新执行init_db.py建表。SQLite 和 MySQL 的字段类型不完全通用,比如db.Text在 MySQL 里是LONGTEXT,一般没问题,但布尔和时间默认值要留意。

加字段的风险在 SQLite 上更明显:它不支持ALTER TABLE ... DROP COLUMN,也不支持改列类型,加字段只能ADD COLUMN。如果你要给contacts加一个wechat字段,操作是先在模型里加一行,然后手动执行:

ALTER TABLE contacts ADD COLUMN wechat VARCHAR(64);

新加的列老数据都是 NULL,to_dict()里要防一下 None。更稳妥的做法是用 Flask-Migrate 管理迁移,但毕设阶段数据量小,删库重建反而更快,前提是演示数据脚本写好了。

6.2 三个观感提升明显的改造点

改造一:加语音输入。前端用浏览器的MediaRecorder录一段,传到后端接一个本地的语音识别库转文字,识别结果直接填进姓名框。这条链路哪怕识别准确率只有七成,演示效果也足够抓眼球,而且技术上完全本地,不依赖任何外部服务。

改造二:加 vCard 导入导出。手机上导出的通讯录是.vcf格式,解析它只需要按行读、按BEGIN:VCARD和END:VCARD分段。实现一个导入接口,用户上传文件后批量入库,同时跑一遍find_duplicates()把重复项标出来让用户勾选。这个功能对答辩加分很大,因为它证明你的系统能和真实生态对接,不是自娱自乐。

改造三:把检索日志用起来。新建一张search_log表,记录每次搜索的关键词和时间。数据攒够之后做两件事:一是统计高频词,把排前几位的做成首页快捷入口;二是找出"搜了但没结果"的词,说明要么数据缺失要么匹配逻辑有漏洞。这个小改动能让你在答辩时说"我做了数据驱动的迭代",比任何技术名词都管用。

6.3 改完之后怎么验证:一份自测表

改代码最怕的是改完 A 功能、B 功能悄悄坏了。下面这张表是我每次改完都要过一遍的,按顺序执行,十分钟能跑完:

验证项操作预期结果
拼音回填新增"李娜",搜索ln能搜到,且initials字段为ln
号码归一化新增号码+86 139-1234-5678phone为13912345678,页面显示原始格式
判重生效再存一遍同名同号返回 409,列出候选和分数
强制保存上一步带force: true重发返回 201,列表多一条
改名同步把"李娜"改成"王芳",搜ln搜不到;搜wf能搜到
分页边界请求?page=0和?page=999不报错,分别返回第一页和空列表

重点看第三行和第五行。判重生效说明归一化和相似度都对,改名同步说明fill_pinyin()挂在了更新路径上——这两个点是最容易在重构中丢掉的。跑完这张表再提交,比改完就打包发出去稳得多。

从那以后我每次动检索和判重相关的代码,都会强制把这张表从头走一遍,哪怕只改了一个阈值。因为这类逻辑的 bug 往往不报错,只是悄悄地漏数据,等发现的时候已经分不清是哪次改动引入的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询