简介面向高校计算机基础与编程课程学习者的网吧计费管理系统实验项目配套武汉理工大学课程实践包含基础计费功能及扩展超级管理员模块可作为课程设计、C/C综合练习与功能扩展参考。压缩包共325个文件约33.1MB以C与头文件源码、OBJ编译中间文件、工程配置及可执行程序为主另含说明文档和版本记录便于还原实验工程、理解模块划分并对照调试。已有3179人学习下载说明该案例在同类课程资源中具有一定参考价值。读者可从中获得完整项目结构、计费与管理功能实现思路、超级管理员权限设计示例以及编译运行与二次开发时可借鉴的排错线索适合需要完成课程实验或扩展管理系统功能的学生与自学者。1. 机器一天开出去三十次账就要对得上三十次课程资源里被选得最多的题目之一就是计费管理但把它当成一道乘法题来做的人答辩时几乎都会被问住。真正要解决的不是「一小时三块钱怎么算」而是这台机器一天被开出去三十次三十次账目能不能一条不落地对上跨零点的时长怎么算、余额扣到分之后差的那一分去哪了、管理员手滑删掉的记录还有没有后悔药。这套方案面向正在做计算机基础与编程实验课程设计的学生用 C 语言控制台程序加文本文件持久化把会员开卡、机位状态、分段计费、结算找零和超级管理员扩展功能串成一条能演示、能自证、能经得起追问的完整链路。2. 数据模型与持久化三张表撑起整个系统控制台程序最容易写成一锅粥全局变量满天飞开卡函数顺手改了机位结算函数又回头去动会员余额。等要加一个「查询历史消费」的功能发现根本无从下手。所以我一般先把数据切成互不重叠的三块——谁在花钱、哪台机器被占、发生过什么——然后再谈代码怎么写。这一层想清楚了后面的计费和权限只是在既定结构上加函数而已。2.1 会员、机位、流水三张核心表的字段设计会员表只描述「人」不关心人坐在哪机位表只描述「机器」不关心这人的历史流水表则是只增不改的事实记录一旦写下就不再修改。这种划分让排查问题变得非常直接余额对不上就查流水机位状态不对就查机位表两边都不背锅。#define NAME_LEN 32 #define CARD_LEN 12 #define MAX_SEAT 60 /* 会员表一行一个持卡人card_id 为主键 */ typedef struct { char card_id[CARD_LEN]; /* 卡号8 位数字 2 位校验位唯一 */ char name[NAME_LEN]; /* 姓名最长 31 字节 */ int balance_cent; /* 余额单位分禁止用 float */ int status; /* 0 正常 / 1 挂失 / 2 注销 */ char created_at[20]; /* 开户时间 yyyy-MM-dd HH:mm:ss */ } Account; /* 机位表固定 MAX_SEAT 行用 seat_no 直接下标寻址 */ typedef struct { int seat_no; /* 机位号1 ~ MAX_SEAT */ int state; /* 0 空闲 / 1 使用中 / 2 故障 */ char card_id[CARD_LEN]; /* 当前占用人空闲时置空串 */ long open_ts; /* 开卡时间戳单位秒 */ int deposit_cent; /* 本单押金单位分 */ } Seat; /* 流水表只追加不修改是全部对账的唯一依据 */ typedef struct { long id; /* 自增流水号重启后从文件尾部续号 */ char card_id[CARD_LEN]; int seat_no; long open_ts; long close_ts; int minutes; /* 实际计费分钟向上取整 */ int fee_cent; /* 本单消费单位分 */ int operator_role; /* 1 普通管理员 / 2 超级管理员 */ } Bill;三张表的字段里有两个地方是刻意设计的。一是balance_cent和fee_cent全部用int存「分」不出现任何double二是机位表缓存了card_id和open_ts看起来像冗余但它让「这台机器现在是谁在用、用了多久」变成一次数组访问而不是去遍历流水表找未闭合记录。冗余的代价是开卡和下机时必须同步更新两处收益是所有高频查询都退化成 O(1)。对于六十个机位这种规模这个取舍几乎没有悬念。seat_no与数组下标之间采用seat_no - 1的映射不做链表也不做哈希。原因很现实课程设计里机位数是常量用下标寻址最不容易写出野指针助教翻代码时一眼就能看懂。2.2 用文本文件做持久化的读写封装持久化方案我建议用「一行一条记录」的纯文本而不是二进制整块写。二进制写起来省事但调试时打不开、改不动一旦字段调整顺序旧文件直接读成乱码连抢救的机会都没有。纯文本的代价是解析代码多几十行换来的是任何时候都能用记事本打开核对——这在答辩现场演示时非常有用。/* accounts.txt 一行一条卡号|姓名|余额分|状态|开户时间 */ static int load_accounts(Account *buf, int cap) { FILE *fp fopen(data/accounts.txt, r); int n 0; if (!fp) return 0; /* 首次运行没有文件返回 0 即可 */ while (n cap fscanf(fp, %11[^|]|%31[^|]|%d|%d|%19[^\n]\n, buf[n].card_id, buf[n].name, buf[n].balance_cent, buf[n].status, buf[n].created_at) 5) { n; } fclose(fp); return n; } /* 保存用「先写临时文件再重命名」避免写到一半断电把原文件清空 */ static int save_accounts(const Account *buf, int n) { FILE *fp fopen(data/accounts.tmp, w); int i; if (!fp) return -1; for (i 0; i n; i) { fprintf(fp, %s|%s|%d|%d|%s\n, buf[i].card_id, buf[i].name, buf[i].balance_cent, buf[i].status, buf[i].created_at); } fflush(fp); fclose(fp); return rename(data/accounts.tmp, data/accounts.txt); }fscanf里的%11[^|]是关键它限定了最大读取长度比%s安全得多。用%s读卡号遇到超长输入会直接越界写穿结构体而%11[^|]最多读 11 个字符超出的部分留在缓冲区里由后续逻辑处理。同理%31[^|]对应char name[32]留一个字节给结尾的\0。保存函数里rename那一行值得单独说。rename在同一文件系统内是原子操作要么旧文件还在要么新文件已就位不存在「文件被清空但新内容没写完」的中间态。直接fopen(..., w)会把文件长度截断为零这时如果程序崩溃全部会员数据当场蒸发。这个坑我见过不止一次加一行rename就能彻底堵上。参数说明cap是缓冲区容量读取时用n cap兜底防止文件被手工改长导致越界返回值n是实际读到的记录数调用方用它初始化内存中的数组长度后续所有遍历都以n为界而不是MAX_SEAT这类常量。2.3 金额一律用整数「分」0.1 0.2 ! 0.3这个经典问题在计费系统里不是学术讨论是真实的账目误差。用float存金额一小时三块、一分钟五分这种除法做几十次之后余额里就会稳定出现12.299999这样的值显示时四舍五入成 12.30但存回去再算一次就变成 12.29。学生演示时余额少一分钱助教一眼就能看见。处理办法是把所有金额的单位下沉到「分」全程用整数做加减乘除。单价也用分表示三块钱一小时就是每分钟 5 分。这样余额、押金、消费额、找零全部是整数加减永远不会丢精度唯一需要做的除法是「分转元」的显示环节。/* 只在显示层做一次除法且固定两位小数 */ static void print_money(const char *label, int cent) { int neg cent 0; int abs_c neg ? -cent : cent; printf(%s%s%d.%02d 元, label, neg ? - : , abs_c / 100, abs_c % 100); }abs_c / 100取整数部分abs_c % 100取小数部分%02d保证5 分显示成0.05而不是0.5。负数单独处理是因为 C 语言的/和%对负数的行为是从零截断-5 / 100得 0、-5 % 100得 -5直接拼出来会变成0.-5。这个细节在退款、押金抵扣场景里一定会碰到。配套还要定一条规则所有涉及金额的输入界面提示必须写清单位。让用户输「30」到底是 30 元还是 30 分这个歧义如果不写清楚测试时充一百块变成充一块很难解释是程序 bug 还是操作失误。3. 计费核心从开卡到结算的状态机计费模块是整个系统里唯一不能出错的部分其他功能错了顶多重启钱算错了就是要一条条手工对账。我一般把它拆成三个互相独立的函数状态迁移开卡/下机、时长计算、费用计算。三者之间只通过结构体传参不共享全局状态这样每一段都能单独构造数据来验证。3.1 机位状态机与冲突校验机位只有三个状态空闲、使用中、故障。合法迁移只有四条——空闲到使用中开卡、使用中到空闲下机、空闲到故障报修、故障到空闲修复完成。使用中不能直接跳到故障必须先把人结算掉否则那笔账就永远悬在半空。/* 开卡返回 0 成功负数为错误码 */ int open_seat(int seat_no, const char *card_id, long now, int deposit_cent) { Seat *s; if (seat_no 1 || seat_no MAX_SEAT) return -1; /* 机位越界 */ s g_seats[seat_no - 1]; if (s-state ! SEAT_FREE) return -2; /* 已被占用或故障 */ if (find_account(card_id) NULL) return -3; /* 卡不存在 */ if (get_balance(card_id) deposit_cent) return -4; /* 余额不足押金 */ s-seat_no seat_no; s-state SEAT_BUSY; s-open_ts now; s-deposit_cent deposit_cent; strncpy(s-card_id, card_id, CARD_LEN - 1); s-card_id[CARD_LEN - 1] \0; return 0; }这个函数里所有的校验都放在写状态之前任何一条不满足就立刻返回机位表保持原样。这种「先全部检查、再统一提交」的顺序看起来啰嗦但它保证了失败时不会留下半改半不改的脏数据——比如卡号写进去了但状态还是空闲下次开卡就会判成冲突。strncpy后面手动补\0也是必须的。strncpy在源串长度大于等于n时不会自动加结尾符直接用会导致后续printf(%s)一直读到结构体外面去。这类问题在测试时不会每次都复现属于典型的玄学 bug加一行兜底最省心。3.2 费率表与分段计费函数费率不做成硬编码的 if-else而是做成一张表。原因很实在课程设计答辩经常被要求「现场把首小时改成两块」表驱动改一行数据就行硬编码要改逻辑、还要重新确认边界。/* 半开区间 [from, to)to -1 表示不封顶单价单位分/分钟 */ typedef struct { int from_min; int to_min; int price_cent; } RateItem; static const RateItem g_rate[] { { 0, 60, 5 }, /* 首小时5 分/分钟即 3 元/小时 */ { 60, 180, 4 }, /* 1~3 小时4 分/分钟即 2.4 元/小时 */ { 180, -1, 3 }, /* 3 小时以上3 分/分钟即 1.8 元/小时 */ };用半开区间而不是闭区间是分段计费里最容易踩的边界问题。如果写成[0,60]和[60,180]第 60 分钟会被算两次写成[0,60)和[60,180)每个整点只归属一段无论怎么改分段点都不会重复计价。/* 按分钟数分段累加minutes 已经向上取整 */ int calc_fee_cent(int minutes) { int fee 0; int i, cnt (int)(sizeof(g_rate) / sizeof(g_rate[0])); if (minutes 0) return 0; for (i 0; i cnt; i) { int from g_rate[i].from_min; int to g_rate[i].to_min; int hi; if (minutes from) break; /* 还没进入本段 */ hi (to 0 || minutes to) ? minutes : to; fee (hi - from) * g_rate[i].price_cent; } return fee; }hi的取值决定了这一段实际计入多少分钟如果总时长还没超出本段上限就取总时长否则取本段上限。循环里的break条件是minutes from因为时长一旦没到本段起点后面的段更不可能进入。整个函数没有浮点、没有取模纯整数运算任何输入都能算出确定结果。时长本身在结算时算且必须向上取整——用了 61 分钟按 62 分钟计费这是行业惯例也避免了「59 分 59 秒免费」的漏洞int minutes (int)((now - s-open_ts 59) / 60); if (minutes 0) minutes 0; /* 时钟回拨兜底详见第 5 章 */3.3 下机结算、押金与找零结算函数要同时完成四件事算时长、算费用、扣押金、写流水。这四步的先后顺序很讲究我的做法是先算出所有结果全部校验通过后再一次性落盘落盘成功才修改内存状态。int close_seat(int seat_no, long now, Bill *out) { Seat *s; Account *a; int minutes, fee, change; if (seat_no 1 || seat_no MAX_SEAT) return -1; s g_seats[seat_no - 1]; if (s-state ! SEAT_BUSY) return -2; /* 没人上机 */ a find_account(s-card_id); if (a NULL) return -3; /* 卡被注销了 */ minutes (int)((now - s-open_ts 59) / 60); if (minutes 0) minutes 0; fee calc_fee_cent(minutes); /* 押金封顶超出部分记为欠费不在这里做负数扣款 */ if (fee s-deposit_cent) fee s-deposit_cent; change s-deposit_cent - fee; /* 组装流水先不落盘 */ out-id next_bill_id(); out-seat_no seat_no; out-open_ts s-open_ts; out-close_ts now; out-minutes minutes; out-fee_cent fee; strncpy(out-card_id, s-card_id, CARD_LEN - 1); if (append_bill(out) ! 0) return -4; /* 落盘失败状态不变 */ a-balance_cent change; /* 押金抵扣后退回余额 */ s-state SEAT_FREE; s-open_ts 0; s-card_id[0] \0; s-deposit_cent 0; return 0; }关键在append_bill的位置。它排在所有内存修改之前写失败就直接返回机位依然保持使用中用户可以重新执行下机操作。如果反过来先改内存再写文件一旦写盘失败机位变空闲了但流水没留这笔账就彻底丢了。押金封顶那条也值得说明。真实网吧遇到余额不足会要求充值后再下机课程设计里没必要做得那么复杂但也不能让余额扣成负数。折中做法是押金封顶、超出部分记 0同时在流水里保留真实的fee_cent不受封顶影响这样对账时能看出这单实际该收多少。change为正表示退款为负表示欠费两种情况下a-balance_cent change都是正确的——欠费时余额减少逻辑自洽。4. 超级管理员扩展权限分级与操作留痕基础版计费系统通常只有一个管理员账号能开卡能结算能改费率。题目要求扩展超级管理员不是加一个账号那么简单核心要解决的是「普通管理员的权限边界在哪、越界之后能不能查出来」。这一章的功能如果只是做成「多一个密码」答辩时基本拿不到扩展分。4.1 两级角色的鉴权入口权限设计的最小可用形态是两级普通管理员负责日常营业开卡、下机、查询超级管理员额外拥有三项高危操作——修改费率表、删除流水、管理账号。千万不要做「按功能点分配权限」的矩阵模型课程设计的代码量撑不起那套复杂度两级加白名单已经足够说清楚问题。typedef enum { ROLE_NONE 0, ROLE_ADMIN 1, ROLE_SUPER 2 } Role; /* 高危操作统一走这个入口返回 0 表示放行 */ static int require_role(Role need, const char *action) { if (g_current_role need) return 0; audit_write(g_current_role, DENY, action); /* 拒绝也要留痕 */ printf(权限不足%s 需要超级管理员权限\n, action); return -1; }用而不是是因为角色编号本身带有偏序关系超级管理员天然包含普通管理员的全部能力。这样写的好处是将来如果真加第三级比如审计员只读只要在枚举里插一个值判断逻辑一行都不用改。audit_write在拒绝时也要调用这一点常被忽略。只记录成功的操作等于放弃了对「有人反复尝试越权」的发现能力。测试时我一般会故意用普通管理员账号去点三次删除然后切到超级管理员查日志能看到三条 DENY 记录这个演示效果比讲十分钟设计思路更直接。4.2 审计日志表与不可抵赖审计日志和业务流水是两回事流水记的是钱日志记的是人。日志同样是只追加结构并且要在每次写完之后立即fflush因为它的价值恰恰在于程序异常退出时那份记录还在。typedef struct { long ts; /* 操作时间戳秒 */ int role; /* 操作者角色 */ char action[16]; /* OPEN / CLOSE / SET_RATE / DEL_BILL / LOGIN */ char detail[64]; /* 关键参数如 seat12 fee350 */ } AuditLog; int audit_write(int role, const char *action, const char *detail) { FILE *fp fopen(data/audit.log, a); /* 追加模式绝不覆盖 */ if (!fp) return -1; fprintf(fp, %ld|%d|%s|%s\n, time(NULL), role, action, detail ? detail : -); fflush(fp); /* 立即刷盘防止进程被杀丢日志 */ fclose(fp); return 0; }a追加模式配合fflush使得日志在任意时刻被中断都不会丢失已有内容。这和会员表的rename策略是两个思路会员表要保证整体一致所以用原子替换日志要保证追加不丢所以用追加加刷盘。日志的detail字段不要图省事写成自由文本最好约定成keyvalue形式例如seat12 card10000001 fee350。这样在实现「按卡号查这条卡的所有操作」时直接用字符串查找就能定位不需要再写一套解析器。4.3 数据维护备份、恢复与跨日结转超级管理员的第二个职责是数据处理。这里至少要提供三个动作一键备份、按日期恢复、跨日结转。备份就是把data/下所有文本复制到backup/yyyyMMdd_HHmmss/实现上用一个简单的复制函数循环处理文件列表即可。跨日结转是容易被忽略的一项。流水表按日期查询时如果只有一个bills.txt随着演示次数增加会越滚越大查询也慢。做法是在每天第一次有操作时检查当前日期若与g_last_day不一致就把bills.txt整体重命名为bills_20250101.txt然后新建空的bills.txt。/* 跨日结转日期变化时把当天流水归档 */ static void roll_bill_file(const char *today) { char old[64], dst[64]; if (strcmp(today, g_last_day) 0) return; /* 同一天不动 */ snprintf(dst, sizeof(dst), data/bills_%s.txt, g_last_day); if (rename(data/bills.txt, dst) 0) { /* 归档成功才继续 */ FILE *fp fopen(data/bills.txt, w); /* 建空文件接当天数据 */ if (fp) fclose(fp); strncpy(g_last_day, today, sizeof(g_last_day) - 1); } }注意rename的返回值判断如果源文件不存在当天还没有任何流水rename会失败这时不应该去创建新文件覆盖也不应该更新g_last_day否则第二天的判断会错乱。恢复功能我建议做成「只允许恢复到备份目录中存在的快照」并且在恢复前强制自动备份当前状态。这条规则看着多余但它把「恢复错了也没事」变成了系统保证而不是靠操作者自觉。课程设计里加上这一句说明答辩时是个很实的加分点。5. 常见问题与排查五个最容易翻车的点这一章的内容基本都是从翻车现场攒出来的。功能写完只是第一步能不能在各种边界情况下保持账目一致才是这套系统真正的难点。下面五条按出现频率从高到低排每一条都给出定位方法。5.1 跨零点时长算成负数现象晚上 23:40 开卡第二天 00:20 下机算出来的时长是负数费用变成 0余额不扣反退。原因分两种。一种是直接用了格式化后的时分秒相减20 - 40 -20没有处理借位另一种是用时间戳但时间戳来源是localtime拆出来的字段再手工拼跨日时日期部分没参与运算。解决时长一律用「关闭时间戳减开启时间戳」时间戳从time(NULL)取得到的是自某一固定时刻起的秒数天然单调递增不存在借位问题。同时在计算处保留一行兜底if (minutes 0) minutes 0;防止手工修改数据或系统时钟被调整导致的异常。这条兜底不是掩盖问题而是让程序在数据异常时仍然能正常结算不至于卡死在某台机器上。5.2 浮点让账单差一分现象会员充 100 元消费若干次后余额显示 87.999999四舍五入成 88.00但再消费一次变成 87.99前后差一分。原因float或double存金额每次乘除都引入微小误差累加后误差放大到可见级别。解决全链路改用int存「分」只在显示时除 100。如果已经用浮点写了一部分改造方法是把所有金额字段的类型换掉在输入输出边界做一次转换中间逻辑一行不用动。检查有没有漏改的办法很简单在代码里搜一遍float、double、%f业务逻辑区域应该是零命中。5.3 写文件中途退出导致数据全空现象演示时手滑按了 CtrlC 或者直接关窗口再打开程序发现会员全部消失。原因用了fopen(path, w)直接覆盖原文件。这个模式在打开瞬间就把文件截断为 0 字节后续写入还没完成进程就没了原数据无法找回。解决写临时文件再rename见 2.2 节的实现。已经出问题的现场可以用备份目录里的快照恢复如果没有备份只能靠bills.txt里剩余的流水反推余额这个过程非常痛苦所以宁可一开始就多写三行代码。5.4 输入缓冲区残留的脏字符现象菜单选完 1 之后下一轮循环没等输入就直接跳过了或者连续读进一堆空字符串。原因scanf(%d, choice)只吃掉数字把换行符留在缓冲区下一次用fgets或%c读取时第一个读到的就是那个换行符。混用scanf和fgets时这个问题几乎必然出现。解决统一输入策略。要么全部用fgets读成字符串再用sscanf或strtol解析要么在每次scanf之后手动清空缓冲区/* 丢弃当前行剩余字符直到换行或 EOF */ static void clear_input(void) { int c; while ((c getchar()) ! \n c ! EOF) { /* 空循环 */ } }我倾向于全部用fgets加sscanf因为fgets有长度上限参数不会像%s那样写穿缓冲区。读取失败时fgets返回NULL顺手就能处理 EOF 退出比scanf的返回值判断更直观。5.5 越权删除与缺失的后悔药现象普通管理员能删掉一条流水或者超级管理员删完之后发现删错了无从恢复。原因删除操作没有走require_role校验或者删除时直接从文件里抹掉记录没有留副本。解决两层防护。第一层是 4.1 节的require_role所有高危操作入口统一拦截第二层是「软删除」——不真正删除而是给流水加一个voided标记查询时默认过滤掉但文件里那条记录还在超级管理员可以撤销标记。软删除的代价是文件只增不减对课程设计的规模完全不是问题换来的是任何误删都能一键恢复。这个设计在答辩时被问到「删错了怎么办」能直接打开文件指着记录说明比口头解释有说服力得多。6. 验收与答辩让助教三分钟看懂你的设计课程设计的评分往往不取决于代码行数而取决于你能不能在有限时间里证明这套东西是对的。我见过功能做得很全但演示时手忙脚乱的也见过代码量不大但每一步都能自证的。后者分数明显更高。所以验收准备的核心不是「把所有功能点一遍」而是设计一条能串起全部要点的演示路径。我一般会准备一条固定脚本按下面这个顺序走全程控制在三到五分钟步骤操作想证明什么1用超级管理员登录新建一张会员卡充值 50 元权限分级入口生效2切到普通管理员尝试修改费率越权拦截并在日志留 DENY3开卡上机故意把系统时间往后调 70 分钟分段计费跨段正确4下机结算展示流水与余额变化押金抵扣与找零逻辑5打开bills.txt与audit.log对照数据落盘真实可查6杀掉进程再启动查询刚才的流水持久化有效第 3 步的「调时间」是整场演示的关键。很多同学不敢动系统时间宁可等一小时实际上临时调整时间不影响任何东西反而能在一分钟内说明跨段计费和跨零点逻辑。调整前记得把时间记下来演示完改回去。如果不想动系统时间另一个办法是在代码里留一个隐藏的调试入口允许手动指定结算时间戳但要保证这个入口只在调试模式下编译进去。被追问时最高频的三个问题答案最好提前背熟第一「余额为什么不用浮点」。答所有金额以分为单位用整数存储浮点在多次累加后会出现一分钱误差整数运算没有这个问题只在显示层做一次除法。第二「写到一半断电数据会不会丢」。答会员和机位表用临时文件加rename原子替换日志用追加模式加fflush两种策略分别对应「整体一致」和「追加不丢」两个不同需求。第三「普通管理员乱删数据怎么办」。答高危操作统一走require_role删除采用软删除加voided标记日志里连拒绝记录都会留下超级管理员可以查也可以恢复。还有一个容易被忽略的细节演示前一定要把data/目录清空或恢复到一个干净的初始状态。带着上次调试留下的几十条脏流水去演示查询结果里混着奇怪的数据助教一问就露馅。我习惯在演示前跑一遍「清空数据 → 建三个测试账号 → 开两次卡下两次机」的预热流程确认整个链路是通的再上场。从那以后我每次交课程设计都会强制走一遍「杀掉进程再重启看数据还在不在」这个动作。这个动作只要十秒却能提前暴露掉绝大多数持久化问题——而这类问题恰恰是最容易在答辩现场翻车的。希望帮到你。本文还有配套的精品资源点击获取