C语言+MySQL控制台图书管理系统:从Demo到可验收课设

发布时间:2026/11/3 8:09:46
C语言+MySQL控制台图书管理系统:从Demo到可验收课设

简介:这份文档面向C语言与MySQL数据库入门学习者,围绕一个可在控制台运行的简易图书管理系统展开,帮助读者把基础语法、数据库连接和SQL增删改查串联成一个小型项目实践。压缩包内共1个doc文件,约137KB,以单一文档集中呈现系统结构、功能说明与关键代码示例,便于本地对照复现。内容覆盖会员借阅、归还、查询图书,以及管理员添加、修改、删除图书等模块,并涉及mysql_query、mysql_error、结果集获取与释放等数据库交互流程;示例还展示按编号借还、按全部或关键词检索、录入书名作者及更新借阅状态等操作,也提示结果集使用后应及时释放。目前已有1574人学习,适合作为课程设计、课后练习或入门练手项目的参考资料,从中理解菜单循环、条件判断、字符串拼接SQL和基础错误处理。

1. 从一份能跑起来的课设说起:C 语言控制台配 MySQL 的图书管理系统

带过几届课设,最容易被选、也最容易在答辩现场翻车的题目,就是 C 语言结合 MySQL 的图书管理系统。选它图的是控制台不用碰图形界面,MySQL 又能把数据库课的知识点全串起来,对初学者来说,这是少数能把 C 语言、SQL、连接层 API 一次练完的题目。麻烦在于大部分人交上来的版本只有半条命:菜单能跑,借书能改状态,可查状态那句 SQL 字段名拼错了,缓冲区开 20 字节去接 200 字节的语句,账号密码明文躺在源码里。这套方案要做的,是把一个「能演示」的 Demo 补成能答辩、能验收的完整课设:两张核心表的数据模型、两种角色的权限分流、增删改查四个动作的闭环,以及一条照着就能搭起来的实现路径。适合手里有 C 语言和数据库基础、但不知道项目怎么收口的人。

2. 数据模型与需求边界:两张核心表撑起四个业务动作

动手之前先把边界划清楚。控制台程序最怕的需求描述是「做一个图书管理系统」,因为这句话可以无限扩张:要不要预约、要不要罚款、要不要用户注册。最后交上来的东西功能列表写了两页,能跑通的只有三个。我一般会先把需求压到「四个动作、两种角色、两张表」,先把闭环跑通,再在文档里写扩展方向,答辩时反而显得思路清楚。

2.1 角色与路径划分:会员四条、管理员五条

会员的操作范围是:借书、还书、查书、退出。管理员的范围是:加书、改书、删书、查书、退出。这两套动作的差别在于,会员只改变书的状态字段,管理员才动书本身的数据。这条界线必须写死在函数里,而不是靠菜单文案暗示——很多人把「删除图书」也放到会员菜单里,测试的时候没事,一答辩被问「会员凭什么删书」就说不清楚。

代码组织上,两个函数各管一摊:userfunction收会员的动作,adminfunction收管理员的动作,主函数只负责连接数据库、登录校验、按角色把人送进对应函数。这样做的好处是,任何一次权限检查都只需要看一个入口,不用在每个分支里重复判断「当前是不是管理员」。

菜单的循环不要用递归,也不要用全局标志位反复穿透。原文用goto ll回到菜单开头,这种写法在小体量课设里其实是能接受的,因为它短、直观、不出栈。真正的风险在后面第 5 章会讲到:goto配合scanf残留,很容易出现菜单疯狂刷屏的连锁反应。

2.2 book 与 userinfo 的字段设计:状态字段怎么定

表结构是整个项目里最该先定下来的东西,后面所有 SQL 都是围绕它写的。按原文的字段走,两张表长这样:

-- 图书表:bid 是主键,bstate 只允许两个取值 create table if not exists book ( bid int not null primary key comment '图书编号', bname varchar(60) not null comment '书名', bauthor varchar(40) not null comment '作者', bstate varchar(10) not null default '未借出' comment '借阅状态' ) engine=InnoDB default charset=gbk; -- 用户表:uid 是整型账号,upwd 是整型密码,够课设用 create table if not exists userinfo ( uid int not null primary key comment '账号', upwd int not null comment '密码', uname varchar(20) comment '昵称' ) engine=InnoDB default charset=gbk; -- 一条管理员账号,账号固定为 1001 insert ignore into userinfo(uid, upwd, uname) values (1001, 1001, 'admin');

