ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

软件工程课设全流程:选课系统需求、设计、实现与测试

软件工程课设全流程:选课系统需求、设计、实现与测试

简介:《软件工程课程设计指导书》是一份面向计算机科学与技术专业学生及软件工程初学者的实践指导文档,围绕课程设计如何从需求分析走向可运行的小型系统展开。资源包共1个doc文件,约539KB,内容以选课系统为贯穿案例,给出10学时上机安排、C++ Builder 6.0与Access 2000开发环境,以及ODBC数据源建立、数据库连接等操作指引。文档还细化登录、查询、选课、退课与成绩管理等功能要求,并列出用例图、序列图、对象模型、状态图和测试数据设计等报告组成部分,便于读者按软件工程规范完成开发与文档撰写。已有99人学习,适合需要对照课程设计任务、搭建实验环境、整理提交报告和查漏补缺的学生参考。

1. 从一份选课系统课设说起:10 个学时也要走完软件工程全流程

十个上机学时,要交一套能跑起来的选课系统,还要附上用例图、序列图、对象模型、状态图,以及二十组不同的测试序列。这是软件工程课程设计指导书里给出的任务书,也是很多人第一次被要求把开发流程从头到尾走完整。多数人的第一反应是先打开 C++ Builder 把窗体拖出来,功能调通以后再回头补文档,结果报告写到需求分析就卡住,因为代码里根本找不到与用例对应的实现。这份指导书的价值不在于教会你某个控件怎么用,而在于把“先立需求、再定结构、最后落代码”的顺序固定下来。选课系统业务熟悉、实体清晰、状态可枚举,是练完整流程最省力的载体,适合计算机专业做课设、毕设起步的同学,也适合手里有老式 C++ Builder 项目、想重新梳理一遍的开发者。

2. 需求先立住:从字段清单倒推实体与用例边界

2.1 三张主表加一张关系表:把散落的字段收敛成四个实体

指导书给出的字段是一张平铺清单:学生姓名、学号、登陆密码、性别、出生年月、籍贯、地址、学生电话、家庭地址,教师号、教师姓名、教研室、职称、性别、教师电话,课名、课号、学分、先行课号、课时、开课教室、人数限制、选课人数,再加上考试成绩、平时成绩、总评成绩。照抄这份清单去建一张大表,写代码的时候会很爽,但会碰到三个很典型的问题。

第一是更新异常。一个学生选了五门课,他的家庭地址就在表里重复了五行,改一个电话号码要改五处,漏改一处就出现两个版本的地址。第二是插入异常。如果主键包含学号,一门刚开、还没人选的课压根插不进去,因为学号是空的。第三是删除异常。学生把最后一门课退掉,整行删掉,他的基本档案也跟着消失了。这三个异常在报告的需求分析章节里正好是拆表的依据,写进去比空谈“满足第三范式”有说服力得多。

按依赖关系收敛之后,得到四个实体:

实体主键主要属性外键关系
学生学号姓名、登陆密码、性别、出生年月、籍贯、地址、学生电话、家庭地址无
教师教师号姓名、教研室、职称、性别、教师电话无
课程课号课名、学分、先行课号、课时、开课教室、人数限制、选课人数教师号→教师;先行课号→课程(自引用)
选课记录学号+课号考试成绩、平时成绩、总评成绩、状态学号→学生;课号→课程

有两处值得单独说明。一个是“选课人数”,它是可以从选课记录表算出来的派生字段,理论上不该存。但课程列表页每刷新一次就COUNT(*)一遍,几百条记录时滚动条会明显卡顿,所以我一般在课程表里冗余存一份,同时要求写入必须在同一个事务里同步更新,再留一个“重算人数”的维护按钮做核对。这就是一笔需要提前想清楚的取舍,报告里写出来就是设计决策,不写就是一个说不清的疑点。

另一个是“先行课号”,它是课程表指向自己的外键。选修一门课之前要先修完它的先行课,这个规则在数据库层面建自关联,在业务层做一次带深度限制的递归校验,防止课程表里出现 A 的先修是 B、B 的先修是 A 这种环。深度限制取 3 层就够了,再多说明课程体系本身设计有问题。

2.2 六类用例的边界:查询和选课不是一回事

