资讯详情 C# WinForm火车订票系统:并发扣减与事务防超卖实战
📅 2026/11/2 11:38:46
简介「C#火车订票系统」是一套面向编程初学者与课程设计需求者的窗体应用实战源码基于C#与SQL Server构建围绕用户注册登录、车次增删改查、订票退票改签等模块展开可用于学习WinForms界面布局、事件驱动编程与数据库交互的完整流程。压缩包共128个文件、约1.85MB以50个cs源码文件为核心配合24个resx与24个resources资源文件承载窗体控件与图标资源另有sln解决方案、csproj工程配置、manifest清单以及少量jpg界面截图与编译产物可通过sln在Visual Studio中打开并还原项目结构。目前已有1437人学习下载。读者能从中获取数据库表设计与SQL语句写法、窗体间跳转与数据传递、事务处理与并发控制、异常捕获与输入校验等具体实现也可对照管理员与普通用户两种角色的权限划分理解中型桌面项目的组织方式适合作为课程设计或毕业设计的参考骨架。1. 从“同一张票卖给了两个人”说起第一版 C# 火车订票系统交付演示时两位同学同时点了最后一张二等座两边都弹出“订票成功”。界面做得再漂亮也没用一个连座位都算不准的订票系统业务上等于没有。这套基于 C# SQL Server 的火车订票系统瞄准的就是这类必须能被追问、被现场验证的交付车次查询、余票、选座下单、退票改签、后台维护一套跑在普通笔记本上、双击就能起来的完整系统。教材里的示例通常把“判断余票”和“扣减余票”拆成两行代码线下没人抢线上立刻出问题。这套方案把并发扣减当作核心设计点来讲围绕它铺开表结构、事务边界、状态流转和验收脚本。适合两类人要交一份能运行、讲得清、经得起追问的完整系统的学生以及想用熟悉桌面技术栈把订单类业务主干走一遍的从业者。下面按需求、架构、实现、避坑、验收的顺序推下去每一步都能直接抄。2. 需求拆解与技术选型WinForm ADO.NET SQL Server 的落地边界动手建表之前先把“谁在用、用什么、数据活多久”三件事定死。很多课程设计写到一半返工原因往往是角色边界没划清管理员窗体里顺手写了旅客的查询逻辑最后权限校验无处可放数据一旦脱离窗口后续改动成本高。2.1 三类角色与功能边界旅客侧包含注册登录、车次查询、余票与票价展示、选择乘客、提交订单、订单列表、退票与改签。售票窗口侧多一层“代售”可以替没有账号的乘客下单可以手工改签可以打印凭条。管理员侧管车次开行计划、经停站、席别与票价、用户状态、按日期统计售票量与收入。关键在于权限落在哪一层。常见做法是把按钮 Visible 设成 false 当权限改个配置文件或换个账号就能绕过去。我一般的处理是窗体只负责“显示不显示”真正的判断写在业务层的CheckPermission(userId, action)越权调用直接抛业务异常。验收时专门留一条“低权限账号直接调服务”的用例这条能顶住答辩里最难回答的那个问题。乘客信息单独建表一个账号可以挂多个乘车人下单时选人而不是重复填身份证。这样改签、退票、历史订单都能挂到同一个乘客身上统计也方便。2.2 为什么是 WinForm 加 ADO.NET而不是 Web 那一套选型不看流行度看交付成本。这类系统的使用场景是单机或局域网、十几个人同时操作、部署环境不可控。WinForm 编译出一个 exe装个 .NET 运行时就能跑不需要 IIS、不需要前后端分离、不需要处理跨域和会话保持SQL Server Express 或 LocalDB 随装随用事务和锁的语义清晰答辩时能指着代码讲清“这行 SQL 在做什么”。数据访问层不引 ORM直接 ADO.NET。理由很实际一是SqlCommand加参数的三行代码就能说清一次数据库交互讲得明白二是事务对象SqlTransaction能一路显式传下去不会被框架封成黑匣子三是出了问题能立刻定位到具体语句。ORM 在这个体量上省不下多少代码反而多一层需要解释的东西。代价也要说在前面。WinForm 的并发上限就是几十个人界面观感朴素ADO.NET 手写 SQL 意味着字段一改就要同步改语句。所以表结构要一次设计到位SQL 全部集中在 DAL 层改的时候只有一个地方动。查询结果的承载方式也要选列表展示用DataTable绑DataGridView省事单条实体用SqlDataReader逐字段读成对象更可控。我的习惯是列表页用 DataTable业务流转下单、退票一律走实体避免在 DataRow 里做业务判断。2.3 数据库先行六张核心表与约束表设计的原则是“能用约束挡住的绝不留给代码判断”。余票不能为负、订单号不能重复、同一车次同一天只能有一份开行计划这些都交给数据库。表名作用关键约束Users账号、密码哈希、角色用户名唯一角色取值受限Passenger乘车人挂在账号下身份证号 账号唯一TrainSchedule某车次某一天的开行计划车次号 日期唯一TrainStop经停站与到发时刻、站序车次 站序唯一SeatInventory区间 席别的库存与票价余票 0 ≤ Remaining ≤ TotalTicketOrder订单主体与状态订单号唯一状态枚举受限核心三张表的建法如下注释里写了每行存在的理由。-- 开行计划一行代表某车次在某一天真正开行而不是车次模板 CREATE TABLE TrainSchedule ( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, TrainNo VARCHAR(10) NOT NULL, -- 车次号如 G1024 RunDate DATE NOT NULL, -- 开行日期跨天车次按始发日算 StartStation VARCHAR(30) NOT NULL, EndStation VARCHAR(30) NOT NULL, DepartTime TIME NOT NULL, ArriveTime TIME NOT NULL, CONSTRAINT UQ_Train_Run UNIQUE (TrainNo, RunDate) ); -- 区间库存把上车站-下车站作为一个可售单元单独记一行 CREATE TABLE SeatInventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, ScheduleId INT NOT NULL, FromStation VARCHAR(30) NOT NULL, ToStation VARCHAR(30) NOT NULL, SeatType VARCHAR(10) NOT NULL, -- 商务 / 一等 / 二等 / 硬卧 Price DECIMAL(10,2) NOT NULL, -- 金额一律 decimal禁止 float Total INT NOT NULL, Remaining INT NOT NULL, CONSTRAINT FK_Inv_Schedule FOREIGN KEY (ScheduleId) REFERENCES TrainSchedule(ScheduleId), CONSTRAINT CK_Inv_Remaining CHECK (Remaining 0 AND Remaining Total), CONSTRAINT UQ_Inv UNIQUE (ScheduleId, FromStation, ToStation, SeatType) ); -- 订单状态用 tinyint 存业务含义由代码里的枚举解释 CREATE TABLE TicketOrder ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo CHAR(18) NOT NULL, -- 对外展示的单号与自增主键解耦 UserId INT NOT NULL, ScheduleId INT NOT NULL, FromStation VARCHAR(30) NOT NULL, ToStation VARCHAR(30) NOT NULL, SeatType VARCHAR(10) NOT NULL, SeatNo VARCHAR(6) NULL, -- 出票后才回填 PassengerName NVARCHAR(20) NOT NULL, IdCardNo CHAR(18) NOT NULL, Amount DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_Order_No UNIQUE (OrderNo), CONSTRAINT FK_Order_Schedule FOREIGN KEY (ScheduleId) REFERENCES TrainSchedule(ScheduleId) );两个细节值得单独说。金额用DECIMAL(10,2)而不是FLOAT票价加手续费一乘一除浮点误差迟早会在对账时冒出来。时间用DATETIME2而不是DATETIME后者精度只有 3 毫秒下单时间密集时排序会乱。订单号单独一列而不直接用自增主键是为了让对外单号可以带日期前缀和校验位也不暴露业务量。3. 总体架构设计三层结构与六个模块的职责划分单窗体里堆 SQL 的写法能跑通功能但改一处崩三处。这套方案按表现层、业务逻辑层、数据访问层切开配上实体模型一共六个模块每个模块只对下一层开口。3.1 分层与目录组织目录结构直接对应分层看文件夹就知道代码该往哪放。TicketSystem.sln ├── TicketSystem.Model 实体类无任何依赖 ├── TicketSystem.DAL SqlHelper 各表仓储只写 SQL ├── TicketSystem.BLL 查询/订单/退改三个服务事务在这里开 ├── TicketSystem.UI WinForm 窗体只做输入校验和展示 └── TicketSystem.Common 配置、日志、单号生成、密码哈希依赖方向单向UI 依赖 BLLBLL 依赖 DAL 和 ModelModel 不依赖任何项目。这条规则看起来教条实际作用是防止“在窗体里写一句 SQL 图方便”一旦破例后面所有 SQL 都会往窗体里跑。UI 层只做三件事收集输入并做格式校验身份证位数、日期不能早于今天、调服务、把结果渲染出来。BLL 层不引用任何System.Windows.Forms的类型这样业务逻辑能脱离界面单独测试答辩时可以直接写个控制台程序跑一遍下单流程这是加分项。3.2 业务层的三个服务查询、订单、退改服务划分按“业务动作”切不按表切。TrainQueryService负责车次检索、经停站、余票展示、票价计算全部只读不开事务。OrderService负责下单、支付回调、超时取消是唯一开启写事务的地方。TicketService负责退票、改签、出票同样开事务但和下单的区别在于它要先读原订单再决定扣多少、退多少。事务的开启权集中在 BLLDAL 只提供“接收一个已存在的事务对象并执行”的能力。这样事务边界一目了然谁开谁负责提交和回滚不会出现 DAL 偷偷提交导致外层回滚失效的情况。这是三层架构里最容易做错的一处。3.3 数据访问层连接、参数化与事务传递所有数据库交互只走一个SqlHelper三件事必须在里面做掉参数化、超时控制、连接释放。// 所有 SQL 一律走参数字符串拼接在评审时直接打回 public static int ExecuteNonQuery(SqlConnection conn, SqlTransaction tran, string sql, params SqlParameter[] ps) { using (var cmd new SqlCommand(sql, conn, tran)) { cmd.CommandTimeout 15; // 默认 30 秒桌面端用户等不了 if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); return cmd.ExecuteNonQuery(); // 返回受影响行数业务层靠它判成败 } } // 标量查询用于取票价、取余票、判断存在性 public static T ExecuteScalarT(SqlConnection conn, SqlTransaction tran, string sql, params SqlParameter[] ps) { using (var cmd new SqlCommand(sql, conn, tran)) { if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); var raw cmd.ExecuteScalar(); return raw null || raw DBNull.Value ? default(T) : (T)Convert.ChangeType(raw, typeof(T)); } }params SqlParameter[]这个签名让调用方没法偷懒拼字符串想传值就得 new 一个参数。ExecuteNonQuery返回受影响行数是整套并发控制的基石——后面扣余票就是靠它判断“到底扣没扣到”。连接管理用using包住SqlConnection实际关闭时连接回到池里不是真的断 TCP。连接字符串放App.config的connectionStrings节点开Poolingtrue默认池大小 100 对这套系统绰绰有余。常见错误是忘了using异常路径上连接不归还跑一天之后报“超时时间已到池中连接已用尽”这个坑在第 5 章展开。4. 核心模块实现余票扣减、订单生成与退改状态机功能列表里最不起眼的是“余票 -1”最难的也是它。这一章把查询口径、下单事务、退改流转三块拆开写每块都给能直接跑的语句和代码。4.1 车次查询与余票的实时口径查询的输入是出发站、到达站、日期输出是车次、时刻、席别、票价、余票。余票不现算直接读库存表——现算意味着每次查询都要遍历全部订单做聚合几十万行订单下页面会卡死。-- 一次查询把车次和该区间的库存带出来只显示有余票的席别 SELECT s.ScheduleId, s.TrainNo, s.DepartTime, s.ArriveTime, i.SeatType, i.Price, i.Remaining FROM TrainSchedule s JOIN SeatInventory i ON i.ScheduleId s.ScheduleId WHERE s.RunDate date AND i.FromStation from AND i.ToStation to AND i.Remaining 0 ORDER BY s.DepartTime, i.Price;参数三个都是SqlParameter日期用SqlDbType.Date避免DateTime带时分秒导致等值匹配失败——这是查询查不出数据最常见的原因比索引问题更常见。区间库存的建模方式决定了这套 SQL 好不好写。这里采用“上车站-下车站直接建行”某车次某天A 到 B、A 到 C、B 到 C 各占一行库存。好处是扣减简单卖 A 到 B 只动 A-B 那一行区间之间不会串坏处是车次经停站一改库存行要重算。提示答辩如果被问到“为什么不用全程一张库存表”答案是区间库存能让短途和长途互不干扰进阶做法是分段库存取区间最小值能进一步提高上座率但扣减逻辑要复杂一档可以作为加分项讲。索引按查询条件建。UQ_Inv那个唯一约束本身就是一个索引如果发现查询慢把它改造成带INCLUDE的唯一索引让余票和票价直接从索引页取不回表。-- 覆盖索引查询只碰索引页避免回表 CREATE UNIQUE INDEX UQ_Inv_Covering ON SeatInventory(ScheduleId, FromStation, ToStation, SeatType) INCLUDE (Remaining, Price);4.2 下单事务锁座、扣减、写订单的原子性下单要保证三件事同时成立余票扣掉了、订单写进去了、钱数算对了。任何一件没做成整体回滚。这里不用“先查余票再更新”的两步走两步之间就是别人插队的窗口。// 整段逻辑只在一个事务里完成任何一步失败整体回滚 public OrderResult CreateOrder(int userId, int scheduleId, string from, string to, string seatType, PassengerInfo p) { using (var conn new SqlConnection(DbConfig.ConnectionString)) { conn.Open(); using (var tran conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 1. 条件扣减把够不够和扣掉压成一条语句交给数据库原子判断 const string deductSql UPDATE SeatInventory SET Remaining Remaining - 1 WHERE ScheduleId sid AND FromStation from AND ToStation to AND SeatType type AND Remaining 1;; // 余票不足时这条语句影响行数为 0 int affected SqlHelper.ExecuteNonQuery(conn, tran, deductSql, new SqlParameter(sid, scheduleId), new SqlParameter(from, from), new SqlParameter(to, to), new SqlParameter(type, seatType)); if (affected 0) { SafeRollback(tran); // 扣不到就撤别留着占锁 return OrderResult.Fail(该席别余票不足请换一趟车或换个席别); } // 2. 票价从库存行取绝不信任界面传上来的值 decimal price SqlHelper.ExecuteScalardecimal(conn, tran, SELECT Price FROM SeatInventory WHERE ScheduleIdsid AND FromStationfrom AND ToStationto AND SeatTypetype, new SqlParameter(sid, scheduleId), new SqlParameter(from, from), new SqlParameter(to, to), new SqlParameter(type, seatType)); // 3. 写订单初始状态 0 待支付 string orderNo OrderNoGenerator.Next(userId); SqlHelper.ExecuteNonQuery(conn, tran, InsertOrderSql, new SqlParameter(no, orderNo), new SqlParameter(uid, userId), new SqlParameter(sid, scheduleId), new SqlParameter(from, from), new SqlParameter(to, to), new SqlParameter(type, seatType), new SqlParameter(name, p.Name), new SqlParameter(idcard, p.IdCardNo), new SqlParameter(amount, price)); tran.Commit(); return OrderResult.Ok(orderNo); } catch (SqlException ex) when (ex.Number 1205) // 被选为死锁牺牲者 { SafeRollback(tran); return OrderResult.Fail(当前订票人数较多请重试); } } } }几个参数和设计点必须解释清楚。Remaining 1写在 UPDATE 的 WHERE 里是整套并发控制的灵魂。SQL Server 执行这条语句时会对目标行加排他锁两个事务同时进来后到的那个要么等前一个提交后重新判断此时 Remaining 已经变成 0影响行数为 0要么直接被阻塞。无论哪种都不会出现负库存。把判断放在 C# 里做if (remaining 0)再 UPDATE等于把锁交给了应用层而应用层根本没有锁。SafeRollback是个小包装先判断tran.Connection ! null再回滚。因为在affected 0分支里已经回滚过一次如果后面抛异常进入 catch再回滚会抛“该事务已完成”。这个细节不写测试环境偶尔崩一次很难复现。死锁错误号 1205要单独接住。两个人交叉下单同一批车次SQL Server 会挑一个成本低的当牺牲品回滚这不是 bug是正常现象。捕获后返回“请重试”或者干脆自动重试一次比抛一个红叉给用户看强得多。金额从数据库读不从界面读。前端传价是经典翻车点抓个包改个数就能一分钱买商务座课程设计里也得防。4.3 退票与改签的状态流转订单状态不能随便改用枚举加白名单控制任何不在表里的转移一律拒绝。public enum OrderStatus : byte { PendingPay 0, // 已占座未付款 Paid 1, // 已付款 Issued 2, // 已出票有座位号 Refunded 3, // 已退票库存已回补 Changed 4, // 已改签原单作废 Cancelled 5 // 超时未付系统自动取消 }允许的转移只有这几条0→1付款、1→2出票、0/1/2→3退票、0/1/2→4改签、0→5超时取消。退票时先校验状态在允许集合里再用一条 UPDATE 改状态成功后把Remaining 1回补到对应库存行两步在同一个事务里。手续费按开车前时长分档规则写在配置里而不是硬编码开车前 8 天以上退票免手续费48 小时以上收 5%24 小时以上收 10%24 小时以内收 20%。退款金额用DECIMAL计算后四舍五入到分和订单金额用同一种类型避免出现 0.9999999 的退款。改签有两种实现一是“退旧开新”旧单置为Changed、回补库存再走一遍下单流程开新单二是原地改签到另一车次保留原单号只改ScheduleId。第一种实现简单、账目清楚代价是新旧单号不同用户会看到两条记录。课程设计用第一种就够第二种要额外维护改签关系表。超时取消靠一个定时器或一个后台线程每 30 秒扫一次Status 0 AND CreateTime DATEADD(MINUTE, -15, SYSDATETIME())的订单批量置为 5 并回补库存。这条 SQL 要放在数据库端执行不要拉回内存里循环。5. 避坑与排查五个能让演示当场冷场的坑下面这五条都是真实会遇到的。现象写清楚原因说到根上解决办法给到能直接改的程度。5.1 余票超卖两个人同时下单两边都提示成功现象演示抢最后一张票两个客户端同时点提交两条订单都生成库存表里Remaining变成 -1或者变成 0 但订单有两条。原因先SELECT Remaining判断大于 0再UPDATE Remaining Remaining - 1。两步之间没有任何锁两个事务都读到 1都判断通过都执行扣减。默认的READ COMMITTED隔离级别下读操作加的是共享锁读完立刻释放管不住后面的写。解决把判断塞进 UPDATE 的 WHERE 条件里用ExecuteNonQuery的返回值当结论返回 0 就是没抢到。这是最省事也最可靠的做法。另一种是显式加锁SELECT ... WITH (UPDLOCK, ROWLOCK)先占住行再判断但要注意加锁顺序一致否则死锁概率上升。数据库端的CHECK (Remaining 0)是最后一道防线它会让超卖直接抛异常而不是默默写脏数据测试阶段千万别为了“跑通”把它删掉。5.2 连接池耗尽跑了一上午突然全部超时现象刚启动时一切正常连续操作几十次之后所有数据库操作都报“超时时间已到但在从池中获取连接之前已经超时”重启程序又好了。原因某条路径上SqlConnection没有释放。最典型的是在try里conn.Open()catch里只写了日志没Dispose或者把连接存成窗体级的字段反复复用异常时没关。连接池默认上限 100泄漏一百次就卡死。解决连接一律using包住生命周期压到单个方法内绝不跨方法传递。连接是稀缺资源不是状态容器。排查时可以在sys.dm_exec_sessions里按program_name看当前会话数涨上去不回落就是泄漏。另外注意SqlDataReader也要关CommandBehavior.CloseConnection用错了同样漏连接。5.3 日期和金额的精度查不到车、退款差一分现象一明明有当天的车次按日期查就是查不出来。现象二退票金额和订单金额差 0.01对账永远对不平。原因日期字段用了DATETIME或DATETIME2存“开行日期”插入时带了时分秒查询时传2024-06-01被解析成2024-06-01 00:00:00等值匹配自然失败。金额用FLOAT或REAL二进制浮点表示不了 0.1累加几次就开始飘。解决纯日期一律用DATE类型参数化时显式指定SqlDbType.Date确实要按天查用 date AND DATEADD(DAY, 1, date)这种半开区间别用CONVERT包字段包了索引就废。金额一律DECIMAL(10,2)C# 侧用decimal而不是doubleMath.Round(x, 2, MidpointRounding.AwayFromZero)明确舍入方式别依赖默认的银行家舍入。5.4 区间库存串号短途卖光了长途还能买现象A 到 C 的票显示还有 10 张但 A 到 B 已经卖出 30 张实际上这趟车中途根本挤不上去。原因库存只按车次建了一行所有区间共用同一个余票数字短途和长途互相不知道对方卖了多少。解决按“上车站-下车站”建独立库存行卖哪个区间扣哪一行区间之间物理隔离。代价是同一趟车会被拆成多行车次经停站调整时要重算库存。再进一步是分段库存A-B、B-C 各一行卖 A-C 时同时扣 A-B 和 B-C余票取区间最小值。这套逻辑复杂一档答辩时当作扩展方案讲比硬套在代码里更划算。5.5 界面假死点下查询窗口白屏五秒现象数据量一大点查询窗口就不响应任务管理器显示“未响应”几秒后才刷新出来。原因查询和数据绑定全在 UI 线程上跑同步阻塞。winform 的界面刷新也在这条线程上被数据库 IO 占住了自然画不出来。解决耗时操作放Task.Run里跑回 UI 用Invoke或await回到原上下文注意别在子线程里直接碰控件跨线程访问控件会抛异常。同时给按钮加禁用状态防止用户连点五次生成五条订单——连点造成的重复下单比并发超卖更常见。查询加TOP 200或分页别一次性把几万行绑到DataGridView虚拟模式该开就开。6. 验收与答辩用一套脚本证明系统真的能跑功能都写完不等于能交。验收要做的是“证明”不是“演示一遍顺利路径”。我的做法是准备一份固定脚本答辩前自己连跑三遍每遍都从干净数据库开始。先把造数据的 SQL 存成文件一条命令灌进去车次、经停站、库存、测试账号全部就位避免现场手忙脚乱地建数据。-- 造 7 天、每天 6 趟车的基础数据席别按二等/一等各建一行 DECLARE d INT 0; WHILE d 7 BEGIN INSERT INTO TrainSchedule (TrainNo, RunDate, StartStation, EndStation, DepartTime, ArriveTime) VALUES (G1024, DATEADD(DAY, d, CAST(GETDATE() AS DATE)), 始发站A, 终点站D, 08:00, 12:30); SET d d 1; END; -- 库存按区间各建一行余票初始给 5 张方便演示抢最后一张验收表按“操作 → 预期”写重点放在失败路径上。验收项操作预期结果余票扣减下单成功后刷新列表余票减 1订单状态为待支付并发抢票两个客户端同时抢最后一张一边成功一边提示余票不足库存不为负超时取消下单后等 15 分钟不付款订单转已取消余票回补 1退票回补对已出票订单退票状态转已退票库存 1退款金额含手续费改签已付款订单改到另一车次原单转已改签新单生成两边库存正确权限越界用普通账号调管理端服务抛出无权限异常不返回数据金额精度连续退 10 张票后核对总额分位无误差断库恢复中途停掉 SQL Server 服务提示友好错误恢复后可继续操作演示顺序有个技巧先跑失败用例再跑成功用例。开场就用两个客户端抢最后一张票把并发控制当场演出来评委的注意力立刻落到设计上接着演示超时取消和退票回补把事务边界讲清楚最后才走一遍完整下单流程。反过来先演示顺利下单等讲并发的时候大家已经走神了。答辩被问到“这套东西和别人的有什么区别”别背功能列表直接打开OrderService.CreateOrder那一段指着AND Remaining 1说清楚为什么判断要放在 SQL 里再说一句“数据库端的 CHECK 是兜底测试期我故意不删它”。这一分钟的含金量高于十个界面截图。从那以后我每次交这类系统都会先把“只剩一张票”的场景写成固定脚本答辩前自己连点三遍确认库存表里那个数字从来没变成过负数。希望帮到你。本文还有配套的精品资源点击获取