news 2026/9/14 12:36:09

PyQt6+MySQL球员管理系统开发:数据库设计、CRUD与打包全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyQt6+MySQL球员管理系统开发:数据库设计、CRUD与打包全攻略

简介:这是一份基于Python、PyQt与MySQL实现球员信息管理的完整教学项目,适合正在学习桌面GUI开发与数据库联动应用的Python初学者,也可作为课程设计或毕业设计的实用参考。压缩包共收录21个文件,包含Python源代码、Qt界面文件(.ui)、Excel格式的NBA赛季数据表,以及运行效果截图和说明文档,整体仅343KB,轻量而完整。项目覆盖了数据库设计、数据增删改查、PyQt窗体布局与信号槽机制、事件驱动响应及异常处理等关键知识点,代码与数据配套整齐,解压后即可对照学习;其中Python文件承担业务逻辑和数据库连接,UI文件定义可视化界面,Excel数据表则提供真实比赛记录。目前已有197人学习下载,对想掌握“Python界面+MySQL存储”开发路径的读者来说,是一份很直观的实战样例。

1. 从 Excel 换到 PyQt + MySQL,先把数据库这张桌子摆正

球员信息管理这种项目,最容易被低估的不是界面,而是数据到底谁来管。很多团队搞了几年编队、训练、考勤,数据全堆在一张共享 Excel 里,字段越加越多,表头越改越乱,最后连“这个人今年 23 还是 24”都要现算。于是用 Python 做界面、PyQt6 做交互、MySQL 做存储的这套组合就成了最常见的升级路线:让录入和查询走数据库,让报表和展示走界面。适合谁呢,写课程设计的学生、给业余球队或青训班搭档案系统的 IT 爱好者,还有那些想把手动档 Excel 换成简单 C/S 架构的运维或开发。这个方案解决的核心问题很集中:一条球员记录从录入、修改、查询、统计,到多台电脑同时访问,不再靠文件锁和人工核对。

要把它做成而不是做成“一堆代码拼成的 demo”,关键在于 MySQL 的表结构、PyQt 的 model/view 机制,以及 CRUD 操作里最容易翻车的事务与重复数据处理。下面按我自己做这类桌面系统的顺序展开:先建表,再做界面,再打通增删改查,最后说打包和验收。

2. 球员表设计的 4 个决策:InnoDB、utf8mb4、索引与冗余字段取舍

建表这一步看似简单,但 80% 的后续问题都从这里来。球员信息管理的数据量通常不会大到要分库分表,但会涉及频繁的插入、更新和按条件过滤,而且字段类型不算少:姓名、出生日期、身高、体重、位置、所属队伍、号码、合同期、备注。设计表的时候,有四个决策要先拍板,否则写到后面每改一次结构都要动几十行代码。

2.1 引擎选 InnoDB,不要选 MyISAM

很多教程里给的建表语句不带ENGINE,或者随手用了 MyISAM,这在只读场景下没事,可一旦出现批量插入球员、导入历史档案、多人同时操作同一张表的情况,MyISAM 的表锁会直接影响界面卡顿,而且没有事务支持。MySQL 8.0 之后默认就是 InnoDB,但如果你之前的库是从低版本迁移或者有人显式改了默认引擎,建表时最好写清楚。

这类系统里真正需要事务的场景很典型:批量导入某个赛季的注册名单,中间有一条号码重复或出生日期不合理,你希望整批回滚而不是留下半份脏数据。InnoDB 的行级锁和事务回滚恰好接得住这个需求。数据量级一般就在十万行以内,InnoDB 的性能也完全不是瓶颈。

2.2 字符集选 utf8mb4,不要用 utf8

球员姓名存在极端情况:少数民族姓名可能有特殊字符,外援姓名有 accented 字母,备注里还有 emoji。MySQL 的utf8实际最多只支持三字节,遇到四个字节的字符就会报Incorrect string value错误。这里的坑很隐蔽,因为表结构在 Navicat 里看起来是 utf8,但真遇到特殊字符时写入直接失败。

建库时建议直接指定:

CREATE DATABASE IF NOT EXISTS player_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

unicode_ci的排序规则对英文大小写不敏感,适合姓氏模糊搜索和排序。如果库已经建好了,修改表级别的字符集也能补救,但已有数据可能因为列级字符集不一致出现乱码,不如新项目一步到位。

2.3 索引不是越多越好,优先覆盖查询高频词