指导书列出的功能是登录、查询开课情况、查询选课情况、查询成绩、选课、退课,到教师侧还要补一个录入成绩。把这七个动作直接画成七个用例放进用例图,看着齐全,但评审提一个问题就会露出破绽:“查询开课情况和查询选课情况有什么区别?”如果答不上来,说明用例边界没想清楚。

我的划法是按数据视角分:查询开课情况是课程维度的,回答“这学期开了哪些课、谁教、在哪间教室、还剩几个名额”;查询选课情况是学生维度的,回答“我选了哪几门、学分合计多少、有没有和别的课撞时间”。两者的数据来源、刷新时机、参与者关注点都不同,所以拆成两个用例是合理的,合并反而是偷懒。

编号用例参与者前置条件后置条件
UC-01登 录学生、教师无建立会话,记录角色
UC-02查询开课情况学生已登录无状态变更
UC-03查询选课情况学生已登录无状态变更
UC-04选课学生已登录、名额未满、未选过、先行课已通过新增选课记录,课程人数+1
UC-05退课学生已登录、记录存在且未录成绩记录置为已退,课程人数-1
UC-06查询成绩学生已登录、成绩已录无状态变更
UC-07录入成绩教师已登录、该课由本人讲授写平时成绩、考试成绩、总评成绩

UC-05 的前置条件里“未录成绩”这四个字很容易被忽略,但它是必须加的。允许学生退掉一门已经录了成绩的课,成绩记录就成了没有归属的数据;如果同时又物理删除了选课记录,那这条成绩直接从库里消失,教师在成绩单上看到的人数比实际少,直接就是一个说不清的问题。加这个前置条件,代价只是多一条SELECT判断。

2.3 用例规约与序列图:一次选课跨了四层

用例图画完只是骨架,评审真正会看的是用例规约写没写细。以 UC-04 选课为例,规约至少要包含下面这些内容,缺一项都会被追问:

项目内容
用例名选课
参与者学生
前置条件已登录;课程存在;选课人数小于人数限制;未选过该课;先行课已通过或该课无先行课
基本流1. 学生在课程列表选中一行 2. 点击“选课” 3. 系统执行三重校验 4. 写入选课记录 5. 课程选课人数加一 6. 刷新列表并提示成功
异常流3a 名额已满,提示并回滚;3b 已选过该课,提示“不能重复选课”;3c 先行课未通过,提示先行课名称
后置条件选课记录加一,课程人数加一,二者保持一致

序列图则要画清楚这一次点击到底经过了哪些对象。我在报告里用的是四层消息表,比纯图形更好核对:

序号发起方接收方消息说明
1学生CourseForm点击“选课”界面层,取当前选中行
2CourseFormCourseManagerSelect(stuId, courseId)控制层,不带控件引用
3CourseManagerCourseDAOGetCourse(courseId)读人数限制、已选人数、先行课号
4CourseManagerCourseDAOExistsSelection(...)重复选课校验
5CourseManagerCourseDAOHasPassedPre(...)先行课校验
6CourseManagerCourseDAOBeginTrans / Insert / IncCount / Commit事务边界在控制层
7CourseForm学生刷新列表、提示结果回显,不参与判断

序列图画到这一层,需求分析章节就不再是凑页数的插图,而是能跟后面的分层设计一一对应上的东西。

课程列表页用到的查询视图,直接在 Access 里建成一个查询对象,界面层只负责绑定:

-- 课程列表视图:剩余名额由人数限制减去已选人数得出,列表页只读这个查询 SELECT c.[课号], c.[课名], t.[教师姓名], c.[学分], c.[课时], c.[开课教室], c.[人数限制], c.[选课人数], c.[人数限制] - c.[选课人数] AS [剩余名额] FROM [课程] AS c LEFT JOIN [教师] AS t ON c.[教师号] = t.[教师号] ORDER BY c.[课号];

这里用LEFT JOIN而不是INNER JOIN,是为了让还没排教师的课程也能出现在列表里,否则新课一开、教师没定,学生在列表上根本看不到这门课。剩余名额作为计算列返回,界面层就不用再写一遍减法,避免了两处算法不一致。

3. 总体设计:三层职责、对象模型与 Access 表结构

3.1 三层职责划分:窗体不写 SQL,DAO 不碰控件

