news 2026/10/30 8:38:09

PyQt5+MySQL简易人事管理系统:数据建模、事务留痕与上线避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyQt5+MySQL简易人事管理系统:数据建模、事务留痕与上线避坑

简介:这份简易人事管理系统源码面向学习 PyQt5 桌面开发与 MySQL 数据库应用的学生及初级开发者,围绕企业人事信息管理场景,提供从登录鉴权到员工、部门、职称、薪资记录维护的完整功能示例,可用于课程设计、毕业设计参考或桌面管理系统入门练习。压缩包共 49 个文件,以 40 个 Python 源码为主体,配合 7 张界面图片、1 份 MySQL 建表脚本和 1 份说明文档,整体约 295KB,体量轻便,便于在本地快速部署与二次修改。目录按功能拆分出主窗口、登录表单、人员增删改查、部门与职称调整、薪资当前表与历史表等模块,并保留若干测试脚本,方便对照数据库操作与界面联动逻辑。已有 2689 人学习,适合希望理解 PyQt5 界面组织、SQL 表结构设计和基础 CRUD 流程的读者按模块阅读与调试。

1. 从一张用了三年的 Excel 台账说起

某公司的 HR 手上有份用了三年的 Excel 台账,两百多号人的入职时间、部门、合同到期日全在里面。问题出在有人离职时直接删行——月底核算工龄工资,三个已离职员工的记录凭空消失,对账对了整整两天。这类场景正是「简易人事管理系统(PyQt5 + MySQL)」要落地的地方:员工档案增删改查、部门与岗位维护、入转调离留痕、按条件检索和导出,全部收进一个双击就能打开的桌面程序,数据存在 MySQL 里而不是散落的表格文件中。它适合五十到五百人规模、没有预算上大型 HR 套件、但需要一个内部同事能自己维护的团队。下面按我实际搭过的一套方案,从表结构一路讲到界面接线和上线后翻车的地方。

2. 先把数据模型立住:员工、部门、异动三张核心表怎么设计

很多人拿到这个需求,第一反应是打开 Qt Designer 把界面拖出来,字段边拖边想。我踩过这个坑:界面做完第三天发现「工龄」要在四个地方显示,而当初只存了一个静态数字,改一次入职日期得手工同步四处。数据库表定错了,后面所有界面代码都是返工。所以这套系统的第一步不是画窗口,是把这三张表想清楚:谁是被管理的实体,谁是分类维度,谁负责记录变化。

2.1 员工主表与部门表的字段取舍

员工表最容易犯的错是「能算的都存一遍」。年龄、工龄、司龄、剩余合同天数,这些全部由日期算出来的字段,一旦落库就需要定时任务去刷,刷漏了就是数据不一致。我的原则是只存事实:出生日期、入职日期、转正日期、合同到期日,其余全部在查询层算。

第二个取舍是删还是改状态。台账时代删行很痛快,但人事数据几乎没有真正该删的——离职员工的历史工资、社保、仲裁记录都要能查。所以员工表里必须有status字段,离职只是状态迁移加一个leave_date,列表页默认过滤掉,需要时能翻出来。

字段类型为什么这么定
emp_noVARCHAR(20)工号是业务主键,对外露出的永远是它,不是自增 id
id_cardVARCHAR(18)加唯一索引,重复录入在数据库层就被拦下
dept_idINT UNSIGNED只存部门 id,部门改名不影响员工记录
hire_dateDATE工龄、司龄、年假天数的唯一计算基准
regular_dateDATE NULL为空即「仍在试用期」,比再存一个布尔字段可靠
statusTINYINT1 在职 / 2 试用 / 3 离职,用整数不用字符串,索引更省

部门表单独建,不要在主表里存部门名字符串。听起来啰嗦,但组织架构一调整、部门一合并,字符串方案就得写 UPDATE 全表刷一遍,还没有事务保护。部门表加parent_id支持两级足够,再深就是给未来挖坑——绝大多数中小企业的人力汇报关系两级就到底了。「最新网络热词」里常说的「降本增效」,落到这里就是:能用一张小表解决的维度,别塞进主表变成冗余字段。

