简介这是一套基于C#开发的电动车租赁会员管理系统完整源码包面向计算机、人工智能、通信、自动化、电子信息等相关专业的在校学生、教师及初级开发者适用于毕业设计、课程设计、项目初期立项或实际工程借鉴。系统源自校园周边一家电动车租赁公司已完整实现登录、会员与非会员租还车、租还情况查询、操作日志查询、个人租借记录、充值查询、收入查询、会员报表、月收入报表、年收入报表、会员管理、非会员管理、分店管理、等级管理、规则管理、充值系统、交接班等业务模块并将费用计算与报表生成整合在一起功能结构接近真实商用场景。压缩包共三百八十个文件大小约五十二点四兆字节以一百七十六个C#源文件为核心附带界面资源文件、动态链接库与可执行文件、SQL数据库脚本以及设计说明书便于对照源码、还原数据库并查阅设计文档。已有二百五十六人学习下载其中会员管理、年/月收入报表、分店与等级规则管理等模块以及加密狗硬件加密设计对理解C#分层开发、数据库设计和真实业务逻辑很有帮助可直接作为二次开发基础。1. 电动车租赁会员系统的代码骨架C# 与 SQL 怎么咬合拿到这套基于 C# 的电动车租赁会员管理系统源码先别急着跑起来。它的价值不在界面多花哨而在数据库表设计与业务逻辑的咬合方式——会员余额扣减、车辆状态流转、租赁超时计费每一步都依赖 SQL 事务和 C# 代码的配合。作为毕业设计它覆盖了从需求分析、数据库建模到编码实现的完整链路作为工程样例它也能提醒你桌面应用最容易忽视的并发和一致性问题。适合正在做课程设计的学生也适合想快速搭一个带会员体系的租赁类管理系统的开发者。下面按数据库、数据访问、业务编码和排错四个层面拆开说。2. 数据库设计会员表、车辆表与租赁订单的事务链2.1 关系模型四张表和一条状态链这套系统的核心表一般分为四张会员表负责存基础信息和余额车辆表存车辆型号与状态租赁订单表记录一次租赁的起止时间、费用和关联的会员/车辆充值记录表则用来追溯余额变化。订单表是中心枢纽它同时引用会员表和车辆表会员表与充值记录表是主从关系所有金额变动都要在充值记录里留痕。实际操作中我一般不会物理删除会员或车辆记录而是在表里加 Status 字段做逻辑删除。原因是租赁订单需要外键关联历史数据一旦物理删除订单上的会员姓名和手机号就变成了悬空引用。如果不想牺牲查询性能可以把会员的删除标记字段建一个过滤索引只索引 Status 0 的行。表名作用关键字段示例Members会员档案与钱包MemberId, Name, Phone, Balance, StatusVehicles车辆信息与状态VehicleId, Model, HourlyRate, StatusRentalOrders租赁业务主表OrderId, MemberId, VehicleId, StartTime, EndTime, TotalFee, StatusRechargeLogs充值/消费流水LogId, MemberId, Amount, ChangeType, CreatedAt其中 RentalOrders 的 MemberId 和 VehicleId 建议建联合索引因为列表页经常按“某个会员的租赁中订单”过滤。Status 字段建议用 TINYINT1 表示租赁中2 表示已归还3 表示已取消避免用字符串导致排序和比较效率下降。2.2 建表脚本里的几个关键约束打开 SQL Server Management Studio直接执行下面的脚本。数据库名按实际调整这里用 ElectricBikeRental 做示例。建表顺序很重要先建主表再建外键表否则会因为引用不存在的表而报错。CREATE DATABASE ElectricBikeRental; GO USE ElectricBikeRental; GO CREATE TABLE dbo.Members ( MemberId INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Phone VARCHAR(20) NOT NULL UNIQUE, Balance DECIMAL(10,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 1, -- 1正常 0停用 CreatedAt DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE dbo.Vehicles ( VehicleId INT IDENTITY(1,1) PRIMARY KEY, Model NVARCHAR(50) NOT NULL, HourlyRate DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 1 -- 1可租 2已租 3维修 ); CREATE TABLE dbo.RentalOrders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, MemberId INT NOT NULL, VehicleId INT NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NULL, TotalFee DECIMAL(10,2) NULL, Status TINYINT NOT NULL DEFAULT 1, CreatedAt DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Rental_Member FOREIGN KEY (MemberId) REFERENCES dbo.Members(MemberId), CONSTRAINT FK_Rental_Vehicle FOREIGN KEY (VehicleId) REFERENCES dbo.Vehicles(VehicleId) ); CREATE INDEX IX_RentalOrders_Member ON dbo.RentalOrders(MemberId); CREATE INDEX IX_RentalOrders_Status ON dbo.RentalOrders(Status);这里有两个经常被忽视的细节。余额和费率必须用 DECIMAL 而不是 FLOATFLOAT 是近似存储累计多次充值后可能出现 0.1 元级别的误差。手机号用 VARCHAR 而不是 NVARCHAR因为手机号是 ASCII 字符用 VARCHAR 能节省一半空间会员姓名才用 NVARCHAR因为要兼容生僻汉字。外键写在建表语句里比在 C# 里手动检查更可靠。2.3 租车存储过程把三件事变成一个原子操作直接让 C# 代码分三次执行“查余额-插订单-改车辆状态”中间任何一个步骤断掉都会留下脏数据。更稳妥的做法是把业务规则写进存储过程用事务包住。租车时的最小操作是检查会员状态和余额、检查车辆是否可租、插入租赁订单、把车辆状态改为已租。下面这个存储过程把每一步都放在同一个事务中。CREATE PROCEDURE usp_RentVehicle MemberId INT, VehicleId INT, StartTime DATETIME AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; DECLARE Balance DECIMAL(10,2); DECLARE MemberStatus TINYINT; DECLARE VehicleStatus TINYINT; SELECT Balance Balance, MemberStatus Status FROM dbo.Members WHERE MemberId MemberId; SELECT VehicleStatus Status FROM dbo.Vehicles WHERE VehicleId VehicleId; IF MemberStatus 1 BEGIN RAISERROR(会员已停用, 16, 1); ROLLBACK; RETURN; END IF VehicleStatus 1 BEGIN RAISERROR(车辆不可出租, 16, 1); ROLLBACK; RETURN; END IF Balance 0 BEGIN RAISERROR(余额不足无法租车, 16, 1); ROLLBACK; RETURN; END INSERT INTO dbo.RentalOrders(MemberId, VehicleId, StartTime, Status) VALUES(MemberId, VehicleId, StartTime, 1); UPDATE dbo.Vehicles SET Status 2 WHERE VehicleId VehicleId; COMMIT TRANSACTION; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRANSACTION; THROW; END CATCH END GO注意这里 RAISERROR 后要先 ROLLBACK 再 RETURN否则事务会处于“有未提交修改”的状态。CATCH 块里用 TRANCOUNT 判断是否还有活动事务避免重复回滚。如果业务中有“预扣押金”的需求可以在租车事务里把余额的一部分冻结到一张 DepositLogs 表而不是直接扣减余额——这样还车时才能区分“消费扣款”和“押金解冻”。3. C# 三层架构与 SQLHelper 封装3.1 为什么把 UI、业务、数据访问拆开WinForms 界面上有会员管理、车辆管理、租赁开单、还车结算等多个窗体。如果每个窗体都直接拼 SQL同样的会员查询逻辑会在五六个地方重复出现改一个字段名就要全项目搜索替换。常见的做法是分三层UI 层只负责显示控件和收集输入BLL 层做业务校验和计算DAL 层只负责执行 SQL 或存储过程。Entity 层放实体类三层之间通过实体对象传递数据。层职责典型文件UI窗体、按钮事件、数据绑定MemberForm.cs, RentForm.csBLL业务规则、流程编排MemberService.cs, RentService.csDALADO.NET 执行 SQL、访问存储过程SqlHelper.cs, MemberDao.csEntity与表结构对应的模型类Member.cs, RentalOrder.cs这样做的好处是数据库从 SQL Server 换到 MySQL 时只需要替换 DAL 层和连接字符串UI 层完全不用动。对于毕业设计来说这种分层也能让你在答辩时讲清楚“高内聚低耦合”是怎么落地的。3.2 SQLHelper连接、查询、执行的统一入口连接字符串放在 App.config 的 connectionStrings 节点下DAL 层通过 ConfigurationManager 读取。下面是最精简的 SQLHelper包含 ExecuteQuery 返回 DataTable、ExecuteNonQuery 返回受影响行数、ExecuteScalar 返回单个值三个方法。它足够支撑管理系统的日常需求。public class SqlHelper { private static readonly string connStr ConfigurationManager.ConnectionStrings[RentalDB].ConnectionString; public static DataTable ExecuteQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) using (SqlDataAdapter da new SqlDataAdapter(cmd)) { if (paras ! null) cmd.Parameters.AddRange(paras); DataTable dt new DataTable(); da.Fill(dt); return dt; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (paras ! null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (paras ! null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteScalar(); } } }用 using 包裹 SqlConnection 和 SqlCommand作用域结束后连接会被自动释放不需要手写 Conn.Close()。参数数组里的 SqlParameter 对象必须与 SQL 中的 占位符一一对应这是防止 SQL 注入的关键。新人在写会员手机号查询时容易把参数直接拼进字符串一旦出现“明明有记录却查不到”的问题优先检查是不是拼接时多了空格或引号。3.3 实体类与参数化查询配合实体类字段建议与表字段一一对应但不需要暴露给 UI 层全部字段。比如会员实体里只放界面要用的属性余额、状态这类字段在 C# 侧用来做判断。public class Member { public int MemberId { get; set; } public string Name { get; set; } public string Phone { get; set; } public decimal Balance { get; set; } public int Status { get; set; } }DAL 层插入会员的写法如下public int Insert(Member m) { string sql INSERT INTO dbo.Members(Name, Phone, Balance, Status) VALUES(Name, Phone, Balance, Status); SELECT CAST(SCOPE_IDENTITY() AS INT);; SqlParameter[] paras { new SqlParameter(Name, SqlDbType.NVarChar, 50) { Value m.Name }, new SqlParameter(Phone, SqlDbType.VarChar, 20) { Value m.Phone }, new SqlParameter(Balance, SqlDbType.Decimal) { Value m.Balance }, new SqlParameter(Status, SqlDbType.TinyInt) { Value m.Status } }; return Convert.ToInt32(SqlHelper.ExecuteScalar(sql, paras)); }这里用 new SqlParameter 明确指定 SqlDbType比 AddWithValue 更安全。AddWithValue 会把 string 一律当作 NVARCHAR如果表列是 VARCHAReven 数据量小时没问题但在 WHERE 条件中会导致索引失效。SELECT CAST(SCOPE_IDENTITY() AS INT) 返回当前连接插入的自增 ID不需要再单独执行一次 SELECT MAX(OrderId)。4. 会员开卡、租车、还车计费的事务实践4.1 开卡与充值避免余额被并发覆盖开卡时先通过 Phone 做唯一校验。不要依赖先 SELECT 再 INSERT 的方式两个会话同时插入同号手机号时检查会被绕过。正确做法是把 Phone 的唯一约束写在表中然后在 C# 里捕获 SqlException判断异常编号 2627 表示主键冲突或唯一约束冲突给出“手机号已存在”的提示。充值时也不要先 SELECT Balance 再在 C# 里计算加法。把“余额加 x”写成 SQL 表达式让数据库在行锁保护下完成public void Recharge(int memberId, decimal amount) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlCommand cmd conn.CreateCommand(); cmd.Transaction conn.BeginTransaction(); cmd.CommandText UPDATE dbo.Members SET Balance Balance amount WHERE MemberId memberId; INSERT INTO dbo.RechargeLogs(MemberId, Amount, ChangeType) VALUES(memberId, amount, 1);; cmd.Parameters.AddWithValue(amount, amount); cmd.Parameters.AddWithValue(memberId, memberId); try { cmd.ExecuteNonQuery(); cmd.Transaction.Commit(); } catch { cmd.Transaction.Rollback(); throw; } } }充值金额必须为正数这个判断放在 BLL 层。如果将来要支持负数退款可以单独走退款流程避免和正负号混淆。日志表中的 ChangeType 用 1 表示充值、2 表示消费、3 表示退款这些值建议放在一个枚举里不要在业务代码里写魔法数字。4.2 租车调用存储过程并捕获业务错误租车界面选择会员和车辆后调用 usp_RentVehicle。存储过程里的 RAISERROR 会以 SqlException 形式抛到 C#可以通过异常编号做用户友好提示而不是直接展示“对象名无效”之类的底层错误。public int RentVehicle(int memberId, int vehicleId) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(usp_RentVehicle, conn)) { cmd.CommandType CommandType.StoredProcedure; cmd.Parameters.Add(new SqlParameter(MemberId, SqlDbType.Int) { Value memberId }); cmd.Parameters.Add(new SqlParameter(VehicleId, SqlDbType.Int) { Value vehicleId }); cmd.Parameters.Add(new SqlParameter(StartTime, SqlDbType.DateTime) { Value DateTime.Now }); conn.Open(); cmd.ExecuteNonQuery(); } object id SqlHelper.ExecuteScalar( SELECT MAX(OrderId) FROM dbo.RentalOrders WHERE MemberId mid, new SqlParameter(mid, memberId)); return Convert.ToInt32(id); }这里偷懒用了 MAX(OrderId) 获取最新订单号并发不高时可以接受。认真做的话应该在租车事务里 OUTPUT 出新订单号或者用 SCOPE_IDENTITY。订单号展示时可以拼成“R20250509-0001”这种格式但不要把日期字符串作为主键否则索引空间会变大。抽查订单时要按 OrderId 排序而不是按 CreatedAt 排序因为同一秒内可能插入多条订单。车辆状态在存储过程里已经更新为 2所以 C# 这边不需要再补一条 UPDATE。如果界面提示“租车成功”但车辆列表里还是“可租”刷新数据源时会发现 Status 没刷新这是 DataGridView 的缓存问题重新查询一下即可。4.3 还车计费分钟数换算与显式事务还车时根据 StartTime 计算实际用时时长。常见计费规则是“不足一小时按一小时计”比如用车 61 分钟按 2 小时收费。用 Math.Ceiling 对分钟数除以 60 向上取整。public decimal ReturnVehicle(int orderId) { string sql SELECT r.VehicleId, r.StartTime, r.MemberId, v.HourlyRate AS Rate FROM dbo.RentalOrders r INNER JOIN dbo.Vehicles v ON r.VehicleId v.VehicleId WHERE r.OrderId orderId AND r.Status 1; DataTable dt SqlHelper.ExecuteQuery(sql, new SqlParameter(orderId, orderId)); if (dt.Rows.Count 0) throw new Exception(订单不存在或已归还); DateTime start Convert.ToDateTime(dt.Rows[0][StartTime]); decimal rate Convert.ToDecimal(dt.Rows[0][Rate]); int memberId Convert.ToInt32(dt.Rows[0][MemberId]); int vehicleId Convert.ToInt32(dt.Rows[0][VehicleId]); int minutes (int)Math.Ceiling((DateTime.Now - start).TotalMinutes); int hours (int)Math.Ceiling(minutes / 60.0); decimal total hours * rate; using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlTransaction trans conn.BeginTransaction(); SqlCommand cmd conn.CreateCommand(); cmd.Transaction trans; cmd.CommandText UPDATE dbo.Members SET Balance Balance - fee WHERE MemberId memberId; UPDATE dbo.RentalOrders SET EndTime GETDATE(), TotalFee fee, Status 2 WHERE OrderId orderId; UPDATE dbo.Vehicles SET Status 1 WHERE VehicleId vehicleId;; cmd.Parameters.AddWithValue(fee, total); cmd.Parameters.AddWithValue(memberId, memberId); cmd.Parameters.AddWithValue(orderId, orderId); cmd.Parameters.AddWithValue(vehicleId, vehicleId); cmd.ExecuteNonQuery(); trans.Commit(); } return total; }三个 UPDATE 必须在一个事务里否则可能出现车还了但余额没扣、或者扣了钱但车仍处于已租状态。EndTime 使用数据库的 GETDATE()而不是 C# 的 DateTime.Now是为了避免客户端时间不准导致账单错误。如果系统支持会员折扣计算总费用时还需要把会员等级对应的折扣率传入这属于业务规则可以放在 BLL 层处理。5. 提升部署体验的进阶处理批量导入、慢查询与备份5.1 用 SqlBulkCopy 批量导入会员数据课程设计的演示数据往往只有几十条但试用时会遇到一次导入几百个 Excel 会员的情况。用循环 INSERT 速度慢且容易超时。SqlBulkCopy 能把 DataTable 直接写入目标表适合会员初始化场景。using (SqlBulkCopy bc new SqlBulkCopy(connStr)) { bc.DestinationTableName dbo.Members; bc.ColumnMappings.Add(Name, Name); bc.ColumnMappings.Add(Phone, Phone); bc.ColumnMappings.Add(Balance, Balance); bc.ColumnMappings.Add(Status, Status); bc.WriteToServer(memberTable); }导入前必须在 DataTable 里过滤重复手机号因为数据库有唯一约束任何一行重复都会让整个批处理回滚。处理办法是先按 Phone 分组保留每组第一条或者改用临时表导入后再用 MERGE 语句合并。5.2 用 Profiler 定位慢查询并加索引还车计费里涉及三个表 JOIN数据量超过十万条后如果缺少索引耗时可能从几十毫秒涨到几秒。用 SQL Server Profiler 新建跟踪筛选 Duration 大于 1000 毫秒的语句执行计划里如果出现 Table Scan优先在 WHERE 和 JOIN 列上补索引。比如 RentalOrders 表的 MemberId 和 Status 上已经建了索引但车辆表的 HourlyRate 被频繁读取可以不用管。Profile 输出的死锁图也要看一眼出现死锁时通常是两个事务以不同顺序更新 Members 和 Vehicles统一更新顺序可以解决。5.3 给数据库加一个自动备份作业答辩或上线前最怕数据库文件损坏。SQL Server 的维护计划里可以创建“备份数据库”任务但手写 T-SQL 更直观。下面这条语句把数据库备份到 D 盘BACKUP DATABASE ElectricBikeRental TO DISK ND:\Backup\Rental_20250509.bak WITH INIT, COMPRESSION;INIT 覆盖同名文件COMPRESSION 启用压缩能减少近一半空间。建议保留最近 7 天备份配合 Windows 任务计划用 PowerShell 删除 7 天前的 .bak 文件。测试备份恢复时把文件复制到另一台机器执行 RESTORE DATABASE _ FROM DISK确认数据库处于“已还原”状态而不是“正在还原”否则说明备份文件不完整。本文还有配套的精品资源点击获取