C++ Builder 6.0 这类 RAD 工具最大的诱惑是把所有逻辑都写在按钮的OnClick里,双击按钮、写三行 SQL、跑通了,看起来效率极高。但一个选课系统的窗体数量在五到八个之间,每个窗体三到五个按钮,事件函数全堆在 Form 的.cpp里,文件很快过千行,改一个校验规则要在里面翻半天。更麻烦的是报告里的对象模型画不出来,因为压根没有“对象”,只有窗体和控件。

我一般按三层切:

层典型类允许做的事禁止做的事
表现层TLoginForm、TCourseForm、TScoreForm取控件值、非空校验、回显结果、按钮启用禁用拼 SQL、开事务
控制层TLoginManager、TCourseManager、TScoreManager业务规则判断、事务边界、组装返回消息引用任何控件
数据访问层TStudentDAO、TCourseDAO、TScoreDAOSQL 与参数绑定、结果集转结构体弹 MessageBox、读界面状态

数据访问层对外只暴露方法,不把TADOQuery指针传出去,这样上层想绕开 DAO 直接查库都没有路径:

// CourseDAO.h:数据访问层只暴露业务方法,内部持有连接,不暴露查询对象 class TCourseDAO { public: TCourseDAO(TADOConnection *conn); ~TCourseDAO(); bool GetCourse(const AnsiString &courseId, CourseInfo &info); bool ExistsSelection(const AnsiString &stuId, const AnsiString &courseId); bool HasPassedPre(const AnsiString &stuId, const AnsiString &preId); bool InsertSelection(const AnsiString &stuId, const AnsiString &courseId); bool IncChosenCount(const AnsiString &courseId, int delta); bool SetSelectionStatus(const AnsiString &stuId, const AnsiString &courseId, const AnsiString &status); bool BeginTrans(); bool Commit(); bool Rollback(); private: TADOConnection *FConn; // 连接由外部注入,DAO 不负责创建和释放 TADOQuery *NewQuery(); };

CourseInfo是一个只有字段的结构体,约有课号、课名、学分、人数限制、选课人数、先行课号六个成员。连接对象由外部注入而不是在 DAO 构造函数里自己创建,好处是全程序共用一个连接,事务才能跨 DAO 生效;如果每个 DAO 各建一个连接,选课事务里“插入记录”和“人数加一”就落在两个连接上,回滚只能回滚一半。NewQuery()是私有工厂方法,负责创建、挂连接、设置LockType,避免每个方法重复三行样板代码。

3.2 对象模型与状态图:选课记录的五个状态

对象模型在报告里通常画成类图,落到文字上可以这样表述:Student与Course是多对多关系,中间由Selection承载,同时Selection带上成绩属性;Teacher与Course是一对多;Course与自身通过先行课号形成自关联。控制层的三个 Manager 分别对应三个业务域,各自依赖若干 DAO。

比类图更容易被评审追问的是状态图。选课记录不是只有“存在”和“不存在”两种状态,加上成绩就有了完整的生命周期:

当前状态事件守卫条件下一状态动作
无记录选课名额未满、先行课通过已选插入记录,人数+1
已选退课未录成绩已退状态置为已退,人数-1
已选录入成绩教师为任课教师成绩已录写平时成绩、考试成绩
成绩已录计算总评两项成绩均非空总评已定计算并写总评
成绩已录退课无保持成绩已录拒绝并提示
已退选课名额未满已选状态改回已选,人数+1

这张表决定了一个实现选择:退课到底做物理删除还是置状态位。做物理删除很省事,但“已退”这个状态就无处安放,状态图上画不出来,学生重复选同一门课时的唯一性判断也得靠删除后的记录是否还存在来做,逻辑会绕。我的做法是选课记录表加一个状态字段(取值“已选/已退”),退课只改状态、不删行,查询选课情况时过滤掉已退记录。代价是表会慢慢变大,课设规模下几十上百条记录完全无感。

3.3 Access 建表与约束:一条 SQL 建完四张表

字段名用中文,跟指导书的字段清单对齐,报告里写数据字典时直接抄,不用做一次中英映射。建表 SQL 通过 ADO 连接执行,也可以在 Access 的查询设计器里切成 SQL 视图直接跑:

