简介:基于.NET与C#构建的旅行社信息管理系统源码及配套解决方案文档,适合.NET初学者、毕业设计或管理信息系统开发人员参考。系统涵盖客户、行程、订单、财务等核心模块,按三层架构思路设计,提供完整的业务逻辑与数据库脚本,其中客户管理支持信息存储与查询,行程管理包含线路规划与报价计算,订单与财务模块覆盖预订、支付及收支分析。资源共1719个文件,包含90个aspx页面、58个cs源码文件、52个xml配置及mdf/ldf数据库文件,另有大量gif/jpg图片资源与系统设计文档,压缩包约50.56MB。目前已有299人学习下载,适合用于理解C#开发流程、数据库建模及前后端交互;通过源码与文档可掌握从需求分析到实现测试的完整设计思路,便于二次开发或课程设计参考;附带的解决方案文档还详细说明了数据库表结构、界面布局与测试计划,可帮助快速上手。
1. 一套旅行社信息管理系统的源码包,先要读懂它的业务骨架
如果你第一次拿到这样命名的压缩包,大概率是公司或学校交付了一个基于 .NET 的 C# 项目:旅行社信息管理系统,附带一份系统设计解决方案文档,和一个能编译的源码工程。这类系统在中小旅行社里确实还在服役,界面以 WinForms 为主,数据库多半是 SQL Server,业务不外乎线路维护、团期安排、报名收款和对账。真正花时间的不是把代码跑起来,而是把文档里描述的业务模型,对应到数据库表、业务类、界面这三层上去。下面按我处理这类系统的顺序展开:先讲技术选型和工程结构,再给核心表与数据访问设计,然后是订单、结算的两处关键实现,最后处理 UI 卡顿和交付排错。
2. .NET/C# 技术栈与系统设计文档的对应关系
2.1 为什么这类系统选择 .NET Framework 和 WinForms
旅行社门店系统的使用环境通常是内网、单机或小局域网,操作密集、表格多、需要快速录入,因此 C# 侧最常见的工程形态是 .NET Framework 4.x + WinForms。这个选型的好处是:目标机器装好 .NET Framework 后,把编译出的 exe 和 dll 整包拷过去就能跑,不依赖额外运行时;老旧的报表控件、皮肤组件可以直接引用;对 Win7/Win10 的兼容性也稳。
快速判断工程类型的办法是打开.csproj,看<TargetFrameworkVersion>节点。如果是v4.6.2、v4.7.2这类值,就按 Framework 项目处理,不要硬迁移到 .NET 8。WinForms 在 .NET Core 也能写,但第三方控件和旧代码不一定能平滑升级,做交付时我一般不动这个层。
| 目标框架 | 常见 Visual Studio 版本 | 部署方式 | 适用场景 |
|---|---|---|---|
| .NET Framework 3.5 | VS2008/2010 | 系统自带或独立安装包 | XP/Win7 老机器 |
| .NET Framework 4.5+ | VS2012~2019 | 拷贝 Release 目录 | 大多数 WinForms 系统 |
| .NET Core/5/6/8 | VS2019/2022 | 发布后带运行时 | 新项目,推荐但控件兼容要验证 |
如果是新写系统,我反而建议用 .NET 8;但拿到的是历史源码包,优先保证原地跑通。
2.2 从系统设计解决方案文档中拆出模块和分层
这类文档一般包含需求分析、功能模块图、数据库 ER 图、界面说明。先跳过背景介绍,直接看 ER 图和模块清单,就能反推出代码里应该有哪些类。架构上最常见的做法是三层:
- 表示层:WinForms 窗体、用户控件,负责录入和展示。
- 业务层:Service/Manager 类,负责状态校验、价格计算、业务流程编排。
- 数据访问层:Repository/DAO 类,负责封装 SQL、事务和数据映射。
模块划分通常是这样一张表:
| 模块 | 典型界面 | 核心业务对象 |
|---|---|---|
| 线路管理 | 线路列表/编辑窗体 | 线路名称、天数、出发城市、报价 |
| 团期管理 | 团期列表/开班窗体 | 团期日期、总座位数、已占座位数 |
| 报名管理 | 订单列表/报名窗体 | 客户、团期、订单状态、金额 |
| 收款管理 | 收款记录列表/收款窗体 | 支付方式、收款金额、收款时间 |
| 用户权限 | 登录窗体/用户管理 | 登录名、密码哈希、角色 |
我一般会把界面用的 ViewModel 和数据库映射的 Entity 分开,比如报名界面上用的是OrderViewModel,包含客户姓名、线路名、状态文本;数据库映射用OrderEntity,字段跟表结构一一对应。两者在业务层转换,这样数据库加字段不会直接炸界面。
2.3 配置入口:App.config 中连接字符串的修改与读取
WinForms 项目的连接配置写在App.config里,安装部署后改名成项目名.exe.config。常见的配置长这样:
<connectionStrings> <add name="TripDB" connectionString="Data Source=.;Initial Catalog=TripDB;User ID=sa;Password=123456;Persist Security Info=True;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings>代码里通过ConfigurationManager读取:
string connStr = ConfigurationManager.ConnectionStrings["TripDB"].ConnectionString; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); // 后续数据操作 }参数说明:Data Source是 SQL Server 实例名,.代表本机;Initial Catalog是数据库名,注意区分大小写习惯;MultipleActiveResultSets=True建议保留,否则在同一个连接上嵌套执行多条命令时会报"连接未关闭"的错误。用户名和密码不建议用 sa 交付,跑通后应改成最小权限账号。
3. 数据库设计与数据访问层的落地
3.1 六张核心表的设计
旅行社业务围绕"线路—团期—订单"展开:一条线路Line可以开多个团期Team,客户Customer在某团期上报名生成Orders,订单下挂明细和收款记录Payments,再加一张SysUser做登录。核心表结构如下:
| 表名 | 主键 | 关键字段 | 说明 |
|---|---|---|---|
| Line | Id | LineName、DepartCity、Days、PriceAdult、PriceChild | 线路基础信息 |
| Team | Id | LineId、StartDate、TotalSeats、BookedSeats、AdultPrice、ChildPrice | 一个团期,价格会覆盖线路默认价 |
| Customer | Id | Name、IdCard、Phone、IsChild | 游客信息,儿童价靠 IsChild 区分 |
| Orders | Id | OrderNo、TeamId、CustomerId、Status、Amount | 报名主表,状态存数字 |
| Payments | Id | OrderId、PayType、Amount、PayTime | 一次收款一条记录 |
| SysUser | Id | UserName、PwdHash、Role | 登录用户 |
建库建表脚本给一个可运行的骨架:
CREATE DATABASE TripDB; GO USE TripDB; GO CREATE TABLE dbo.Team ( Id INT IDENTITY(1,1) PRIMARY KEY, LineId INT NOT NULL, StartDate DATE NOT NULL, TotalSeats INT NOT NULL, BookedSeats INT NOT NULL DEFAULT 0, AdultPrice DECIMAL(10,2) NOT NULL, ChildPrice DECIMAL(10,2) NOT NULL ); CREATE TABLE dbo.Orders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, TeamId INT NOT NULL, CustomerId INT NOT NULL, Status INT NOT NULL DEFAULT 1, Amount DECIMAL(10,2) NOT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() );表设计上有一个关键点:团期表里冗余了AdultPrice和ChildPrice,而不是直接读线路表价格。因为同一个线路在不同出发日期会调价,结算时应当按报名时团期的价格算,价格字段用DECIMAL(10,2)而不是float,避免小数误差。
3.2 余位控制:一条 UPDATE 防止超卖
报名时最怕两个操作员同时卖同一团期的最后一个座位。先在 C# 里 SELECT 再判断再 UPDATE 一定有竞态,正确做法是把余位检查推进 SQL:
UPDATE dbo.Team SET BookedSeats = BookedSeats + 1 WHERE Id = @TeamId AND TotalSeats - BookedSeats >= 1; IF @@ROWCOUNT = 0 BEGIN RAISERROR(N'该团期已满,无法报名', 16, 1); END这里WHERE条件里的TotalSeats - BookedSeats >= 1是核心:SQL Server 在更新时对行加锁,两个会话并发执行时只有一个能更新成功。@@ROWCOUNT = 0表示没有行被更新,此时不允许继续插入订单。整个报名流程要用事务包住,UPDATE 和 INSERT 一起提交。
3.3 数据访问层用 Dapper 减少样板代码
历史项目里大量使用SqlCommand和DataTable也能跑,但字段名散落在 SQL 字符串里,改起来费劲。我通常会引入 Dapper,在DBHelper上包一层,数据访问代码可以压缩成:
public class OrderRepository { private readonly string _connStr; public OrderRepository(string connStr) { _connStr = connStr; } public IEnumerable<OrderEntity> GetByTeamId(int teamId) { using var conn = new SqlConnection(_connStr); return conn.Query<OrderEntity>( "SELECT * FROM dbo.Orders WHERE TeamId = @TeamId", new { TeamId = teamId }); } }Query<T>会把查询结果按列名映射到实体属性,参数用@TeamId传匿名对象,避免字符串拼接注入。注意Orders表的列名和实体属性名最好保持一致;表名不要叫Order,它是 SQL 保留字,每次查询都要加方括号,容易埋雷。
事务写法同样要用到 Dapper 的Execute重载,把事务对象传进去:
using var conn = new SqlConnection(_connStr); conn.Open(); using var tx = conn.BeginTransaction(); try { int affected = conn.Execute( "UPDATE dbo.Team SET BookedSeats = BookedSeats + 1 WHERE Id = @TeamId AND TotalSeats - BookedSeats >= 1", new { TeamId = teamId }, tx); if (affected == 0) throw new InvalidOperationException("团期余位不足"); conn.Execute("INSERT INTO dbo.Orders(OrderNo, TeamId, CustomerId, Status, Amount) VALUES(@OrderNo, @TeamId, @CustomerId, 1, @Amount)", new { OrderNo = orderNo, TeamId = teamId, CustomerId = customerId, Amount = amount }, tx); tx.Commit(); } catch { tx.Rollback(); throw; }这里最容易漏的是:BeginTransaction之后,每条 SQL 都要显式把tx传进Execute,否则命令默认走独立事务,Commit 之后你以为数据一起提交了,实际上只有一行生效。
4. 订单状态机与结算模块的实现细节
4.1 订单状态用数字枚举,不要存中文字符串
很多源码包里的订单状态直接存"已报名""已交款"这类中文,导致报表统计时同名不同值。我习惯用数字状态码,界面显示时再映射文本。C# 侧定义枚举:
public enum OrderStatus { Pending = 0, // 意向 Booked = 1, // 占位 Signed = 2, // 签约 Traveled = 3, // 已出团 Settled = 4, // 已结算 Canceled = 9 // 已取消 }数据库Orders.Status存 int,下拉框数据源绑定枚举,SelectedValue直接转 int。状态流转要走业务层统一校验,比如已取消的订单不能再签约,已出团的不能回退到意向。不要把状态判断散落在十个窗体的按钮事件里,否则改一个状态规则要改十处。
4.2 报价计算:团期价格快照与金额精度
报名界面常见的逻辑:选择团期后自动带出成人价、儿童价,按人数算总额,支持团队折扣。我计算金额时用decimal,并且把明细存进订单明细表,而不是只在主表记一个总价。因为后续对账要拆开看成人收了多少钱、儿童收了多少、加床多少。
public decimal CalcOrderAmount(Team team, int adultCount, int childCount) { decimal amount = adultCount * team.AdultPrice + childCount * team.ChildPrice; if (adultCount >= 10) // 团队优惠 { amount *= 0.95m; } return decimal.Round(amount, 2, MidpointRounding.AwayFromZero); }注意0.95m后面的m后缀,不要写0.95,否则编译时是 double 参与运算,结果再转 decimal 可能引入额外精度误差。MidpointRounding.AwayFromZero把 0.005 这类边界值按四舍五入处理,而不是银行家舍入。
4.3 报表导出:用 NPOI 替代 Office COM 组件
报名列表导出 Excel 是高频需求。用Microsoft.Office.Interop.Excel逐格填充,遇到没有安装 Office 的机器会直接抛"拒绝访问"。我一般引入 NPOI 写 .xlsx,不依赖本机 Office:
using NPOI.XSSF.UserModel; public void ExportOrders(DataTable dt, string filePath) { using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write); var workbook = new XSSFWorkbook(); var sheet = workbook.CreateSheet("订单"); // 写入列头 var headerRow = sheet.CreateRow(0); headerRow.CreateCell(0).SetCellValue("订单号"); headerRow.CreateCell(1).SetCellValue("客户姓名"); headerRow.CreateCell(2).SetCellValue("金额"); // 写入数据行 for (int i = 0; i < dt.Rows.Count; i++) { var row = sheet.CreateRow(i + 1); row.CreateCell(0).SetCellValue(dt.Rows[i]["OrderNo"].ToString()); row.CreateCell(1).SetCellValue(dt.Rows[i]["CustomerName"].ToString()); row.CreateCell(2).SetCellValue(Convert.ToDouble(dt.Rows[i]["Amount"])); } workbook.Write(fs, true); }XSSFWorkbook生成的是 xlsx 格式,处理几万行也不卡;FileStream用using保证释放,否则文件被占用导不出第二次。如果只是临时给财务用,直接导出 CSV 更省事,但注意 CSV 里的金额列要在 Excel 里保留两位小数,NPOI 这种方式更可控。
5. 报团列表循环加载与 UI 刷新卡顿的处理
5.1 循环数据加载导致界面卡死的根因
在报名列表或团期列表加载时,最容易犯的错是在事件方法里直接写循环往DataGridView加行。每次Rows.Add都触发重绘,数据量一大,界面就像死机。背后的原因是循环在 UI 线程执行,消息泵没机会处理绘制消息。
不要用Application.DoEvents()解卡顿,它会在循环中重入事件,比如按钮被连续点击两次,产生重复数据。推荐把耗时查询放到后台线程:
private async void btnLoad_Click(object sender, EventArgs e) { btnLoad.Enabled = false; try { var data = await Task.Run(() => _orderRepository.GetOrderList()); dataGridView1.DataSource = data; } finally { btnLoad.Enabled = true; } }Task.Run把查询扔到线程池,await恢复时自动回到 UI 线程,DataSource赋值不需要手动Invoke。这是最简单、最不容易写错的异步刷新方案,适用于任何 WinForms 项目。如果循环里还要做数据采集或逐条处理,用List<T>先收集,最后一次性赋值给DataSource,而不是一条条 Add。
5.2 DataGridView 虚拟模式应对几万行数据
当订单量涨到几万行,即使一次性绑定DataTable也会让滚动变得迟钝。开启虚拟模式后,控件只请求可见区域的行数据,滚动时按需触发CellValueNeeded事件:
dataGridView1.VirtualMode = true; dataGridView1.RowCount = 50000; dataGridView1.CellValueNeeded += (s, e) => { // 根据 e.RowIndex 从缓存或分页结果中取数据 if (_rowCache.TryGetValue(e.RowIndex, out var row)) { e.Value = row[e.ColumnIndex]; } };开启虚拟模式后不能同时设置 DataSource,RowCount告诉控件总行数。实际项目里不要一次性把五万行都读进缓存,而是按滚动区间分页加载,比如每 1000 行查一次,否则等于把卡顿从界面层转移到内存层。
5.3 文本框失去焦点与跨线程访问的边界问题
用户常反馈"输入完订单号后光标自动跳走",通常不是多线程问题,而是控件在数据绑定过程中被重置,触发了Validating事件。给不需要主动校验的文本框设置CausesValidation = false,能避免失焦时验证事件抢焦点。另外,在 DataGridView 编辑状态下调用BindingSource.EndEdit()会把当前行数据强制写回实体,如果后端字段校验失败,界面上会弹异常,先做空值判断再提交。
关于跨线程访问控件,Control.CheckForIllegalCrossThreadCalls = false可以屏蔽报错,但它掩盖了真正的线程安全问题,不推荐。用Control.BeginInvoke把更新控件的代码切回 UI 线程才是正路。
6. 跑通系统后要用的三个验证手段
6.1 连不上数据库时的三分钟定位
把问题拆成网络、账号、库名三块。先用sqlcmd验证登录:
sqlcmd -S . -U sa -P 123456 -d master -Q "SELECT DB_ID(N'TripDB')"能返回数据库 ID,说明网络和账号没问题,问题在连接字符串;如果提示登录失败,去 SQL Server 管理工具里确认sa账号是否启用、认证模式是否为混合模式。改完连接字符串后,在程序启动处打印一次ConnectionString,防止发布环境里加载的还是旧的 exe.config。
6.2 团期对账 SQL:人数和收款是否一致
交付前跑一遍对账,看每个团期应签人数和实收金额是否匹配:
SELECT t.Id AS TeamId, SUM(CASE WHEN o.Status IN (2, 3, 4) THEN 1 ELSE 0 END) AS SignedCount, SUM(p.Amount) AS PaidAmount FROM dbo.Team t LEFT JOIN dbo.Orders o ON o.TeamId = t.Id LEFT JOIN dbo.Payments p ON p.OrderId = o.Id GROUP BY t.Id;LEFT JOIN保证没有订单的团期也出现在结果里,CASE WHEN过滤掉已取消状态。哪一行金额对不上,就从订单明细往下追。这一步能把数据库设计、状态流转和收款逻辑里的低级错误一次扫出来。
6.3 发布时检查 .NET 版本与运行环境
WinForms 发布直接拷贝 Release 目录,但目标机器如果提示"已安装更高版本"或者找不到运行库,多半是 .NET Framework 版本不匹配。把.csproj里的TargetFrameworkVersion降到 v4.6.2 重新编译,WinForms 代码一般不用改;如果工程里引用了新版 NuGet 包,先降包版本再编译。交付后验证路径固定为:登录、创建一个团期、做一笔报名、导一次 Excel,四步都过了再签收。
本文还有配套的精品资源,点击获取