简介:这份毕业设计资料包面向计算机相关专业学生、Python初学者及需要完整项目案例的开发者,主题为AI智能联系人管理系统。系统围绕联系人增删改查、按姓名电话邮箱检索分类、CSV或VCF导入导出等基础功能展开,并预留自然语言查询解析与用户行为预测的智能推荐思路,可用于课程设计、毕设答辩与功能演示。包内共50个文件,以7个py源码、10个pyc字节码、8个ui界面文件、6个html页面为主,另含xml配置、xlsx数据表、doc使用说明与开发文档、pdf云服务Key申请指南、jpg和png素材及exe可执行程序,压缩包约141.21MB,既可阅读源码也可直接运行体验。开发文档覆盖设计理念、架构、实现方法与测试案例,已有614人学习,适合借此梳理项目目录结构、模块划分、界面组织与AI能力接入方式,并对照文档完成环境配置、功能验证和二次开发。
1. 先看清这套 AI 智能联系人管理程序:通讯录不是塞进数据库就完事
手机通讯录里存了八百多个人,想找“上次那个做 PCB 的谁”却翻了三分钟,最后靠翻聊天记录才想起来——这几乎是每个毕业设计季都会遇到的真实场景。这套 Python 源码做的 AI 智能联系人管理程序,处理的正是这件事:导入一份 CSV 或一段名片文本,它自动清洗手机号和邮箱、生成拼音索引、按公司关键词打标签分组、把重复的联系人合并掉,再给一个能按姓名、首字母、手机号模糊搜索的 Web 界面。它面向的是拿它做毕业设计、课程设计,或者想二次开发成个人小工具的开发者。代码量不大但链路完整,从数据表设计到接口、页面、开发文档说明都能跑通,适合照着改、按自己需求加字段。下面按“能干什么—怎么跑—怎么改”的顺序,把它拆开讲。
2. 技术栈与目录结构:Flask、SQLite 和轻量 NLP 是怎么拼起来的
这个项目的技术选型很有意思:它没有用 Django 那样的大而全,也没有上 FastAPI 玩异步,而是 Flask + Flask-SQLAlchemy + SQLite 的老三样,AI 部分也没接大模型,用的是 pypinyin、rapidfuzz、jieba 这几个轻量库做规则和统计层面的“智能”。对毕业设计来说这是很聪明的取舍——答辩时能讲清楚每一层在做什么,部署时也不用折腾 GPU 和模型权重。
2.1 为什么是 Flask + SQLite 而不是 Django
先说要解决的核心诉求:单机跑、数据量在几千到几万条联系人、需要 Web 界面演示、要能在答辩现场用笔记本直接启动。这几条摆在一起,SQLite 几乎是唯一合理的选择——零配置、单文件、拷走就能跑,不用先装 MySQL 再建库建用户。Flask 的好处是路由和视图函数一一对应,老师在页面上点一个搜索按钮,你能立刻指出「这个请求进的是哪个函数、查的是哪张表」,链路短、好讲。
Django 的 ORM 和 admin 确实省事,但它自带的那套目录约定对毕业设计反而是负担:settings 里一堆配置、migrations 要单独管、admin 后台的风格和自己的前端对不上。这个项目的做法是用 Flask 手写 REST 接口,前端用原生 HTML + fetch 调用,页面不多但每条接口都能在开发文档里找到对应的参数说明。AI 那一层用 jieba 做词性标注抽人名、用 rapidfuzz 算字符串相似度、用 pypinyin 生成拼音索引,全是纯 Python 实现,装完依赖就能用,不涉及任何模型下载。
需要注意的是,这套组合在联系人量超过十万条、或者要多人同时在线编辑时会吃力。SQLite 的写锁是库级别的,写并发一高就容易database is locked,这一点在第 5 章会细讲。但作为毕业设计和单人使用的工具,这个规模完全够用。
2.2 目录结构:每个文件夹在项目里的角色
拿到源码包之后先别急着pip install,把目录树看一遍能省掉后面很多“文件放错位置”的麻烦。这个项目的结构大致是这样:
contact_ai/ ├── app.py # 应用入口,注册蓝图、建表、启动服务 ├── config.py # 数据库路径、上传目录、分页大小等配置 ├── requirements.txt # 依赖清单 ├── models/ │ ├── __init__.py │ └── contact.py # Contact / Tag / ImportLog 三张表的模型定义 ├── services/ │ ├── importer.py # CSV 导入、字段清洗、去重入口 │ ├── matcher.py # 相似度打分、合并逻辑 │ ├── grouper.py # 关键词规则分组 │ └── extractor.py # 名片/简历文本的实体抽取 ├── api/ │ ├── __init__.py │ └── contacts.py # 搜索、详情、合并、导出等路由 ├── static/ │ ├── app.js # 前端调接口的逻辑 │ └── style.css ├── templates/ │ └── index.html # 列表页 + 搜索框 + 导入按钮 ├── data/ │ └── contacts.db # SQLite 数据库文件,首次运行自动生成 └── docs/ └── 开发文档说明.md # 模块职责、接口清单、表结构说明这个划分的逻辑是「入口薄、服务厚」:app.py只负责把蓝图挂上去和初始化数据库,真正的业务都在services/下面。好处是你改去重算法的时候只需要动matcher.py,不用碰路由;改搜索排序的时候只动api/contacts.py,不会影响导入。开发文档说明里对每个 service 的输入输出都有描述,建议先读docs/开发文档说明.md里的接口清单,再回头对代码,理解会快很多。
data/目录平时是空的,第一次运行初始化命令之后才会生成contacts.db。这个设计是为了让源码包干净——不要把带个人数据的数据库一起提交或打包。
2.3 环境准备与三条启动命令
Python 版本建议 3.9 以上,因为代码里用了一些类型标注的写法。虚拟环境一定要建,不然和你机器上其他项目的依赖会打架,这个问题我在别的项目上翻过车,血泪经验就是别嫌麻烦。
# 1. 建虚拟环境并激活 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 2. 装依赖(版本以安装时最新稳定版为准,requirements 里已经列好) pip install -r requirements.txt # 3. 初始化数据库表结构 flask --app app.py init-db # 4. 启动开发服务,默认 5000 端口 python app.pyinit-db是一个自定义的 Flask CLI 命令,定义在app.py里,作用是读models/下的模型定义并db.create_all(),把三张表建出来。它不会删已有数据,重复执行是安全的,所以忘了建表的时候直接再跑一遍就行。
启动后浏览器打开http://127.0.0.1:5000就能看到列表页。如果端口被占用,改app.py最后一行app.run(debug=True, port=5000)里的端口号即可。debug=True在开发时很有用,改完代码自动重载,但注意它会在出错时把调用栈暴露在页面上,答辩现场演示前最好关掉,避免一些不该出现的调试信息被看到。
依赖清单里关键的就五个:Flask、Flask-SQLAlchemy、pypinyin、rapidfuzz、jieba。前两个是 Web 和 ORM 的地基,后三个是“智能”部分的来源。如果你不想装 jieba 那套词典,抽取模块里可以退化成正则匹配,精度会掉一些但启动更快,这个取舍在第 5 章会再提。
3. 数据模型与入库链路:从一条 CSV 记录到可拼音检索的联系人
这一章是整套代码里最该看懂的部分。联系人管理看起来简单,但真正难的是“同一个人在三个来源里有三种写法”这件事:通讯录里叫「张三」,微信里叫「张三(客户)」,Excel 里手机号带+86前缀。数据模型设计得好不好,直接决定了后面去重和搜索能不能做。
3.1 三张核心表的设计与字段含义
项目里没有把标签做成独立的多对多关系表,而是用逗号分隔的字符串存在contacts.tags里,这是典型的毕业设计级简化。好处是查询简单、导入快;坏处是标签统计要全表扫。如果你要二次开发成正式工具,第一件事就是把这张表拆开,后面第 6 章会给改法。
# models/contact.py from datetime import datetime from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Contact(db.Model): __tablename__ = "contacts" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), nullable=False, index=True) # 全拼和首字母各存一列,搜索时直接命中索引,不用每次现算 name_pinyin = db.Column(db.String(128), index=True) name_initials = db.Column(db.String(32), index=True) phone = db.Column(db.String(32), index=True) email = db.Column(db.String(128)) company = db.Column(db.String(128)) title = db.Column(db.String(64)) group_name = db.Column(db.String(32), default="未分组", index=True) tags = db.Column(db.String(256), default="") # 被合并掉的记录不删除,只指向主记录,保留追溯能力 merged_into = db.Column(db.Integer, nullable=True, index=True) source = db.Column(db.String(32), default="manual") # manual / csv / card created_at = db.Column(db.DateTime, default=datetime.utcnow) class ImportLog(db.Model): __tablename__ = "import_logs" id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(128)) total_rows = db.Column(db.Integer, default=0) inserted = db.Column(db.Integer, default=0) skipped = db.Column(db.Integer, default=0) merged = db.Column(db.Integer, default=0) created_at = db.Column(db.DateTime, default=datetime.utcnow) class Tag(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(32), unique=True, nullable=False) color = db.Column(db.String(16), default="#4a90d9")几个字段的设计意图值得单独说。name_pinyin和name_initials是冗余字段,写入时算一次、查询时零成本,这是通讯录类应用的标准做法,比在 SQL 里调函数快一个数量级。merged_into是去重模块的关键——合并时不是DELETE,而是给被合并的记录打一个指针,查询时统一加merged_into.is_(None)过滤。这样做的价值在于误合并可以撤销,把指针清空记录就恢复了,相当于给自己留了一颗后悔药。
ImportLog单独成表是因为导入是最容易出问题的环节:老师问你「上次那个 CSV 为什么少了 20 条」,你能直接查日志说“跳过 18 条手机号格式非法、合并 2 条重复”。Tag表和contacts.tags并存看起来有点重复,实际用途不同——Tag存的是标签元信息(颜色、显示名),contacts.tags存的是关联关系,用字符串是为了导入时少一次关联查询。
3.2 导入服务:手机号、邮箱、姓名的归一化清洗
导入的入口在services/importer.py。整个流程是:读文件 → 逐行解析 → 字段归一化 → 相似度查重 → 落库或合并 → 写日志。中间任何一步失败都只跳过当前行,不中断整批,这是批量导入必须遵守的规则,否则一个格式错误的手机号能让整份名单白导。
# services/importer.py import re from pypinyin import lazy_pinyin, Style PHONE_RE = re.compile(r"^1[3-9]\d{9}$") EMAIL_RE = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$") def normalize_phone(raw: str) -> str | None: """去掉空格、横线、+86 前缀,返回 11 位纯数字或 None""" digits = re.sub(r"[^\d]", "", raw or "") if digits.startswith("86") and len(digits) == 13: digits = digits[2:] return digits if PHONE_RE.match(digits) else None def normalize_email(raw: str) -> str | None: email = (raw or "").strip().lower() return email if EMAIL_RE.match(email) else None def build_pinyin_index(name: str): """姓名 -> (全拼小写, 首字母小写),如 张三 -> ('zhangsan', 'zs')""" name = (name or "").strip() full = "".join(lazy_pinyin(name)) initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER)) return full.lower(), initials.lower() def parse_row(row: dict) -> dict | None: """把 CSV 的一行转成可入库的字典,必要字段缺失则返回 None""" name = (row.get("姓名") or row.get("name") or "").strip() if not name: return None phone = normalize_phone(row.get("手机") or row.get("phone")) email = normalize_email(row.get("邮箱") or row.get("email")) if not phone and not email: return None # 一个联系方式都没有,留着没意义 full, initials = build_pinyin_index(name) return { "name": name, "name_pinyin": full, "name_initials": initials, "phone": phone, "email": email, "company": (row.get("公司") or row.get("company") or "").strip(), "title": (row.get("职位") or row.get("title") or "").strip(), "source": "csv", }normalize_phone里的处理顺序很重要:先把所有非数字字符干掉,再处理86前缀,最后才用正则校验。如果反过来先校验再清洗,带横线的138-0000-0000会直接被判非法。startswith("86") and len == 13这个条件是为了避免把以 86 开头的 11 位号码误截——正常的 11 位手机号第二位不可能是 8(因为要符合1[3-9]),所以这个判断是安全的。
parse_row的容错策略值得抄:姓名为空直接丢,手机号和邮箱至少有一个,否则也丢。很多毕业设计的导入模块是“有一个字段空就整行报错”,实际用起来体验极差,因为 Excel 里总有几个人的邮箱是空的。
3.3 拼音索引:首字母搜索为什么比 LIKE '%name%' 好用
通讯录搜索的用户预期是「输入 zs 出张三」。如果用name LIKE '%zs%'去查,因为表里存的是中文,永远查不到。正确做法就是在写入时把拼音算好存进name_pinyin和name_initials,查询时对这几列做LIKE。
# api/contacts.py 中的搜索实现 from flask import request, jsonify from sqlalchemy import or_ from models.contact import Contact def register_search(app): @app.get("/api/contacts/search") def search_contacts(): q = (request.args.get("q") or "").strip().lower() page = max(int(request.args.get("page", 1)), 1) size = min(int(request.args.get("size", 20)), 100) if not q: return jsonify({"total": 0, "page": page, "size": size, "items": []}) like = f"%{q}%" # 只在未被合并的主记录里搜,被合并的记录不重复出现 query = Contact.query.filter(Contact.merged_into.is_(None)).filter( or_( Contact.name.ilike(like), Contact.name_pinyin.ilike(like), Contact.name_initials.ilike(like), Contact.phone.ilike(like), Contact.company.ilike(like), ) ) total = query.count() rows = (query.order_by(Contact.name_pinyin) .offset((page - 1) * size) .limit(size) .all()) return jsonify({ "total": total, "page": page, "size": size, "items": [r.to_dict() for r in rows], })这段代码有三个细节。第一,q先做了lower(),因为拼音列存的是小写,不转的话用户输入大写ZS会查不到。第二,size用min(..., 100)封顶,防止有人传size=100000把内存打爆,这是接口层的自我保护。第三,排序用name_pinyin而不是name,这样列表按拼音字母序排,符合通讯录的使用习惯。
需要提醒的是,LIKE '%xxx%'前缀带通配符,走不了索引,全表扫。几千条数据没什么感觉,十万条以上就要考虑换方案:要么给name_initials单独建前缀索引改用LIKE 'zs%',要么上 SQLite 的 FTS5 全文索引。这个项目的数据规模用LIKE完全够,但答辩时如果能主动说出「这里的 LIKE 是全表扫,数据量大了要换 FTS5」,是加分项。
4. 智能能力拆解:去重打分、规则分组和名片抽取的完整链路
“AI”这个词放在这个项目上,说实话有点被用滥了。它做的不是深度学习,而是三条基于规则和字符串算法的自动化链路:相似度去重、关键词分组、文本实体抽取。但恰恰是这三条链路,构成了这个项目最值得看的部分,因为它们解决的是真实痛点,而且每一行代码你都能讲清楚为什么这么写。
4.1 去重合并:相似度打分与主记录指向
重复联系人的来源通常是多份名单合并:A 名单里有「张三 13800000001」,B 名单里有「张三 13800000001 某某科技」,C 名单里有「张 三 138-0000-0001」。人眼一看就知道是同一个人,代码要判断就得靠打分。
# services/matcher.py from rapidfuzz import fuzz from models.contact import db, Contact def similarity(a: Contact, b: Contact) -> float: """0~1 的相似度,手机号相同直接定性,其余按权重叠加""" score = 0.0 if a.phone and a.phone == b.phone: score += 0.7 # 手机号唯一性最强,权重最高 score += 0.2 * fuzz.ratio(a.name, b.name) / 100 if a.email and a.email == b.email: score += 0.1 return round(min(score, 1.0), 3) def find_duplicate(candidate: dict, threshold: float = 0.85): """在库里找最像的一条,超过阈值就返回它,否则 None""" best, best_score = None, 0.0 # 先用手机号或拼音首字母做粗筛,避免每次全表比对 pool = Contact.query.filter(Contact.merged_into.is_(None)).filter( (Contact.phone == candidate.get("phone")) | (Contact.name_initials == candidate.get("name_initials")) ).all() for row in pool: s = similarity(row, _as_obj(candidate)) if s > best_score: best, best_score = row, s return best if best_score >= threshold else None def merge_into(main: Contact, dup: Contact): """把 dup 合并到 main:补空字段 + 打指针,不物理删除""" for field in ("phone", "email", "company", "title"): if not getattr(main, field) and getattr(dup, field): setattr(main, field, getattr(dup, field)) dup.merged_into = main.id db.session.commit()similarity的权重分配是这套算法的核心:手机号 0.7、姓名 0.2、邮箱 0.1。这意味着手机号相同、姓名完全一样时得分 0.9,必然合并;手机号相同但姓名不同时得分 0.7 + 0~0.2,通常达不到 0.85 的阈值,会被判为「同号不同人」,不会误合并——共用手机号的夫妻、同事就属于这种情况。阈值定在 0.85 是权衡的结果,调到 0.7 会误合并一堆同号码的不同人,调到 0.95 又基本合不掉,这个数在实际数据上跑一两轮就能找到手感。
find_duplicate里的粗筛是性能关键。如果每导入一条就拿它跟全表逐条算相似度,一万条数据导入就是上亿次比较,导入能卡到你以为程序死机了。这里先用手机号或姓名首字母过滤出一个候选池,通常只有几条到几十条,再在这个池子里算相似度,速度快两个数量级。
merge_into采用的是「补空不覆盖」策略:主记录缺的字段用重复记录的补上,主记录已有的坚决不动。这避免了后导入的脏数据把手工维护的好数据冲掉,是很实用的保守策略。
4.2 自动分组:关键词规则打底,模型兜底
分组这块项目用的是纯规则,没上模型。原因很实际:联系人分组的类别是有限的、可枚举的(同学、同事、客户、家人、供应商),用关键词规则覆盖率能到七八成,剩下的归入「未分组」让用户手动整理,比训一个小分类模型划算得多。
# services/grouper.py import re # 顺序有讲究:越具体的规则越靠前,先命中先返回 GROUP_RULES = [ (r"大学|学院|实验室|研究所", "同学/校友"), (r"银行|证券|保险|基金", "金融"), (r"科技|信息|软件|网络|数据", "同行"), (r"医院|诊所|药", "医疗"), (r"物流|运输|供应链", "供应链"), ] def auto_group(company: str, remark: str = "") -> str: """按公司名和备注里的关键词判断分组,命中不到就返回未分组""" text = f"{company or ''}{remark or ''}" for pattern, group in GROUP_RULES: if re.search(pattern, text): return group return "未分组" def apply_groups(contact_ids: list[int] | None = None): """批量重算分组,不传 id 就全量跑一遍""" query = Contact.query.filter(Contact.merged_into.is_(None)) if contact_ids: query = query.filter(Contact.id.in_(contact_ids)) changed = 0 for c in query.all(): g = auto_group(c.company, c.tags) if g != c.group_name: c.group_name = g changed += 1 db.session.commit() return changed规则表的顺序是有意设计的:大学|学院排在科技前面,因为「某某科技大学」既含科技又含大学,按业务语义应该归到同学/校友而不是同行。这种“先具体后宽泛”的排序是规则引擎的常见坑,顺序乱了会出现分组结果跟直觉不符。
apply_groups支持传 id 列表做增量重算,这个设计是为了配合导入——导入完成后只对新增的联系人跑分组,不用全表扫。全量重算留给「规则改了之后重新洗一遍数据」的场景。函数返回变更条数,前端可以弹一个「本次调整了 N 条分组」的提示,体验会好很多。
如果你想把它升级成带模型的版本,合理的做法是用公司名做分词,训一个朴素贝叶斯或者用现成的文本分类接口,把预测置信度低于阈值的仍然退回规则结果。不要一上来就上大模型,成本和收益不成比例。
4.3 名片与简历文本抽取:正则 + 词性标注
这个模块解决的是「从一段粘贴的文本里自动抠出姓名、电话、邮箱、公司」。输入可能是微信名片截图 OCR 出来的文字,也可能是一段简历正文,格式完全不固定。做法是正则抓结构化字段(手机号、邮箱有固定模式),jieba 的词性标注抓人名(nr标记)。
# services/extractor.py import re import jieba.posseg as pseg PHONE_RE = re.compile(r"1[3-9]\d{9}") EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+") COMPANY_RE = re.compile(r"[\u4e00-\u9fa5]{2,20}(?:有限公司|集团|科技|工作室)") def extract_contact(text: str) -> dict: """从自由文本里抽取联系人字段,抽不到的位置为 None""" text = (text or "").strip() result = {"name": None, "phone": None, "email": None, "company": None} m = PHONE_RE.search(text) if m: result["phone"] = m.group() m = EMAIL_RE.search(text) if m: result["email"] = m.group().lower() m = COMPANY_RE.search(text) if m: result["company"] = m.group() # 只在开头 200 字里找人名,名片和简历的姓名都在前部,越往后误判越多 for word, flag in pseg.cut(text[:200]): if flag == "nr" and 2 <= len(word) <= 4: result["name"] = word break return result三个正则的分工很明确:手机号严格 11 位、邮箱走通用模式、公司名靠后缀词兜。公司的正则用了[\u4e00-\u9fa5]{2,20}加后缀,能匹配「某某科技有限公司」「某某工作室」,但匹配不了英文公司名和外企的XX (China) Ltd.,这是已知局限,实际用的时候可以再加一条英文规则。
人名的抽取限定在前 200 字是个很实用的取舍。jieba 的nr标记准确率不算高,文本越长误判越多,把范围卡在开头能显著提升准确率。另外2 <= len(word) <= 4这个长度限制能过滤掉单字和超长噪声。如果抽出来的人名不对,最省事的兜底是让用户在页面上手动改一下,不要指望算法 100% 准。
4.4 搜索接口:分页参数与返回结构
第 3 章给过搜索接口的实现,这里补一下返回结构和前端约定,因为二次开发时最容易在这里改出问题。to_dict()方法定义在Contact模型上,返回的是前端能直接渲染的扁平结构,注意不要把name_pinyin这类内部字段暴露出去,列表接口返回的字段越少,前端改起来越省事。
# models/contact.py 中的序列化方法 def to_dict(self): return { "id": self.id, "name": self.name, "phone": self.phone, "email": self.email, "company": self.company, "title": self.title, "group": self.group_name, "tags": [t for t in (self.tags or "").split(",") if t], "source": self.source, "created_at": self.created_at.strftime("%Y-%m-%d %H:%M"), }分页参数约定是page从 1 开始、size默认 20 上限 100,返回体里带上total让前端算总页数。这套约定和主流前端分页组件是对齐的,你要换成 Element Plus 或者 Ant Design 的表格,直接接就行,不用改接口。tags在库里是逗号分隔字符串,序列化时拆成数组,前端拿到就能直接v-for渲染标签,省去一次前端 split。
搜索接口的错误处理也要说一句:int(request.args.get(...))在参数是非数字时会抛ValueError,直接返回 500。稳妥的写法是包一层 try 或者用request.args.get("page", type=int),后者在转换失败时返回默认值,是这个项目里更推荐的做法。
5. 避坑与排查:导入乱码、模型体积和写库锁的四个翻车点
这一章是我在本地把项目跑起来、又用几份真实名单试导入时踩到的坑。有些问题不看代码根本想不到,写在这里省得你重复走一遍。
5.1 导入相关:中文乱码与表头错位
现象:用 Excel 另存的 CSV 导入,联系人的姓名全是「锟斤拷」或者一串问号,公司和职位两列的内容整体错位一格。
原因:Excel 在中文 Windows 下另存的 CSV 默认是 GBK 编码,而 Python 的open()默认按 UTF-8 读,编码不对就会解出乱码;表头错位则是因为某些导出工具会在第一列前加一个看不见的 BOM 字符\ufeff,导致csv.DictReader读出来的第一个字段名变成\ufeff姓名而不是姓名,后面按列名取值全部落空,只能按位置错位取。
解决:读取时显式指定编码并处理 BOM,兼容两种编码的写法是先用utf-8-sig试,失败再退回gbk。表头统一做一次strip().lstrip('\ufeff')清洗,再建列名到字段的映射表,不要直接用 CSV 的列名去row.get()。如果导入还是错位,最直接的办法是打开 CSV 用文本编辑器确认第一行的真实内容,看清楚分隔符到底是逗号还是分号。
现象:导入日志显示「总计 500 行、成功 480 行、跳过 20 行」,但用户找不到是哪 20 行出了问题。
原因:跳过时只continue了,没有把行号和原因记录下来,日志表只统计了数量没记明细。
解决:给ImportLog加一个detail字段存 JSON 字符串,记录每条跳过记录的{row_no, reason, raw}。排查时直接查日志表就能定位到具体行。这个改动很小,但对“数据对不上”这类问题的定位效率提升是巨大的。
5.2 运行相关:首次启动慢、模型占空间、写库被锁
现象:第一次启动或第一次调用抽取接口时,程序卡住五六秒没反应,之后才恢复正常,看起来像死机。
原因:jieba 首次使用时会加载词典并构建前缀树,这个初始化是同步阻塞的,词典有几 MB,冷启动就是要花这个时间。pypinyin 首次调用也会加载拼音表。这两个库的初始化成本都落在第一次调用上,不是代码写错了。
解决:在app.py启动时预热一次,主动调一下jieba.initialize()和随便算一个拼音,把初始化成本从「用户第一次请求」挪到「服务启动时」。另外jieba默认会往临时目录写缓存文件,容器化部署时如果目录只读会报错,需要指定jieba.dt.tmp_dir。如果打包体积敏感,可以把 jieba 换成pkuseg的小模型或者干脆只保留正则抽取,抽取精度会降但启动快很多。
现象:导入大批量数据时偶发sqlite3.OperationalError: database is locked,尤其是导入还没跑完、用户又在前端点了保存的时候。
原因:SQLite 的写锁是数据库文件级别的,一个写事务没提交,另一个写请求就只能等,默认等待超时是 5 秒,超了就抛这个错。导入逻辑如果在循环里逐条commit(),写事务持续时间长,和并发的写请求撞上的概率就高。另一个常见诱因是调试时开了两个进程同时连同一个 db 文件。
解决:把导入改成批量提交,比如每 200 条commit()一次,缩短单次写事务的持续时间;同时在config.py里给 SQLAlchemy 的连接串加上?timeout=30,把等待时间拉长。根本性的解法是导入期间加一个进程内的锁标志,前端检测到导入进行中就禁用保存按钮。如果确实需要多人同时写,那就该考虑换 PostgreSQL 了,SQLite 不适合这个场景。
现象:搜索结果里同一个人出现两次,点进去发现两条记录内容几乎一样。
原因:去重只在导入时跑,手动新增的联系人没有走查重逻辑;或者两条记录的手机号一个带+86一个不带,normalize_phone在不同的入口被调用的时机不一致。
解决:把find_duplicate的调用点从「仅导入」扩展到「所有写入路径」,包括手动新增和接口导入。同时确认所有入口都先过一遍normalize_phone再落库,不要有任何一条路径直接把原始手机号写进去。已经产生的重复记录用apply_groups旁边的批量查重脚本跑一遍,把可疑对列出来人工确认后合并。
6. 二次开发改造:把毕业设计改成能天天用的联系人工具
这套代码作为毕设是完整的,但真要日常用,有三处我会优先动。第一处是标签的表结构:现在是contacts.tags里的逗号分隔字符串,做标签统计要全表扫、要按标签过滤只能LIKE '%,xx,%',数据一多就慢。改成独立的contact_tags关联表,(contact_id, tag_id)联合主键,查询和统计都是索引命中,改动量大概半天。
第二处是搜索:LIKE '%q%'走不了索引,等联系人过万就会明显卡。可行的路子是启用 SQLite 的 FTS5 虚拟表,把姓名、拼音、公司、备注拼成一个搜索文档,查询改成MATCH。改动集中在建表和搜索接口两个文件,其他代码不用动。
第三处是导入:现在只吃 CSV,实际用的时候微信导出的联系人、手机备份的 vcf 都很常见。加一个 vcf 解析器,把FN、TEL、EMAIL、ORG几个字段映射到现有的parse_row输出结构上,复用后面的清洗和去重逻辑,不用改任何下游代码。
| 改造方向 | 主要改动位置 | 复用现有逻辑 | 预估工作量 |
|---|---|---|---|
| 标签拆表 | models/contact.py、api/contacts.py | 分组和去重不受影响 | 半天 |
| 搜索换 FTS5 | 新增迁移脚本、api/contacts.py | 返回结构不变 | 一天 |
| 支持 vcf 导入 | services/importer.py | 清洗、去重全部复用 | 半天 |
| 分组接模型 | services/grouper.py | 规则作为兜底保留 | 一天 |
举个最小改动的例子,给auto_group加一条新规则并验证,不用动任何其他文件:
# services/grouper.py 里追加一条,注意放在靠前的位置 GROUP_RULES.insert(0, (r"教育|培训|学校", "教育")) # 验证:不启动 Web,直接跑一段脚本看分组结果 from services.grouper import auto_group assert auto_group("某某教育科技有限公司") == "教育" assert auto_group("某某科技有限公司") == "同行" print("规则顺序校验通过")这个写法比在页面上点来点去快得多,规则表本来就是纯函数,能脱离 Flask 单独测。加完规则之后记得跑一次apply_groups()全量重算,否则老数据的group_name不会更新,页面上看不出变化,容易误以为改动没生效。
我自己的习惯是:每次动services/下的任何文件,先在终端跑一遍这样的断言脚本,确认输入输出符合预期,再去启动服务点页面。有一次我偷懒直接改完就刷新浏览器,结果规则顺序写反了,几百条记录的分组全被冲成「同行」,恢复数据花了半小时。从那以后我每次改规则表或者相似度阈值,都强制先跑一遍离线断言再碰数据库。希望这套拆解能帮你少走点弯路,把这份源码真正跑成自己能用的东西。
本文还有配套的精品资源,点击获取