-- 学生表:学号做主键,登陆密码存摘要而不是明文,长度按 SHA1 十六进制 40 位留 CREATE TABLE [学生] ( [学号] TEXT(12) NOT NULL, [姓名] TEXT(20) NOT NULL, [登陆密码] TEXT(40) NOT NULL, [性别] TEXT(2), [出生年月] DATETIME, [籍贯] TEXT(30), [地址] TEXT(60), [学生电话] TEXT(20), [家庭地址] TEXT(60), CONSTRAINT [PK_学生] PRIMARY KEY ([学号]) ); -- 课程表:教师号与先行课号都是外键,先行课号指向本表 CREATE TABLE [课程] ( [课号] TEXT(10) NOT NULL, [课名] TEXT(40) NOT NULL, [学分] INTEGER, [先行课号] TEXT(10), [课时] INTEGER, [开课教室] TEXT(20), [人数限制] INTEGER NOT NULL, [选课人数] INTEGER NOT NULL, [教师号] TEXT(10), CONSTRAINT [PK_课程] PRIMARY KEY ([课号]) ); -- 选课记录:联合主键保证同一学生同一课程只有一条记录 CREATE TABLE [选课记录] ( [学号] TEXT(12) NOT NULL, [课号] TEXT(10) NOT NULL, [平时成绩] DOUBLE, [考试成绩] DOUBLE, [总评成绩] DOUBLE, [状态] TEXT(4) NOT NULL, CONSTRAINT [PK_选课记录] PRIMARY KEY ([学号], [课号]) );

人数限制和选课人数都设了NOT NULL,是因为它们参与条件更新,允许为空会让WHERE 选课人数 < 人数限制在空值行上直接判假,出现“有名额也选不上”的异常现象,排查起来很费时间。成绩字段用DOUBLE而不是整型,总评保留一位小数时不用再做类型转换。

外键约束在 Access 里通过 ADO 执行ALTER TABLE有时不生效,稳妥做法是建完表以后在“关系”窗口里手动拖连线并勾选“实施参照完整性”。这一步报告里要留截图,因为它是“数据库设计”章节里唯一能证明约束真的建了的东西。

3.4 界面原型与事务边界:哪些操作必须打包

界面不用做得多漂亮,但要能一眼看出角色差异。我的窗体清单一般是五张:

窗体主要控件关键交互
TLoginForm两个 Edit、两个 Button回车即登录,区分学生/教师入口
TCourseFormDBGrid、选课/退课/刷新按钮选中行后按钮才启用,防止空选
TSelectionFormDBGrid、学分合计 Label只读,过滤掉已退记录
TScoreQueryFormComboBox、DBGrid选课号即刷新,不做“查询”按钮
TScoreInputFormDBGrid、两个 Edit成绩列可编辑,其余列只读

事务边界只有三处需要严格打包:选课时“插入记录”和“课程人数加一”;退课时“状态置已退”和“课程人数减一”;批量录入成绩时的整批提交。C++ Builder 里BeginTrans / CommitTrans / RollbackTrans挂在TADOConnection上,不能嵌套,所以控制层要保证同一时刻只有一个事务在跑,做法是在 Manager 里加一个FBusy标志,进入事务前置位、__finally里复位。

4. 关键实现:ODBC 打通链路与业务代码落地

4.1 数据源与连接:DSN 与无 DSN 两条路怎么选

指导书给的附录走的是老路子:先用 Access 建 mdb,再到控制面板建 ODBC 数据源,然后在 BDE Administrator 里新建一个 ODBC 别名指过去,最后在 C++ Builder 里用 BDE 组件连。这条链路能通,但步骤多、依赖本机配置,答辩换一台机器就断。

现在更省事的是直接用 ADO 组件连 Access,两种写法:

// 写法一:走 DSN,换机器只要重新建一个同名数据源,代码不动 ADOConnection1->LoginPrompt = false; ADOConnection1->ConnectionString = "Provider=MSDASQL.1;Persist Security Info=False;Data Source=选课数据源"; ADOConnection1->Connected = true; // 写法二:无 DSN 直连,mdb 放 exe 同目录的 data 子目录,整个文件夹拷走就能跑 AnsiString dbPath = ExtractFilePath(Application->ExeName) + "data\\选课.mdb"; ADOConnection1->LoginPrompt = false; ADOConnection1->ConnectionString = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + dbPath + ";"; ADOConnection1->Connected = true;