球员信息系统的查询场景通常是这几种:按姓名模糊查、按所属队伍加号码精确查、按位置筛选、按年龄段统计。建索引最忌讳的是全表每个字段都加一遍索引,因为插入和更新时要同步维护索引树,索引越多写入越慢。

我一般会给这张表设计以下索引:

索引用途索引字段建议类型
主键player_idPRIMARY KEY
同一队伍内号码唯一(team_id, jersey_no)UNIQUE KEY
姓名模糊搜索(last_name, first_name)普通索引
按位置筛选统计position普通索引
按年龄统计birth_date普通索引

(team_id, jersey_no)联合唯一索引特别重要,它直接在数据库层面挡住“同队同号码”的脏数据,比在界面端查重更可靠。姓名索引不要建在整个 name 字段上做前缀索引那种取巧做法,因为球员姓名通常长度不会超过很多,直接建完整列索引即可。

2.4 建表 SQL 及字段说明

以下是一份可以落地的建表脚本,字段命名统一用snake_case,方便 PyMySQL 和 PyQt 代码里不区分大小写的映射:

CREATE TABLE IF NOT EXISTS player ( player_id INT UNSIGNED AUTO_INCREMENT COMMENT '球员内部ID', first_name VARCHAR(50) NOT NULL COMMENT '名', last_name VARCHAR(50) NOT NULL COMMENT '姓', position VARCHAR(20) DEFAULT NULL COMMENT '位置', jersey_no TINYINT UNSIGNED DEFAULT NULL COMMENT '球衣号码', birth_date DATE DEFAULT NULL COMMENT '出生日期', height_cm SMALLINT UNSIGNED DEFAULT NULL COMMENT '身高cm', weight_kg DECIMAL(5,2) UNSIGNED DEFAULT NULL COMMENT '体重kg', team_id SMALLINT UNSIGNED NOT NULL COMMENT '所属队伍ID', on_loan TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否外租', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '建档时间', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (player_id), UNIQUE KEY uk_team_jersey (team_id, jersey_no), KEY idx_name (last_name, first_name), KEY idx_position (position), KEY idx_birth (birth_date), CONSTRAINT fk_team FOREIGN KEY (team_id) REFERENCES team(team_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

TINYINT 用于号码和布尔字段,SMALLINT 用于身高这类不会超过 300 的数值,DECIMAL(5,2) 用于体重则避免浮点误差。on_loan用 TINYINT(1) 而不是 VARCHAR 的'Y'/'N',是为了让查询条件写WHERE on_loan = 1而不是WHERE on_loan = 'Y',省去一层字符转换的思考成本。

位置字段到底用固定编码还是自由文本,取决于球队是否需要后续统计。如果青训队有固定分档,建议建一个dict_position字典表存位置列表,用外键 ID 关联;如果只是业余队填着玩,直接 VARCHAR 就好,不要过度设计。

2.4.1 一个容易被忽略的边界:删除队伍时的关联处理

上面外键fk_team默认是RESTRICT,也就是存在球员记录时不能直接删除队伍。某些业务希望删队伍时把队员也清掉,可以把外键约束改成ON DELETE CASCADE,但这很危险,误删一个队伍会把整队球员档案全带走。我的习惯是:平时用 RESTRICT,明确需要清理历史数据时才手动执行联删 SQL,不在表结构层面做级联删除。

3. 用 PyQt6 搭主窗口:从接 MySQL 到显示球员列表的最小闭环

PyQt 部分的复杂度不在于控件拖拽,而在于 Qt 的 model/view 架构。很多从 Tkinter 转过来的人习惯性用QListWidgetQTableWidget直接塞数据,这在数据量小的时候没什么问题,但当球员人数到了几千,每次全量刷setItem的代价就太高,而且过滤、排序、更新单行都要写一堆手工逻辑。

这一阶段的目标就一个:建立数据库连接,把球员表显示到表格里,并且能响应简单的点击排序。

3.1 驱动选择和连接串写法

PyQt6 默认不带 MySQL 驱动,PyQt5 时代你需要额外装PyMySQL并做QSqlDatabase.addDatabase("QMYSQL"),但如果驱动编译环境不对,很容易出现Driver not loaded的错误。更稳妥也更常见的做法是:用PyMySQL作为纯 Python 的 MySQL 客户端,做数据访问层,然后用 Qt 的 model/view 承载查询结果。这样驱动匹配问题被彻底绕开,而且 PyMySQL 是纯 Python 包,pip 安装即可。

import pymysql conn = pymysql.connect( host="localhost", port=3306, user="player_admin", password="your_password", database="player_db", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, # 结果集以字典返回 )

charset这里也必须写utf8mb4而不是utf8,否则查询结果里的特殊字符会乱。DictCursor很适合后续代码里用字段名取值,可读性比元组下标好得多,不过代价是比默认的元组游标略慢,数据量在十万行以内这个差别可以忽略。

3.2 用 QSqlTableModel 最简单,但别碰排序和类型映射的雷

如果你想少写代码,PyQt 自带的QSqlTableModel确实是一条捷径。它直接把一张数据库表映射到QTableView,插入、修改、删除一行都有现成方法。一个能显示球员列表的最小代码是这样:

from PyQt6.QtSql import QSqlDatabase, QSqlTableModel from PyQt6.QtWidgets import QApplication, QTableView app = QApplication([]) # 注册数据库连接,这里用 QSQLITE 演示本地直接用 SQLite 文件 db = QSqlDatabase.addDatabase("QSQLITE") db.setDatabaseName("player.db") db.open() model = QSqlTableModel() model.setTable("player") model.setEditStrategy(QSqlTableModel.EditStrategy.OnManualSubmit) model.select() # 执行 SELECT * FROM player view = QTableView() view.setModel(model) view.show() app.exec()

setEditStrategy有三个可选策略:OnFieldChange 每改一格就写库,OnRowChange 行焦点切换时写库,OnManualSubmit 必须手动调用submitAll()才写库。对这个场景,建议用 OnManualSubmit,因为在单元格里编辑时很容易误触提交,写完才发现数据还没填完。缺点是你需要自己监听submitAll的返回值并在失败时revertAll()回滚。

QSqlTableModel的局限也很明显:多表 JOIN 查询无能为力,类型显示会依赖数据库驱动对类型的映射,日期字段经常显示成2024-05-01这种字符串。如果标题里说的“球员信息管理”只是入门级课程设计,这个方案完全可行;如果还要跨队伍统计、按赛事分组,建议直接用 QAbstractTableModel 加自定义查询。

3.3 自写数据访问层,把 SQL 和界面彻底分开

超过业务复杂度阈值后,我通常会把数据库访问收口到单独的PlayerRepository类里,PyQt 窗口只关心 UI。这样做的核心收益是:未来从 PyQt6 换到别的框架,或者把桌面端换成 Flask API,数据库逻辑一行不用为主界面改。哪怕不换框架,单元测试时也可以直接对 Repository 跑 SQL,不需要打开窗口。

下面是 Repository 的骨架,重点在参数化查询和结果集转换:

class PlayerRepository: def __init__(self, conn): self.conn = conn def find_by_name(self, keyword: str) -> list[dict]: with self.conn.cursor() as cursor: sql = """ SELECT player_id, last_name, first_name, position, jersey_no, birth_date, team_id, height_cm, weight_kg FROM player WHERE last_name LIKE %s OR first_name LIKE %s ORDER BY last_name, first_name """ like = f"%{keyword}%" cursor.execute(sql, (like, like)) return cursor.fetchall() def insert_player(self, data: dict) -> int: columns = list(data.keys()) placeholders = ", ".join(["%s"] * len(columns)) col_names = ", ".join(columns) sql = f"INSERT INTO player ({col_names}) VALUES ({placeholders})" with self.conn.cursor() as cursor: result = cursor.execute(sql, list(data.values())) self.conn.commit() return result

注意cursor.execute的参数是用%s占位符,无论字段是什么类型,传进来的 Python 变量都由 PyMySQL 转义,绝不要用 f-string 拼 SQL。find_by_name里的LIKE %s配合用户输入的关键字,可以防注入;如果你用cursor.execute(sql % (user_input))写出来的东西,那不是防注入,那是在自杀。数据库的LIKE通配符%_在用户输入中如果也要当作普通字符处理,需要再用escape过滤,对球员名这种短字段影响不大,但值得知道。

3.4 主窗口布局与加载策略

主窗口不需要花哨,满足三个功能点即可:顶部搜索框,中间表格,底部状态栏。代码如下:

from PyQt6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QLineEdit, QTableView, QStatusBar class PlayerMainWindow(QMainWindow): def __init__(self, repo: PlayerRepository): super().__init__() self.repo = repo self.setWindowTitle("球员信息管理") self.resize(960, 600) central = QWidget() layout = QVBoxLayout(central) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("输入姓名或号码搜索") self.search_edit.textChanged.connect(self.reload_players) self.table = QTableView() self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) layout.addWidget(self.search_edit) layout.addWidget(self.table) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) self.reload_players() def reload_players(self, _text: str = ""): keyword = self.search_edit.text().strip() rows = self.repo.find_by_name(keyword) if keyword else self.repo.find_all() # 这里把 rows 塞给 QTableView 的 model,具体见 3.5