几个字段值得单独说。bstate用varchar存「已借出 / 未借出」而不是tinyint,原因是控制台打印的时候不用做映射,查询结果直接就能看懂,调试成本最低;代价是拼写错一个汉字就查不出来,所以建表时给默认值、代码里用常量字符串统一表示状态,别在两处地方写不同的字。bid用int而不是自增,是因为课设演示时希望编号可控,借书直接输 1001 这类数字;但这也意味着添加图书时编号冲突要自己处理,第 4 章会给具体做法。

upwd用int是这个项目里比较典型的偷懒做法,好处是scanf("%d")直接读,不用处理字符串;代价是密码不能带前导零,也没法做哈希。课设场景下可以用,但文档里必须写一句「生产环境应改为哈希存储」,这是答辩时最容易被追问的点之一。

注意:建表和连接必须用同一个字符集。建表用 gbk 而连接用 utf8,表现出来就是查询结果里所有中文全变成问号,而且只在某些机器上出现,非常折腾。

2.3 连接层落地:从 mysql_init 到字符集设置

连接数据库的代码只在main里出现一次,但它决定了后面所有操作的成败。原文的写法是mysql_init(0)加mysql_real_connect,然后mysql_query(pconn, "set names gbk")。这个顺序没错,但缺了三样东西:连接失败之后的处理、字符集的可靠设置方式、以及连接超时。

#include <mysql.h> #include <stdio.h> /* 打开数据库连接,成功返回句柄,失败返回 NULL */ MYSQL *db_open(void) { MYSQL *conn = mysql_init(NULL); if (conn == NULL) { fprintf(stderr, "mysql_init 失败:内存不足\n"); return NULL; } /* 数据库服务刚启动时连不上是常事,给 5 秒超时比默认值更可控 */ unsigned int timeout = 5; mysql_options(conn, MYSQL_OPT_CONNECT_TIMEOUT, &timeout); /* 参数依次为:句柄、主机、账号、密码、库名、端口、socket、标志位 */ if (mysql_real_connect(conn, "localhost", "root", "root", "bookinfo", 3306, NULL, 0) == NULL) { fprintf(stderr, "连接失败:%s\n", mysql_error(conn)); mysql_close(conn); return NULL; } /* 用 API 设字符集,比执行 set names 语句更早生效,也不占一次查询 */ if (mysql_set_character_set(conn, "gbk") != 0) { fprintf(stderr, "字符集设置失败:%s\n", mysql_error(conn)); } return conn; }

这段代码的每个参数都有实际含义,答辩时能讲清楚就是加分项。主机写localhost表示走本机;端口写 3306 是默认端口,写成 0 也等价;第七个参数unix_socket在 Windows 下传NULL即可,因为用不上;最后一个client_flag传 0,需要多语句执行时才改成CLIENT_MULTI_STATEMENTS。

字符集这一步,我一般会推荐用mysql_set_character_set而不是执行set names gbk。区别在于前者在客户端就把编码约定好了,后者要额外发一次查询;更重要的是,如果连接本身失败了,set names那条语句会给出一个和真实原因无关的错误,把排查方向带偏。原文先打印「连接成功」再设编码,这个顺序是合理的,但连接失败时它也会打印「连接成功」,这是第一个要修的地方。

mysql_init(NULL)里的参数在旧版本里可以传已有的句柄,现在的 API 基本都传 NULL。传 0 和传 NULL 效果一样,但写成 NULL 更清楚。

3. 主流程实现:登录分流与借还书的状态闭环

主流程是这个项目里唯一一条贯穿所有模块的线,它的顺序是:连接数据库、读账号密码、查用户表、按角色进函数、函数内部循环、退出时关连接。任何一环出错,表现出来的现象都差不多——「登录没反应」或者「一直提示密码错误」。所以这一段代码要写得能让错误自己说话。

3.1 登录校验:一条查询决定走哪个函数