两条路的差别很实际:DSN 方式要求每台机器都配一遍数据源,好处是路径不写死在代码里;无 DSN 方式把路径拼在运行时,整个项目目录拷到哪都能跑,代价是 mdb 必须跟着 exe 走。课设要交的是“能运行的系统”,我一般选无 DSN,并在启动时做一次自检:

// 启动自检:连不上就在主窗体出现前说清楚,别等点“选课”才抛异常 bool __fastcall TMainForm::CheckDb() { try { ADOConnection1->Connected = true; return true; } catch (Exception &e) { ShowMessage("数据库连接失败,请检查 data 目录下的 mdb 文件:\n" + e.Message); return false; } }

自检放在OnCreate里,返回 false 就直接Application->Terminate()。这样答辩现场如果环境不对,看到的是明确提示,而不是一个能打开、点哪儿都报错的空壳。

4.2 登录:口令摘要、失败计数与角色分流

登录有三个容易做浅的点。第一是口令不能明文比对,第二是 SQL 不能拼接,第三是角色要能分流到不同主界面。

// 登录校验:参数化查询 + 摘要比对,返回角色(0 失败 / 1 学生 / 2 教师) int TLoginManager::Login(const AnsiString &userId, const AnsiString &pwd) { AnsiString digest = Sha1Hex(pwd); // 与入库时同一套摘要算法 TADOQuery *q = FStudentDAO->NewQuery(); try { q->SQL->Text = "SELECT [登陆密码], [角色] FROM [学生] WHERE [学号] = :id"; q->Parameters->ParamByName("id")->Value = userId; q->Open(); if (q->Eof) return 0; // 用户不存在,不区分提示 if (q->FieldByName("登陆密码")->AsString != digest) { IncFailCount(userId); // 失败次数落库,不落内存 return 0; } ResetFailCount(userId); return q->FieldByName("角色")->AsInteger; } __finally { q->Close(); delete q; } }

Sha1Hex用 Indy 自带的TIdHashSHA1实现,入库时对明文算一次十六进制串存进登陆密码字段,登录时对输入再算一次比对。IncFailCount和ResetFailCount分别对失败次数、最后失败时间两列做更新,连续失败五次就把锁定时间置为当前时间加十分钟,登录前先判这个时间。失败计数落库而不放内存,是因为 C++ Builder 的程序关掉就清零,内存计数等于没有。

参数化这件事值得单独强调。用q->SQL->Text = "... WHERE [学号] = '" + userId + "'"这种拼法,学生姓名里出现一个单引号(录入时手滑、或者姓名本身带撇号)就会直接抛 SQL 语法异常,界面上弹一串英文,答辩时很难解释。

4.3 选课与退课:名额、重复、先行课三道校验

选课是整个系统里唯一需要事务的地方,也是唯一会被并发影响的地方。虽然课设是单机,但“先查后写”这个习惯必须在这里纠正过来。

// 选课:校验在控制层,事务边界也在控制层,DAO 只管执行 bool TCourseManager::Select(const AnsiString &stuId, const AnsiString &courseId, AnsiString &msg) { CourseInfo info; if (!FCourseDAO->GetCourse(courseId, info)) { msg = "课程不存在"; return false; } if (FCourseDAO->ExistsSelection(stuId, courseId)) { msg = "不能重复选课"; return false; } // 先行课校验:只查一层,多级先行课靠事先录入时保证顺序 if (!info.PreCourseId.IsEmpty() && !FCourseDAO->HasPassedPre(stuId, info.PreCourseId)) { msg = "请先修完先行课:" + info.PreCourseId; return false; } if (!FCourseDAO->BeginTrans()) { msg = "事务开启失败"; return false; } try { if (!FCourseDAO->InsertSelection(stuId, courseId)) { FCourseDAO->Rollback(); msg = "写入选课记录失败"; return false; } // 条件更新:影响行数为 0 说明名额在写入瞬间被占满 if (FCourseDAO->IncChosenCount(courseId, 1) <= 0) { FCourseDAO->Rollback(); msg = "选课人数已满"; return false; } FCourseDAO->Commit(); msg = "选课成功"; return true; } catch (...) { FCourseDAO->Rollback(); msg = "选课过程出现异常,已回滚"; return false; } }

IncChosenCount内部的 SQL 是条件更新,不是简单加一:

-- 名额判断交给数据库:受影响行数为 0 就说明满了,由调用方回滚 UPDATE [课程] SET [选课人数] = [选课人数] + 1 WHERE [课号] = ? AND [选课人数] < [人数限制];