textChanged信号每次敲键都会触发reload_players,如果查询很慢,界面输入就会卡。对球员表这个量级,单表 SQL 基本在几十毫秒完成,问题不大;但将来做复杂 JOIN 时,建议把textChanged改成editingFinished或者加一个定时器防抖,等用户停手再查。

3.5 把查询结果装进 QAbstractTableModel

与其依赖QSqlTableModel的弱映射,我更推荐自己实现一个薄薄的PlayerTableModel,它继承QAbstractTableModel,把 Repository 返回的list[dict]原样包装。关键方法是rowCountcolumnCountdataheaderData

from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt COLUMNS = [ ("player_id", "ID"), ("last_name", "姓"), ("first_name", "名"), ("position", "位置"), ("jersey_no", "号码"), ("birth_date", "出生日期"), ("team_id", "队伍ID"), ("height_cm", "身高cm"), ("weight_kg", "体重kg"), ] class PlayerTableModel(QAbstractTableModel): def __init__(self, rows: list[dict]): super().__init__() self._rows = rows def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self._rows) def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(COLUMNS) def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole): if not index.isValid(): return None row = self._rows[index.row()] key = COLUMNS[index.column()][0] value = row.get(key) if role == Qt.ItemDataRole.DisplayRole: if value is None: return "" return str(value) if role == Qt.ItemDataRole.TextAlignmentRole: # 数字列右对齐,观察更舒服 if key in ("jersey_no", "height_cm", "weight_kg"): return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter return None def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: return COLUMNS[section][1] return None