登录的本质是一次带条件的查询:账号和密码同时匹配,就返回一行记录。原文用mysql_num_rows(res2) > 0判断,思路是对的,但结果集释放漏了,而且从row[1]里再取一次 uid 是多余的。

/* 返回 1 表示登录成功,0 表示账号密码不匹配,-1 表示数据库出错 */ int login(MYSQL *conn, int uid, int upwd, int *is_admin) { char sql[256]; MYSQL_RES *res; int rows; snprintf(sql, sizeof(sql), "select uid from userinfo where uid = %d and upwd = %d", uid, upwd); if (mysql_query(conn, sql) != 0) { fprintf(stderr, "登录查询失败:%s\n", mysql_error(conn)); return -1; } res = mysql_store_result(conn); if (res == NULL) { /* 取结果集失败也要报出来 */ fprintf(stderr, "取结果集失败:%s\n", mysql_error(conn)); return -1; } rows = (int)mysql_num_rows(res); mysql_free_result(res); /* 不管成功失败,必须释放 */ if (rows == 0) { return 0; } *is_admin = (uid == 1001); /* 管理员用固定账号识别 */ return 1; }

这里有两个设计取舍值得写进文档。第一,判断管理员身份用的是用户输入的账号,而不是从结果集里取出来的字段,因为结果集只select了uid一列,取row[1]会越界读到 NULL;原文那段atoi(row[1])就是踩了这个坑,只是刚好结果没被用到,测试时看不出来。第二,查询只select uid而不select *,减少列数能降低取错列的概率,控制台程序里这种「最小查询」习惯养成之后,后面所有查询都会稳很多。

密码比对放在 SQL 里做,而不是查出来在 C 里比,原因是能省一次字符串比较,也避免把用户表整行读进内存。代价是不能做哈希校验,这个在第 4 章末尾还会提。

提示:登录失败后问一句「是否重新登录」,读入Y或y再回去,这个交互在原文里已经有了,保留它,答辩演示时比起直接退出体面很多。

3.2 借书与还书:先查后改,两次访问数据库

借书是这个项目里唯一一个「需要根据当前状态决定要不要执行」的动作,也是最容易写错的地方。原文的思路是先把状态查出来,用strcmp判断,再更新;但代码里select bstaet字段名拼错了,mysql_store_result又放在了mysql_query前面,结果查出来的永远是空,判断逻辑形同虚设。

正确的顺序是两次独立的数据库访问:第一次查状态,第二次带条件更新。

/* 返回值:0 成功,-1 书不存在,-2 已被借出,-3 数据库错误 */ int borrow_book(MYSQL *conn, int bid) { char sql[256]; MYSQL_RES *res; MYSQL_ROW row; /* 第一步:查当前状态,用 bstate 而不是拼错的字段名 */ snprintf(sql, sizeof(sql), "select bstate from book where bid = %d", bid); if (mysql_query(conn, sql) != 0) { fprintf(stderr, "查询状态失败:%s\n", mysql_error(conn)); return -3; } res = mysql_store_result(conn); if (res == NULL) { fprintf(stderr, "取结果集失败:%s\n", mysql_error(conn)); return -3; } row = mysql_fetch_row(res); if (row == NULL) { /* 一行都没有,说明编号不存在 */ mysql_free_result(res); return -1; } if (strcmp(row[0], "未借出") != 0) { /* 原代码这里判断的是"已借出" */ mysql_free_result(res); return -2; } mysql_free_result(res); /* 第二步:把状态条件写进 where,防止两次操作之间被别人改掉 */ snprintf(sql, sizeof(sql), "update book set bstate = '已借出' where bid = %d and bstate = '未借出'", bid); if (mysql_query(conn, sql) != 0) { fprintf(stderr, "更新失败:%s\n", mysql_error(conn)); return -3; } if (mysql_affected_rows(conn) == 0) { /* 影响 0 行说明状态已经变了 */ return -2; } return 0; }

关键点在于第二条 SQL 的where里带了bstate = '未借出'。控制台单人操作其实碰不到并发,但这个写法能顺手解决另一个问题:如果第一步查完状态、第二步更新前,程序出了任何岔子,更新语句本身也不会把已借出的书再写一遍。mysql_affected_rows的返回值就是用来兜这个底的,返回 0 表示没有行被改动。