IncChosenCount返回q->RowsAffected。把判断压进WHERE的好处是,从“读到还剩一个名额”到“写回加一”之间不存在窗口,哪怕将来改成多机运行,这一句也不会超卖。退课则是它的镜像:

-- 退课:只有状态为“已选”的记录能退,重复点按钮时第二次影响行数为 0 UPDATE [选课记录] SET [状态] = '已退' WHERE [学号] = ? AND [课号] = ? AND [状态] = '已选'; UPDATE [课程] SET [选课人数] = [选课人数] - 1 WHERE [课号] = ? AND [选课人数] > 0;

第二条带[选课人数] > 0是为了防脏数据把人数减成负数。两条语句放在同一个事务里,任一条失败就回滚,这样才不会出现“记录退了、人数没减”的错位。

4.4 成绩录入与总评计算:公式只写一处

总评的计算规则很简单,平时三成、考试七成,但写法上有三个地方会出错:界面里算一遍、DAO 里又算一遍,两处保留小数的规则不一样;缺考按 0 分算还是按空值算;四舍五入用Round时对.5的处理不符合预期。

// 总评计算:唯一入口,界面和报表都调它,不在别处重写公式 double TScoreManager::CalcFinal(double daily, double exam, bool absent) { if (absent) return 0.0; // 缺考直接记 0,不参与加权 double total = daily * 0.3 + exam * 0.7; // 先放大十倍取整再缩回去,避开浮点直传 Round 时 0.5 的边界抖动 return Floor(total * 10.0 + 0.5) / 10.0; }

absent由界面上的“缺考”勾选框传入,勾了就不看两个分数。如果只是把考试成绩留空,总评会按 0 分加权算出一个非零结果,成绩单上看起来像“参加考试但考砸了”,跟实际缺考是两回事,报告里的测试用例正好可以覆盖这一条。

录入时的事务边界按“一批”划:教师在一张表里改十几行,点一次保存整批提交,中间任何一行失败全部回滚,避免出现半批数据。具体做法是遍历 DBGrid 的每一行、逐行调用UpdateScore,全部成功才Commit,失败就Rollback并把出错的行号返回给界面高亮。

5. 避坑与排查:五个让课设当场翻车的点

5.1 环境与部署类:换台机器就跑不起来

现象一:在自己电脑上双击 exe 一切正常,拷到答辩用的机器上提示“未找到数据源名称”或者“未找到提供程序”。

原因:连接串写的是 DSN,而 DSN 是注册表里的本机配置,不会跟着项目走;或者目标机器上装的是 64 位 Office,没有 32 位的 Jet 引擎,Microsoft.Jet.OLEDB.4.0直接找不到。

解决:改用无 DSN 连接串,把 mdb 放到 exe 同目录的data子目录里,路径用ExtractFilePath(Application->ExeName)运行时拼。如果确实要用 DSN,就在答辩前把建数据源的步骤做成一个说明页附在报告后面,现场照着配一遍,两分钟能搞定。

现象二:编译没有任何问题,双击运行报“BDE 初始化失败”或弹出一堆别名配置对话框。

原因:附录里那套 BDE 方案依赖运行期的 dll 和注册表项,只在装过 C++ Builder 的机器上有效。把 exe 单独拷出来就是个空壳。

解决:整个项目统一走 ADO,不用 BDE 组件。如果指导书要求必须演示 BDE,就老老实实现场用装了开发环境的机器跑,不要试图把 exe 拷来拷去。

5.2 数据一致性类:人数对不上、成绩被覆盖

现象一:课程列表显示还剩 2 个名额,连续点五次选课全部成功,选课人数超出人数限制。

原因:校验用的是界面上的缓存值,或者“查人数”和“写人数”是两条独立语句,中间没有事务,也没有把判断压进WHERE。

解决:按 4.3 的写法,用条件更新WHERE 选课人数 < 人数限制,看RowsAffected判断成败,失败就回滚。同时在选课成功后立刻重刷列表,不要让界面继续显示旧值。

现象二:学生退课后课程人数没减,或者减了两次变成负数。

原因:退课按钮没做幂等,用户手快点了两下;或者退课只改了记录状态,忘了同步人数。

