简介:面向高校计算机基础与编程课程学习者的一套完整实验项目源码,选题为网吧计费管理系统,并在基础功能之外扩展了超级管理员权限模块,适合正在做课程设计、C语言综合实验或想练手上机项目的大学生参考。压缩包共325个文件,约33.1MB,涵盖.c与.h源文件、.vcxproj与.filters工程配置、.obj中间产物、.pdb与.ilk调试文件,另有.txt说明、.json配置及Git版本管理相关文件,构成一个可直接导入IDE编译运行的完整工程。资源已有3179人学习下载,说明该实验选题在同类课程中需求较集中。项目把网吧场景中的上机计时、费用结算、会员与管理员权限分级等业务落地为可运行代码,读者可据此理解模块划分、结构体设计与文件存储思路,并对照自己的实验要求补全或改造功能,减少从零搭建工程与排查环境配置问题的时间。
1. 需求切入:一张卡上机三小时,账要怎么算才不算错
计算机基础与编程实验里,网吧计费管理系统几乎是出现频率最高的题目,但真正能当场把账算对的人不多。多数初版方案把“上机时长”直接当小时数取整,一到 47 分钟、跨零点、中途换机这几种情况就翻车。这套方案要解决的核心不是界面好不好看,而是一张卡从上机到下机的每一分钟怎么落账,以及普通管理员和扩展出来的超级管理员之间那道权限线画在哪里。适合两类人参考:正在做课程设计、需要一个能跑通还能讲清设计思路的完整实现的学生;以及拿它当 C 语言综合练习、想看看文件持久化和权限分级怎么落地的开发者。整套东西跑在控制台里,不依赖任何第三方库,编译完就能演示。
2. 需求拆解与角色权限:三类用户、五张表的数据边界
动手写代码之前,先把“谁能做什么”写清楚。这类系统的需求描述通常只有一两页纸,看似简单,但如果角色边界不划清,后面每个功能都会变成一堆 if-else 互相嵌套,改一个地方崩三个地方。我的习惯是先画一张权限矩阵,把功能按行、角色按列铺开,只填“有/无”,填完基本就知道该拆几个模块了。
2.1 三类角色的权限边界
普通用户(上网顾客)只面向自己的卡:查询余额、查询本次上机时长与预估费用、下机结账。他不能看别人的记录,也不能改费率,更不可能碰管理员账号。这一层的实现要点是“只能操作当前登录卡号”,所有查询接口都应该把当前卡号作为隐式参数传下去,而不是让界面传一个卡号进来——界面传参就意味着可以被改成别人的卡号。
普通管理员负责日常运营:开卡、充值、挂失、解挂、上机、下机、查看当日流水。费率可以看,但不能改。这一层的边界最容易被写糊,很多人把“改费率”直接放进管理员菜单,理由是“管理员不就是管这些的吗”,结果超级管理员这个扩展点就失去了存在意义。
超级管理员是这份课程设计里的扩展加分项,管的是“管理员本身”和“系统本身”:新增/停用管理员账号、重置管理员密码、修改费率、强制下机、导出与恢复数据、查看全量操作日志。它的典型特征是低频、高危、必须留痕。凡是超级管理员能做的操作,执行前后都要往审计日志里写一条,这条规则后面会展开。
| 功能 | 普通用户 | 管理员 | 超级管理员 |
|---|---|---|---|
| 查余额 / 查上机时长 | 有 | 有 | 有 |
| 开卡 / 充值 / 挂失 | 无 | 有 | 有 |
| 上机 / 下机结账 | 有(限本人) | 有(代操作) | 有 |
| 查看费率 | 有 | 有 | 有 |
| 修改费率 | 无 | 无 | 有 |
| 管理员账号管理 | 无 | 无 | 有 |
| 强制下机 | 无 | 无 | 有 |
| 数据备份 / 恢复 | 无 | 无 | 有 |
| 查看操作日志 | 无 | 无 | 有 |
这张表看起来平平无奇,但它是后面所有代码的地基。你可以在答辩时把它当作第一页 PPT,讲清楚“为什么改费率只能超级管理员做”——因为费率变动直接影响所有在机用户的结算金额,属于影响面最大的操作,必须单独授权并留痕。这个理由比“老师要求加超级管理员”有说服力得多。
2.2 数据模型:五张表与文件存储结构
课程设计基本不允许上数据库,所以数据落盘只能靠文件。文件方案的关键不是“能不能存”,而是“存进去之后能不能高效查、坏了能不能恢复”。我把数据拆成五个逻辑表,每个表对应一个文件,用结构体数组直接二进制读写,卡号这类字符串主键靠线性查找就够了——几百条记录的量级,没必要上哈希。
第一张是会员卡表,字段包括卡号、姓名、余额、状态、上机时间、创建时间。第二张是会话表,也叫上机记录表,记录卡号、开始时间、结束时间、时长、金额、结账状态。第三张是费率表,只有一条记录,存基础费率和夜间包段参数。第四张是管理员表,存账号、密码摘要、角色、启用状态。第五张是审计日志表,只追加不修改。
/* 会员卡结构:金额统一用"分"存整数,从根上避免浮点误差 */ typedef struct { char card_id[16]; /* 卡号,主键,例如 "C0001" */ char name[32]; /* 持卡人姓名 */ int balance; /* 余额,单位:分 */ int status; /* 0 空闲 1 上机中 2 挂失 3 停用 */ time_t onboard_at; /* 本次上机开始时间,未上机为 0 */ time_t created_at; /* 开卡时间 */ } MemberCard; /* 会话记录:一次上机一条,下机后补全 end_at 与 fee */ typedef struct { long seq; /* 流水号,自增 */ char card_id[16]; time_t start_at; time_t end_at; /* 0 表示仍在机 */ int minutes; /* 结算分钟数,向上取整 */ int fee; /* 实收金额,单位:分 */ int settled; /* 0 未结 1 已结 2 强制下机 */ } Session;金额用int存“分”而不是float存“元”,这是这份设计里我最想强调的一个细节。float只有 6~7 位有效数字,0.1 元这类值在累加几十次之后就会出现 0.30000001 之类的尾巴,结账时打印出来很难看,对账时更麻烦。换成整数分之后,所有加减都是精确的,只在最终显示时除以 100.0 一次,问题彻底消失。这个改动成本极低,收益极高。
费率表单独开一条记录,而不是硬编码在代码里,原因是费率需要在运行时由超级管理员修改。结构里我会放基础费率、起计分钟数、夜间起始与结束分钟、夜间费率四个字段,够覆盖课程设计里常见的“白天 3 元/小时、夜间 2 元/小时、不足半小时按半小时计”这类规则。
typedef struct { int base_cent; /* 白天费率,单位:分/小时,例如 300 表示 3 元/小时 */ int min_unit; /* 起计单位,分钟,例如 30 表示不足 30 分钟按 30 分钟算 */ int night_start; /* 夜间包段起始,分钟,例如 1380 表示 23:00 */ int night_end; /* 夜间包段结束,分钟,例如 420 表示次日 07:00 */ int night_cent; /* 夜间费率,单位:分/小时 */ } Rate;夜间时段用“一天内的分钟数”表示而不是time_t,是为了处理跨零点。23:00 到次日 07:00 这个区间,如果直接用时间戳比较会非常难受,换成“从零点起算的分钟数”,判断逻辑就退化成简单的区间重叠计算。
2.3 技术选型:C 语言 + 二进制文件 + 控制台
选 C 不是为了炫技,而是这门课的要求本身就限定在 C 语言范围内,同时它逼着你把内存布局、文件读写、时间处理这三件事真正搞清楚。用 Java 或 Python 当然更快,但课程设计要交的是“你理解了系统怎么运转”,而不是“你调用了多少库”。
二进制文件对比文本文件(CSV)的取舍值得在报告里写一段。二进制的好处是读写快、结构体直接映射、不需要解析;坏处是可读性差、结构体一旦改字段旧数据就废了。课程设计阶段结构体会反复改,这一点非常致命。所以我的做法是:正式数据用二进制存,同时提供一个“导出为文本”的功能,超级管理员可以把五张表导成可读文本用于检查或提交报告。这样两边的好处都占到了。
提示:结构体一旦定型就尽量不要再改字段顺序。实在要改,先在版本号里记一笔,写一个小的迁移函数把旧文件读出来、按新结构重写一遍,不要让程序直接读旧文件。
控制台界面部分不要花太多时间,用函数指针数组做一个菜单分发表就够了。真正影响评分的是数据的正确性和异常处理,界面丑一点没关系,但不能出现“输入字母就死循环”这种低级问题。所有scanf之后都要清缓冲区,这属于血泪经验,后面避坑章节会专门讲。
3. 总体架构设计:计费引擎、会话状态机与分层职责
需求理清之后进入结构设计。这个系统最大的陷阱是把“计时”和“算钱”揉在一起写——一旦揉在一起,测试就变成了纯手工活:你必须真的等一小时才能验证一小时多少钱。正确做法是先分层,再把计费抽成一个纯函数,让它只依赖输入参数、不依赖任何全局状态,这样就能用任意构造的时间戳瞬间跑完全部边界用例。
3.1 分层结构与六个模块的职责划分
整体分成三层:表现层(菜单与输入输出)、业务层(卡管理、会话管理、计费、权限、审计)、存储层(文件读写与事务保护)。表现层只负责收集参数和打印结果,不写任何业务判断;业务层不直接碰FILE*,全部通过存储层接口;存储层不做业务校验,只负责“把这块内存安全地写进去”。
业务层再细分成六个模块,每个模块对应一组函数:
- 卡管理模块:开卡、充值、挂失、解挂、按卡号查询、余额变更。所有余额变更必须走同一个
balance_change()函数,不允许各处直接card->balance -= x。 - 会话模块:上机、下机、强制下机、查询在机列表。负责维护卡的
status和onboard_at两个字段的一致性。 - 计费模块:输入起止时间与费率,输出分钟数和金额。纯函数,无副作用。
- 权限模块:登录、角色判定、操作授权。所有敏感操作入口先过一遍
check_perm()。 - 审计模块:写日志。任何超级管理员操作、任何失败的授权尝试都记一条。
- 报表模块:按日汇总流水、按卡汇总消费、按管理员汇总操作次数。
这个划分方式有一个隐含好处:答辩时老师问“你这个系统有哪些模块”,你可以顺着讲“每个模块我只暴露少数几个接口,模块之间不共享全局变量”,这比背功能列表更能体现设计能力。
3.2 计费引擎:把“计时”和“算钱”拆开
计费是整个系统里唯一值得写成纯函数的部分。它的输入是开始时间、结束时间、费率结构,输出是分钟数和金额。纯函数意味着同样的输入永远得到同样的输出,不读文件、不取当前时间、不修改全局变量,因此可以用一组写死的时间戳批量测试。
/* 把时间戳拆成"当天第几分钟",用于夜间时段判断 */ static int minute_of_day(time_t t) { struct tm *lt = localtime(&t); return lt->tm_hour * 60 + lt->tm_min; } /* 核心计费:返回应收金额(分),分钟数通过 out_min 带回 */ int calc_fee(time_t start, time_t end, const Rate *r, int *out_min) { if (end <= start) { *out_min = 0; return 0; } long secs = (long)(end - start); long mins = (secs + 59) / 60; /* 向上取整:多 1 秒也按 1 分钟 */ if (mins < r->min_unit) mins = r->min_unit; /* 不足起计单位按起计单位收 */ long fee = 0; long remain = mins; long night_used = 0; /* 逐分钟判定费率段:课程设计量级(单次上机 < 24h)完全够用 */ for (long i = 0; i < mins; i++) { int mod = minute_of_day(start + i * 60); int is_night = (r->night_start <= r->night_end) ? (mod >= r->night_start && mod < r->night_end) : (mod >= r->night_start || mod < r->night_end); if (is_night) night_used++; } /* 白天与夜间分别按小时单价折算,最后合并 */ long day_min = remain - night_used; fee = (day_min * r->base_cent + 59) / 60 + (night_used * r->night_cent + 59) / 60; *out_min = (int)mins; return (int)fee; }这段代码有三个参数需要说明。min_unit控制起计单位,设为 30 就是“不足半小时按半小时”,设为 0 或 1 就是不设起计门槛,改动它就能适配不同课程设计的要求。night_start与night_end用分钟表示,且允许start > end表示跨零点,判断时用一个三元表达式切换“普通区间”和“跨零点区间”两种逻辑。逐分钟循环看起来笨,但单次上机最多 24 小时也就是 1440 次迭代,微秒级开销,换来的是逻辑绝对清晰——不用去推导区间重叠公式,也就不容易写错。
注意:
(day_min * r->base_cent + 59) / 60里的+59是向上取整的惯用写法,整数除法向下取整,加 59 之后恰好把非整除部分进位。这个技巧在算钱的地方必须用,否则 1 分钟会被算成 0 元。
有了纯函数,测试就好写了。构造 47 分钟、59 分钟、60 分钟、61 分钟、跨零点 8 小时这几组时间戳,直接把预期金额写死做断言,跑一遍就知道对不对。这比坐在机房里等一小时强太多。
3.3 会话生命周期与异常退出的兜底
会话状态机只有四个状态:空闲、上机中、已结算、强制结算。合法迁移是“空闲 → 上机中 → 已结算”,以及“上机中 → 强制结算”。任何其他迁移都要拒绝并写日志。比如对一张已经在机上的卡再执行一次上机,必须报错而不是覆盖onboard_at——覆盖了就等于用户白玩,结算时按最新时间算,账就少了。
异常退出是这个系统最容易漏的地方。程序被 Ctrl+C 或直接关窗口杀掉时,内存里的数据不会落盘,用户卡状态停在“上机中”,下次启动就变成一个永远在计费的幽灵会话。我的处理方式是两条:一是每次状态变更后立刻落盘,不做批量延迟写;二是程序启动时扫一遍会员卡表,凡是status == 1但对应会话settled == 0的记录,提示管理员“发现 N 条未结算会话”,让管理员选择按当前时间强制结算还是作废。这个启动自检逻辑只要十几行,但答辩演示时是加分项,因为它证明你想过“程序崩了怎么办”。
/* 启动自检:找出 status=1 但会话未结的幽灵记录 */ int recover_unsettled(MemberCard *cards, int n, Rate *r) { int found = 0; time_t now = time(NULL); for (int i = 0; i < n; i++) { if (cards[i].status == 1 && cards[i].onboard_at > 0) { int mins = 0; int fee = calc_fee(cards[i].onboard_at, now, r, &mins); printf("发现未结算会话:卡号 %s,上机于 %s,预估 %d 分 %d.%02d 元\n", cards[i].card_id, ctime(&cards[i].onboard_at), mins, fee / 100, fee % 100); found++; /* 这里不自动结算,交给管理员确认,避免把测试数据也算成钱 */ } } return found; }注意这里故意不自动结算,而是打印出来让管理员决定。自动结算听起来“智能”,实际上会把调试期间的测试数据也算成真实消费,反而制造麻烦。把决策权交回给人,是这类小系统里更稳妥的做法。
4. 核心模块实现:从开卡到结账的完整链路
结构定下来之后,实现阶段真正要盯的是三件事:钱不能错、数据不能丢、权限不能越。这三件事分别对应事务保护、文件原子写、操作授权与留痕。下面按调用链顺序展开,从开卡一直走到结账出报表。
4.1 会员卡与余额扣款的事务保护
开卡时要做的校验比想象中多:卡号不能重复、姓名不能为空、初始充值金额不能为负、卡号格式要符合约定(比如C开头加四位数字)。这些校验集中放在card_create()里,任何一个不通过就整体拒绝,不允许“部分成功”——比如卡建好了但充值失败,那张卡就成了一个余额为 0 的孤儿。
充值、扣款、退款统一走一个balance_change(),它做三件事:检查余额是否够扣(扣款时)、修改余额、返回新余额。所有涉及钱的调用都必须经过它,这样将来要加“单笔充值上限”或者“余额不足自动挂失”之类的规则,只改一个函数。
/* 余额变更唯一入口:amount 为正表示充值,为负表示扣款 */ int balance_change(MemberCard *c, int amount) { if (c->status == 2) return -1; /* 挂失卡不允许资金变动 */ long next = (long)c->balance + amount; if (next < 0) return -2; /* 余额不足 */ if (next > 100000000) return -3; /* 上限 100 万元,防溢出 */ c->balance = (int)next; return 0; }用long做中间计算是防溢出的常规操作。虽然课程设计里余额不会大到溢出int,但把中间结果放long里几乎零成本,养成习惯没坏处。
真正的事务保护在存储层。二进制文件直接fopen("member.dat", "wb")覆盖写,一旦写到一半断电或程序被杀,原文件就被截断了,几百条会员数据全没——这个场景在演示时出一次,整份作业基本就废了。标准解法是“写临时文件 + 原子重命名”:
/* 原子保存:先写 member.tmp,成功后 rename 覆盖 member.dat */ int save_members(const MemberCard *arr, int n) { FILE *fp = fopen("member.tmp", "wb"); if (!fp) return -1; size_t w = fwrite(arr, sizeof(MemberCard), (size_t)n, fp); if (w != (size_t)n) { fclose(fp); remove("member.tmp"); return -2; } fflush(fp); fclose(fp); if (rename("member.tmp", "member.dat") != 0) { remove("member.tmp"); return -3; } return 0; }rename在同一文件系统内是原子操作,要么旧文件完整,要么新文件完整,不存在“写了一半”的中间态。代价是每次保存要写两次磁盘(临时文件 + 重命名),对几百条数据完全无感。这个模式我在五个文件上全部套用,代码量增加不到二十行,但彻底消灭了“数据文件损坏”这类问题。
4.2 上机下机与时间差计算
上机做的事很少:校验卡存在、状态为空闲、余额大于起计金额,然后把status置 1、onboard_at置当前时间、插入一条settled = 0的会话记录。下机做的事多:算费用、扣余额、补全会话的end_at与fee、把卡状态置回空闲、onboard_at清零。这两步都必须“全部成功或全部不做”,中间任何一步失败都要回滚。
/* 下机结算:先算钱,再扣款,最后写会话,任一步失败都不落状态 */ int checkout(MemberCard *c, Session *s, Rate *r, time_t now) { if (c->status != 1) return -1; /* 不在机上 */ int mins = 0; int fee = calc_fee(c->onboard_at, now, r, &mins); /* 纯函数,先算 */ if (balance_change(c, -fee) != 0) { /* 余额不足也要放行下机 */ fee = c->balance; /* 余额扣到 0 为止 */ balance_change(c, -fee); } s->end_at = now; s->minutes = mins; s->fee = fee; s->settled = 1; c->status = 0; c->onboard_at = 0; return 0; }这段里有个业务决策值得单独说:余额不足时,是拒绝下机还是欠费放行?我的选择是扣到 0 为止放行,会话标记为已结但金额不足。理由是现实中人已经走了,你不放行也没用,欠费记录留给人处理比卡在机器上合理。答辩时如果被问到这个点,能讲出这个取舍就是加分。
时间差计算方面,time_t相减得到秒数,直接用,不要去做tm结构体的年月日相减——那样跨月跨年会错得离谱。跨零点的问题已经在计费函数里通过“当天第几分钟”处理掉了,会话模块本身不需要关心。
4.3 超级管理员扩展:权限分级与操作审计
超级管理员这部分是整份设计的差异化亮点,实现上主要靠两样东西:一张操作表和一个日志函数。操作表把“操作名 → 所需角色 → 处理函数”三者的映射集中在一处,授权逻辑只写一遍;日志函数在每次敏感操作前后各写一条,记录谁、什么时候、做了什么、结果如何。
typedef enum { ROLE_GUEST = 0, ROLE_ADMIN = 1, ROLE_SUPER = 2 } Role; typedef struct { const char *op; /* 操作名,与日志里记录的名字保持一致 */ Role required; /* 执行该操作所需的最低角色 */ int (*handler)(void *ctx); } OpEntry; /* 操作表:新增功能只加一行,不用改授权逻辑 */ static const OpEntry g_ops[] = { { "member.add", ROLE_ADMIN, op_member_add }, { "member.recharge", ROLE_ADMIN, op_member_recharge }, { "session.checkout", ROLE_ADMIN, op_session_checkout }, { "rate.set", ROLE_SUPER, op_rate_set }, { "admin.grant", ROLE_SUPER, op_admin_grant }, { "data.restore", ROLE_SUPER, op_data_restore }, }; /* 统一入口:先判权限,再记日志,最后执行 */ int dispatch(const char *op, Role cur, void *ctx) { for (size_t i = 0; i < sizeof(g_ops)/sizeof(g_ops[0]); i++) { if (strcmp(g_ops[i].op, op) == 0) { if (cur < g_ops[i].required) { audit_log(current_user(), cur, op, "denied", -1); return -403; /* 权限不足,且必须留痕 */ } int ret = g_ops[i].handler(ctx); audit_log(current_user(), cur, op, "done", ret); return ret; } } return -404; }表驱动的价值在于“权限不足也要留痕”这条规则只写在一个地方。如果每个功能自己写 if-else 判断角色,早晚会漏掉一两个,而漏掉的那个往往就是费率修改。审计日志本身用追加模式写文本,一行一条,制表符分隔,方便最后用文本工具或脚本统计。
/* 审计日志:追加模式,每条写完立即关闭,宁可慢也不丢 */ int audit_log(const char *actor, Role role, const char *op, const char *detail, int ret) { FILE *fp = fopen("audit.log", "a"); if (!fp) return -1; time_t now = time(NULL); char ts[24]; strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", localtime(&now)); fprintf(fp, "%s\t%s\t%d\t%s\t%s\t%d\n", ts, actor ? actor : "-", (int)role, op, detail, ret); fclose(fp); return 0; }每次打开-写入-关闭确实比长期持有文件句柄慢,但日志量小(一天几十条),换来的是“程序任何时刻崩溃都不丢已写日志”。另外日志里不要写密码、不要写完整卡号(可以只写后四位),这是基本的数据保护意识,报告里提一句会显得你想得比较周全。
强制下机是超级管理员的另一个功能,逻辑和普通下机类似,区别是会话标记为settled = 2,并且日志里必须记录操作人和原因。数据备份与恢复则是把五个文件复制到带时间戳的备份目录,恢复前先做一次当前数据备份,防止恢复错了没后悔药。
4.4 报表统计与数据落盘
报表模块是答辩时最容易被要求现场演示的部分。至少要准备三张:当日流水(按会话列出卡号、时长、金额)、按卡汇总消费(卡号、总时长、总金额、消费次数)、按管理员汇总操作(账号、操作次数、被拒次数)。前两张基于会话表遍历即可,第三张基于审计日志统计。
/* 按日汇总:遍历会话表,累加指定日期的金额与笔数 */ int report_daily(const Session *s, int n, time_t day, int *out_cnt, int *out_fee) { int cnt = 0, fee = 0; struct tm want = *localtime(&day); for (int i = 0; i < n; i++) { if (s[i].settled == 0) continue; struct tm got = *localtime(&s[i].end_at); if (got.tm_year == want.tm_year && got.tm_yday == want.tm_yday) { cnt++; fee += s[i].fee; } } *out_cnt = cnt; *out_fee = fee; return 0; }用tm_yday(一年中的第几天)比较日期比比较年月日三个字段省事,也天然处理了跨月。注意localtime返回的是内部静态缓冲区指针,连续调用会被覆盖,所以要先解引用拷贝出来再比较——这是一个很隐蔽的坑,写的时候不觉得,调试时能折腾半天。
报表输出到控制台之外,最好再支持导出为文本文件,方便直接粘进实验报告。导出功能不用新写一套逻辑,把printf换成fprintf到文件即可,用同一个格式化函数传不同的FILE*就能复用。
5. 避坑与排查:最容易翻车的五个点
这套东西我在不同场合见过很多版本,出错的地方高度集中。下面五条基本覆盖了 80% 的现场翻车场景,每条按“现象 → 原因 → 解决”写,照着排查能省不少时间。
5.1 输入字母就无限刷屏
现象:菜单要求输入数字,手滑输了个字母,屏幕上菜单疯狂滚动停不下来,只能强杀进程。
原因:scanf("%d", &x)读失败时不消费输入流,那个字母一直留在缓冲区里,下一次循环又读到它,又失败,于是死循环。返回值也没检查,读失败后x还是上一次的旧值,菜单会根据旧值再执行一遍。
解决:所有scanf都检查返回值,失败就清空缓冲区。封装一个read_int(),内部用fgets读整行再用sscanf解析,解析失败返回错误码并提示重输。fgets天然按行消费,从根上避免残留。另外清缓冲区不要用fflush(stdin),那是未定义行为,在某些编译器上根本不起作用。
5.2 结算金额出现 2.999999 元
现象:上机一小时,费率 3 元,结账打印出来是 2.999999 或者 3.000001,四舍五入后又对不上总账。
原因:用float或double存金额,0.01 这类十进制小数在二进制浮点里无法精确表示,累加多次后误差放大;再叠加显示时的格式化和取整,误差就暴露了。
解决:金额全程用int存“分”,只在最终显示时除以 100.0 一次。涉及除法的地方一律用整数向上取整(a + b - 1) / b,不要中途转浮点。改完之后所有对账都是一分不差,这个改动大概半小时,收益贯穿整个项目。
5.3 程序崩溃后数据文件打不开
现象:调试时按了 Ctrl+C 或者直接关窗口,重新启动提示“读取会员数据失败”,文件大小变成 0 或者一个奇怪的值。
原因:用了fopen(path, "wb")直接覆盖原文件,写操作进行到一半进程被杀,文件已经被截断但新内容没写完,数据两头不靠。
解决:改成“写临时文件 +rename原子替换”的模式,所有五个数据文件统一处理。同时增加启动自检,检测文件大小是否是结构体大小的整数倍,不是就提示从备份恢复。备份策略可以很简单:每次成功保存前,把旧文件复制一份成.bak,只留最近一份就够用。
5.4 同一张卡被重复上机,结算金额变成 0
现象:对一张已经在机上的卡再点一次“上机”,程序不报错,onboard_at被刷新成当前时间,用户下机时只算了几分钟,钱几乎没收。
原因:上机函数只检查了卡是否存在,没检查status是否为“空闲”,或者检查了但用的是if (status == 0) {...}而没有else分支报错,导致非法操作被静默接受。
解决:所有状态变更函数入口第一件事就是校验当前状态,不合法立即返回错误码并打印原因,同时写一条审计日志。状态迁移要列成显式的表,只允许表里出现的迁移,其余全部拒绝。这条规则对下机、挂失、解挂同样适用。
5.5 超级管理员改完费率,在机用户按新价结算
现象:管理员把费率从 3 元改成 5 元,正在上机的用户下机时按 5 元结算,用户不认可,认为上机时的价格是 3 元。
原因:费率只在结算时读取,没有记录“会话开始时的费率快照”。这是业务逻辑漏洞,不是代码 bug,测试时很难发现,因为大部分测试都是改完费率再上机。
解决:会话表里增加费率快照字段,上机时把当前费率整份拷贝进会话记录,结算时用快照而不是当前费率。这样改价只影响之后上机的用户,边界清晰。同时在修改费率的操作里增加二次确认,并强制要求填写修改原因,写进审计日志。
6. 验收与答辩:一份能自证正确的演示脚本
功能做完不等于能过。答辩现场时间有限,老师不会给你半小时慢慢点菜单,通常只给三到五分钟。这三五分钟里如果只是把功能挨个点一遍,评委看到的是“能用”,但看不到“你想清楚了”。我一般的做法是准备一份固定脚本,按“正常流程 → 边界情况 → 异常恢复 → 权限验证”四段走,每段都对应一个明确要证明的结论。
第一段走正常流程,开卡、充值 50 元、上机、等两分钟、下机、打印小票。这段证明主链路通。第二段走边界:手动构造一个 47 分钟的时间戳调计费函数,证明不足一小时按分钟计;再构造一个 23:30 到次日 00:30 的跨零点场景,证明夜间段正确。这两条不要靠真等,直接在演示前把测试用例写进一个selftest()函数里,现场调一下跑出结果,比口述有说服力得多。
第三段演示异常恢复:故意在“上机中”状态下强杀进程,重启后展示自检提示“发现 1 条未结算会话”,然后选择强制结算。这段是全场最有杀伤力的部分,因为它证明你考虑过失败路径。第四段演示权限:用管理员账号尝试改费率被拒并留下日志,再用超级管理员账号改成功并留下日志,最后把审计日志打印出来对照。
| 演示段 | 操作 | 要证明的结论 | 常见追问 |
|---|---|---|---|
| 正常流程 | 开卡→充值→上机→下机 | 主链路闭环,金额正确 | 余额扣款在哪一层做的 |
| 边界用例 | 47 分钟、跨零点 | 计费不依赖真实等待 | 起计单位怎么配 |
| 异常恢复 | 强杀后重启自检 | 数据不丢、有兜底 | 为什么不用数据库 |
| 权限验证 | 管理员被拒、超管成功 | 分级有效、操作留痕 | 日志会不会被篡改 |
常见追问里“为什么不用数据库”几乎必问。标准答法是:课程设计的约束是单机、无第三方依赖、数据量在千条以内,文件方案足够;同时文件方案逼着我把结构体布局、原子写、异常恢复这些底层细节做扎实,这些在数据库里被隐藏掉了。这个回答既承认了约束,又把约束转成了优势。
“日志会不会被篡改”这种问题不用慌,如实说当前是纯文本追加,没有做防篡改,如果要防篡改可以加链式哈希——每条日志带上一条的摘要。承认边界比硬编一个不存在的功能强,评委更在意你知不知道自己的方案边界在哪。
从那以后我每次交这类课程设计,都会强制走一遍这套四段式演示脚本,并且提前把边界用例写成自测函数。因为现场翻车往往不是因为功能没做,而是因为没在别人面前完整跑过一遍。希望帮到你。
本文还有配套的精品资源,点击获取