还书的实现更简单,一条update加mysql_affected_rows判断就够了。但要注意,还书不需要检查当前状态——重复还书虽然没意义,但把它当成错误处理反而会让演示难做,直接更新成「未借出」并提示成功,是课设里更稳妥的选择。

原文用sprintf拼 SQL,缓冲区声明成 200 字节。这个长度对单条 update 够用,但加上书名和作者就悬了,第 4 章会统一换成snprintf。

3.3 查询模块:全量列表与模糊搜索共用一个打印函数

查询被会员和管理员各写了一遍,代码几乎一样,这类重复在课设里很常见,也是最容易在维护时出问题的地方——改了一个忘了另一个。合理的做法是抽一个「执行并打印」的函数,两边都调它。

/* 执行已经发出去的查询,并把结果按列对齐打印出来 */ void print_result(MYSQL *conn) { MYSQL_RES *res = mysql_store_result(conn); MYSQL_ROW row; unsigned int i, n; if (res == NULL) { if (mysql_field_count(conn) == 0) { /* 不是查询语句 */ printf("影响行数:%llu\n", mysql_affected_rows(conn)); return; } printf("取结果集失败:%s\n", mysql_error(conn)); return; } n = mysql_num_fields(res); MYSQL_FIELD *fields = mysql_fetch_fields(res); for (i = 0; i < n; i++) { printf("%-14s", fields[i].name); /* 先打表头 */ } printf("\n"); while ((row = mysql_fetch_row(res)) != NULL) { unsigned long *len = mysql_fetch_lengths(res); for (i = 0; i < n; i++) { /* row[i] 可能是 NULL,长度要用 mysql_fetch_lengths 取 */ printf("%-14.*s", (int)len[i], row[i] ? row[i] : "NULL"); } printf("\n"); } mysql_free_result(res); }

全量查询就是select * from book加一次print_result。模糊搜索的关键在于通配符和转义:

char kw[64]; printf("请输入书名关键字:"); scanf("%63s", kw); char esc[128]; /* 先把用户输入里的单引号转义,再加百分号做模糊匹配 */ mysql_real_escape_string(conn, esc, kw, (unsigned long)strlen(kw)); char sql[512]; snprintf(sql, sizeof(sql), "select * from book where bname like '%%%s%%'", esc); if (mysql_query(conn, sql) != 0) { printf("查询失败:%s\n", mysql_error(conn)); } else { print_result(conn); }

mysql_real_escape_string的作用是把输入里的'、\这类字符转义成 SQL 里的安全形式。原文直接把temp拼进like语句,理论上输一个单引号就能把语句截断,虽然课设没人会这么测,但这是「SQL 注入」这个词在答辩里唯一的落点,写上就是加分。

注意:mysql_use_result和mysql_store_result不能混用。前者是逐行读,读完之前不能发新查询;后者一次性把结果拉到客户端。控制台程序数据量小,统一用mysql_store_result更省心。

4. 管理员端的增删改:SQL 拼装、主键冲突与输入长度

管理员这几个功能,表面上是三个独立的菜单项,实际上是同一套模式:读输入、拼 SQL、执行、看返回值、给提示。模式本身不难,难的是那些「正常操作碰不到、一换输入就炸」的边界。

4.1 添加图书:主键冲突与字段长度兜底

添加图书最容易翻车的地方不是 SQL 写错,而是编号重复。bid是主键,插一个已存在的编号,mysql_query会返回非 0,mysql_error会给你一句「Duplicate entry」。如果代码只是笼统打印「添加失败」,演示的时候还得去翻数据库才知道为什么,体验很差。正确做法是用mysql_errno区分错误类型:

int add_book(MYSQL *conn, int bid, const char *name, const char *author) { char name_esc[128], author_esc[128]; char sql[512]; /* 输入先转义,书名里带单引号也不会把语句打断 */ mysql_real_escape_string(conn, name_esc, name, (unsigned long)strlen(name)); mysql_real_escape_string(conn, author_esc, author, (unsigned long)strlen(author)); snprintf(sql, sizeof(sql), "insert into book(bid, bname, bauthor, bstate) " "values(%d, '%s', '%s', '未借出')", bid, name_esc, author_esc); if (mysql_query(conn, sql) != 0) { if (mysql_errno(conn) == 1062) { /* 1062 = 主键重复 */ printf("添加失败:编号 %d 已存在\n", bid); } else { printf("添加失败:%s\n", mysql_error(conn)); } return -1; } return 0; }