2.2 异动流水表:为什么要留历史而不是直接改字段

这是整套系统里最容易被砍掉、又最容易在半年后让人后悔的一张表。场景很具体:某员工三月从技术部调到产品部,你执行了一条UPDATE employee SET dept_id = 5。三个月后财务要按部门分摊人力成本,问「三月中旬那半个月算哪个部门」,你的主表里只有一个当前值,历史是个黑匣子。

异动表的思路是只增不改:每一次关键字段变化写一条记录,记录变更类型、生效日期、变更前后的值、操作人。before_val和after_val用 JSON 存,好处是不用为每种变更类型建一堆列;坏处是查询时要解析,所以只放需要追溯的字段,别把整行塞进去。

CREATE TABLE employee_change ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, emp_id INT UNSIGNED NOT NULL, change_type VARCHAR(16) NOT NULL COMMENT 'hire/regular/transfer/leave', eff_date DATE NOT NULL COMMENT '生效日期,不是录入日期', before_val JSON DEFAULT NULL COMMENT '变更前,例如 {"dept_id":3,"job_title":"开发"}', after_val JSON DEFAULT NULL COMMENT '变更后', operator VARCHAR(32) NOT NULL COMMENT '操作人账号,审计要用', remark VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_emp_eff (emp_id, eff_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

参数上两个点值得说。eff_date和created_at必须分开:生效日期是业务时间,可能是补录上个月的事;创建时间是系统时间,用来查「谁在什么时候动了数据」。emp_id加eff_date的联合索引不能省,员工详情页打开时要按时间倒序拉这条人的全部异动,没索引就是全表扫。

JSON 类型要求 MySQL 5.7 以上。如果对方服务器还是 5.6,把两个字段换成TEXT并约定好序列化格式,读取时自己json.loads,功能不受影响,只是没法用 MySQL 的 JSON 函数做条件查询。

2.3 建库建表的最小 SQL 与字符集坑

三张表的建库脚本我一般放在项目根目录的sql/schema.sql,第一次运行由程序检测表是否存在,不存在就整段执行。字符集必须从建库那一刻就定成utf8mb4,不是建表时再改——库的默认字符集会一路继承下去,后面再 ALTER 表要重建,数据量大了很折腾。

CREATE DATABASE IF NOT EXISTS hr_demo DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE hr_demo; CREATE TABLE department ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 表示顶级部门', manager VARCHAR(50) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_name_parent (name, parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE employee ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 0 COMMENT '0 未知 1 男 2 女', id_card VARCHAR(18) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, dept_id INT UNSIGNED NOT NULL, job_title VARCHAR(50) DEFAULT NULL, hire_date DATE NOT NULL, regular_date DATE DEFAULT NULL COMMENT '空表示仍在试用期', contract_end DATE DEFAULT NULL, status TINYINT NOT NULL DEFAULT 2 COMMENT '1 在职 2 试用 3 离职', leave_date DATE DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_emp_no (emp_no), UNIQUE KEY uk_id_card (id_card), KEY idx_dept (dept_id), KEY idx_status_hire (status, hire_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里的取舍是唯一索引一次建到位。uk_emp_no和uk_id_card不只是为了查询快,更重要的是把「重复录入」这个错误挡在数据库层。界面层的校验总会有漏网的时候——用户粘贴、并发提交、程序异常,唯一索引是最后一道防线。idx_status_hire这个联合索引是给「查在职且入职时间在某区间」的报表用的,状态选择性低但配合日期范围后能走索引。

注意id_card允许为 NULL 但唯一。MySQL 的唯一索引不约束多个 NULL,所以没录身份证的员工不会互相冲突,录了就必须唯一,这个行为刚好符合人事场景。

3. 环境搭起来:PyQt5 与 MySQL 驱动连通的最小骨架

界面写得再漂亮,连不上数据库就是一张图片。这一章的目标很小:在一台干净的机器上,用最短的路径让 PyQt5 窗口里显示出SELECT COUNT(*) FROM employee的结果。别小看这一步,我在不同机器上见过卡在驱动、卡在字符集、卡在权限的,每种现象长得都不一样。

3.1 依赖安装与版本对齐

先说版本。PyQt5 用 5.15.x 系列,Python 用 3.8 到 3.11 之间,太新的 Python 版本在 Windows 上装 PyQt5 轮子偶尔会缺。数据库驱动这块有两条路,得先想清楚走哪条,因为后面所有代码都跟着变。

第一条是 Qt 原生的QSqlDatabase配QMYSQL驱动。好处是能和QSqlTableModel无缝配合,写列表页代码量极小;坏处是驱动不是 pip 装完就有的,PyQt5 自带的sqldrivers目录里虽然常见qsqlmysql.dll,但它依赖一个外部客户端库,位数必须和 Python 解释器一致,缺了或者不匹配就报QMYSQL driver not loaded,用 PyInstaller 打包后路径又变,是新手最容易卡住的地方。

第二条是纯 Python 的 PyMySQL 直接连库,自己写 Qt 的 Model 去渲染。多写大概两百行模型代码,换来的是打包零依赖问题。我一般推荐团队走这条,尤其是要交付给非技术同事双击运行的场景,少一个外部 DLL 就少一半售后。

pip install PyQt5==5.15.9 PyMySQL==1.1.0 DBUtils==3.0.3 openpyxl==3.1.2

四个包的职责分别是:界面框架、MySQL 协议实现、连接池、导出 Excel。版本号只是我当时跑通的一组,实际装的时候用pip index versions看一下当前可用的稳定版,不必死磕这几个数字,但 PyQt5 尽量锁在 5.15 大版本内,5.14 和 5.15 在高分屏缩放的默认行为上有差异,会让你的界面在同事的 4K 显示器上糊成一片。

3.2 用 PyMySQL 建连接池并做启动自检

连接池这个词听起来重,其实就是「开几个连接放着复用」。PyQt5 的界面里每次翻页、每次搜索都新建连接的话,MySQL 那边会看到大量短连接,wait_timeout一到就开始报连接已断开。DBUtils 的PooledDB帮你管这件事。

# db.py —— 全项目唯一的数据库入口,界面层只调用这里暴露的函数 import pymysql from dbutils.pooled_db import PooledDB # 参数集中在这里,方便交付时改成客户环境的地址 DB_CONF = dict( host="127.0.0.1", port=3306, user="hr_app", password="change_me", database="hr_demo", charset="utf8mb4", # 必须显式和建库一致,否则中文写进去是问号 cursorclass=pymysql.cursors.DictCursor, # 返回 dict,界面层按下标取字段 autocommit=False, # 所有写操作显式 commit,事务边界才可控 ) POOL = PooledDB( creator=pymysql, maxconnections=8, # 桌面程序 8 个并发连接绰绰有余 mincached=2, # 启动先建 2 个,避免第一次点查询时的等待感 maxcached=4, blocking=True, # 池满时阻塞等待,不直接抛异常 ping=1, # 每次取连接前 ping 一次,自动重连被服务端掐掉的空闲连接 **DB_CONF, ) def get_conn(): """从池里取一个连接,调用方负责 close(),close 实际是归还""" return POOL.connection() def health_check() -> tuple[bool, str]: """启动自检:连得上、库对、表齐、字符集对,四件事一次查完""" try: with get_conn() as conn, conn.cursor() as cur: cur.execute("SELECT VERSION() AS v") ver = cur.fetchone()["v"] cur.execute(""" SELECT COUNT(*) AS c FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN ('employee','department','employee_change') """) cnt = cur.fetchone()["c"] if cnt < 3: return False, f"数据库连上了(MySQL {ver}),但核心表缺失,请先执行 schema.sql" return True, f"就绪:MySQL {ver},字符集 {DB_CONF['charset']}" except Exception as e: return False, f"连接失败:{e}"

ping=1这个参数值得单独说。MySQL 默认wait_timeout是八小时,桌面程序经常是早上打开、中午吃饭、下午接着用,中间空闲久了连接被服务端单方面关掉,池子里还留着这个死连接,下一次查询直接抛(2006, MySQL server has gone away)。ping=1让 DBUtils 在取连接时先探活,探不通就换一个,这是我在现场排查了半小时才定位到的问题。

autocommit=False是另一个关键选择。默认自动提交意味着每条 INSERT 独立成事务,插入员工和插入异动流水这两步之间一旦程序崩了,就留下一条没有异动的孤儿记录。关掉自动提交,事务的边界由你显式控制。

自检函数在MainWindow.__init__里调一次,失败就弹一个模态框显示具体原因然后退出。宁可启动时明确报错,也不要让用户进到一个空白界面里点哪都没反应。

3.3 线程边界:查询为什么不能直接扔进主线程

本地库、两百条数据,直接在主线程查当然没事。真正的问题是数据攒到几千条、或者数据库在另一台机器上、或者某次网络抖动——主线程被阻塞,窗口变灰显示「未响应」,用户的第一次反应是狂点,于是事件队列堆一堆重复查询,彻底卡死。

Qt 的规矩很简单:所有界面控件只能在主线程碰,所有耗时操作放工作线程,两边用信号槽通信。PyMySQL 的连接对象不是线程安全的,一个连接不能同时被两个线程用,但连接池天然解决了这个问题——每个线程各自从池里取。

from PyQt5.QtCore import QThread, pyqtSignal from db import get_conn class QueryWorker(QThread): # 结果和错误都通过信号回主线程,绝不在子线程里直接改控件 finished_ok = pyqtSignal(list) failed = pyqtSignal(str) def __init__(self, sql: str, params: tuple = (), parent=None): super().__init__(parent) self.sql = sql self.params = params def run(self): try: with get_conn() as conn, conn.cursor() as cur: # 参数化查询,params 由 pymysql 负责转义,杜绝拼接注入 cur.execute(self.sql, self.params) self.finished_ok.emit(cur.fetchall()) except Exception as e: self.failed.emit(str(e))

用法上是worker.finished_ok.connect(self.fill_table),然后worker.start()。有个细节必须记住:QThread 对象要保留引用,比如挂到self._workers列表里,否则函数一返回对象被回收,线程跑一半直接消失,现象是「有时候查得出有时候查不出」,非常玄学。这也是血泪经验,第一次遇到时对着日志看了半天没头绪。

子线程里绝不能碰任何 QWidget。有人图省事在run()里直接self.table.setItem(...),运气好时界面正常,运气不好直接段错误闪退,没有任何堆栈信息。

4. 界面和数据怎么接:QTableView 加自定义模型的增删改查

界面这块,新手最容易掉进「逐格 setItem」的坑:拿到一批数据,双层循环往 QTableWidget 里塞,两百行还行,两千行开始肉眼可见地卡。Qt 的正确姿势是模型/视图,数据在模型里,视图只负责画可见的那几十行。

4.1 主窗口骨架与模块划分

主窗口我一般用「左侧导航 + 右侧 QStackedWidget」的结构。左侧 QListWidget 放五个入口:员工档案、部门管理、异动记录、统计报表、系统设置。右侧每个页面对应一个独立的 QWidget 子类,各自管自己的查询和刷新。

# main_window.py from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QListWidget, QStackedWidget) from pages.employee_page import EmployeePage from pages.dept_page import DeptPage from pages.change_page import ChangePage class MainWindow(QMainWindow): def __init__(self, user: str): super().__init__() self.user = user # 当前登录账号,写异动流水时要用 self.setWindowTitle(f"人事管理系统 - {user}") self.resize(1280, 760) self.nav = QListWidget() self.nav.setFixedWidth(180) self.stack = QStackedWidget() # 页面按导航顺序注册,索引和 nav 行号一一对应 self.pages = [EmployeePage(self.user), DeptPage(), ChangePage()] for title, page in zip(["员工档案", "部门管理", "异动记录"], self.pages): self.nav.addItem(title) self.stack.addWidget(page) self.nav.currentRowChanged.connect(self.stack.setCurrentIndex) self.nav.setCurrentRow(0) central = QWidget() layout = QHBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.nav) layout.addWidget(self.stack, 1) self.setCentralWidget(central)

页面拆成独立类的理由是可维护性。所有代码堆在 MainWindow 里,三个月后加一个字段要在两千行文件里翻半天。拆开之后,员工页面的改动不会碰到部门页面,出错时的堆栈也直接指向具体文件。

user参数一路传下去,是为了在异动表里记operator。审计字段现在看着没用,等哪天有人问「这条调岗记录是谁录的」,你就知道值了。

4.2 列表页用自定义模型,不要逐格塞数据

QAbstractTableModel看起来抽象,实际要实现的就四个方法:行数、列数、取单元格内容、取表头。剩下的排序、选中、滚动,视图全包了。

from PyQt5.QtCore import QAbstractTableModel, Qt, QVariant class EmployeeModel(QAbstractTableModel): # 列定义集中在一处:标题、数据库字段名、显示宽度,加字段只改这一行 COLUMNS = [ ("工号", "emp_no", 90), ("姓名", "name", 80), ("部门", "dept_name", 110), ("岗位", "job_title", 110), ("入职日期", "hire_date", 100), ("状态", "status_text", 70), ("手机", "phone", 120), ] def __init__(self, rows=None, parent=None): super().__init__(parent) self._rows = rows or [] def set_rows(self, rows): # beginResetModel/endResetModel 之间改数据,视图会自动重画且不丢选中 self.beginResetModel() self._rows = rows self.endResetModel() def rowCount(self, parent=None): return len(self._rows) def columnCount(self, parent=None): return len(self.COLUMNS) def data(self, index, role=Qt.DisplayRole): if not index.isValid() or role != Qt.DisplayRole: return QVariant() key = self.COLUMNS[index.column()][1] val = self._rows[index.row()].get(key) return "" if val is None else str(val) def headerData(self, section, orientation, role=Qt.DisplayRole): if orientation == Qt.Horizontal and role == Qt.DisplayRole: return self.COLUMNS[section][0] return QVariant() def row_at(self, row: int) -> dict: """双击某行时,用这个把整条记录取出来开编辑窗""" return self._rows[row]

beginResetModel/endResetModel是必须成对调用的。直接在self._rows上赋值而不通知视图,界面就还是老数据,你会怀疑查询是不是没执行——实际上数据早换了,只是视图不知道。这个现象我见过不止一次,调试时先打印rowCount()就能确认。

状态字段在 SQL 里就用CASE WHEN转成中文,别在 Python 里再包一层映射函数。SELECT status, CASE status WHEN 1 THEN '在职' WHEN 2 THEN '试用' ELSE '离职' END AS status_text这样查出来直接可用,模型的data()保持干净。

4.3 入职与离职表单:校验放哪一层

校验必须做三层,缺一层都会漏。界面层负责即时反馈,输入框失焦时提示「手机号格式不对」,这是体验;服务层在提交前再校验一次,因为界面可能有绕过路径,比如程序内部调用;数据库层用唯一索引和 NOT NULL 兜底,这是最后一道。

重点是入职和离职这两类操作要包在一个事务里,员工表动一次,异动表记一条,要么都成,要么都不成。

# services/employee_service.py from datetime import date from db import get_conn def add_employee(data: dict, operator: str) -> int: """新增员工 + 记录入职异动,同一事务内完成""" required = ("emp_no", "name", "dept_id", "hire_date") missing = [k for k in required if not data.get(k)] if missing: raise ValueError(f"缺少必填字段:{', '.join(missing)}") with get_conn() as conn: try: with conn.cursor() as cur: cur.execute(""" INSERT INTO employee (emp_no, name, gender, id_card, phone, dept_id, job_title, hire_date, regular_date, contract_end, status) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,2) """, (data["emp_no"], data["name"], data.get("gender", 0), data.get("id_card") or None, data.get("phone"), data["dept_id"], data.get("job_title"), data["hire_date"], data.get("regular_date"), data.get("contract_end"))) emp_id = cur.lastrowid cur.execute(""" INSERT INTO employee_change (emp_id, change_type, eff_date, before_val, after_val, operator) VALUES (%s, 'hire', %s, NULL, %s, %s) """, (emp_id, data["hire_date"], '{"dept_id":%d}' % data["dept_id"], operator)) conn.commit() # 两表都成功才提交 return emp_id except Exception: conn.rollback() # 任一失败整体回滚,不留孤儿数据 raise

id_card那里特意写了data.get("id_card") or None。空字符串在 MySQL 的唯一索引里是一个具体值,第二个没填身份证的员工就会撞唯一键报错——这是我在测试环境造数据时踩到的,三个人都没填身份证,第三个人录入直接失败。空串必须转成 NULL。

异常处理里raise不能吞。上层要区分「唯一键冲突」和「连不上库」两种情况,前者提示用户「工号已存在」,后者提示「数据库连接失败」,统一弹一个「操作失败」等于什么都没说。

4.4 搜索、分页与导出

数据量一过千,列表就得加分页,否则每次进页面都是全表拉一遍。分页的写法很朴素,LIMIT %s OFFSET %s,关键在条件拼装必须参数化。

def search_employees(keyword="", dept_id=None, status=None, page=1, size=50): where, params = ["1=1"], [] if keyword: # 姓名和工号都用前缀匹配,能走索引,别用 %kw% where.append("(e.name LIKE %s OR e.emp_no LIKE %s)") params += [keyword + "%", keyword + "%"] if dept_id: where.append("e.dept_id = %s") params.append(dept_id) if status: where.append("e.status = %s") params.append(status) sql = f""" SELECT e.id, e.emp_no, e.name, d.name AS dept_name, e.job_title, e.hire_date, e.phone, CASE e.status WHEN 1 THEN '在职' WHEN 2 THEN '试用' ELSE '离职' END AS status_text FROM employee e LEFT JOIN department d ON d.id = e.dept_id WHERE {' AND '.join(where)} ORDER BY e.hire_date DESC LIMIT %s OFFSET %s """ params += [size, (page - 1) * size] with get_conn() as conn, conn.cursor() as cur: cur.execute(sql, params) # 条件全走占位符,天然防注入 return cur.fetchall()

LIKE 'kw%'和LIKE '%kw%'的性能差别在万级数据上非常明显,前者能走索引,后者必然全表扫。人事系统里搜工号、搜姓名,前缀匹配够用了,用户真想模糊搜,就让他多打两个字。

导出的坑在编码。用utf-8-sig写 CSV,Excel 打开才不乱码;直接utf-8写,中文全是方块。

import csv def export_csv(rows, path): headers = ["工号", "姓名", "部门", "岗位", "入职日期", "状态", "手机"] keys = ["emp_no", "name", "dept_name", "job_title", "hire_date", "status_text", "phone"] # utf-8-sig 会写入 BOM,Excel 靠它识别编码,这是不乱码的关键 with open(path, "w", newline="", encoding="utf-8-sig") as f: w = csv.writer(f) w.writerow(headers) for r in rows: w.writerow([r.get(k, "") for k in keys])

导出的数据量如果超过几万行,别一次性fetchall到内存,用游标迭代逐行写,否则内存峰值能到几百兆。

5. 避坑排查:上线当天最容易翻车的五个问题

这一节全部来自实际交付现场。每一条都是「明明本地跑得好好的,一到客户机器上就不对」的典型。

5.1 中文全变问号:建库 utf8、连接却用 latin1

现象是插入的员工姓名在界面里正常显示(因为内存里是对的),但关掉程序重新打开全是???。原因几乎都是连接字符集没设。PyMySQL 的charset参数默认值在部分版本里不是 utf8mb4,建库时设的默认字符集只管表结构,管不到客户端连接。解决办法是在DB_CONF里显式写charset="utf8mb4",并且建库、建表、连接三处保持同一个值。已经写进脏数据的,只能按原始数据重导,字符集不匹配的转换是不可逆的。

5.2 打包成 exe 后启动就报驱动缺失

现象是源码运行正常,PyInstaller 打出来的 exe 双击后弹窗说找不到数据库驱动。原因是 PyQt5 的sqldrivers插件目录没被打包进去,或者打包进去了但程序运行时的搜索路径找不到它。解决路径有两条:一是走本文的 PyMySQL 方案,压根不依赖 Qt 数据库插件,打包时用--hidden-import pymysql就够;二是坚持用QSqlDatabase的话,得在 spec 文件里把PyQt5/Qt5/plugins/sqldrivers整个目录加进 datas,并在程序入口设置QApplication.addLibraryPath。我的建议是前者,省下的排查时间够把统计报表写完。

5.3 连点两次提交,数据库里多一条重复记录

现象是用户手快,点「保存」两下,员工表里出现两条工号相同的记录——如果唯一索引建了应该报错,但用户看到的是弹了两个红框,体验很差。原因在于按钮点击后没有立即禁用,第一次请求还在事务里,第二次已经进来了。解决办法是提交开始就把按钮setEnabled(False),在finally里恢复,同时数据库唯一索引必须存在作为兜底。界面防重是体验,唯一索引是数据正确性,两者都不能少。

5.4 试用期到期提醒差一天

现象是明明 3 月 1 日入职、约定试用期三个月,系统在 5 月 31 日就提示到期。原因是用了DATEDIFF(NOW(), hire_date) >= 90这种按天数硬算的写法。正确做法是用DATE_ADD(hire_date, INTERVAL 3 MONTH)直接算出到期日再比对,MySQL 的日期加法会自动处理月末不足的情况。更稳妥的是入职时就把regular_date算好落库,查询时只做regular_date <= CURDATE() AND status = 2这样一个简单条件,避免每次查询都做日期运算。

5.5 空闲一中午,下午点查询就报连接已断开

现象是程序早上开着没人动,午休回来点任何查询都报(2006, MySQL server has gone away),重启程序又好了。原因是服务端wait_timeout到期单方面关闭了空闲连接,而客户端池子里还留着这个死连接。解决办法是PooledDB里设置ping=1,取连接时先探活;同时把maxcached调小,让池里不会长期驻留太多空闲连接。如果服务端的wait_timeout可以调,把它从默认的 28800 秒降到 600 秒左右,反而能让连接更快被回收重建,减少死连接窗口。

6. 收口技巧:登录分级与工龄口径的两个可验证写法

功能跑通之后,真正决定这套系统能不能长期用的是两件小事:谁能改数据,以及报表上的数字跟 HR 手工算的对不对得上。

权限别做复杂的角色引擎,两个字段就够。用户表存username、pwd_hash、role,role 只有admin和viewer两档。密码用hashlib.pbkdf2_hmac加盐存,不要明文也不要裸 MD5:

import hashlib, os, hmac def make_hash(pwd: str, salt: bytes = None) -> tuple[str, str]: salt = salt or os.urandom(16) # 20 万次迭代在普通办公机上约 100ms,登录感知不明显,暴力破解成本足够高 dk = hashlib.pbkdf2_hmac("sha256", pwd.encode(), salt, 200_000) return dk.hex(), salt.hex() def verify(pwd: str, stored_hash: str, salt_hex: str) -> bool: dk = hashlib.pbkdf2_hmac("sha256", pwd.encode(), bytes.fromhex(salt_hex), 200_000) # 用 compare_digest 做常数时间比较,避免时序侧信道 return hmac.compare_digest(dk.hex(), stored_hash)

登录页拿到用户名后查一行记录,verify通过就进主窗口,把 role 传下去。viewer 角色下,所有编辑按钮统一走一个apply_permission()在页面初始化时禁用,别在每个按钮的回调里写 if,漏一个就是越权入口。

工龄口径统一到一条 SQL 里,所有报表都调它,不要各处各写一遍:

口径写法用途
司龄(年)TIMESTAMPDIFF(YEAR, hire_date, CURDATE())年假天数、工龄工资
在职天数DATEDIFF(IFNULL(leave_date, CURDATE()), hire_date)成本分摊、人均产值
试用期剩余DATEDIFF(regular_date, CURDATE())到期提醒,负数即已过期
合同剩余DATEDIFF(contract_end, CURDATE())续签预警,小于 30 天标红

验证方法很土但有效:让 HR 从旧台账里随机抽十条,覆盖跨年入职、月中离职、试用期未转正三种情况,手工算出四个数字,跟系统跑出来的对比。任何一条对不上,先查口径再查代码——我遇到过的三次不一致,两次是口径定义不同(比如离职当月算不算在职工龄),只有一次是真 bug。

这套系统我前前后后改了三版,最大的教训是:第一版花了两周把界面做得挺像样,结果表结构里存了「年龄」和「工龄」两个字段,第三个月要按季度统计人力成本时发现历史数据全是错的,只能全部推倒重算。现在我的习惯是,任何一张业务表动手写之前,先问自己一句「这个字段半年后会不会有人问它是怎么来的」,答不上来就说明该拆成流水表了。

希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 8:35:58

S1 data forwarding测试:TEID、End Marker与抓包

简介&#xff1a;围绕S1数据转发测试整理的docx小结&#xff0c;面向移动通信核心网、无线侧测试人员及协议分析学习者&#xff0c;聚焦切换过程中用户面数据连续性验证。内容从切换前后的数据流向入手&#xff0c;梳理SGW、源eNodeB与目标eNodeB间的转发路径&#xff0c;结合T…

作者头像 李华
网站建设 2026/10/30 8:35:46

S1切换数据转发验证:用TEID与End Marker检查点定位业务中断

简介&#xff1a;围绕S1数据转发测试的docx小结&#xff0c;面向移动通信核心网、无线侧测试工程师及LTE切换学习者&#xff0c;聚焦切换过程中用户面数据连续性验证这一关键问题。内容从数据流向入手&#xff0c;梳理切换前SGW经源eNodeB至终端、切换中经SGW回传再转目标eNode…

作者头像 李华
网站建设 2026/10/30 8:33:34

TIF/TIFF多页合并与拆分:位深、分块和BigTIFF避坑指南

简介&#xff1a;TIF/TIFF文件的合并及拆分工具是一份面向图形设计、扫描归档与印刷从业者的小型Windows实用程序&#xff0c;用于把多幅图像拼合为多页TIFF&#xff0c;或从多页文件中按页提取单页&#xff0c;并兼顾元数据保留与批量处理需求。压缩包共3个文件&#xff0c;以…

作者头像 李华
网站建设 2026/10/30 8:33:29

Java Socket+Swing局域网聊天程序设计与实现

简介&#xff1a;ACCP5.0第一学期结业项目是一套面向编程初学者的完整社交应用实战源码&#xff0c;聚焦在线聊天场景&#xff0c;涵盖好友管理、实时通信、头像上传、陌生人权限控制及个人资料维护等核心功能模块&#xff0c;助力学员系统掌握C#开发、WinForms界面设计、SQL S…

作者头像 李华
网站建设 2026/10/30 8:29:02

苏州工厂宣传片拍摄怎么选?7个维度让片子带来询盘

苏州工厂宣传片拍摄怎么选&#xff0c;先看一个判断&#xff1a;这支片子是给谁看的、看完之后对方下一步做什么。如果选片方只比设备型号和报价&#xff0c;最后大概率得到一支画面漂亮、却带不来询盘的“形象片”。对苏州的制造企业来说&#xff0c;真正值得选的&#xff0c;…

作者头像 李华
网站建设 2026/10/30 8:27:45

频道、花名册与 @ 路由:把多智能体协作塞进聊天界面

频道、花名册与 路由&#xff1a;把多智能体协作塞进聊天界面 【免费下载链接】OpenMausBot Open-source Grok Bot alternative with a virtual machine that bots can use 项目地址: https://gitcode.com/GitHub_Trending/op/OpenMausBot 当“多智能体”从演示 demo 走…

作者头像 李华