这里有个细节:QAbstractTableModeldata方法会被 Qt 频繁调用,里面不要做数据库查询、文件 IO 这类重操作。self._rows是一次查询的纯内存结果,数据展示才会流畅。parent.isValid()返回 True 时,Qt 询问的是树状子节点,表格模型不需要,直接给 0 行。

把 3.4 里的注释补齐,模型和视图的绑定只需三行:

model = PlayerTableModel(rows) self.table.setModel(model) self.table.horizontalHeader().setStretchLastSection(True)

表格列宽默认是均匀分配的,setStretchLastSection让最后一列占满剩余宽度,避免右侧留白过大。

4. 增删改查联调:重复录入、事务回滚与批量导入的三个坑

界面和数据库通了,接下来最耗时间的是 CRUD 的边界情况处理。球员信息管理和普通的企业用户管理相比,特殊之处在于“同一队伍号码唯一”和“批量导入赛季名单”这两个高频操作。它们牵涉到的坑很容易被忽略,写代码前先把这三个坑想清楚。

4.1 新增球员时的查重策略:数据库唯一索引优先

新增球员时最怕的是:界面提示“保存成功”,实际上因为号码冲突被数据库拒绝,但没查返回值,用户和程序都认为成功了。正确做法分两层。第一,数据库层面已经有(team_id, jersey_no)唯一索引,无论如何都不会出现重复,这层是底裤。第二,界面层在提交前主动查一次,给用户直接提示,而不是等数据库抛异常,体验更好。

def is_jersey_taken(self, team_id: int, jersey_no: int, exclude_player_id: int | None = None): with self.conn.cursor() as cursor: if exclude_player_id: sql = "SELECT 1 FROM player WHERE team_id=%s AND jersey_no=%s AND player_id!=%s LIMIT 1" cursor.execute(sql, (team_id, jersey_no, exclude_player_id)) else: sql = "SELECT 1 FROM player WHERE team_id=%s AND jersey_no=%s LIMIT 1" cursor.execute(sql, (team_id, jersey_no)) return cursor.fetchone() is not None

编辑球员时要排除自己,否则保存基本信息不动号码时,查重会误报“号码已存在”。这个exclude_player_id参数是容易被漏掉的。

4.2 事务:批量导入必须整体成功或整体失败

批量导入球员名单通常来自 Excel 或 CSV。逐条执行 INSERT 不放在事务里的话,一旦中间某条数据格式不对,前面已写入的记录就留在库里,而界面可能只提示“最后一条失败”,操作人员很难知道哪些进去了、哪些没进去。