1062 这个错误码值得单独记一下,MySQL 里所有主键或唯一键冲突都是它。除了这个,还应该顺手处理 1406(字段太长)。原文里book_name[20]配scanf("%s", &book_name),两个问题叠在一起:一是数组名取地址在某些编译器下会给出警告,二是超过 19 个字符的输入直接把栈写穿。

char book_name[64]; printf("请输入书名(不超过 60 个字符):"); /* scanf 加宽度限制,最多读 63 个字符 + 结尾 0,不会溢出 */ if (scanf("%63s", book_name) != 1) { printf("输入无效\n"); return -1; }

宽度限制%63s里的 63 是「最多读多少字符」,不是缓冲区大小,所以数组开 64 正好。这个数字和varchar(60)对齐,超长输入在 C 层就被截断,不会走到数据库报 1406。

提示:scanf读字符串遇到空格就停。如果演示时书名带空格,要么改成fgets再手动去掉换行,要么就在文档里声明「书名不支持空格」。前者更稳妥。

4.2 修改图书:按旧编号定位、按新编号落库

修改功能的需求描述通常是「改一本书的信息」,但这里藏着一个歧义:能不能改编号?原文的做法是允许改,update book set bid = 新编号 ... where bid = 旧编号。这个操作在数据库层面是合法的,但会带来两个后果:一是新编号如果已经存在,直接主键冲突;二是原来借这本书的人手里的编号就失效了。

课设里最简单的处理是:允许改编号,但把冲突判断清楚,同时提示风险。

int update_book(MYSQL *conn, int old_bid, int new_bid, const char *name, const char *author) { char name_esc[128], author_esc[128], sql[640]; mysql_real_escape_string(conn, name_esc, name, (unsigned long)strlen(name)); mysql_real_escape_string(conn, author_esc, author, (unsigned long)strlen(author)); snprintf(sql, sizeof(sql), "update book set bid = %d, bname = '%s', bauthor = '%s' " "where bid = %d", new_bid, name_esc, author_esc, old_bid); if (mysql_query(conn, sql) != 0) { if (mysql_errno(conn) == 1062) { printf("修改失败:新编号 %d 已被占用\n", new_bid); } else { printf("修改失败:%s\n", mysql_error(conn)); } return -1; } if (mysql_affected_rows(conn) == 0) { printf("修改失败:编号 %d 不存在\n", old_bid); return -1; } return 0; }

注意mysql_affected_rows返回 0 有两种可能:编号不存在,或者新值和旧值完全一样。这两种情况提示语不同,但处理方式都是「不当作成功」,代码里统一提示「不存在」是为了简化,文档里写清楚即可。

如果更保守,可以干脆不允许改编号,只改书名和作者,把编号从修改表单里去掉。我一般倾向于这个方案,因为借阅状态是挂在编号上的,改编号等于换了一本书。

4.3 改用预处理语句:把拼接 SQL 的口子堵上

前面用mysql_real_escape_string已经能挡住大部分注入问题,但它对「整数」是无能为力的——数值输进去就是数值,没有转义的概念。真正干净的做法是预处理语句,参数和语句分离,数据库自己处理类型。

int add_book_prepared(MYSQL *conn, int bid, const char *name, const char *author) { const char *sql = "insert into book(bid, bname, bauthor, bstate) values(?, ?, ?, '未借出')"; MYSQL_STMT *stmt = mysql_stmt_init(conn); MYSQL_BIND bind[3]; if (stmt == NULL) { return -1; } if (mysql_stmt_prepare(stmt, sql, (unsigned long)strlen(sql)) != 0) { fprintf(stderr, "预处理失败:%s\n", mysql_stmt_error(stmt)); mysql_stmt_close(stmt); return -1; } memset(bind, 0, sizeof(bind)); bind[0].buffer_type = MYSQL_TYPE_LONG; /* bid 是 int */ bind[0].buffer = &bid; unsigned long name_len = (unsigned long)strlen(name); bind[1].buffer_type = MYSQL_TYPE_STRING; /* bname 是字符串 */ bind[1].buffer = (void *)name; bind[1].buffer_length = name_len; bind[1].length = &name_len; unsigned long author_len = (unsigned long)strlen(author); bind[2].buffer_type = MYSQL_TYPE_STRING; bind[2].buffer = (void *)author; bind[2].buffer_length = author_len; bind[2].length = &author_len; if (mysql_stmt_bind_param(stmt, bind) != 0 || mysql_stmt_execute(stmt) != 0) { fprintf(stderr, "执行失败:%s\n", mysql_stmt_error(stmt)); mysql_stmt_close(stmt); return -1; } mysql_stmt_close(stmt); return 0; }