解决:UPDATE 选课记录的WHERE里带状态 = '已选',第二次点击影响行数为 0,直接跳过人数更新;按钮点击后立即Enabled = false,事务结束再恢复。

现象三:某门课的总评成绩被别的课覆盖,学生查成绩时两门课分数一样。

原因:更新成绩的WHERE只写了学号,漏了课号,UPDATE命中该学生所有选课记录。

解决:所有更新语句的WHERE必须同时带学号和课号,写完以后拿一条测试数据专门验一次“改一门课的成绩,另一门不受影响”。

5.3 代码与安全类:SQL 拼接和明文口令

现象一:录入一个带单引号的学生姓名,登录或者查询直接报 SQL 语法错误,弹出一长串英文异常。

原因:用字符串拼接构造 SQL,用户输入里的特殊字符直接改变了语句结构。

解决:全部改成Parameters->ParamByName(...)绑定,包括看起来“肯定是数字”的学号字段。数字字段拼进去虽然不会报错,但一旦有人输入1 OR 1=1这类内容,登录校验就会被绕过,这在报告的安全小节里是可以写一条的。

现象二:mdb 文件被同学拷走,用 Access 一打开,所有账号口令一览无余。

原因:口令明文入库。

解决:入库前算 SHA1 摘要,登录时对输入再算一次比对。这个改动量很小,但报告里“安全性考虑”这一节就从空话变成了有代码支撑的设计点。

现象三:编译时提示找不到某个单元,或者改了头文件之后另一处报重复定义。

原因:C++ Builder 6.0 的预编译头机制比较老,.h里直接#include了别的窗体头文件,形成循环引用。

解决:头文件里尽量用前置声明,只在.cpp里#include具体头文件;实在绕不开就重新Build All Projects,不要只按 F9 增量编译。

6. 测试与答辩:20 组序列怎么凑齐,报告怎么写得像真做过

指导书要求书面提交至少 20 个不同的对象测试序列,这个数字听着多,用三套方法交叉一铺就够了。等价类负责覆盖“合法/非法”两大类:合法学号、不存在的学号、格式错的学号、空输入,四个输入就换出四组。边界值盯着两个地方:人数限制刚好等于已选人数时,第 N 个选课应该成功、第 N+1 个应该失败;成绩的 0 分、100 分、101 分三个点,验证校验有没有卡住上限。

状态迁移是最容易被忽略、但最能体现“做过”的一类:先选课再退课再选同一门课,验证状态位改回“已选”而不是插出两条记录;先录成绩再退课,验证被拒绝;录完成绩改分数,验证总评跟着重算而不是留着旧值。这三条写进测试表,评审一看就知道状态图不是画着玩的。

编号前置状态输入预期输出覆盖方法
T-01未登录正确学号+正确口令进入学生主界面等价类-合法
T-02未登录正确学号+错误口令提示失败,失败次数+1等价类-非法
T-03连续失败 5 次正确口令提示账号锁定边界值
T-04名额已满点击选课提示人数已满,人数不变边界值
T-05名额剩 1点击选课成功,人数达到上限边界值
T-06已选该课再次点击选课提示不能重复选课等价类-非法
T-07先行课未通过选后续课提示先行课名称等价类-非法
T-08已选未录成绩点击退课状态置已退,人数-1状态迁移
T-09已退再次选同一门课状态改回已选,不新增行状态迁移
T-10已录成绩点击退课拒绝并提示状态迁移

表格列十行作样例,剩下十组按同样格式补:成绩录入的边界(0、60、100、101)、总评计算的舍入(59.95、60.05)、缺考勾选、查询结果的空集与非空集、教师越权录入他人课程。每组都要写清“输入—预期—实际—是否通过”四列,实际结果那列不要全部填“通过”,留两三组写“首次不通过、修改后通过”并注明改了什么,这才是真实测试记录的样子。

报告章节和答辩问题的对应关系,可以提前做成一张自检表:

报告章节评审想看什么常见扣分点
需求分析用例图有边界、有参与者、有包含/扩展关系只画图不写用例规约
软件设计对象模型有类、有属性、有关系,状态图有守卫条件类图里全是窗体,没有业务类
数据库设计表结构、主外键、字段类型和约束一张大表,没有范式说明
软件测试20 组序列、覆盖方法、实际结果全是“通过”,没有失败记录
源代码只贴手写部分,有注释把

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进