PyMySQL 和 MySQL 的事务控制可以这样写:

def import_players(self, players: list[dict]) -> tuple[bool, str, int]: if not players: return False, "名单为空", 0 try: with self.conn.cursor() as cursor: for i, p in enumerate(players, start=1): sql = """INSERT INTO player (first_name, last_name, position, jersey_no, birth_date, height_cm, weight_kg, team_id, on_loan) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""" cursor.execute(sql, ( p.get("first_name"), p.get("last_name"), p.get("position"), p.get("jersey_no"), p.get("birth_date"), p.get("height_cm"), p.get("weight_kg"), p.get("team_id"), 0, )) if i % 100 == 0: # 每 100 条先预提交,避免长事务锁表影响其他客户端 self.conn.commit() self.conn.commit() return True, "导入成功", len(players) except pymysql.IntegrityError as e: self.conn.rollback() code = e.args[0] if code == 1062: return False, "号码重复导致导入失败,已全部回滚", 0 if code == 1452: return False, "存在无效的队伍ID,已全部回滚", 0 return False, f"唯一约束冲突或外键错误: {e}", 0 except Exception: self.conn.rollback() raise

IntegrityError捕获的是唯一键冲突和外键约束失败这两类最常见的批量导入错误。e.args[0]是 MySQL 错误码,1062 是Duplicate entry,1452 是Cannot add or update a child row,也就是外键不存在。这里还做了一个小优化:每 100 条commit()一次。如果名单有几千人,一条长事务从头开到尾,会把整张表相关的行锁住很久,其他窗口查同队球员会等锁;分段提交可以缩小锁范围,代价是分段点之间的记录无法整体回滚。业务上这通常可接受,因为错误基本集中在字段格式,而不是后半程的脏数据。

4.3 更新时的乐观锁设计

更新球员档案时,界面加载一条记录,用户改了 3 分钟才点保存。这期间另一位管理员可能已经把这条记录的号码改走了。如果保存时不做任何限制,后提交的人会直接覆盖先提交的人,产生典型的“更新丢失”。

常用解决方案是给表加version字段,更新时带上旧版本号:

UPDATE player SET first_name=%s, last_name=%s, version=version+1 WHERE player_id=%s AND version=%s

execute返回的受影响行数为 0,就说明期间有人改过了。界面可以弹提示:“该球员档案已被其他操作员修改,是否强制覆盖?”如果确认强制覆盖,就再执行一次不带version条件的更新。

这种做法在桌面 C/S 架构里很容易被忽略,但恰好是最能体现“5 年以上经验”的细节。球员少的时候不容易遇到,赛季注册期多人同时录入时,版本冲突几乎是必然的。

4.4 删除球员:软删除还是物理删除

删除一个球员后,历史考勤、参赛记录、训练统计这些数据如果都关联了外键,物理删除会把历史痕迹也抹掉。球员管理系统的常规做法是增加status字段,0 表示正常,1 表示离队,2 表示退役。列表查询默认只显示status=0,全部历史在后台留档。这要求建表时加字段,如果表已经建了,执行ALTER TABLE player ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT '0在队 1离队 2退役'即可。好处是以后做“离队球员再签约”时,不需要重新建档,只需要改 status 和队伍 ID。物理删除只在一年一度清理测试数据时使用。

5. PyInstaller 打包、日志与乱码治理:release 前必调的 4 个细节

功能全部跑通,离交付还差一步:把 Python 环境从开发机搬到用户机器上。这里的“用户”可能是球队教练,也可能是教务老师,你不能指望他们装 Python 和 MySQL。PyInstaller 是打包的常规方案,但有几个坑在打包前必须处理干净。

5.1 打包命令和资源路径

pyinstaller --noconfirm --onefile --windowed --name PlayerManager main.py

--onefile把 Python 解释器和依赖全部塞进单个 exe,缺点是启动时会先解压,第一次打开偏慢,但便于分发。--windowed是去掉命令行黑窗口的关键,否则用户每次启动都会弹一个 cmd 窗口。如果程序里用到了 QSS 样式文件、图标或 ini 配置,打包后这些外部文件不会自动跟着走,最好用--add-data "resources;resources"把整个资源目录打进去。

资源路径在开发环境和打包环境不一致,需要这样处理:

import sys import os def resource_path(relative: str) -> str: base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, relative)