这段代码比拼接版长不少,答辩时不必全讲,讲清楚三件事就行:?是占位符,MYSQL_BIND里的buffer_type决定这个参数按什么类型传,length指针用于变长字符串。课设里全用预处理没必要,挑「添加」和「登录」这两个入口用上,其余保留拼接版,文档里说明取舍,反而是个有说服力的设计点。

5. 常见问题与避坑:五个最容易在验收时被问住的点

调试这个项目的血泪经验是:报错信息往往指向的地方和真正的问题不在同一处。下面这几条按「现象 → 原因 → 解决」整理,基本覆盖了这个题目 80% 的翻车场景。

5.1 编码类:中文乱码与 set names gbk 的坑

现象:程序里打印出来的书名全是问号,或者命令行窗口里中文变成了方块;换个同学电脑跑,又正常了。

原因:MySQL 表的字符集、连接的字符集、Windows 控制台的代码页,这三层任何一层不一致都会乱码。原文用set names gbk,如果建表时用的是utf8,这条语句反而是把连接改成了 gbk,结果就是写进去乱、读出来更乱。

解决:三层统一。建表语句显式写default charset=gbk,连接用mysql_set_character_set(conn, "gbk"),控制台在程序开头执行一次system("chcp 936")切到简体中文代码页。如果要做成跨平台的,就把这三处统一换成utf8mb4,Windows 控制台那边用chcp 65001。关键是「统一」,不是「哪个更好」。

/* 放在 main 开头,让控制台代码页和数据库字符集对齐 */ system("chcp 936 > nul");

现象:设置完编码,代码里写死的汉字还是乱码。

原因:源文件本身的保存编码和控制台代码页不一致。VS 默认用 GBK 存源文件,如果代码页切到了 65001,字符串常量就会乱。

解决:源文件另存为带 BOM 的 UTF-8,同时代码页用 65001;或者反过来,都留在 GBK。别一半一半。

5.2 结果集类:没释放、没判断、把状态查成了空

现象:借书功能不管书是不是已借出,永远提示「借阅成功」。

原因:原文先mysql_store_result(pconn)再判断,但前面那条查状态的mysql_query根本没执行,或者执行了但字段名写成了bstaet,导致res是空的,strcmp拿一个未初始化或者空指针去比,结果不可预期。

解决:把「发查询 → 取结果 → 判断行数 → 取值 → 释放」当成一个固定动作,每一步都判断返回值。字段名靠 SQL 报错发现,不要靠肉眼。写代码时养成习惯:每次mysql_query之后立刻跟一个if,把mysql_error(conn)打出来,这句错误信息能省掉大量猜测。

现象:程序跑一段时间后内存占用越来越高,或者第二次查询直接失败。

原因:mysql_store_result拿到的MYSQL_RES *没有mysql_free_result。原文在添加、修改、删除的分支里都没有释放,只有查询分支释放了。

解决:把结果集变量声明在函数内部,每次使用后立刻释放,别跨分支保存。如果逻辑复杂,就抽一个「查询并打印」的函数,让释放动作只出现在一个地方——第 3.3 节的print_result就是这个思路。

注意:mysql_store_result返回 NULL 有两种情况,一种是出错,一种本身就是非查询语句(比如 insert)。判断方式是看mysql_field_count(conn)是不是 0,不看mysql_error有没有内容。

5.3 交互类:scanf 残留、goto 跳转和明文密码

现象:菜单疯狂刷屏,输入一个数字之后程序自己往下跑了好几屏。

