简介:一套面向Oracle数据库课程设计的完整资料包,以“网上订餐系统”为典型业务场景,C#负责用户界面与交互逻辑,Oracle承担后台数据存储。项目覆盖需求分析、界面实现、核心代码、数据库设计和功能演示等完整流程,特别适合正在完成Oracle大作业、课程设计或希望学习C#与Oracle集成开发的学生。整个ZIP包约1.02MB,共80个文件,主要包含31个C#源码文件、13个界面资源文件、12个资源文件,以及若干配置、可执行程序、数据库脚本、说明文档和20页实验报告;文件按源代码、配置、文档、脚本等模块分类存放,使用Visual Studio打开解决方案即可快速进入编译调试环境。20页实验报告逐项说明项目背景、功能需求、表结构与关系设计、关键代码解析和运行效果,配合SQL脚本与说明文档,可对照理解Oracle建表、数据初始化和C#数据访问实现思路。已有3387人学习,无论作为课程设计的整体参考,还是在此基础上扩展更多功能用于毕业设计或答辩展示,这套资料都具备较强实用性。
1. 一个能跑通全流程的课程大作业:这套网上订餐系统到底解决了什么
很多同学拿到“Oracle大作业”题目后,界面拖控件花了两天,连数据库却连了三天。这套基于C#和Oracle实现的网上订餐系统,恰好把数据库课程里最实用的三件事揉进了同一个工程:表结构设计、存储过程封装、事务控制。它本质上是一个C/S架构的信息管理系统,前台给普通用户点餐用,后台给管理员维护菜品和处理订单用,覆盖从注册登录、浏览菜品、加入购物车到生成订单的完整闭环。适合正在找Oracle课程设计思路的学生,也适合想看C# WinForms怎么和Oracle配合的开发者。它不是一个生产级平台,它的价值恰恰在于做减法——让你看清楚每一句SQL是怎么被界面调用起来的。
2. 技术选型与工程骨架:C# 搭配 Oracle 的分工逻辑
2.1 为什么是 C# WinForms 配 Oracle
课程指定用 Oracle 数据库,客户端这边用 C# 做界面,这是数据库大作业里最稳妥的搭配。C# 这边选 WinForms 而不是 WPF,原因很实际:WinForms 拖控件就能出界面,课程答辩时现场改一个按钮逻辑也快。整个项目的技术分工非常清晰——WinForms 负责与用户交互,Oracle 负责数据落盘,中间通过 ADO.NET 访问数据库。
Oracle 版本建议用 11g 或 12c,这两个版本在课程机房和老笔记本上兼容性最好。驱动层面选 Oracle.ManagedDataAccess(ODP.NET 托管驱动),它不需要安装完整的 Oracle 客户端,一个 DLL 就够,部署时省去一堆环境变量配置。早期很多大作业还用 System.Data.OracleClient,但那个组件早已过时,新机器上很容易报找不到程序集,不建议再碰。
2.2 解决方案结构与三层职责
这套系统的工程结构走的是经典三层:UI 层放窗体,BLL 层写业务逻辑,DAL 层操作数据库,公共部分抽到 Common。分层对课程大作业来说不是过度设计——答辩时老师几乎必问“你的项目是怎么分层的”,而且把 SQL 从窗体代码里拆出来之后,调 Bug 会舒服很多。
OrderSystem.sln ├── OrderSystem.UI // WinForms 窗体与控件绑定 │ ├── Forms │ │ ├── LoginForm.cs │ │ ├── MainForm.cs │ │ ├── DishListForm.cs │ │ ├── CartForm.cs │ │ └── AdminForm.cs ├── OrderSystem.BLL // 业务逻辑:订单状态判断、价格计算 │ ├── UserService.cs │ ├── DishService.cs │ ├── CartService.cs │ └── OrderService.cs ├── OrderSystem.DAL // 数据访问:ADO.NET 封装、存储过程调用 │ ├── DbHelper.cs │ ├── UserDAL.cs │ ├── DishDAL.cs │ └── OrderDAL.cs ├── OrderSystem.Model // 实体类 │ ├── User.cs │ ├── Dish.cs │ ├── CartItem.cs │ └── Order.cs └── OrderSystem.Common // 通用工具 ├── ConfigHelper.cs └── ResultMsg.csUI 层只做两件事:把用户输入转成实体对象,把 BLL 返回的结果显示到界面。BLL 层校验业务规则,比如用户余额是否足够、菜品是否在售。DAL 层则只负责拼参数、执行 SQL、把结果集映射成实体。这个目录结构能直接拿来讲分层设计,答“各层之间如何解耦”时也有的说。
2.3 数据库连接:连接字符串与技术选型细节
连接配置集中在 App.config 里,DAL 层的 DbHelper 统一读取。连接字符串里最关键的是 Data Source 部分的 SERVICE_NAME,它必须和 Oracle 监听器里的服务名一致,否则必报 ORA-12514。最常见的本机开发配置是下面这样:
<configuration> <connectionStrings> <add name="OracleDb" connectionString="Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)));User Id=scott;Password=tiger;" providerName="Oracle.ManagedDataAccess.Client" /> </connectionStrings> </configuration>参数说明里最容易看晕的是 SID 和 SERVICE_NAME 的区别:SID 是数据库实例名,多实例环境下连 SID 并不可靠;SERVICE_NAME 是监听器对外提供的服务名,连接串里建议统一写 SERVICE_NAME。另外 Oracle.ManagedDataAccess 默认开启了连接池,连接串里 Pooling=true 时,频繁开关连接并不会真去断 TCP,这对窗体应用反复查询场景是友好的。
3. 核心模块拆解:一个订单从进入到落库的完整链路
3.1 数据表设计:从用户到订单明细分了几层
数据库部分是这个项目里最有分量的内容。整库一共六张核心表:USERS 用户表、CATEGORIES 菜品类别表、DISHES 菜品表、CART 购物车表、ORDERS 订单主表、ORDER_ITEMS 订单明细表。
| 表名 | 关键字段 | 作用 |
|---|---|---|
| USERS | USER_ID, USERNAME, PASSWORD, PHONE, ADDRESS, USER_TYPE | 用户与管理员合一 |
| CATEGORIES | CATEGORY_ID, CATEGORY_NAME, SORT_NO | 菜品分类 |
| DISHES | DISH_ID, CATEGORY_ID, DISH_NAME, PRICE, STATUS | 菜品基本信息 |
| CART | CART_ID, USER_ID, DISH_ID, QUANTITY | 临时购物车 |
| ORDERS | ORDER_ID, ORDER_NO, USER_ID, TOTAL_PRICE, STATUS | 订单主表 |
| ORDER_ITEMS | ITEM_ID, ORDER_ID, DISH_ID, DISH_NAME, PRICE, QUANTITY | 下单瞬间的菜品快照 |
订单明细表单独存 DISH_NAME 和 PRICE 是刻意的设计——如果只存 DISH_ID,菜品改名或调价后,历史订单显示就会错乱。这就是典型的“快照字段”思路,需求分析文档里通常会把这条写进非功能需求。建表时还配了序列和默认值:
CREATE SEQUENCE seq_user_id START WITH 1 INCREMENT BY 1; CREATE SEQUENCE seq_order_id START WITH 1 INCREMENT BY 1; CREATE SEQUENCE seq_order_no START WITH 1000 INCREMENT BY 1; CREATE TABLE ORDERS ( ORDER_ID NUMBER(12) PRIMARY KEY, ORDER_NO VARCHAR2(20) NOT NULL, USER_ID NUMBER(12) NOT NULL, TOTAL_PRICE NUMBER(8,2) DEFAULT 0, STATUS NUMBER(1) DEFAULT 0, ORDER_TIME DATE DEFAULT SYSDATE, CONSTRAINT fk_orders_user FOREIGN KEY (USER_ID) REFERENCES USERS(USER_ID) );这里用序列而不是直接 MAX(ORDER_ID)+1,是为了避开并发插入时的主键冲突。大作业到答辩阶段常被问“两个人同时下单会不会主键重复”,用序列就是标准答案。STATUS 字段用数字存而不是字符串,是为了排序和统计方便,显示层再映射成“未支付/已支付/已配送”等中文状态。
3.2 下单链路:存储过程把事务收口
下单是整个系统最核心的操作,涉及购物车数据、订单主表、订单明细表三处改动,任何一步失败都要全部回滚。这套系统的做法是把它封装成存储过程,让事务在数据库端完成,而不是靠 C# 端手动控制:
CREATE OR REPLACE PROCEDURE sp_create_order ( p_user_id IN USERS.USER_ID%TYPE, p_order_id OUT ORDERS.ORDER_ID%TYPE, p_order_no OUT ORDERS.ORDER_NO%TYPE, p_total_price OUT ORDERS.TOTAL_PRICE%TYPE ) AS v_total NUMBER(8,2) := 0; BEGIN SELECT seq_order_id.NEXTVAL INTO p_order_id FROM DUAL; SELECT 'O' || TO_CHAR(SYSDATE, 'yyyymmddhh24miss') || LPAD(seq_order_no.NEXTVAL, 4, '0') INTO p_order_no FROM DUAL; INSERT INTO ORDERS (ORDER_ID, ORDER_NO, USER_ID, STATUS) VALUES (p_order_id, p_order_no, p_user_id, 0); INSERT INTO ORDER_ITEMS (ITEM_ID, ORDER_ID, DISH_ID, DISH_NAME, PRICE, QUANTITY) SELECT seq_item.NEXTVAL, p_order_id, d.DISH_ID, d.DISH_NAME, d.PRICE, c.QUANTITY FROM CART c JOIN DISHES d ON c.DISH_ID = d.DISH_ID WHERE c.USER_ID = p_user_id; SELECT SUM(PRICE * QUANTITY) INTO v_total FROM ORDER_ITEMS WHERE ORDER_ID = p_order_id; UPDATE ORDERS SET TOTAL_PRICE = v_total WHERE ORDER_ID = p_order_id; DELETE FROM CART WHERE USER_ID = p_user_id; p_total_price := v_total; COMMIT; EXCEPTION WHEN OTHERS THEN ROLLBACK; RAISE; END sp_create_order;这段存储过程的逻辑顺序很讲究:先插入订单主表拿到订单ID,再根据购物车明细批量插入订单明细,最后算总价并清空购物车。注意明细表插入用的是 SELECT 而不是循环,这种基于集合的写法比逐行 INSERT 快得多,也是数据库老师喜欢看到的手法。OUT 参数把订单号、订单ID、总价回传给客户端,界面可直接显示结算结果。
C# 端调用这个存储过程时,用 OracleParameter 显式声明输出参数,否则取不到返回值:
using (OracleConnection conn = new OracleConnection(connStr)) { conn.Open(); using (OracleCommand cmd = conn.CreateCommand()) { cmd.CommandType = CommandType.StoredProcedure; cmd.CommandText = "sp_create_order"; cmd.Parameters.Add("p_user_id", OracleDbType.Int64).Value = currentUserId; OracleParameter pOrderId = cmd.Parameters.Add("p_order_id", OracleDbType.Int64); pOrderId.Direction = ParameterDirection.Output; OracleParameter pOrderNo = cmd.Parameters.Add("p_order_no", OracleDbType.Varchar2, 20); pOrderNo.Direction = ParameterDirection.Output; OracleParameter pTotal = cmd.Parameters.Add("p_total_price", OracleDbType.Decimal); pTotal.Direction = ParameterDirection.Output; cmd.ExecuteNonQuery(); string orderNo = pOrderNo.Value.ToString(); decimal total = Convert.ToDecimal(pTotal.Value); } }这里最容易被忽略的是输出参数没指定 Size,导致返回的字符串被截断。OracleParameter 的 Varchar2 类型输出参数最好显式给足长度,否则默认可能只回传个位数长度。这是 C# 调 Oracle 存储过程非常经典的翻车点。
3.3 界面层的订单状态流转:谁在改订单的状态
系统把订单状态设计成了整条业务链的主线:0 未支付,1 已支付,2 已配送,3 已完成,4 已取消。普通用户只能在未支付状态取消订单,管理员可以把订单从已支付推到已配送、再推到已完成。这个状态机写在 BLL 层,而不是散落在各个按钮事件里:
public bool ChangeOrderStatus(string orderNo, int newStatus, int operatorType) { var order = orderDAL.GetByOrderNo(orderNo); if (order == null) return false; bool allowed = (operatorType == 1) // 管理员操作 ? (order.Status == 1 && newStatus == 2) || (order.Status == 2 && newStatus == 3) : (order.Status == 0 && newStatus == 4); // 普通用户只能取消未支付订单 if (!allowed) return false; return orderDAL.UpdateStatus(orderNo, newStatus); }状态流转的权限规则被集中收口在一处,比在按钮 Click 事件里到处写条件要安全得多。管理员端是以订单列表为核心做的,筛选框有:全部订单、待支付、待配送、已完成,点行可以看明细。这套设计不算花哨,但足够支撑一次答辩演示——从下单到改状态每一步都能在界面上看到结果。
4. 从源码到跑通:环境搭建与启动全流程
4.1 环境清单:一对一配平
拿到源码后先核对运行环境,Oracle 大作业跑不起来的首要原因往往是驱动版本和数据库版本不配套。
| 组件 | 建议版本 | 说明 |
|---|---|---|
| 操作系统 | Windows 10/11 | WinForms 项目最稳的平台 |
| Oracle 数据库 | 11g XE 或 12c | 学习版占用资源小 |
| ODAC 驱动 | ODAC 12c Release 4 | 对应 Oracle.ManagedDataAccess.dll |
| Visual Studio | 2019 或 2022 | 直接打开 .sln 编译 |
| .NET Framework | 4.6.1+ | 老项目常见目标框架 |
驱动不匹配的典型表现是编译通过但运行时报 ORA-12720 或找不到 Oracle 客户端。托管驱动一般不需要装 Oracle Client,但如果公司或机房机器上装了完整客户端,App.config 里可能要改成 DllPath 指向客户端的 ODP.NET 程序集,这里按错误提示微调即可。
4.2 初始化数据库:从建立表到填充演示数据
项目里通常带着 create_tables.sql、sequences.sql、init_data.sql 这三个脚本。在命令行用 SQL*Plus 一次性导入是最省事的做法:
sqlplus scott/tiger@localhost:1521/orcl @init_db.sqlinit_db.sql 内部把建表、序列、存储过程、演示数据按顺序串起来,方便重复执行。常见做法是开头先 DROP 掉旧表,避免脚本重复跑时因对象已存在而报错。演示数据方面,系统默认会插入一个管理员账号和一个测试用户账号,密码通常是 123456,如果登录失败可以在数据初始化脚本里确认哈希字段,若存的是明文就直接对着改。
4.3 编译启动:改连接、Build、F5
把代码拉下来之后,核心操作就是三步:
# 1. 用 VS 打开 OrderSystem.sln,还原 NuGet 包 # 2. 修改 App.config 里的 Data Source、User Id、Password # 3. 按 F5 编译运行打开界面后先不要点登录,去“菜品列表”所在的窗体走一遍流程,这样能迅速暴露数据库连接问题。管理员登录后进入后台管理窗体,能看到菜品增删改和订单状态更新的入口;普通用户登录后则是浏览菜品、加购物车、下单支付的操作序列。这套流程完整跑通一遍,整个项目就基本吃透了。
5. 避坑指南:Oracle 与 C# 联调最容易翻车的四个场景
5.1 ORA-12514:监听服务名对不上
现象:程序启动后任何查库操作都抛出 ORA-12514: TNS:listener does not currently know of service requested。
原因:连接串里写的 SERVICE_NAME 和数据库实际的服务名不一致,最常见的是写成了 SID(比如 ORCL 写成了 xe 或安装时的实例名)。监听器只认 tnsnames.ora 里声明过的服务名,连接串里的名字和它不一致就会报这个错。
解决:先执行lsnrctl status看监听器实际注册了哪些服务名,再把连接串里 CONNECT_DATA 的 SERVICE_NAME 改成和输出一致。如果不想动连接串,也可以在监听配置里加一个与服务名等效的静态条目,但我一般建议直接改连接串,毕竟配置文件改起来快。
5.2 中文乱码和字符集不匹配
现象:界面上菜品名称和地址显示成问号,或者 DB 里看中文正常、应用读出来乱码。
原因:Oracle 数据库端 NLS_CHARACTERSET 和客户端 NLS_LANG 不一致。大作业安装 Oracle 时很多人随手选了默认字符集,如果是 WE8MSWIN1252,存中文就会出问题。
解决:在系统环境变量里设置 NLS_LANG=SIMPLIFIED CHINESE_CHINA.AL32UTF8,并且保证建库时选 AL32UTF8。如果表里已经进了乱码数据,备份导出时加字符集转换参数,或者干脆清掉重建,这批数据反正也只是演示用。
5.3 OracleParameter 输出参数取不到值
现象:存储过程执行成功,但 C# 里输出参数拿到的始终是 DBNull,或者字符串被截断。
原因:两个常见错误叠加。一是输出参数没有声明 Direction=Output,默认是 Input,天然拿不到东西;二是 Varchar2 类型的输出参数没有指定 Size,Oracle 驱动无法预知缓冲区大小,返回内容稍长就会被截断或置空。
解决:每个输出参数都显式写出 Direction,并且按如下写法指定长度。取订单号这类业务字段,Size 给满 50 就不会踩坑:
OracleParameter pOrderNo = new OracleParameter("p_order_no", OracleDbType.Varchar2, 50); pOrderNo.Direction = ParameterDirection.Output; cmd.Parameters.Add(pOrderNo);5.4 订单明细和主表不同步
现象:下单后 ORDERS 表多了一行,但 ORDER_ITEMS 是空的,或者购物车没有被清空。
原因:存储过程里的 COMMIT 位置不对,或者在 C# 端调用时先执行了别的更新。更隐蔽的情况是:C# 代码里把多个命令放在同一个连接里,中途某条 SQL 抛异常后没有及时回滚,DAL 层也没有用事务包住。
解决:存储过程内务必将整条插入链路放在同一个事务块里,COMMIT 放在最后,EXCEPTION 里统一 ROLLBACK。C# 端如果要在代码里组合多步操作,使用 OracleTransaction 保持一致:
using (OracleTransaction tx = conn.BeginTransaction()) { try { // 执行多步 SQL tx.Commit(); } catch { tx.Rollback(); throw; } }6. 二次开发:把课程大作业往工程化方向推三步
6.1 把 BLL 接口化,为替换实现留后路
拿到的源码里 BLL 和 DAL 大多以类为主,业务层还没有抽象接口。第一次改造我会把 OrderService 和 UserService 各抽出一个 IOrderService、IUserService 接口,窗体代码只引用接口。这样后续要接 EF Core 或换成远程服务时,实现类直接替换,界面层不用动。答辩时讲“面向接口编程”,也是一个很加分的点。
6.2 给菜品列表加缓存,少让 Oracle 干活
点餐首页每次加载都把整张 DISHES 表查一遍,重复点击菜单就会反复执行相同 SQL。可以用最轻量的方式引入缓存。不用急着上 Redis,先写一个静态 ConcurrentDictionary 存菜品列表,并设定五分钟过期时间,等有跨进程需求再换 Redis。对课程作业来说,这样一处改造就能讲清楚“缓存减少数据库压力”的实践意义。
private static ConcurrentDictionary<int, (List<Dish> Dishes, DateTime ExpireTime)> dishCache = new ConcurrentDictionary<int, (List<Dish>, DateTime)>(); public List<Dish> GetDishListByCategory(int categoryId) { if (dishCache.TryGetValue(categoryId, out var entry) && entry.ExpireTime > DateTime.Now) return entry.Dishes; var fresh = dishDAL.GetListByCategory(categoryId); dishCache[categoryId] = (fresh, DateTime.Now.AddMinutes(5)); return fresh; }6.3 给订单号加上随机后缀,防止并发重复
现在订单号是时间戳加序列拼接,同一秒内序列只增不会重复,但在多实例部署或演示时多个页面同时下单,时间戳部分完全一样,肉眼不好区分。可以改成在序列号后面再加三位随机数字,或者在订单号前缀带上用户 ID,让同一个用户同一秒下的单也能一眼分开。这个改造虽小,却是从“能用”走向“好用”的一个细节。从那以后我拿到任何课程项目,都习惯先把订单号这类生成规则拿出来捋一遍,确定它是唯一且可读的再往下改。这样一个流程走下来,改起来安心很多,希望帮到你。
本文还有配套的精品资源,点击获取