简介面向C语言学习者和课程设计学生提供一套完整的外来人员进出监控与管理系统项目。系统以控制台交互实现分为访客与管理端两条主线访客可在线提出申请、完成入校与离校登记并区分个人和团体两种访客类型管理员则负责审批申请、增删管理员和查询申请记录流程覆盖申请、审批、登记、查询的完整闭环。压缩包共25个文件、约1.58MB含2个C源文件与配套头文件、1份实验报告、1份PPT演示文稿、16张运行截图及多份Markdown说明文档。源码结构完整实验报告对设计思路与测试过程有详细说明PPT可直接用于答辩展示运行截图便于对照界面理解功能文档之间相互呼应方便按需查阅。已有128人学习特别适合正在完成C语言课程设计、需要参考完整项目结构或数据流设计的读者。通过源码与文档配合可快速掌握控制台菜单编写、业务拆分等实用技能是一份落地度高、开箱即用的参考资料也可作为后续扩展系统功能的起点。1. 从纸质台账到C语言控制台系统校园外来人员管理怎么落地大多数高校的访客管理还停留在门卫登记簿上姓名、电话、事由靠手写事后追溯要翻一整天纸。这套基于C语言实现的外来人员进出监控与管理系统把访客从“提出申请 - 管理员审批 - 入校登记 - 离校登记”整条链路搬进控制台程序课程设计的核心编号为100010644正好对应一个完整的信息管理闭环。它不依赖数据库用文件持久化和内存链表就能跑起来非常适合C语言课程设计与期末练手。系统拆成访客端和管理员端两块访客端覆盖个人和团体两种入校场景管理员端管审批、账号扩充和申请记录查询。下面我按数据建模、访客流程、管理员操作、编译调试这几个关键环节把可复现的写法拆开讲。2. 数据建模结构体字段设计、状态机与文件持久化方案2.1 为什么用“结构体数组 链表”而不是数据库课程设计场景里MySQL 或 SQLite 属于上纲上线——评委看的是 C 语言基本功不是框架集成。稳妥的数据组织方式是用结构体描述一条访客申请记录程序启动时从文件加载到内存链表运行时增删改查都在链表上完成退出时再整体写回文件。常见做法是定义VisitorRecord结构体包含申请编号、姓名、联系方式、单位/学校、访问事由、来访类别、预约进入时间、实际进入时间、实际离开时间、审批状态共 10 个字段。这里有一个关键决策状态字段不要用 int 裸奔而是定义成 5 个宏从0到4分别表示“待审批 / 已通过 / 已拒绝 / 已入校 / 已离校”后续所有逻辑分支都在这组宏上做比较可读性和可维护性会好很多。#define STATUS_PENDING 0 // 待审批 #define STATUS_APPROVED 1 // 已通过 #define STATUS_REJECTED 2 // 已拒绝 #define STATUS_CHECKED_IN 3 // 已入校 #define STATUS_CHECKED_OUT 4 // 已离校 #define MAX_NAME_LEN 32 #define MAX_PHONE_LEN 16 #define MAX_REASON_LEN 128 #define MAX_COMPANY_LEN 64 typedef struct VisitorRecord { int record_id; // 申请编号自增 char name[MAX_NAME_LEN]; // 访客姓名 char phone[MAX_PHONE_LEN]; // 联系电话 char company[MAX_COMPANY_LEN]; // 单位/学校 char reason[MAX_REASON_LEN]; // 访问事由 int visitor_type; // 1个人访客 2团体访客 char visit_time[32]; // 预约入校时间格式 YYYY-MM-DD HH:MM char enter_time[32]; // 实际入校时间 char leave_time[32]; // 实际离校时间 int status; // 状态取值为上述宏之一 struct VisitorRecord *next; // 链表指针 } VisitorRecord;结构体字段的顺序应当与将来fread/fwrite落盘的布局保持一致这一点在放上next指针的时候尤其重要。上面的next指针在代码里参与链表操作但真正写入文件时不能把指针值一起落盘否则下次进程启动时文件里存的地址早就失效了。常见处理手段是 write 时逐字段写入而非整体fwrite(record, sizeof(record), 1, fp)或者定义另一个“纯数据”结构体专门用来持久化。2.2 文件存储选型二进制还是文本二进制方案一次fwrite把整个结构体数组写进records.dat加载时用fread读回代码最短。文本方案每行一条记录字段间用|分隔fprintf写、fscanf读。我一般倾向文本方案原因有三第一出问题时可以直接cat records.dat看数据排查快第二评委检查代码时扫一眼文件内容就能理解你的字段设计第三避免结构体对齐导致的兼容性问题。代价是读写代码稍长但每行也就是一个fprintf 一个fscanf的功夫。下面给出落盘实现int save_records(VisitorRecord *head, const char *filename) { FILE *fp fopen(filename, w); if (fp NULL) { perror(fopen for write failed); return -1; } VisitorRecord *p head; while (p ! NULL) { fprintf(fp, %d|%s|%s|%s|%s|%d|%s|%s|%s|%d\n, p-record_id, p-name, p-phone, p-company, p-reason, p-visitor_type, p-visit_time, p-enter_time, p-leave_time, p-status); p p-next; } fclose(fp); return 0; }这里用|作为分隔符是因为姓名和单位字段内几乎不会出现竖线即便出现读入时按%[^|]解析也会安全跳过。fprintf写法相比fwrite多了一个格式控制层好处是文件内容可读且跨平台无字节序问题坏处是字符串字段里若真的混入|读回后字段会错位——所以保存前要对用户输入做一次合法性检查拒绝含分隔符的输入。读取函数对应使用fscanf(fp, %d|%[^|]|%[^|]|..., ...)每次读回一条记录就malloc申请一个节点挂到链表尾部。2.3 加载数据时按 ID 自增重建最后一个编号新申请记录的record_id不能固定从 1 开始否则覆盖旧申请这一点容易踩坑。加载文件时把链表遍历一遍记录出现过的最大 ID再定义一个全局g_next_id初始化为max_id 1。如果records.dat是空文件或不存在g_next_id从 1 开始。这个方案简单够用但有一个并发隐患两个终端同时运行系统时可能生成相同的 ID。课程设计通常单机演示这个隐患不需要解决但可以在代码注释里写清楚“若需支持多实例运行应将 ID 生成改为记录数统计或时间戳拼接”。3. 访客端流程实现入校申请、入校登记与离校登记3.1 入校申请新增一条状态为“待审批”的记录访客端第一个要完成的操作是“提出申请”。个人访客录入姓名、电话、单位、事由、预约入校时间团体访客在此基础上补充团体人数——一种偷懒但够用的方案是在reason字段中追加“团_8人”前缀另一种规范做法是单独设计团体表。为了控制篇幅下面的实现采用“单独记录团体人数”的方式在visitor_type 2时把人数拼入reason字段审批时读取该前缀即可展示。这个做法的好处是不改结构体、文件格式不变查询时看到的是一个带人数的完整事由字段。VisitorRecord *create_application() { VisitorRecord *r (VisitorRecord *)malloc(sizeof(VisitorRecord)); if (r NULL) { perror(malloc failed); exit(EXIT_FAILURE); } memset(r, 0, sizeof(VisitorRecord)); printf(来访类型 (1个人 2团体): ); int type 0; scanf(%d, type); r-visitor_type type; printf(姓名: ); scanf(%31s, r-name); printf(联系电话: ); scanf(%15s, r-phone); printf(单位/学校: ); scanf(%63s, r-company); if (type 2) { int group_count 0; printf(团体人数: ); scanf(%d, group_count); snprintf(r-reason, MAX_REASON_LEN, 团体预约_%d人_%s, group_count, 集中入校); } else { printf(访问事由: ); scanf(%127s, r-reason); } printf(预约入校时间 (YYYY-MM-DD HH:MM): ); getchar(); fgets(r-visit_time, sizeof(r-visit_time), stdin); r-visit_time[strcspn(r-visit_time, \n)] \0; r-record_id g_next_id; r-status STATUS_PENDING; r-next NULL; return r; }上面这段代码把团体访客的人数用snprintf拼进reason字段保证主结构体字段不膨胀。scanf读整数后残留的换行符会被fgets直接吞掉导致时间字段为空因此读取时间前先用getchar()清掉缓冲区里的\n这是控制台程序最常见的输入坑之一。%31s、%15s、%63s分别限制读写长度不超过name[32]、phone[16]、company[64]防止用户输入超长字符串把栈打穿。新增记录插入链表时用头插法还是尾插法本质上不影响正确性但头插法可以少一次遍历新申请的记录正好排在最前面展示时符合“最近的在前”的习惯。3.2 入校登记只有“已审批”状态才能变为“已入校”入校登记是访客在门卫处刷证件后执行的核销操作。系统中这一操作要检查两条规则第一申请必须存在即输入姓名或申请编号能在链表中找到节点第二当前状态必须是STATUS_APPROVED待审批或已拒绝的申请不能入校。状态检查是出入控制系统的核心逻辑漏掉这个判断就等于任何人都能绕过审批直接进校。int check_in(VisitorRecord *head, int record_id, const char *time_str) { VisitorRecord *p head; while (p ! NULL) { if (p-record_id record_id) { if (p-status ! STATUS_APPROVED) { printf(状态错误: 当前状态%d, 仅有已通过(1)的申请可入校\n, p-status); return -1; } snprintf(p-enter_time, sizeof(p-enter_time), %s, time_str); p-status STATUS_CHECKED_IN; printf(入校登记成功: %s %s\n, p-name, p-enter_time); return 0; } p p-next; } printf(未找到ID%d的申请记录\n, record_id); return -1; }状态机流转在这里体现得最清楚新申请创建时为PENDING(0)管理员审批通过置为APPROVED(1)入校登记时由1变为CHECKED_IN(3)离校登记时由3变为CHECKED_OUT(4)。拒绝2与签名4都是终态不存在从终态回退到1或3的路径程序也没有提供这样的接口。这里time_str由调用方传入实际业务中是time(NULL)后通过localtime_r格式化的时间戳写法上建议统一使用YYYY-MM-DD HH:MM字符串便于与visit_time做比较。3.3 离校登记更新时间戳并处理早退与超时离校登记与入校登记结构类似区别在于前置状态是CHECKED_IN并且在登记前先读取当前时间与enter_time比较。常见规则是如果离开时间早于预约的visit_time的次日则输出一行提示“该访客提前离校”只记录不处罚。下面的实现里我加了一个超时提醒逻辑用strptime把两个时间字符串解析为struct tm再转成time_t求差值int check_out(VisitorRecord *head, int record_id, const char *time_str) { VisitorRecord *p head; while (p ! NULL) { if (p-record_id record_id) { if (p-status ! STATUS_CHECKED_IN) { fprintf(stderr, 离校失败: 当前状态%d, 访客未入校或已经离校\n, p-status); return -1; } struct tm enter_tm, leave_tm; memset(enter_tm, 0, sizeof(enter_tm)); memset(leave_tm, 0, sizeof(leave_tm)); strptime(p-enter_time, %Y-%m-%d %H:%M, enter_tm); strptime(time_str, %Y-%m-%d %H:%M, leave_tm); time_t enter_ts mktime(enter_tm); time_t leave_ts mktime(leave_tm); double diff_min difftime(leave_ts, enter_ts) / 60.0; if (diff_min 0) { printf(警告: 离开时间早于进入时间请检查系统时间\n); } else if (diff_min 8 * 60) { printf(提示: 访客在校时长超过8小时建议核实访问事由\n); } snprintf(p-leave_time, sizeof(p-leave_time), %s, time_str); p-status STATUS_CHECKED_OUT; printf(离校登记成功在校时长 %.0f 分钟\n, diff_min); return 0; } p p-next; } return -1; }strptime是 POSIX 函数在 Windows 的 MSVC 环境下不提供因此这段代码放在#ifdef __linux__里Windows 下退化为直接sscanf拆分年月日时分再组合能保证课程设计在两种平台都能编译过。difftime返回双精度秒数除以 60 得分钟。判断“超 8 小时”是我这边常见的管理要求不是通用规范你可以把阈值改成 6 或 12 小时做成全局宏#define MAX_STAY_MINUTES 480更清晰。团体访客的离校登记以记录为单位操作实际场景中可能存在团队分批离校建议在团体场景下把check_out的入参改为团体编号内部循环更新所有成员的状态这在原系统文件中没有体现是扩展方向。4. 管理员端实现审批权限校验、申请记录查询与管理员账号维护4.1 管理员登录与新增授权密文存储的权衡管理员功能的入口是登录校验系统启动后先进入主菜单选项 1 是访客端选项 2 是管理员端。管理员端登录要校验用户名和密码课程设计里常见的错误做法是把密码用明文写在代码里例如if (strcmp(input, 123456) 0)评审老师一眼就能看出问题。作为 C 语言课设做到下面这一步已经足够买单密码不硬编码在源码中而是存放于admin.dat文件首次启动程序时自动创建默认账号admin / 123456。文件每行存用户名和密码的简单哈希值哈希算法用一个极简的 FNV-1a 实现unsigned long admin_hash(const char *str) { unsigned long hash 2166136261UL; while (*str) { hash ^ (unsigned char)(*str); hash * 16777619UL; } return hash; } int verify_admin(const char *user, const char *pass) { FILE *fp fopen(admin.dat, r); if (fp NULL) return 0; char line[128]; while (fgets(line, sizeof(line), fp)) { char stored_user[32]; unsigned long stored_hash; if (sscanf(line, %31s %lu, stored_user, stored_hash) 2) { if (strcmp(user, stored_user) 0 stored_hash admin_hash(pass)) { fclose(fp); return 1; } } } fclose(fp); return 0; }实践中密码哈希要比 FNV-1a 至少用 SHA-256 或 scrypt但 C 语言课设边界内内核不引第三方库FNV-1a 是“演示意图”的表达——明文密码绝不落盘哈希值落盘后可抵抗直接cat admin.dat泄密。如果用%lu打印哈希值到文件读回时也用相同格式就不会有类型宽度问题。注意sscanf的格式串%31s防止user缓冲越界line长度 128 也避免 fgets 截断带来的解析错位。新增管理员在登录成功后执行录入新用户名、新密码调用admin_hash计算后追加一行到admin.dat。追加前检查用户名是否已存在存在则拒绝。这里引入一个小的运维细节当前没有提供“修改密码”和“删除管理员”功能文件只能追加、不能原地改对应真实门禁系统的做法是新建临时文件重命名覆盖可以用rename(admin.dat.tmp, admin.dat)实现代码工作量增加约 30 行建议作为进阶需求补上。4.2 审批申请查询待审批队列与更新状态审批操作的核心是“拿到所有STATUS_PENDING的申请逐条展示由管理员操作通过或拒绝”。查询本质是对链表的过滤。int list_pending(VisitorRecord *head) { VisitorRecord *p head; int cnt 0; printf(---- 待审批申请列表 ----\n); while (p ! NULL) { if (p-status STATUS_PENDING) { printf(ID%-5d %-16s %-16s 类别%d 预约%s 事由%s\n, p-record_id, p-name, p-phone, p-visitor_type, p-visit_time, p-reason); cnt; } p p-next; } if (cnt 0) printf((当前无待审批申请)\n); return cnt; }审批时输入record_id再用两个循环分别处理“通过”和“拒绝”通过调用update_status(head, id, STATUS_APPROVED)拒绝调用update_status(head, id, STATUS_REJECTED)。更新后立即调用save_records写盘。这里必须澄清一个细节如果程序在每次操作后都写盘内存链表与磁盘就是“同步写入”好处是断电不丢数据坏处是大量新增申请时频繁 IO。课程设计规模下记录量不会超过几千条写盘耗时几乎为零所以“每次变更即保存”是推荐的稳妥策略。查询申请记录里的“全部申请”“按状态查询”“按姓名查询”可以合并成一个带子条件的查询函数void query_records(VisitorRecord *head, int status_filter, const char *name_filter) { VisitorRecord *p head; while (p ! NULL) { int match_status (status_filter -1) || (p-status status_filter); int match_name (name_filter NULL) || (strstr(p-name, name_filter) ! NULL); if (match_status match_name) { printf([%d] %s 状态:%s 入校:%s 离校:%s 事由:%s\n, p-record_id, p-name, status_to_string(p-status), p-enter_time[0] ? p-enter_time : -, p-leave_time[0] ? p-leave_time : -, p-reason); } p p-next; } }status_filter -1表示不筛选name_filter NULL表示不按姓名筛选。使用strstr实现子串模糊匹配“张”能命中“张三”和“张伟”。“状态筛选”配合status_to_string把数字翻译成汉字展示这个函数就是一个简单的 switch-case。查询本身不修改数据所以不需要写盘。4.3 批量审批与审批记录的导出批量审批是课设加分项把list_pending出来的 ID 一次性输入如1,3,5内部用strtok按逗号拆分逐个处理。实现要点在strtok会修改原字符串因此必须先strdup一份再拆分。批量通过提升了系统的实操价值毕竟真实校园每天的外来人员申请可能超过 100 条逐条审批的体验很差。审批记录导出到approval_log.txt作为日志存档每次都追加写不覆盖void log_approval(int admin_id, int record_id, int action) { FILE *fp fopen(approval_log.txt, a); if (fp NULL) return; time_t now time(NULL); struct tm *tm_now localtime(now); fprintf(fp, %04d-%02d-%02d %02d:%02d:%02d admin%d record%d action%s\n, tm_now-tm_year 1900, tm_now-tm_mon 1, tm_now-tm_mday, tm_now-tm_hour, tm_now-tm_min, tm_now-tm_sec, admin_id, record_id, action 0 ? REJECT : APPROVE); fclose(fp); }日志的意义是“审批留痕”这在门禁审计场景是刚需。上面代码里的tm_now是静态分配的指针localtime不是线程安全的但控制台单线程场景可忽略评审老师问起来你回答“用localtime_r替换”就体现了进阶意识。admin_id当前版本里没做管理员 ID 维护直接写死为 0 也可以但这行日志格式为后续扩展保留了空间。5. 编译验证与内存调试从 Valgrind 到 strace 的排错路径访问控制系统的隐患大多不在功能逻辑而在内存破坏。给出三个调试口令级技巧。第一用-Wall -Wextra编译并打开-g生成调试符号gcc -Wall -Wextra -g -o monitor system_call_main.c system_call.c -lm valgrind --leak-checkfull --show-leak-kindsall ./monitor-lm仅当代码使用了数学库函数时才需要上述代码用不到可以去掉。Valgrind 输出里重点关注definitely lost与Invalid read/write两类报告。链表节点的创建与释放若成对出现基本能清零内存泄漏若save_records里打开文件失败后未fcloseValgrind 会报still reachable这属于资源泄漏的温和形态但评审看到会扣印象分。free链表时注意逐节点记录next再释放当前节点避免释放后访问野指针void free_all(VisitorRecord *head) { while (head ! NULL) { VisitorRecord *tmp head-next; free(head); head tmp; } }第二程序性验证状态机流转。构造一张测试用例表申请→审批通过→入校→离校逐步确认每一步状态值再跑“审批拒绝后尝试入校”预期返回错误且enter_time未被写入最后跑“同一 ID 重复离校”预期输出“已离校”拒绝信息。这类边界条件测试逐项记录结果可以作为实验报告附件提交。第三用strace观察程序实际打开的文件和写入的字节数验证持久化逻辑是否按预期工作strace -f -e traceopen,openat,write,close ./monitor 21 | grep -E records.dat|admin.datstrace的输出会显示openat(AT_FDCWD, records.dat, O_WRONLY|O_CREAT|O_TRUNC, 0666) 3这一行说明保存函数正常创建并截断了文件。如果系统调用的返回值为-1 EACCES说明当前目录无写权限需要检查运行目录。最后提一个被多数课程设计忽略的文件锁问题多个进程同时打开同一records.dat会造成数据互相覆盖在main函数开头调用flock(fileno(fp), LOCK_EX | LOCK_NB)加排他锁再加载数据能直接避免这道隐性丢数据问题实验室或宿舍环境两个人同时调试同一份代码时这个细节往往决定最终数据是否完整。本文还有配套的精品资源点击获取