校园身份预约系统
# 第三章:C语言 校园身份预约系统
本章是综合案例的第三关·多态入门——从"银行账户"的单一数据结构,升级到学生/教师/管理员三种身份、各自不同的菜单。核心挑战是:在C语言没有继承和多态的情况下,如何优雅地实现同一套框架支持三种不同的行为?
答案:函数指针表(手动vtable)——这是C语言实现"多态"的标准手法,也是理解C++虚函数表底层原理的最佳入口。本案例会系统展示:三身份分层 → 枚举状态机 → 多文件持久化三件事,构建一个接近真实项目规模的CLI系统。
对比前两关,本关新增四个挑战:
- 身份抽象:
enum UserRole+ 函数指针表 — C语言版的"多态分发" - 枚举状态机:预约的"待审→通过/拒绝→取消"贯穿全程
- 多文件协同:
users.txt+orders.txt两个文件独立读写 - 权限分离:学生只能看自己的预约,教师能审所有预约,管理员能操作全局
⚠️ 本关新增"造bug高峰":
- §06 预约时机房容量不检查 → OOM式超订 → 亲手修复
- §08 文件加载顺序错 → 孤儿预约全丢 → 学会"被引用方先加载"
# 渐进学习节奏
先读这段,再开始敲代码!本案例按四阶段渐进推进:
阶段 ① 数据结构 + 登录(§02-04)· 40 min
└ Step 1.1: enum 身份 + struct 用户/预约/机房 → 编译过
└ Step 1.2: 登录验证 → 账号密码匹配 → 三种身份登录成功
└ Step 1.3: 主菜单 → switch 分发到各身份子菜单
阶段 ② 各身份功能(§05-07)· 80 min 【枚举状态机 + 权限分离】
└ Step 2.1: 学生功能 → 申请预约/查看我的/取消
└ Step 2.2: 教师功能 → 查看全部/审核预约
└ Step 2.3: 管理员功能 → 添加账号/查看账号/清空预约
阶段 ③ 机房容量校验(§06.2)· 15 min 【造bug → 修复】
└ Step 3.1: 故意不检查容量 → 超订 → 亲眼看见bug
└ Step 3.2: 添加容量检查 → 拒绝超订 → 理解防御式编程
阶段 ④ 文件持久化(§08)· 25 min 【多文件协同】
└ Step 4.1: users.txt 保存/加载
└ Step 4.2: orders.txt 保存/加载 → 演示加载顺序bug
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
🎯 每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就编译运行一次(看到 🧪 标志立刻动手)
- 看到预期输出再写下一个 Step
⚠️ C语言新手最易犯的五个错:
scanf后忘记getchar()→ 下次输入被跳过- 数组访问不检查
count < MAX→ segfault fscanf返回值不检查 → 静默跳过损坏行enum当int用 → 类型不安全(对比 C++enum class)- 文件加载顺序错 → 孤儿数据(§08 现场演示)
🎯 本案例的"灵魂三问"(动手前先想清楚):
- §03 登录前:三种身份怎么在同一个
users数组里区分?是用三个数组还是带role字段?- §06 预约前:预约记录怎么跟用户关联?机房容量怎么跟预约数量关联?
- §08 持久化前:两个文件的加载顺序重要吗?
⚠️ 本案例的四处"陷阱预警":
- §03 登录越权:不校验
role与登录选择的身份匹配 → 教师用学生密码也能登- §06 机房超订:不检查容量 → 20人机房预约了50人(§06.2 演示)
- §07 允许对自己审核:管理员身份跳过所有校验(§07 讨论)
- §08 加载顺序错:先加载预约再加载用户 → 外键找不到(§08 演示)
# 案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★★☆☆ |
| 预估时长 | 3 小时(跟打 2h + 自测 1h) |
| 前置章节 | 结构体、枚举、文件操作、函数指针 |
| 覆盖知识点 | typedef struct、typedef enum、函数指针表、fopen/fprintf/fscanf、枚举状态机、多文件协同、外键关联 |
| 设计亮点 | 枚举状态机 + 权限分离 + 函数指针模拟多态 + 加载顺序与外键校验 |
| ⚠ 已知局限 | 密码明文存储、无加密、无并发、无数据库事务 |
| 最终产物 | 单一可执行文件 + 两个数据文件 users.txt/orders.txt |
| 代码规模 | 约 600 行 / 1 个源文件 |
# 目录快速导航
- 渐进学习节奏 【🔑 必读】
- 案例元信息
- 01.系统需求
- 02.身份枚举与数据结构 【阶段①·灵魂三问⭐】
- 03.登录验证
- 04.主菜单分发 【switch多态】
- 05.学生功能
- 06.教师功能与机房容量校验 【造bug高峰⭐】
- 07.管理员功能
- 08.文件持久化 【加载顺序bug演示⭐】
- 09.总结与C++对比
- 10.延伸挑战
# 01.系统需求
# 1.1 三种身份功能矩阵
| 功能 | 学生 | 教师 | 管理员 |
|---|---|---|---|
| 登录 | ✅ | ✅ | ✅ |
| 申请预约机房 | ✅ | ❌ | ❌ |
| 查看自己的预约 | ✅ | ❌ | ❌ |
| 取消自己的预约 | ✅ | ❌ | ❌ |
| 查看所有预约 | ❌ | ✅ | ✅ |
| 审核预约 | ❌ | ✅ | ❌ |
| 添加学生/教师账号 | ❌ | ❌ | ✅ |
| 查看所有账号 | ❌ | ❌ | ✅ |
| 清空预约 | ❌ | ❌ | ✅ |
这种"按身份分发功能"的设计在工业界叫 RBAC(基于角色的访问控制)。
# 1.2 机房信息与C语言技术栈
| 机房 | 容量 |
|---|---|
| 1号机房 | 20人 |
| 2号机房 | 50人 |
| 3号机房 | 100人 |
| C语言技术 | 在本案例中的应用 |
|---|---|
typedef struct | User、Order、Room 三大结构体 |
typedef enum | UserRole、OrderStatus 两大枚举 |
| 数组 | 定长数组存储所有数据 |
fopen/fprintf/fscanf | 文本文件读写 |
| 线性查找 | 根据 ID 查用户、根据订单号查预约 |
| 枚举状态机 | 预约:待审→通过/拒绝→取消 |
# 02.身份枚举与数据结构
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:定义三大结构体 + 两个枚举 + 全局数组 + 登录验证 │
│ 不做什么:不写任何业务功能——各个子菜单先放占位 │
│ 验收标准:编译通过 + 三种身份登录成功 + 各自看到不同菜单 │
│ 预计耗时:40 分钟 │
│ 关键思路:enum 区分身份 → struct 装数据 → 全局数组做容器 │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
# 2.0 灵魂三问
🎯 在敲键盘之前,停 2 分钟问自己三个灵魂问题。
❓ 问题一:三种身份怎么在同一个 users 数组里区分?
来看反例——三个独立的数组:
// ❌ 反例:学生/教师/管理员各一个数组
UserStudent students[100]; int studentCount = 0;
UserTeacher teachers[100]; int teacherCount = 0;
UserAdmin admins[10]; int adminCount = 0;
2
3
4
问题:
- 三个几乎一样的结构体——字段重复 3 次(id/name/password 完全一样)
- 登录时要遍历 3 个数组——查找逻辑重复 3 次
- 添加用户要判断 3 种类型——switch 蔓延
✅ 正确做法:一个结构体 + 一个 role 字段区分身份。
typedef enum { ROLE_STUDENT = 1, ROLE_TEACHER = 2, ROLE_ADMIN = 3 } UserRole;
typedef struct {
int id;
char name[50];
char password[20];
UserRole role; // ⭐ 一个字段区分身份
} User;
User users[100];
int userCount = 0;
2
3
4
5
6
7
8
9
10
11
好处:登录时遍历一个数组 users[i],查找一个函数搞定三种身份。
❓ 问题二:预约记录怎么跟用户关联?
在 C 语言里(没有外键约束),关联靠**"存对方的 ID + 使用时再查"**:
typedef struct {
int orderId;
int studentId; // ⭐ 外键:哪个学生提交的
char studentName[50]; // 冗余:避免每次查用户表
int roomId; // ⭐ 外键:哪个机房
// ...
OrderStatus status;
} Order;
2
3
4
5
6
7
8
为什么 studentName 是冗余? 因为 studentId 已经能查到姓名了。但这里故意冗余——让你体验"反范式设计":用空间换时间,减少一次数组遍历。真实数据库系统里这叫"视图/物化视图"。
❓ 问题三:为什么用 enum 而不是 int 表示状态?
来看反例——用 int 表示状态:
// ❌ 反例:魔术数字
orders[i].status = 0; // 0 是什么?待审?已通过?没人知道
orders[i].status = -1; // -1 又是什么?
// 三个月后回来看代码:完全忘了 0/-1/1/2 分别代表什么
if (orders[i].status == 0) { ... } // ← 这是什么状态?
2
3
4
5
6
✅ 正确做法:typedef enum 赋予语义。
typedef enum {
STATUS_PENDING = 0, // 待审核
STATUS_APPROVED = 1, // 已通过
STATUS_REJECTED = -1, // 已拒绝
STATUS_CANCELED = 2 // 已取消
} OrderStatus;
if (orders[i].status == STATUS_PENDING) { ... } // ← 一目了然
2
3
4
5
6
7
8
🔑 三问连起来的领悟:"一个结构体带 role 字段 + 一个枚举表状态 + 一个外键关联" — 这就是 C 语言版的"关系模型"。没有继承、没有外键约束,全靠字段约定和手动校验。
# 2.1 完整数据结构定义
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_USERS 100
#define MAX_ORDERS 500
#define NAME_LEN 50
// ===== 枚举定义 =====
typedef enum {
ROLE_STUDENT = 1,
ROLE_TEACHER = 2,
ROLE_ADMIN = 3
} UserRole;
typedef enum {
STATUS_PENDING = 0,
STATUS_APPROVED = 1,
STATUS_REJECTED = -1,
STATUS_CANCELED = 2
} OrderStatus;
// ===== 结构体定义 =====
typedef struct {
int id;
char name[NAME_LEN];
char password[20];
UserRole role;
} User;
typedef struct {
int orderId;
int studentId;
char studentName[NAME_LEN];
int roomId;
char date[11]; // YYYY-MM-DD
char timeSlot[10]; // 上午/下午
int peopleCount;
OrderStatus status;
} Order;
typedef struct {
int roomId;
int capacity;
} Room;
// ===== 全局数据 =====
User users[MAX_USERS];
int userCount = 0;
Order orders[MAX_ORDERS];
int orderCount = 0;
int orderIdCounter = 1;
Room rooms[] = { {1,20}, {2,50}, {3,100} };
int roomCount = 3;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
🔑 数组大小为什么这样选? MAX_USERS=100 — 学校人数有上限,100 够用。MAX_ORDERS=500 — 每个学生可能有多条预约,500 是适度估计。在 C 语言里你需要主动预估上限——C++ 的 vector 替你做这件事。
┌─ 📌 Step 1.1 小结 ────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • typedef enum 定义语义枚举 │
│ • typedef struct 定义三大数据载体 │
│ • 全局数组 + 计数器 = C语言版的"容器" │
│ • 枚举状态机:PENDING → APPROVED/REJECTED/CANCELED │
│ 💡 领悟: │
│ "C语言的'关系模型' = 外键字段 + 手动校验。 │
│ C++ 有 map, C 只有数组——但设计思想是一样的" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 03.登录验证
// 按 ID + 密码查找用户,返回索引,找不到返回 -1
int findUser(int id, const char *password) {
for (int i = 0; i < userCount; i++) {
if (users[i].id == id &&
strcmp(users[i].password, password) == 0) {
return i;
}
}
return -1;
}
// 登录主流程:返回 1 成功、0 失败
int login(UserRole *outRole, char *outName, int *outId) {
int id;
char password[20];
printf("请输入账号: "); scanf("%d", &id);
printf("请输入密码: "); scanf("%19s", password);
int idx = findUser(id, password);
if (idx == -1) {
printf(">>> 账号或密码错误!\n");
return 0;
}
*outRole = users[idx].role;
strcpy(outName, users[idx].name);
*outId = users[idx].id;
printf(">>> 登录成功!欢迎 %s\n", users[idx].name);
return 1;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
🔑 指针做输出参数:C 语言没有"返回多个值"的能力。login() 需要返回 3 个值(角色/姓名/ID),做法就是用 UserRole *outRole + char *outName + int *outId 三个指针参数。这在 C 语言里是最常见的模式。
🧪 测试登录:
gcc -std=c11 main.c -o campus_sys
./campus_sys
2
操作:必须先有账号才能登录。临时在 main 开头添加种子数据:
// 临时种子数据
users[0].id = 1001; strcpy(users[0].name, "张三");
strcpy(users[0].password, "123456"); users[0].role = ROLE_STUDENT;
users[1].id = 2001; strcpy(users[1].name, "李老师");
strcpy(users[1].password, "654321"); users[1].role = ROLE_TEACHER;
users[2].id = 0; strcpy(users[2].name, "admin");
strcpy(users[2].password, "admin123"); users[2].role = ROLE_ADMIN;
userCount = 3;
2
3
4
5
6
7
8
# 04.主菜单分发
void showMainMenu() {
printf("\n===== 校园机房预约系统 =====\n");
printf("1. 学生登录\n");
printf("2. 教师登录\n");
printf("3. 管理员登录\n");
printf("0. 退出\n");
printf("=============================\n");
printf("请选择: ");
}
int main() {
loadAllData(); // 阶段 ④:启动时加载
int choice;
while (1) {
showMainMenu();
scanf("%d", &choice);
getchar();
if (choice == 0) {
printf("再见!\n");
saveAllData(); // 阶段 ④:退出前保存
return 0;
}
if (choice < 1 || choice > 3) {
printf("无效选择!\n");
continue;
}
UserRole role;
char name[NAME_LEN];
int userId;
if (!login(&role, name, &userId)) continue;
// ⭐ 根据角色进入不同子菜单 — C语言的"多态分发"
switch (role) {
case ROLE_STUDENT: studentMenu(userId, name); break;
case ROLE_TEACHER: teacherMenu(userId, name); break;
case ROLE_ADMIN: adminMenu(userId, name); break;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
🔑 设计要点:main 函数只负责"身份分发"——不侵入任何角色的业务逻辑。将来加"访客"身份只需加一个 case 和一个新函数,不动现有代码。这就是命令模式的雏形,在 C 语言里用 switch + enum 实现。
┌─ 📌 阶段 ① 小结 ────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • 一个 enum + 一个 struct 装三种身份 │
│ • login 通过指针输出多个返回值 │
│ • main 的 switch(role) 是 C 语言的"多态分发" │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 学生/教师/管理员的真实功能 │
│ • 文件持久化 │
│ 💡 本阶段最大领悟: │
│ "C语言的继承 = enum role 字段 + switch 分发" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
# 05.学生功能
┌─ 🎯 阶段 ② 目标(学生) ─────────────────────────────┐
│ 完成什么:学生登录 → 申请预约/查看我的预约/取消预约 │
│ 不做什么:不做容量校验——那是 §06 的造bug环节 │
│ 验收标准:申请 → 看到 → 取消 → 再到查看(状态变为已取消) │
│ 预计耗时:30 分钟 │
└────────────────────────────────────────────────────────┘
2
3
4
5
6
void studentMenu(int userId, const char *name) {
int choice;
while (1) {
printf("\n--- 学生菜单 (%s) ---\n", name);
printf("1. 申请预约 2. 查看我的预约\n");
printf("3. 取消预约 0. 注销登录\n");
printf("请选择: ");
scanf("%d", &choice);
getchar();
switch (choice) {
case 1: studentApply(userId, name); break;
case 2: studentView(userId); break;
case 3: studentCancel(userId); break;
case 0: return;
}
}
}
void studentApply(int userId, const char *name) {
if (orderCount >= MAX_ORDERS) {
printf("预约已满!\n"); return;
}
// 显示可用机房
printf("\n--- 可用机房 ---\n");
for (int i = 0; i < roomCount; i++) {
printf("机房%d: 容量%d人\n", rooms[i].roomId, rooms[i].capacity);
}
Order *o = &orders[orderCount];
o->orderId = orderIdCounter++;
o->studentId = userId;
strcpy(o->studentName, name);
printf("选择机房 (1/2/3): "); scanf("%d", &o->roomId);
printf("日期 (YYYY-MM-DD): "); scanf("%10s", o->date);
printf("时段 (上午/下午): "); scanf("%9s", o->timeSlot);
printf("人数: "); scanf("%d", &o->peopleCount);
// ⚠️ 注意:这里暂时不检查机房容量——§06 会故意让这个 bug 暴露
o->status = STATUS_PENDING;
orderCount++;
printf(">>> 预约提交成功!订单号: %d(待教师审核)\n", o->orderId);
}
void studentView(int userId) {
int found = 0;
printf("\n--- 我的预约 ---\n");
printf("订单号 | 日期 | 机房 | 时段 | 人数 | 状态\n");
printf("--------------------------------------------------\n");
for (int i = 0; i < orderCount; i++) {
if (orders[i].studentId == userId) {
const char *statusText;
switch (orders[i].status) {
case STATUS_PENDING: statusText = "待审核"; break;
case STATUS_APPROVED: statusText = "已通过"; break;
case STATUS_REJECTED: statusText = "已拒绝"; break;
case STATUS_CANCELED: statusText = "已取消"; break;
default: statusText = "未知";
}
printf("%-7d | %-10s | %-4d | %-4s | %-4d | %s\n",
orders[i].orderId, orders[i].date,
orders[i].roomId, orders[i].timeSlot,
orders[i].peopleCount, statusText);
found = 1;
}
}
if (!found) printf("暂无预约记录。\n");
}
void studentCancel(int userId) {
int orderId;
printf("请输入要取消的订单号: "); scanf("%d", &orderId);
for (int i = 0; i < orderCount; i++) {
if (orders[i].orderId == orderId &&
orders[i].studentId == userId) {
if (orders[i].status == STATUS_CANCELED) {
printf("该预约已取消!\n"); return;
}
if (orders[i].status != STATUS_PENDING) {
printf("该预约已被审核,无法取消!\n"); return;
}
orders[i].status = STATUS_CANCELED;
printf(">>> 取消成功!\n"); return;
}
}
printf("未找到该订单或无权限!\n");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
🔑 三句关键的权限校验:
orders[i].studentId == userId— 学生只能操作自己的预约orders[i].status != STATUS_PENDING— 已审核的预约不能取消orders[i].status == STATUS_CANCELED— 已取消的不能重复取消
这就是权限分离在C语言里的体现:没有 private 字段,全靠 if-else 手动校验。
🧪 测试学生功能:
# 操作:登录学生(1001) → 选 1 → 机房1 2026-06-01 上午 15人
>>> 预约提交成功!订单号: 1(待教师审核)
# 选 2 查看
订单号 | 日期 | 机房 | 时段 | 人数 | 状态
--------------------------------------------------
1 | 2026-06-01 | 1 | 上午 | 15 | 待审核
# 选 3 → 订单号 1
>>> 取消成功!
2
3
4
5
6
7
8
9
10
# 06.教师功能与机房容量校验
┌─ 🎯 阶段 ② 目标(教师) ─────────────────────────────┐
│ 完成什么:教师登录 → 查看所有预约 → 审核通过/拒绝 │
│ 关键环节:§6.2 故意不检查容量 → 超订 → 亲手修复 │
│ 验收标准:审核后状态变更 + 机房容量超限被拒绝 │
│ 预计耗时:25 分钟 │
└────────────────────────────────────────────────────────┘
2
3
4
5
6
# 6.1 教师菜单与审核
void teacherMenu(int userId, const char *name) {
int choice;
while (1) {
printf("\n--- 教师菜单 (%s) ---\n", name);
printf("1. 查看所有预约 2. 审核预约 0. 注销\n");
printf("请选择: ");
scanf("%d", &choice);
getchar();
switch (choice) {
case 1: teacherViewAll(); break;
case 2: teacherReview(); break;
case 0: return;
}
}
}
void teacherViewAll() {
printf("\n--- 所有预约 ---\n");
if (orderCount == 0) { printf("暂无预约记录。\n"); return; }
printf("订单号 | 学生 | 日期 | 机房 | 时段 | 人数 | 状态\n");
printf("------------------------------------------------------------\n");
for (int i = 0; i < orderCount; i++) {
const char *statusText;
switch (orders[i].status) {
case STATUS_PENDING: statusText = "待审核"; break;
case STATUS_APPROVED: statusText = "已通过"; break;
case STATUS_REJECTED: statusText = "已拒绝"; break;
case STATUS_CANCELED: statusText = "已取消"; break;
default: statusText = "未知";
}
printf("%-7d | %-5s | %-10s | %-4d | %-4s | %-4d | %s\n",
orders[i].orderId, orders[i].studentName, orders[i].date,
orders[i].roomId, orders[i].timeSlot,
orders[i].peopleCount, statusText);
}
}
void teacherReview() {
int orderId, decision;
printf("请输入订单号: "); scanf("%d", &orderId);
for (int i = 0; i < orderCount; i++) {
if (orders[i].orderId == orderId) {
if (orders[i].status != STATUS_PENDING) {
printf("该预约已被处理!当前状态不可审核。\n"); return;
}
printf("\n当前预约: %s | 机房%d | %s | %d人\n",
orders[i].studentName, orders[i].roomId,
orders[i].timeSlot, orders[i].peopleCount);
printf("1.通过 2.拒绝: "); scanf("%d", &decision);
orders[i].status = (decision == 1) ?
STATUS_APPROVED : STATUS_REJECTED;
printf(">>> 审核完成!\n");
return;
}
}
printf("未找到订单!\n");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 6.2 机房容量校验 — 造bug → 修复
┌─ ⚠️ 造BUG高峰 ────────────────────────────────────┐
│ 先让学生预约机房人数超过容量 → 让你看到"超订" │
│ 然后在审核环节加容量检查 → 理解"防御式编程" │
└──────────────────────────────────────────────────────┘
2
3
4
❓ 灵魂一问:学生申请预约时,机房 1 号容量是 20 人,如果 10 个学生各申请 15 人——会发生什么?
回答前先跑一次:
# 操作:连续用 3 个学生账号,每个都预约机房1、15人
学生A: 机房1 15人 → 订单1 Pending
学生B: 机房1 15人 → 订单2 Pending ← 两个预约加起来 30 人,机房容量才 20!
学生C: 机房1 15人 → 订单3 Pending ← 45 人!严重超订
2
3
4
现在看 studentApply 的代码——完全没有检查 o->peopleCount <= rooms[].capacity!这就是 bug。
🛠 修复方案:在教师审核时加上容量检查。为什么不在学生申请时检查? 因为学生申请时可能一个机房有多个 Pending 预约——最终是否超限要等审核结果。更简单的做法:审核通过时统计该机房所有已通过的预约人数,加上当前这个,判断是否超限。
void teacherReview() {
// ... (前面查找逻辑不变) ...
// ⭐ 容量检查:统计该机房已通过的预约总人数
if (decision == 1) { // 只有"通过"才需要检查
int totalPeople = orders[i].peopleCount;
for (int j = 0; j < orderCount; j++) {
if (orders[j].roomId == orders[i].roomId &&
orders[j].status == STATUS_APPROVED &&
orders[j].orderId != orders[i].orderId) {
totalPeople += orders[j].peopleCount;
}
}
// 找到对应机房的容量
int capacity = 0;
for (int r = 0; r < roomCount; r++) {
if (rooms[r].roomId == orders[i].roomId) {
capacity = rooms[r].capacity;
break;
}
}
if (totalPeople > capacity) {
printf(">>> 审核拒绝:机房%d 容量%d人,已审批+当前申请=%d人,超限!\n",
orders[i].roomId, capacity, totalPeople);
return; // 拒绝通过,维持 Pending 状态让教师手动拒绝
}
}
orders[i].status = (decision == 1) ? STATUS_APPROVED : STATUS_REJECTED;
printf(">>> 审核完成!\n");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
🧪 修复后测试:
# 场景:机房1 容量20人
# 学生A 申请15人 → 教师审核通过 → 通过(15 <= 20)✅
# 学生B 申请15人 → 教师审核 →
>>> 审核拒绝:机房1 容量20人,已审批+当前申请=30人,超限!
2
3
4
🔑 核心领悟:校验要放在"写操作"之前——这和银行案例的转账前置校验是同一原则。C 语言没有异常机制,全靠 if-else 手动做"前置条件检查"。
┌─ 📌 阶段 ② 小结 ────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • 学生:申请/查看/取消 — 三次权限校验 │
│ • 教师:查看全部/审核 — 权限不受限(可看所有预约) │
│ • 容量校验:亲手看见超订 bug → 修复为前置容量检查 │
│ │
│ 🔑 亲眼看见的关键现象: │
│ • 不加容量检查 → 20人机房预约 45人 → "超订" │
│ • 修复后 → 审核通过前统计已有审批人数 → 超限拒绝 │
│ │
│ 💡 本阶段最大领悟: │
│ "C语言的权限 = if (studentId == userId), │
│ C语言的容量校验 = 遍历已有数据 + if (total > capacity)。 │
│ 没有框架替你验证,全靠你自己写检查" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 07.管理员功能
void adminMenu(int userId, const char *name) {
int choice;
while (1) {
printf("\n--- 管理员菜单 (%s) ---\n", name);
printf("1. 添加账号 2. 查看所有账号\n");
printf("3. 清空预约 0. 注销\n");
printf("请选择: ");
scanf("%d", &choice);
getchar();
switch (choice) {
case 1: adminAddUser(); break;
case 2: adminViewAll(); break;
case 3: adminClearOrders(); break;
case 0: return;
}
}
}
void adminAddUser() {
if (userCount >= MAX_USERS) {
printf("用户数已达上限!\n"); return;
}
// ⚠️ 先检查 ID 是否已存在
int newId, roleChoice;
printf("身份 (1.学生 2.教师): "); scanf("%d", &roleChoice);
if (roleChoice != 1 && roleChoice != 2) {
printf("无效身份!只能添加学生或教师。\n"); return;
}
printf("账号ID: "); scanf("%d", &newId);
// ⭐ ID 唯一性检查
for (int i = 0; i < userCount; i++) {
if (users[i].id == newId) {
printf("该ID已被占用!\n"); return;
}
}
User *u = &users[userCount];
u->id = newId;
u->role = (roleChoice == 1) ? ROLE_STUDENT : ROLE_TEACHER;
printf("姓名: "); scanf("%49s", u->name);
printf("密码: "); scanf("%19s", u->password);
userCount++;
printf(">>> 账号添加成功!当前用户总数: %d\n", userCount);
}
void adminViewAll() {
printf("\n--- 所有账号 ---\n");
printf("ID | 姓名 | 身份\n");
printf("----------------------\n");
for (int i = 0; i < userCount; i++) {
const char *roleText;
switch (users[i].role) {
case ROLE_STUDENT: roleText = "学生"; break;
case ROLE_TEACHER: roleText = "教师"; break;
case ROLE_ADMIN: roleText = "管理员"; break;
default: roleText = "未知";
}
printf("%-4d | %-8s | %s\n",
users[i].id, users[i].name, roleText);
}
printf("----------------------\n");
printf("共 %d 个用户\n", userCount);
}
void adminClearOrders() {
char confirm;
printf("确认清空所有预约记录?(y/n): ");
scanf(" %c", &confirm);
if (confirm == 'y' || confirm == 'Y') {
orderCount = 0;
orderIdCounter = 1;
printf(">>> 已清空所有预约记录!\n");
} else {
printf("操作已取消。\n");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
🔑 管理员权限的最高级别:
adminAddUser— 可以创建新用户(不限制是否管理员本人)adminClearOrders— 可以清空所有预约(需要二次确认y/n)- 管理员不能自己审核预约—那是教师的职责
⚠️ 安全提醒:管理员不能通过菜单创建另一个管理员(roleChoice 只允许 1 或 2)。这是简易的"权限隔离"——真实 RBAC 系统会用更复杂的层级管理。
# 08.文件持久化
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:程序退出后数据不丢 — 2 个文本文件协同读写 │
│ 不做什么:不做加密、不做备份、不做 CSV 格式 │
│ 验收标准:加账号+预约 → 退出 → 重启 → 数据完整恢复 │
│ 预计耗时:25 分钟 │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
🔑 为什么用文本格式而不是二进制?
| 格式 | 可读性 | 可手动编辑 | 跨平台 | 本案例选择 |
|---|---|---|---|---|
| 文本(fprintf) | ✅ cat 可看 | ✅ 可 vim 改 | ✅ 通用 | 本案例 |
| 二进制(fwrite) | ❌ 乱码 | ❌ 需 hex 编辑器 | ⚠️ 端序 | 第 01 关 |
# 8.1 用户文件 users.txt
格式: ID 姓名 密码 角色(1=学生 2=教师 3=管理员)
1001 张三 123456 1
2001 李老师 654321 2
0 admin admin123 3
2
3
4
#define USERS_FILE "users.txt"
#define ORDERS_FILE "orders.txt"
void saveUsers() {
FILE *f = fopen(USERS_FILE, "w");
if (!f) { printf("无法打开 %s!\n", USERS_FILE); return; }
for (int i = 0; i < userCount; i++) {
fprintf(f, "%d %s %s %d\n",
users[i].id, users[i].name,
users[i].password, (int)users[i].role);
}
fclose(f);
printf(">>> 已保存 %d 个用户到 %s\n", userCount, USERS_FILE);
}
void loadUsers() {
FILE *f = fopen(USERS_FILE, "r");
if (!f) {
printf("未找到 %s,将使用默认管理员账号。\n", USERS_FILE);
// 首次启动:自动创建默认管理员
users[0].id = 0;
strcpy(users[0].name, "admin");
strcpy(users[0].password, "admin123");
users[0].role = ROLE_ADMIN;
userCount = 1;
return;
}
userCount = 0;
int roleInt;
while (userCount < MAX_USERS &&
fscanf(f, "%d %s %s %d",
&users[userCount].id,
users[userCount].name,
users[userCount].password,
&roleInt) == 4) {
users[userCount].role = (UserRole)roleInt;
userCount++;
}
fclose(f);
printf(">>> 已加载 %d 个用户\n", userCount);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 8.2 预约文件 orders.txt
格式: 订单ID 学生ID 学生姓名 机房ID 日期 时段 人数 状态
1 1001 张三 1 2026-06-01 上午 15 0
2 1002 李四 2 2026-06-02 下午 30 1
2
3
void saveOrders() {
FILE *f = fopen(ORDERS_FILE, "w");
if (!f) { printf("无法打开 %s!\n", ORDERS_FILE); return; }
for (int i = 0; i < orderCount; i++) {
fprintf(f, "%d %d %s %d %s %s %d %d\n",
orders[i].orderId, orders[i].studentId,
orders[i].studentName, orders[i].roomId,
orders[i].date, orders[i].timeSlot,
orders[i].peopleCount, (int)orders[i].status);
}
fclose(f);
printf(">>> 已保存 %d 条预约到 %s\n", orderCount, ORDERS_FILE);
}
void loadOrders() {
FILE *f = fopen(ORDERS_FILE, "r");
if (!f) { orderCount = 0; return; }
orderCount = 0;
int statusInt, maxOrderId = 0;
while (orderCount < MAX_ORDERS &&
fscanf(f, "%d %d %s %d %s %s %d %d",
&orders[orderCount].orderId,
&orders[orderCount].studentId,
orders[orderCount].studentName,
&orders[orderCount].roomId,
orders[orderCount].date,
orders[orderCount].timeSlot,
&orders[orderCount].peopleCount,
&statusInt) == 8) {
orders[orderCount].status = (OrderStatus)statusInt;
if (orders[orderCount].orderId > maxOrderId) {
maxOrderId = orders[orderCount].orderId;
}
orderCount++;
}
fclose(f);
orderIdCounter = maxOrderId + 1; // 恢复自增计数器
printf(">>> 已加载 %d 条预约\n", orderCount);
}
// 统一入口
void loadAllData() {
loadUsers(); // ⭐ 先加载被引用方
loadOrders(); // ⭐ 再加载引用方(预约引用了用户ID)
}
void saveAllData() {
saveUsers();
saveOrders();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 8.3 加载顺序与外键校验
⚠️ 经典错误:先加载预约再加载用户——预定记录的 studentId 引用了用户表的主键,如果用户表还没加载,外键校验找不到人。
// ❌ 错误版本
void loadAllData_WRONG() {
loadOrders(); // ← 先加载预约:此时 userCount = 0,所有预约的 studentId 都是"孤儿"
loadUsers(); // ← 后加载用户:太晚了,预约已经加载完了
}
2
3
4
5
本案例中预约文件加载时不做活跃的外键校验(因为 loadOrders 设计为只负责读数据),但顺序决定了数据一致性。在真实系统中,应该在加载预约后扫描一遍,标记出 studentId 在用户表中不存在的"孤儿预约"。
🧪 测试持久化(完整二轮测试):
# 第一轮:创建数据
$ ./campus_sys
未找到 users.txt,将使用默认管理员账号。 ← 首次启动
>>> 已加载 1 个用户
请选择: 3 (管理员登录)
账号: 0 密码: admin123
>>> 登录成功!欢迎 admin
--- 管理员菜单 ---
1. 添加账号 ...
选 1 → 学生 1001 张三 123 → 添加成功
选 1 → 学生 1002 李四 456 → 添加成功
# 退出 → 登学生1001 → 预约机房1 → 退出 → 登教师2001 → 审核
选 0 → 再见!(触发 saveAllData)
$ cat users.txt
0 admin admin123 3
1001 张三 123 1
1002 李四 456 1
$ cat orders.txt
1 1001 张三 1 2026-06-01 上午 15 1 ← status=1 表示已通过
# 第二轮:冷启动验证
$ ./campus_sys
>>> 已加载 3 个用户
>>> 已加载 1 条预约
选 1 → 账号 1001 密码 123 → 登录成功 → 选 2 → 看到预约 ✅
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─ 📌 阶段 ③ 小结 ────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • users.txt + orders.txt 双文件协同读写 │
│ • fprintf/fscanf 格式化文本持久化 │
│ • loadAllData/saveAllData 统一入口 │
│ • 加载顺序:被引用方(users)先于引用方(orders) │
│ • orderIdCounter 从文件中恢复最大ID+1 │
│ │
│ 💡 本阶段最大领悟: │
│ "两个文本文件 = 两张数据库表。加载顺序就是外键约束的 │
│ 手动实现——被引用方先加载,引用方后加载" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 09.总结与C++对比
# 9.1 本关掌握的核心能力
| 能力 | C 语言体现 | 掌握标志 |
|---|---|---|
| 身份多态 | enum UserRole + switch 分发 | 能解释为什么不用三个独立数组 |
| 枚举状态机 | OrderStatus — PENDING→APPROVED/REJECTED/CANCELED | 能画状态转移图 |
| 外键关联 | orders[i].studentId + 手动查找 | 能解释加载顺序为什么重要 |
| 权限分离 | if (studentId == userId) 手动校验 | 能识别三种身份的所有权限边界 |
| 容量校验 | 审核时统计已有审批人数 + 对比 capacity | 能解释为什么不在申请时检查 |
| 多文件持久化 | 两个文本文件 + 加载顺序 | 能 cat 文件后手工修复一条记录 |
# 9.2 架构对比
| 维度 | C语言实现(本案例) | C++实现(同章节C++篇) |
|---|---|---|
| 身份抽象 | enum UserRole + switch 分发 | User 抽象基类 + 三态派生 |
| 多态 | switch-case 手动分发 | 虚函数表 vtable 自动分发 |
| 容器 | 定长数组 users[100] + userCount | map<string, shared_ptr<User>> |
| 查找 | 线性遍历 O(n) | map O(log n) |
| 状态机 | typedef enum | enum class ResStatus 类型安全 |
| 外键 | 手动查找 + 加载顺序约定 | map.count() + 加载后校验 |
| 文件 | 2 个文本文件 | 3 个 CSV 文件 + 类型标签 |
| 代码量 | ~600 行(单文件) | ~1200 行(14 个文件) |
# 9.3 代码对比:多态分发
C 语言版(本案例):
// 用 switch + enum 手动分发
switch (role) {
case ROLE_STUDENT: studentMenu(userId, name); break;
case ROLE_TEACHER: teacherMenu(userId, name); break;
case ROLE_ADMIN: adminMenu(userId, name); break;
}
// ⚠️ 加一种身份:加 case + 新函数 — 至少改 1 处
2
3
4
5
6
7
C++ 等效版:
// 用虚函数自动分发
shared_ptr<User> u = sys->login(id, pwd);
u->mainMenu(); // ⭐ 一行通吃三种身份 — vtable 自动分发
// ✅ 加一种身份:只新增子类 — main 一行不改
2
3
4
C++ 的多态就是让 switch-case 消失进 vtable 里。
# 10.延伸挑战
完成主流程后,尝试把下列四项自己加上:
挑战 1(基础)· 加身份证校验
在 login() 函数中增加校验:用户选择的登录类型(1=学生 2=教师 3=管理员)必须与 users[idx].role 一致。防止教师用学生账号登录。
// 在 login() 中追加
int selectedRole; // 从主菜单传入
if ((UserRole)selectedRole != users[idx].role) {
printf("身份不匹配!请选择正确的登录类型。\n");
return 0;
}
2
3
4
5
6
挑战 2(进阶)· 加日期校验
在学生申请预约时,检查日期不能是过去的日期:
#include <time.h>
// 解析输入的日期字符串,转换为时间戳
// 与当前时间比较:if (inputDate < today) → 拒绝
2
3
挑战 3(进阶)· 用函数指针表替换 switch
体验 C 语言的真正"多态"——用函数指针模拟虚函数表:
// 定义菜单项结构体
typedef struct {
const char *label;
void (*action)(int userId, const char *name);
} MenuItem;
// 学生菜单的函数指针表
MenuItem studentActions[] = {
{"申请预约", studentApply},
{"查看预约", studentView},
{"取消预约", studentCancel},
};
// 菜单循环改造为遍历函数指针表
for (int i = 0; i < 3; i++) {
printf("%d. %s\n", i + 1, studentActions[i].label);
}
// ... 根据输入调用 studentActions[choice-1].action(userId, name);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
完成后再对比 C++ 的虚函数表——你会发现原理一模一样。
挑战 4(终极)· 移植到 C++
把 C 版本逐模块改写成 C++,对比代码行数变化:
| 模块 | C 版本 | C++ 版本 |
|---|---|---|
| 身份定义 | enum UserRole ~10 行 | class User ~60 行 |
| 多态分发 | switch(role) 手动 | u->mainMenu() 自动 |
| 容器 | User users[100] | map<string, shared_ptr<User>> |
| 查找 | for + if O(n) | map.find() O(log n) |
| 文件 | fprintf/fscanf | ofstream/ifstream |
- ⬅ 上一案例:02.银行账户管理系统
- ➡ 下一案例:04.JSON与内存数据库