sys._MEIPASS只在一文件模式打包后才存在,用它取临时解压目录;没有它时回退到当前工作目录。程序里读图标、样式、配置文件都要经由这个函数,而不是硬编码相对路径。

5.2 MySQL 免安装版环境变量

目标机器如果没有装 MySQL 服务,常见的补位做法是使用 MySQL 免安装版,解压目录后手动初始化。mysqld --initialize-insecure会生成一个无密码的 root 用户,用于本地开发可以,生产环境建议初始化后再指定密码。程序里连接数据库的用户名和密码不要硬编码在源码中,常见做法是放一个config.ini放到程序所在目录,首次启动时读取;如果数据库连不上,给出明确的错误提示而不是直接崩溃。

5.3 日志落盘,异常不黑屏

PyQt 程序的异常如果没被捕获,通常表现为界面突然关闭,没有留任何线索。加一个全局异常钩子,把堆栈写到日志文件里,成本低、排查价值高:

import logging import sys logging.basicConfig( filename="player_manager.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8", ) def handle_exception(exc_type, exc_value, exc_tb): logging.error("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb)) sys.__excepthook__(exc_type, exc_value, exc_tb) sys.excepthook = handle_exception

loggingencoding="utf-8"很关键,Windows 上默认编码可能不是 utf-8,日志里一旦出现中文就乱码甚至报错。sys.excepthook只能捕获未处理异常,QTimer回调里抛出的异常有时不经过这里,建议在QApplicationnotify上再做一层保护,不过对球员系统这个规模,excepthook 已经够了。

5.4 用一段 SQL 做发布前验证

代码写到最后,值得花两分钟做一次数据自检。下面这段 SQL 可以快速验证建表、索引和基本数据的正确性:

SELECT COUNT(*) AS total_players, COUNT(DISTINCT team_id) AS team_count, SUM(CASE WHEN jersey_no IS NULL THEN 1 ELSE 0 END) AS missing_jersey, MAX(CHAR_LENGTH(remark)) AS max_remark_len FROM player;

正常结果应该是missing_jersey=0,也就是每个球员都有号码。如果missing_jersey大于 0,说明界面录入时校验漏了字段。再跑一次SHOW INDEX FROM player;确认唯一索引存在,否则“同队同号码”的边界迟早会在线上炸开。这两个命令加起来不过几十秒,却能把最核心的约束和完整性检查到位。

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

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

Android Raspberry 请求 api 失败 iOS 请求成功【ssl 证书配置问题】

好几个月之前,我用 node.js 部署了一个 api,然后用树莓派 python 调用竟然失败了,没找到原因,就搁置了 最近写 React Native 项目 同一个 api https://hongweizhu.com:3000/x_mood Android 模拟器和真机请求失败 iOS 及模拟器请求…

作者头像 李华
网站建设 2026/9/14 12:30:12

无刷电机定子:磁场生成与控制的核心原理

1. 为什么说定子是无刷电机的“磁场心脏”——从物理本质讲起很多人一听到“无刷电机”,第一反应是转子上那几块永磁体在转,磁场是转子“自带”的,定子不过是绕几圈铜线、通个电、起个“推一把”的作用。这种理解错得离谱,而且错在…

作者头像 李华
网站建设 2026/9/14 12:29:20

font-awesome图标字体加载失败排查与构建优化:从woff2 404到性能压降

简介:开发工具 Font Awesome 压缩版样式文件,是面向 Web 前端开发者的图标字体工具资源,适用于需要在网页中快速加载矢量图标、减少图片请求的个人站点、企业官网或后台管理系统等场景。文件采用单一 CSS 格式,整个资源包仅含 1 个…

作者头像 李华
网站建设 2026/9/14 12:28:10

智能体评测系统架构与工程化落地实践

1. 项目概述:这不是一个“跑个benchmark”的玩具系统“智能体评测系统架构与工程化落地”——光看标题,很多人第一反应是:又一个论文里的评估框架?配几个指标图,跑几组LLM在HotpotQA或ToolAlpaca上的准确率&#xff0c…

作者头像 李华
网站建设 2026/9/14 12:26:43

大语言模型函数调用:从理论到实践的AI进化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 12:25:26

力扣 LeetCode 51. N皇后(Day14:回溯算法)

解题思路:每次进入backtracking都表示进入下一行每个backtracking中处理当前行的各个列,看各列是否合法isValid中因为是一行一行向下遍历的,所以对应的当前行一定满足条件,没有放置过其他皇后,只需要看对应的列是否满足…

作者头像 李华