简介:这份文档面向软件工程学习者、系统分析与设计初学者以及需要撰写课程作业或项目文档的开发者,围绕中国铁路窗口售票系统展开完整的需求分析,帮助读者理解从业务调研到需求落地的整体思路。压缩包内共1个docx文件,约275KB,内容为一份结构规范的需求说明书,涵盖引言、总体要求、业务需求、辅助模块与结语等章节,并细化了订票、改签、退票三大业务模块及票价计算方法。文档还给出系统总体目标、功能要求与体系架构,涉及前端界面、后端服务与数据库的分层设计,以及用户管理、支付服务、统计分析等辅助模块,可作为需求分析报告的写作模板与案例参考。目前已有582人学习下载,适合需要借鉴文档结构、梳理业务流程或准备系统设计材料的中级学习者使用。
1. 窗口售票系统需求分析:一份能直接落地的 V3.0.0 规格书
很多人以为窗口售票系统就是“查余票、收钱、打票”三步走,真拿到这份需求说明书才发现,光退票手续费就有四档时间窗口,改签还牵扯到差额补退和“只办一次”的硬约束。这份《车站窗口售票系统》需求说明书 V3.0.0 把订票、改签、退票三条主业务线,加上票价计算、火车信息、个人信息管理这些辅助模块,全部拆成了可执行的规则条目。它适合两类人:一是要照着做课程设计或毕设的开发者,二是接手老售票系统二次开发、需要一份业务口径参考的工程人员。技术选型上写明基于 WinForm + WCF 与后台逻辑交互,属于典型的 C/S 桌面端架构,读起来不玄学,规则都是能直接翻译成 if-else 和 SQL 的。
2. 体系架构与模块边界:WinForm + WCF 的 C/S 分层怎么切
2.1 三层结构在需求书里的对应关系
需求书 2.3 节只给了一句话——“基于(WinForm+WCF)与后台逻辑进行数据交互”,但这句话背后其实定死了整个项目的分层方式。窗口售票是典型的坐席制场景:每个售票员一台固定终端,装的是桌面客户端,不是浏览器。所以前端选 WinForm 是合理的,它天然适合做密集表单录入、快捷键操作和本地打印机调用——打票这个动作必须落到本地,浏览器方案在这一步会很别扭。
按这个选型往下推,合格的做法是切成三层:
| 层 | 技术载体 | 职责 | 对应需求章节 |
|---|---|---|---|
| 表现层 | WinForm 窗体 | 查询条件录入、乘客信息核对、票面预览、打印 | 3.1.1 订票、4.1 火车信息 |
| 服务层 | WCF 服务契约 | 余票查询、订单生成、改签校验、退票计费 | 3.1.2 改签、3.1.3 退票、3.2 票价 |
| 数据层 | 关系库 + 存储过程 | 车次、席位、订单、交易流水、用户 | 4.2 常用信息管理 |
这里有个容易翻车的点:需求书把“票价计算”放在业务需求 3.2 节,而不是辅助模块,说明票价不是展示层算的,必须由服务端统一计算。我一般会把票价计算做成 WCF 服务里的一个独立方法,客户端只传里程、席别、车种这些入参,绝不让前端自己乘系数——否则改签补退差额时,前后端算出来的数对不上,就是血泪经验。
2.2 服务契约的划分思路
WCF 的核心是契约先行。按需求书的业务模块,服务契约至少拆成四组,别塞进一个大接口:
// 售票服务契约:对应需求 3.1.1 订票模块 [ServiceContract] public interface ITicketService { // 余票查询:出发地、目的地、日期为必选条件 [OperationContract] List<TrainRemain> QueryRemain(string fromStation, string toStation, DateTime departDate); // 中转查询:多一个中转站参数 [OperationContract] List<TransferRoute> QueryTransfer(string from, string mid, string to, DateTime date); // 完成订单:传入乘客证件与所选席位,返回票号 [OperationContract] TicketResult CreateOrder(OrderRequest request); } // 改签服务契约:对应需求 3.1.2,注意“只允许改签一次”的约束 [ServiceContract] public interface IChangeService { [OperationContract] ChangeResult ChangeTicket(string ticketNo, string newTrainNo, DateTime newDate); } // 退票服务契约:对应需求 3.1.3,手续费按时间窗口分档 [ServiceContract] public interface IRefundService { [OperationContract] RefundResult RefundTicket(string ticketNo, DateTime refundTime); }这段契约代码的关键不在语法,而在参数设计。QueryRemain的三个参数对应需求书里“出发地、目的地、出发时间都是必须选择”的硬约束,所以方法签名里就不给默认值,客户端不填就调不通。RefundTicket特意把refundTime作为显式入参而不是在服务端取当前时间,是为了让退票手续费计算可测试——测试时能直接构造“开车前 20 小时”这种边界场景,不用改系统时钟。ChangeTicket没有暴露“改签次数”参数,因为次数校验必须在服务端查订单表完成,客户端传什么都不能信。
2.3 数据库表的最小集合
需求书没给表结构,但按业务反推,下面这几张表是绕不开的,缺一张业务就断链:
-- 车次与席位余量:支撑余票查询和中转查询 CREATE TABLE train_remain ( train_no VARCHAR(10) NOT NULL, -- 车次,如 T8349 seat_type VARCHAR(10) NOT NULL, -- 席别:硬座/软座/硬卧上中下/软卧上下 depart_date DATE NOT NULL, from_station VARCHAR(20) NOT NULL, to_station VARCHAR(20) NOT NULL, remain_count INT NOT NULL, -- 余票数,扣减时加行锁 price DECIMAL(10,2) NOT NULL -- 该席别该区段单价 ); -- 订单主表:改签、退票都围绕它做状态流转 CREATE TABLE ticket_order ( ticket_no VARCHAR(20) PRIMARY KEY, passenger_id VARCHAR(18) NOT NULL, -- 身份证号 train_no VARCHAR(10) NOT NULL, seat_type VARCHAR(10) NOT NULL, depart_date DATE NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL, -- 0已售 1已改签 2已退 3已作废 change_count TINYINT DEFAULT 0 -- 改签次数,硬约束只允许 1 次 );train_remain的remain_count在扣减时必须用UPDATE ... WHERE remain_count > 0加行锁,这是窗口售票高并发下的基本操作,需求书没写但这是常识。ticket_order.change_count这个字段直接对应需求书“变更到站只办理一次”的规则,把业务约束落到字段上,比在代码里到处判断要可靠得多。
3. 三大业务模块拆解:订票、改签、退票的规则怎么落成代码
3.1 订票模块:查询与订单生成的两段式
需求书 3.1.1 把订票拆成“车票查询”和“完成订单”两步,这个拆分很关键,因为它对应两种完全不同的技术处理。查询是只读的、可以走缓存;下单是写操作、必须保证余票扣减和订单插入在同一个事务里。
先看余票查询。需求书要求出发地、目的地、日期三个条件必选,还额外支持中转查询。中转查询的本质是两次单程查询再做笛卡尔积过滤,代码上不用搞复杂:
// 中转查询:先查 A->M,再查 M->B,按时间衔接过滤 public List<TransferRoute> QueryTransfer(string from, string mid, string to, DateTime date) { // 第一段:出发地到中转站,当天发车 var leg1 = QueryRemain(from, mid, date); // 第二段:中转站到目的地,从中转日当天起查,留出换乘时间 var leg2 = QueryRemain(mid, to, date); var routes = new List<TransferRoute>(); foreach (var a in leg1) { foreach (var b in leg2) { // 换乘间隔至少 30 分钟,避免生成赶不上的组合 if (b.DepartTime > a.ArriveTime.AddMinutes(30)) { routes.Add(new TransferRoute { First = a, Second = b }); } } } return routes; }这段逻辑的说明点在于:AddMinutes(30)这个换乘缓冲不是需求书写的,是我按实际场景补的——不补的话会生成“到站即发车”的无效组合,旅客拿着这种票到窗口会直接吵起来。参数上date只传一个日期,第二段查询默认从中转当天开始,如果跨天换乘,需要把date往后推一天再查一次,这个边界在实现时容易漏。
下单环节的核心是事务。需求书说“核对信息、旅客给车票费后,就可以完成订单,打印车票”,翻译成代码就是:扣余票、插订单、返回票号,三步一个事务:
// 完成订单:扣减余票与插入订单必须原子 public TicketResult CreateOrder(OrderRequest req) { using (var tran = _db.BeginTransaction()) { // 条件更新扣票,remain_count > 0 保证不超卖 int affected = _db.Execute( @"UPDATE train_remain SET remain_count = remain_count - 1 WHERE train_no=@tno AND seat_type=@st AND depart_date=@d AND from_station=@f AND to_station=@to AND remain_count > 0", new { tno = req.TrainNo, st = req.SeatType, d = req.DepartDate, f = req.From, to = req.To }); if (affected == 0) { tran.Rollback(); return TicketResult.Fail("余票不足,请重新查询"); } // 生成票号并落订单,金额以服务端票价计算结果为准 string ticketNo = GenerateTicketNo(); _db.Execute(@"INSERT INTO ticket_order(ticket_no,passenger_id,train_no, seat_type,depart_date,amount,status) VALUES(@no,@pid,@tno,@st,@d,@amt,0)", new { no = ticketNo, pid = req.PassengerId, tno = req.TrainNo, st = req.SeatType, d = req.DepartDate, amt = req.Amount }); tran.Commit(); return TicketResult.Ok(ticketNo); } }参数说明:req.Amount虽然由客户端传入,但服务端在插入前应该用 3.2 节的票价算法重算一遍并覆盖,防止客户端篡改。affected == 0的分支就是需求书里“所有车票都已售完”的情况,返回给前端后要引导售票员换查询条件,而不是直接报错弹窗。
3.2 改签模块:一次约束与差额补退
改签是三个模块里规则最密的。需求书列了三条:变更到站无需退票另购且不收手续费、变更到站只办一次、差额按退票费标准核收。这三条要落成代码,核心是状态校验和金额计算两段。
先做前置校验,把“能不能改”判断清楚:
public ChangeResult ChangeTicket(string ticketNo, string newTrainNo, DateTime newDate) { var order = _db.QuerySingle<TicketOrder>( "SELECT * FROM ticket_order WHERE ticket_no=@no", new { no = ticketNo }); if (order == null) return ChangeResult.Fail("票号不存在"); if (order.Status == 2) return ChangeResult.Fail("已退票,无法改签"); if (order.ChangeCount >= 1) return ChangeResult.Fail("该票已办理过改签,不再办理"); // 开车前 48 小时以上才允许窗口改签,对应需求书时间约束 if (order.DepartDate.AddHours(-48) < DateTime.Now) return ChangeResult.Fail("距开车不足 48 小时,请到窗口按现行规则处理"); // 计算新旧票价差额,新票价由票价服务按新区段重算 decimal newPrice = _priceService.Calc(newTrainNo, order.SeatType, newDate); decimal diff = newPrice - order.Amount; // ...后续更新订单、记录差额 }这里的ChangeCount >= 1就是需求书“只办理一次”的直接映射,AddHours(-48)对应“开车前 48 小时以上”的时间窗。差额处理上,需求书说“新车票价格低于原车票时退还差额,对差额部分核收退票费”,意味着退差额时还要再套一次退票费率,这个嵌套容易写漏。我一般把差额退款单独抽一个方法,内部复用退票模块的费率函数,保证两处口径一致。
3.3 退票模块:四档手续费的时间窗口
退票的规则在需求书 3.1.3 写得最细:15 天以上免手续费,48 小时到 15 天收 5%,24 到 48 小时收 10%,不足 24 小时收 20%。这是一个典型的分段函数,用时间差算档位最稳:
// 按开车前剩余小时数确定退票费率 public decimal GetRefundRate(DateTime departTime, DateTime refundTime) { double hours = (departTime - refundTime).TotalHours; if (hours >= 24 * 15) return 0.00m; // 15 天以上,免手续费 if (hours >= 48) return 0.05m; // 48 小时~15 天 if (hours >= 24) return 0.10m; // 24~48 小时 return 0.20m; // 不足 24 小时 } // 退票主流程:作废订单但保留交易记录 public RefundResult RefundTicket(string ticketNo, DateTime refundTime) { var order = _db.QuerySingle<TicketOrder>( "SELECT * FROM ticket_order WHERE ticket_no=@no", new { no = ticketNo }); if (order == null || order.Status == 2) return RefundResult.Fail("票号无效或已退"); decimal rate = GetRefundRate(order.DepartDate, refundTime); decimal fee = Math.Round(order.Amount * rate, 2); decimal refundAmount = order.Amount - fee; // 状态置为已退,交易流水单独记录,不物理删除订单 _db.Execute("UPDATE ticket_order SET status=2 WHERE ticket_no=@no", new { no = ticketNo }); _db.Execute(@"INSERT INTO refund_log(ticket_no,refund_time,fee,refund_amount) VALUES(@no,@t,@fee,@amt)", new { no = ticketNo, t = refundTime, fee = fee, amt = refundAmount }); return RefundResult.Ok(refundAmount, fee); }GetRefundRate的边界要特别注意:需求书写的是“开车前 48 小时~15 天”,这个区间是左闭右开的,hours >= 48且< 24*15,代码里用顺序判断天然满足。Math.Round(..., 2)保留两位是票价场景的惯例,不四舍五入到分会导致对账差几分钱,这种小坑在真实项目里很磨人。退票不删订单只改状态,是因为需求书明确“交易记录还是会保留下来”,物理删除会让后续审计和统计断数据。
4. 票价计算与辅助模块:从里程到联合票价的完整链路
4.1 票价计算的分步实现
需求书 3.2 节给了一套完整的票价算法,从基础票价一路算到联合票价,这是整份文档里技术含量最高的部分。它的计算链条是:基础票价 → 软座/附加票价 → 附加费 → 联合票价。基础票价公式是“该区段里程数 × 该区段票价率 + 前各区段累计票价”,文档还给了个例子:里程 2375,(2375-1500)*0.035166+71.5042=102.27445。这个例子说明票价率是分段累进的,不是单一费率乘里程。
按这个逻辑,基础票价要按里程分段查费率表累加:
# 基础票价:分段累进,费率表按里程区间配置 RATE_TABLE = [ (0, 200, 0.05861), (200, 500, 0.05275), (500, 1000, 0.04688), (1000, 1500, 0.04103), (1500, 2500, 0.035166), # 文档示例落在这一段 ] def base_price(mileage): total = 0.0 for low, high, rate in RATE_TABLE: if mileage <= low: break seg = min(mileage, high) - low total += seg * rate return round(total, 5) # 联合票价 = (基本票价 + 附加票价) × (1 + 上浮率) + 附加费 def joint_price(mileage, seat_type, train_type, has_sleeper=False): base = base_price(mileage) # 附加票价按车种和席别取比例,比例来自需求书 3.2 节 ratio = {"普快": 0.20, "快速": 0.40, "空调": 0.25, "硬卧上": 1.10, "硬卧中": 1.20, "硬卧下": 1.30, "软卧上": 1.75, "软卧下": 1.95}.get(train_type, 0.0) extra = base * ratio # 附加费:客票发展金 + 候车室空调费 + 卧铺订票费 fee = 0.5 if base <= 5 else 1.0 if mileage > 200 and seat_type in ("硬座", "硬卧上", "硬卧中", "硬卧下"): fee += 1.0 if has_sleeper: fee += 10.0 return round((base + extra) * 1.0 + fee, 2)这段代码的关键说明:RATE_TABLE的费率是我按文档示例反推的近似值,实际落地时费率表应该做成数据库配置,因为票价率会调整,硬编码在代码里每次调价都要发版。ratio字典直接对应需求书列出的普快 20%、快速 40%、空调 25%、硬卧上中下 110%/120%/130%、软卧上下 175%/195%。fee部分对应客票发展金(5 元分界)、候车室空调费(超 200 公里硬席)、卧铺订票费(10 元)。注意文档提到保险费“于 2013 年元旦取消”,所以代码里不再计算 2% 保险费,这个历史包袱要主动丢掉,别照着老公式抄。
4.2 火车信息与线路查询
辅助模块 4.1 要求能查某列车的信息:车型、车厢数、每节车厢座位数、行驶方向、经过站点。这本质是车次主数据和线路站顺两张表的事:
-- 车次主数据:车型、编组信息 CREATE TABLE train_info ( train_no VARCHAR(10) PRIMARY KEY, train_type VARCHAR(10), -- 普快/快速/空调 car_count INT, -- 车厢数 direction VARCHAR(20) -- 行驶方向 ); -- 线路站顺:一条线路上的站点顺序与站间距离 CREATE TABLE route_station ( route_id INT, station_name VARCHAR(20), seq INT, -- 站序,从 1 开始 distance DECIMAL(8,2) -- 距起点累计里程 );route_station.distance存累计里程而不是站间距离,是为了票价计算方便——任意两站里程直接相减,不用循环累加。需求书 4.1.1 说“可以查看每两个站点之间的距离”,用累计里程相减就能得到,查询语句一条就够。这个设计选择在需求书里没写,但做过里程计费的人都会这么存。
4.3 个人信息管理的权限边界
4.2.1 节说用户可以查看账号、手机号,修改个人信息和登录密码。这里有个隐含的权限问题:窗口售票系统里,“用户”其实是售票员,不是旅客。所以个人信息管理改的是售票员自己的账号,不是旅客资料。实现上密码修改必须走旧密码校验加服务端哈希,不能明文存:
// 修改密码:旧密码校验 + 新密码哈希存储 public bool ChangePassword(string account, string oldPwd, string newPwd) { var user = _db.QuerySingle<User>("SELECT * FROM sys_user WHERE account=@a", new { a = account }); if (user == null) return false; // 旧密码用同样盐值哈希后比对,不存明文 if (Hash(oldPwd, user.Salt) != user.PasswordHash) return false; string newSalt = Guid.NewGuid().ToString("N"); _db.Execute("UPDATE sys_user SET password_hash=@h, salt=@s WHERE account=@a", new { h = Hash(newPwd, newSalt), s = newSalt, a = account }); return true; }参数说明:Salt每个用户独立,Hash用带盐的 SHA256 或 PBKDF2 都行,重点是别用 MD5 裸哈希。需求书没提加密方式,但售票系统涉及身份信息,这一层不能省。
5. 避坑与排查:需求书落地时最容易翻车的五处
5.1 余票扣减超卖
现象:两个窗口同时卖最后一张票,两边都下单成功,实际席位超了。原因:先查余票再扣减,查和扣之间没有锁。解决:扣减用UPDATE ... WHERE remain_count > 0条件更新,靠影响行数判断成败,别用“先 SELECT 再 UPDATE”的两段式。
5.2 改签次数校验被绕过
现象:同一张票被改签了两次。原因:改签次数只在客户端判断,或者服务端判断和更新不在一个事务里。解决:change_count的校验和自增放在同一个事务,更新时加WHERE change_count < 1条件,让数据库兜底。
5.3 退票费率边界算错
现象:开车前正好 48 小时退票,费率算成 10% 而不是 5%。原因:>=和>用混了。解决:按需求书“48 小时~15 天收 5%”的表述,48 小时整应落在 5% 档,判断顺序从大到小写,hours >= 48在前,天然覆盖边界。
5.4 票价前后端不一致
现象:客户端显示一个价,下单后订单金额是另一个价。原因:前端自己乘了系数,服务端又算了一遍,两边费率表不同步。解决:票价只在服务端算,客户端只做展示,下单时服务端重算并覆盖客户端传的金额。
5.5 中转查询生成无效组合
现象:中转方案里出现换乘时间只有几分钟甚至为负的组合。原因:两段查询独立执行,没做时间衔接过滤。解决:第二段发车时间必须晚于第一段到站时间加缓冲(我一般留 30 分钟),跨天换乘要把查询日期往后推。
6. 二次开发改造:把需求书变成可演进系统的几个手法
拿到这份需求书做二次开发,最忌讳的是把规则硬编码散落在各处。我的习惯是先做一层“规则配置化”,把最容易变的东西抽出来。退票费率四档、改签时间窗 48 小时、换乘缓冲 30 分钟,这些数字在需求书里是写死的,但业务上一定会调,所以第一件事是把它们挪到配置表:
CREATE TABLE biz_rule ( rule_key VARCHAR(50) PRIMARY KEY, rule_value VARCHAR(100), remark VARCHAR(200) ); -- 初始化:退票费率档位用 JSON 存,改规则不用改代码 INSERT INTO biz_rule VALUES ('refund_rate', '[{"minHours":360,"rate":0},{"minHours":48,"rate":0.05},{"minHours":24,"rate":0.10},{"minHours":0,"rate":0.20}]', '退票费率'), ('change_window_hours', '48', '窗口改签最小提前小时数'), ('transfer_buffer_min', '30', '中转换乘最小间隔');改造后的GetRefundRate从表里读 JSON 反序列化,运营调价时改数据就行。验证方法也简单:写一组边界用例——开车前 360 小时、48 小时、24 小时、1 小时各退一张,断言费率分别是 0、0.05、0.10、0.20,跑通就说明配置读取和分段逻辑都对。
第二个改造点是服务契约的版本兼容。WCF 加字段容易,改字段签名会让老客户端直接崩。我的做法是新增方法而不是改老方法,比如要支持“批量改签”,就加一个BatchChange契约,老的ChangeTicket保留不动。需求书里提到“同一订单中相同日期、车次、出发站、目的站、乘坐类型的车票方可批量改签”,这个约束在批量方法里做分组校验,组内不一致直接拒绝整批。
第三个点是打印。需求书说“生成车票,然后打印车票”,打印在 WinForm 里走PrintDocument,但票面模板最好做成可配置的,因为不同车站的票面格式有差异。我一般把票面字段顺序和字体存成模板文件,打印时读模板渲染,换车站只换模板不换代码。
最后说个我踩过的坑:这份需求书是 2015 年的,里面“保险费于 2013 年取消”这种历史说明如果不清理,新人照着实现会把保险费又加回去。从那以后我每次接手老需求文档,都强制先把所有带年份的规则条目过一遍,确认哪些已失效、哪些还有效,再动手写代码。希望这份拆解能帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取