原因:scanf("%d", &x)读整数时,如果输入的是字母,这个字符不会被读走,留在缓冲区里,下一次scanf立刻又读到它,形成死循环。原文用goto回到菜单开头,正好把这个循环放大成了刷屏。

解决:所有scanf都判断返回值,读失败就清空缓冲区。

int x; if (scanf("%d", &x) != 1) { int ch; while ((ch = getchar()) != '\n' && ch != EOF) { } /* 丢弃整行 */ printf("输入无效,请重新选择\n"); continue; }

现象:答辩时被问「密码是怎么存的」,答不上来。

原因:upwd是int,SQL 里明文比对,源码里还能直接看到1001/1001这个管理员账号。

解决:课设阶段不必真上哈希,但要在文档和答辩里主动说明:当前用整型明文是为了降低课设复杂度,正式系统应改为varchar存加盐哈希,登录时在服务端计算后比对。主动说出这句话,比被追问出来要好得多。另外,管理员账号判断尽量走用户表里的角色字段,而不是在代码里硬编码uid == 1001,硬编码是答辩里第二个高频追问点。

现象:goto跳转之后,某些变量的值不符合预期。

原因:goto可以往前跳,但跳过去的代码如果依赖跳转前初始化的局部变量,行为就不确定。原文case 4里有个goto ll后面跟着printf,那行代码永远不会执行。

解决:goto只用于「回到菜单开头」这一种场景,跳转标签放在菜单循环的第一行,跳转前不做任何需要保留的局部状态。真要写复杂流程,改成while (1)加switch加break,可读性立刻上一个台阶。

6. 验收与答辩:一份可以照着走的检查单

验收这一步,很多人把时间花在「把功能演示一遍」,结果被问三个问题就卡住。我一般建议反过来:先准备好一份检查单,自己按顺序走一遍,走不通的地方就是答辩当天的雷。

第一轮查连通性。删掉数据库里的bookinfo库,从建库建表脚本开始重建,再启动程序。这一步能暴露所有「依赖我电脑上某个配置」的问题。检查单上的条目是:连接失败有没有明确提示、字符集设没设、管理员账号在不在、空表能不能正常查询。

第二轮查状态闭环。准备三条数据:一本未借出的书、一本已借出的书、一个不存在的编号。分别走借书、还书、重复借、重复还、查不存在的书。这五个动作里,最容易出问题的是「已借出的书再借一次」和「不存在的编号还书」,前者考验状态判断,后者考验mysql_affected_rows有没有用上。

第三轮查输入边界。书名输 60 个字符、输带空格的、输带单引号的,编号输 0 和负数,菜单输字母。这几项不需要全部处理得完美,但要做到「不崩溃、有提示」。带单引号那条尤其重要,它是 SQL 注入在答辩里唯一的实证。

验收项通过标准常见失败原因
建库建表在干净环境执行脚本无报错脚本里漏了字符集声明
登录分流1001 进管理员,其他账号进会员角色判断写死在菜单里
借书已借出的书提示失败,状态不变查状态的 SQL 字段名写错
还书不存在的编号有提示没判断mysql_affected_rows
查询中文正常显示,结果集正确释放编码不统一、漏mysql_free_result
添加编号重复有明确提示只打印了mysql_error
删除删完再查确实没了拼接 SQL 时编号写成了字符串

答辩环节,我通常会让对方准备一张纸,画三样东西:表结构、函数调用关系、一条借书的完整数据流。能画出来,说明真的理解自己写了什么;画不出来,代码跑得再顺也只是背下来的。被问到「为什么用goto」时,标准答案是「课设体量小,用goto减少嵌套层级,正式项目会改成循环加状态机」,不用硬撑说它好。被问到「安全性」时,主动说清楚密码明文是简化,预处理语句在哪几处用了,比含糊过去强。

最后一条是我自己的习惯:答辩前一天,把程序在一个没装过 MySQL 客户端的虚拟环境里跑一遍。听起来多余,但我见过太多次「在我电脑上好好的」,问题全出在 DLL 路径和字符集上。从那以后我每次交课程设计,都强制走一遍「干净环境 + 检查单」,宁可多花两小时,也不要在讲台上对着一个黑匣子解释为什么它今天不听话。希望帮到你。

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