ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASP.NET+SQLServer美妆商城实战:从数据库设计到订单事务处理

ASP.NET+SQLServer美妆商城实战:从数据库设计到订单事务处理 差不多每个接触过.NET开发的人都接过这类“xx网站设计与实现”的课题。今天我想认真聊聊用 ASP.NET SQLServer 做美妆网站这件事。这个组合在当下的技术圈里算是老资历了但放在美妆商城这个场景里其实特别合适——商品多、图片多、规格型号复杂、订单流程长SQL Server 处理这种关系型数据非常稳ASP.NET 做后台管理页面也很顺手。这篇内容是我自己完整做完一个美妆商城后的复盘从数据库设计到核心功能实现再到运行阶段踩过的坑都会详细写出来。适合正在做毕业设计的学生、准备转行的初级开发者以及任何想用这套技术栈快速搭出一个可用商城系统的朋友。先说个总体的感受这种项目能不能做好七成取决于前期设计三成才看编码。所谓“设计”不是说你画出几张页面原型就完了而是要想清楚每个功能背后数据怎么流转、页面怎么和数据交互、哪些逻辑该放在数据库层、哪些该放在业务层。我见过太多同学上来就写代码写到购物车才发现订单表设计不合理最后要么硬撑要么重构非常痛苦。所以这篇文章我希望你耐心一点跟着我把它当成一个工程来做而不是一个课程作业来应付。1. 项目整体设计与技术选型思路1.1 为什么是ASP.NETSQLServer这套组合先说为什么会选 ASP.NET 而不是别的。美妆网站这类项目本质上是典型的业务管理系统加内容展示系统核心就是增删改查、用户登录、订单状态流转。ASP.NET Web Forms 用控件驱动的模式页面逻辑和 HTML 混在一起写上手门槛低特别适合生命周期短、需要快速交付的管理后台。虽然现在 .NET 已经发展到了 ASP.NET Core但传统的 ASP.NET 在现有的企业系统和课程设计里依然大量存在尤其很多教材和题库还在用这套所以掌握它并不是在做无用功。再来说 SQL Server。美妆商品有一个特点属性维度多。色号、容量、适用肤质、功效、品牌这些东西如果用文件型数据库或者内存型存储去管理查询起来会很别扭。SQL Server 是典型的关系型数据库对事务的支持非常成熟订单和库存这种东西必须要强一致性不能出现付款了库存没扣的情况。另外SQL Server 的图形化管理工具做数据维护、写存储过程、看执行计划都很方便对新手特别友好。比如你要查用户订单数据直接用 SQL 写几条查询语句就能定位问题比在代码里打一堆日志要快得多。还有一个实际考虑是部署环境。很多学校机房、小型企业的 Windows 服务器上装 SQL Server 和 IIS 是标配ASP.NET 应用直接发布过去就能跑。这一点在项目验收或者真实上线的时候非常省心不用像某些技术栈那样折腾一堆环境配置。1.2 系统模块划分与核心场景梳理这个美妆网站从功能上拆可以分成前台和后台两个大端。前台面向普通消费者核心链路是“注册登录 - 浏览商品 - 搜索筛选 - 加入购物车 - 下单 - 模拟支付 - 查看订单”。后台面向管理员核心链路是“商品上架 - 库存管理 - 订单处理 - 用户管理 - 数据统计”。我最终落地的功能模块大致如下端别功能模块核心说明前台用户账户注册、登录、个人信息维护、密码修改前台商品展示分类浏览、品牌筛选、商品搜索、商品详情前台购物车加入、修改数量、删除、批量结算前台订单中心提交订单、模拟支付、查看订单状态、确认收货后台商品管理商品信息的增删改查、图片上传、上下架、SKU管理后台分类管理商品分类的树形管理支持多级分类后台订单管理查看订单、修改状态、发货操作、取消订单后台用户管理用户列表、角色权限控制、账号状态管理这种模块拆分是参考了主流电商系统的思路既保证功能完整度又不会让开发量失控。如果你在做类似项目我建议把范围控制在“核心链路完整、边缘功能从简”的粒度上比如优惠券、积分这类锦上添花的功能可以放到二期再做别一开始就背上大包袱。1.3 页面布局与用户体验设计要点美妆网站和一般的后台系统不一样它对视觉设计的容忍度很低。同样是商品列表页配色乱一点、图片比例不统一用户马上就走。所以我在设计页面时没有把全部精力放在代码上而是先用母版页MasterPage把整体框架卡死前台一个母版页后台一个母版页公共的导航栏、页脚、样式资源都放在母版页里。前台首页的重点是“第一屏”。我用了一个 Bootstrap 响应式轮播图放主推商品下面接商品分类导航和热销榜单。商品列表页和详情页则用了模板化的 Repeater 控件输出数据因为美妆商品图片最重要所有卡片都采用统一的图文比例并做了图片懒加载。后台界面反而简单用一个多说一点的上中下布局左侧菜单放功能模块右侧内容区展示数据表格和表单重要的是操作效率而不是美观度。这一块的经验是美妆站的页面重心一定不要放在特效上而是放在信息层级和信息密度上。用户进来是为了看商品、比较商品导航清晰、分类明确、图片清楚比任何花哨的动画都重要。这也是为什么这类项目完全不需要引入复杂的前端框架服务端渲染加简单的 jQuery 和 CSS 就足够了。2. 数据库设计与核心难点2.1 核心表结构设计数据库设计是这类项目的灵魂表结构如果设计得合理后面写代码会一路顺畅设计得不行后面就是无穷无尽的补丁。我设计的核心表包括用户表 Users、分类表 Category、商品表 Product、购物车表 Cart、订单表 Orders、订单明细表 OrderDetail以及一张用于商品规格属性的 ProductSKU 表。以用户表和商品表为例看一下设计的思路CREATE TABLE [dbo].[Users]( [Id] INT IDENTITY(1,1) PRIMARY KEY, [UserName] NVARCHAR(50) NOT NULL, [PasswordHash] NVARCHAR(200) NOT NULL, [Salt] NVARCHAR(50) NOT NULL, [Email] NVARCHAR(100) NULL, [Phone] NVARCHAR(20) NULL, [Avatar] NVARCHAR(200) NULL, [CreateTime] DATETIME NOT NULL DEFAULT(GETDATE()), [IsLocked] BIT NOT NULL DEFAULT(0) ) CREATE TABLE [dbo].[Product]( [Id] INT IDENTITY(1,1) PRIMARY KEY, [CategoryId] INT NOT NULL, [ProductName] NVARCHAR(100) NOT NULL, [SubTitle] NVARCHAR(200) NULL, [MainImage] NVARCHAR(200) NOT NULL, [Detail] NTEXT NULL, [Price] DECIMAL(10,2) NOT NULL, [Stock] INT NOT NULL DEFAULT(0), [SalesCount] INT NOT NULL DEFAULT(0), [Status] INT NOT NULL DEFAULT(1), [CreateTime] DATETIME NOT NULL DEFAULT(GETDATE()) )这里有几个容易踩坑的点。第一个是字符串类型一定用 NVARCHAR 而不是 VARCHAR因为 SQL Server 里 NVARCHAR 能正确处理 Unicode 字符避免中文乱码问题。第二个是价格字段要使用 DECIMAL(10,2)不能用 FLOATFLOAT 是浮点数做金额运算时会丢失精度这在订单总价计算中是致命的。第三个是每个表都加上 CreateTime 默认值这样的好处是插入数据时不需要显式赋值同时后面做数据统计时有时间维度可用。外键关系方面Product 表的 CategoryId 关联 Category 表OrderDetail 表的 OrderId 关联 Orders 表、ProductId 关联 Product 表。我建议外键约束一定要建但不要建太多级联删除防止误删数据。比如删除一个商品分类时应该由业务层来判断分类下是否还有商品而不是靠数据库级联去硬删。2.2 商品SKU与图片存储方案美妆商品有一个很典型的场景同一款口红会有好几个色号同一款精华会有不同的容量规格。这时候如果简单地把商品作为一张表来管理就会出现要么一个规格一条记录页面和库存很难处理要么不同规格硬塞到一个字段里数据混乱。我用的方案是独立的 SKU 表Product 表存“商品主体”SKU 表存“具体可销售的规格型号”CREATE TABLE [dbo].[ProductSKU]( [Id] INT IDENTITY(1,1) PRIMARY KEY, [ProductId] INT NOT NULL, [SpecName] NVARCHAR(100) NOT NULL, -- 比如“#01 正红色 / 3.5g” [SpecCode] NVARCHAR(50) NULL, -- 商家自定义编码 [Price] DECIMAL(10,2) NOT NULL, -- SKU级价格可覆盖默认价格 [Stock] INT NOT NULL DEFAULT(0), [SKUImage] NVARCHAR(200) NULL -- 不同规格颜色可以有不同的图 )这样做的核心价值是商品详情页展示基础信息用户选择“色号”和“容量”后前端切换对应的价格、库存和图片提交订单时记录的是具体的 SKUId 而不是模糊的商品 Id库存扣减也能精确到规格。商品图片存储是另一个很多人纠结的点。网上有两种常见方案一种是把图片二进制直接存到 SQL Server 的 Image 字段里另一种是图片上传到服务器目录数据库只存路径。我强烈推荐后者。把图片存进数据库会让数据库文件迅速膨胀备份和迁移都很痛苦而且读取时要经过一层序列化转换性能上不划算。路径存储的实现也很简单上传时用 GUID 重命名文件防止路径冲突然后把相对路径保存到 MainImage 字段。发布时把整个图片目录一起部署就行。2.3 订单状态机与事务处理订单是电商系统里逻辑最重的部分没有之一。美妆网站的订单我定义了五个状态待支付、已支付待发货、已发货、已完成、已取消。状态之间的流转不是随意跳的比如已取消的订单不能被支付已发货的订单不能被修改地址这些约束在代码里要有一层校验。订单提交的核心流程涉及多张表的写入操作先生成订单主表数据再逐条插入订单明细然后扣减 SKU 库存同时更新商品销量。这三步必须在一个事务里完成否则会出现“订单生成成功但库存没扣”这种事故。在 ASP.NET 里我推荐用 TransactionScope 来做它可以把多个 SqlCommand 操作包在一个隐式事务里代码写起来非常干净using (TransactionScope scope new TransactionScope()) { // 1. 插入订单主表拿到订单号 // 2. 遍历购物车明细插入订单明细表 // 3. 扣减库存UPDATE ProductSKU SET Stock Stock - count WHERE Id skuId AND Stock count // 4. 更新商品销量 scope.Complete(); // 全部成功才提交 }这里有个非常关键的细节就是库存扣减的 SQL 写法。新手最容易犯的错误是“先查询库存余额再判断够不够最后执行扣减”这个思路在单用户调试时没问题但在并发场景下一定会出问题因为两个用户可能同时读到同一个库存数量都认为自己买到最后一瓶。正确的做法是把判断条件合并到 UPDATE 语句里UPDATE ProductSKU SET Stock Stock - count WHERE Id skuId AND Stock count这条语句自带“有货才扣”的原子性如果受影响行数为 0说明库存不足事务直接回滚。这种思路在真实项目中非常重要它比我见过很多复杂的锁机制都简单且可靠。2.4 索引设计与SQL优化数据库量小的时候索引的威力体现不出来但为了养成好习惯索引还是要设计。我在外键字段上建了普通索引比如 Product 表的 CategoryId、SKU 表的 ProductId、OrderDetail 表的 OrderId在需要频繁查询的字段上建了索引比如 Users 表的 UserName 做了唯一索引防止重名。做搜索时要注意以通配符开头的模糊查询用不上常规索引SELECT * FROM Product WHERE ProductName LIKE %口红%这种语句会让索引失效全表扫描。美妆站这种数据量还好但如果商品表到了几十万条搜索就会明显变慢。这时候可以考虑用到全文索引或者在业务上引导用户通过分类和属性筛选来缩小范围。实际开发里还可以配合 SQL Server 自带的“执行计划”功能看查询开销我调试的时候习惯把 SSMS 的“包含实际执行计划”打开一眼就能看出 SQL 慢在哪里再针对性地调整索引或改写查询语句。3. 核心功能模块的实操实现3.1 用户注册登录与密码加密用户模块是所有网站的基础。注册登录这一块我的经验是密码绝对不能明文存储也不要只做简单哈希。明文存储等于把用户数据拱手送人简单的 MD5 哈希又很容易被彩虹表破解。我采用的是“随机盐 Rfc2898DeriveBytes”的方式做密码哈希每次注册都生成一个随机盐然后把盐和密码一起做多轮迭代哈希这样即使两个用户密码相同最终存到数据库里的哈希值也不同。注册页的后端逻辑大概长这样// 生成随机盐 byte[] saltBytes new byte[16]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(saltBytes); } string salt Convert.ToBase64String(saltBytes); // 计算密码哈希 string passwordHash HashPassword(txtPassword.Text, salt); // 写入数据库 string sql INSERT INTO Users(UserName, PasswordHash, Salt, Email, Phone) VALUES(UserName, PasswordHash, Salt, Email, Phone);private string HashPassword(string password, string salt) { byte[] saltBytes Convert.FromBase64String(salt); using (var rfc2898 new Rfc2898DeriveBytes(password, saltBytes, 10000)) { byte[] hashBytes rfc2898.GetBytes(32); return Convert.ToBase64String(hashBytes); } }登录时根据用户名取出盐再用同样的算法计算哈希和数据库里的哈希值比对。这种方式大家做项目时可以直接抄不用再找什么第三方加密库。登录状态我用 Session 保存用户信息页面基类里判断 Session 是否为空为空就跳转到登录页。考虑到安全和用户体验的平衡Session 的有效期我设置了 30 分钟空闲超时操作超时就要求重新登录避免有人离开电脑后账号被他人误操作。3.2 商品列表、搜索与分页商品列表页是前台访问量最大的页面。数据展示用 Repeater 控件配合 Bootstrap 的卡片样式输出商品图和价格。分页是这类页面的硬需求不能一次把几百条数据全部绑定到页面上否则页面会非常臃肿、体验极差。我用的是存储过程分页的方式核心就是 ROW_NUMBER() 函数CREATE PROCEDURE [dbo].[sp_GetProductPage] PageIndex INT, PageSize INT, CategoryId INT NULL, Keyword NVARCHAR(100) NULL AS BEGIN SET NOCOUNT ON; DECLARE StartRow INT (PageIndex - 1) * PageSize 1; DECLARE EndRow INT PageIndex * PageSize; SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RowNum, * FROM Product WHERE (CategoryId IS NULL OR CategoryId CategoryId) AND (Keyword IS NULL OR ProductName LIKE % Keyword %) AND Status 1 ) AS ProductPage WHERE RowNum BETWEEN StartRow AND EndRow END这个存储过程的好处是把分页、分类筛选、关键词搜索统一放在一条 SQL 里逻辑集中也避免在 C# 代码里拼接出冗长的查询语句。调用时只要动态传入参数就可以。前台的页码导航我用了一个简单的用户控件根据总页数输出页码链接点击页码时把 pageIndex 作为查询字符串传给页面比如?page2categoryId5。一定要注意搜索关键词不能直接拼 SQL必须使用 SqlParameter 参数化查询。这既是安全问题也是性能问题。我之前见过有同学写出SELECT * FROM Product WHERE ProductName LIKE % keyword %这种代码如果 keyword 里包含单引号轻则报错重则整个表被删。3.3 购物车的两种实现方案购物车我前后对比过两种方案。一种是基于 Session 的购物车用户把商品加入购物车时把购物车数据序列化后放进 Session 里等提交订单时一次性写入数据库。另一种是基于数据库表的购物车Cart 表保存用户 Id、SKU Id、数量每次操作都实时读写数据库。这两种方案各有优劣。Session 方案的优势是性能好、实现简单不用频繁操作数据库劣势是用户换了设备购物车就丢了而且如果用户没登录就使用购物车Session 过期后数据就没了。数据库方案的优劣势正好反过来它能跨设备同步用户登录后在任何终端都能看到购物车但要处理匿名用户购物车合并的问题。我最终用的是数据库方案但在用户未登录的情况下允许游客先把商品加入购物车存放时临时记录一个 Guid 形式的匿名标识用户登录时检查匿名购物车里的数据合并到该用户名下并清除匿名记录。这样既保证了体验又不至于让代码过于复杂。加购的 SQL 逻辑用了“存在就更新数量不存在就插入”的写法IF EXISTS (SELECT 1 FROM Cart WHERE UserId UserId AND SkuId SkuId) UPDATE Cart SET Quantity Quantity Quantity WHERE UserId UserId AND SkuId SkuId ELSE INSERT INTO Cart(UserId, SkuId, Quantity) VALUES(UserId, SkuId, Quantity)为了避免并发导致购物车数量异常我把它包在了一个事务里执行。这种写法在实际项目里很常见大家可以记下来。3.4 订单提交与库存扣减订单提交是整个系统里最需要小心处理的一步。用户从购物车勾选商品点击“去结算”进入确认订单页面然后提交订单。这中间后端要做的事包括读取购物车数据、计算总金额、生成订单号、插入订单主表和明细表、扣减库存、清空已结算的购物车记录。订单号的生成我推荐用时间加序号的方式比如202501051030001由年月日时分秒加三位随机数组成尽量保证唯一。这里不推荐直接用数据库的自增 Id 当订单号因为订单号是要展示给用户的自增 Id 容易暴露系统每天的订单量不够严谨。提交订单的代码核心要点是事务。用 TransactionScope 将上面的步骤全部包起来任何一步失败都整体回滚保证数据一致性。模拟支付的话我做了个简化的支付页面用户点击“立即支付”后直接更新订单状态为“已支付”同时记录支付时间。真实项目里这里要对接第三方支付接口回调验签的流程比这个复杂得多但项目的核心流程已经演示清楚了。关于库存扣减我再强调一次就是用上面的单条 UPDATE 原子语句。我见过很多教程里是先 SELECT 出库存再判断再 UPDATE这种写法在项目验收或者演示时没问题但只要上了生产环境早晚要出事。大家直接采用原子更新的写法就是积累了正确的工程经验。3.5 后台商品管理后台的商品管理本质上是一套围绕 Product 和 ProductSKU 两张表的 CRUD 页面。但美妆站有一个特别的点就是图片上传频率非常高。上架一个新的口红色号可能要传好几张细节图。所以商品管理页面做了多个文件上传控件用前面说的 GUID 重命名方式保存文件并且在上传时做了文件类型校验只允许 .jpg、.png、.webp 格式大小限制在 2MB 以内。后台的权限我设置了两个角色管理员和运营人员。管理员可以操作所有模块包括用户管理和系统设置运营人员只能操作商品管理和分类管理。实现方式是定义一个用户角色字段然后在后台母版页的 Page_Load 里判断 Session 中的角色信息没有权限的模块直接不渲染菜单。这种方式虽然简单但对这个规模的系统来说完全够了。后台列表页的数据展示用 GridView 控件自带分页、排序和编辑功能能省不少开发时间。但 GridView 的自动编辑功能有个坑就是它会自动生成可编辑字段如果格式没控制好编辑日期或小数时容易出错。我的做法是把列表设计成“只读表格 弹窗编辑”的模式点“编辑”按钮弹出一个新的表单页数据更清晰也更好控制校验逻辑。4. 常见问题与排查技巧实录4.1 数据库连接与安装老问题这个项目做完之后我把整个工程从开发机迁移到一台干净的 Windows 服务器上结果各种环境问题接踵而来。最典型的是“无法连接到 SQL Server”和“用户登录失败”。这两个问题排查方向完全不一样。连接字符串要检查几点服务器实例名是否写对尤其是否漏了命名实例身份验证方式是否匹配是否启用了 TCP/IP 协议。连接字符串推荐写成这样Serverlocalhost,1433;DatabaseBeautyShop;User Idsa;Password你的密码;EncryptFalse;注意了如果是新版 SQL Server默认可能会启用强制加密老程序连过去会报证书相关错误这时候要显式加EncryptFalse或者连接串里设置 Trust Server Certificate 为 True。另外一定要在 SQL Server 配置管理器里检查 TCP/IP 协议是否启用、SQL Browser 服务是否启动很多远程连接失败都是这两个地方没配置好。还有一类很常见的情况是 SQL Server 服务启动不了报错 17051。这个错误码通常表示评估期已过期解决方法是输入正式的产品密钥或者重新安装。网上有些教程教你改系统时间、删除注册表项这些都不正规而且有风险不要去试直接找官方渠道解决。4.2 数据类型转换与日期函数的坑开发过程中遇到最多的一类报错就是“将 nvarchar 转换为数据类型 int 时失败”这类转换错误。为什么会出现根源在于 SQL Server 里使用 CASE 或 UNION 时不同类型的数据会自动做隐式转换一旦前后数据类型不一致就会报错。比如有人喜欢把所有查询参数都用字符串拼接进去当页面传过来的是空字符串时SQL 里就可能出现WHERE Id 这种语句SQL Server 尝试把空字符串转成 int 自然就失败。解决方式是使用 SqlParameter 时把类型设置准确cmd.Parameters.Add(CategoryId, SqlDbType.Int).Value string.IsNullOrEmpty(txtCategoryId.Text) ? DBNull.Value : Convert.ToInt32(txtCategoryId.Text);日期函数也是重灾区。SQL Server 的 GETDATE() 返回的是带时间的日期如果按天统计订单直接WHERE CreateTime GETDATE()肯定查不到数据因为精确到了时分秒。正确写法是用 CAST 转换到日期类型WHERE CAST(CreateTime AS DATE) CAST(GETDATE() AS DATE)或者用 CONVERT 函数。-- 当天订单数经典写法 SELECT COUNT(*) FROM Orders WHERE CreateTime CONVERT(DATETIME, CONVERT(DATE, GETDATE())) AND CreateTime DATEADD(DAY, 1, CONVERT(DATETIME, CONVERT(DATE, GETDATE())))这个写法利用日期范围查询还能让 CreateTime 上的索引生效比使用函数包裹字段要快很多。4.3 删除重复数据的经典场景有一次我在后台导入商品数据时不小心把同一批数据跑了两遍结果系统里出现了大量重复的商品记录。更麻烦的是这些记录没有唯一标识可以用来区分。这时候要删除重复数据保留一条我用的方法是借助 ROW_NUMBER() 和临时表;WITH cte AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY ProductName, CategoryId ORDER BY Id) AS rn FROM Product ) DELETE FROM cte WHERE rn 1这就是热词里“sqlserver删除重复数据只保留一条 无id”的完美解法。核心思路是按重复的业务字段分区给每组记录编号然后删除编号大于 1 的记录。第一次做这操作前一定要先备份数据或者用 SELECT 预览一下会删掉哪些行避免误删。4.4 安全加固SQL注入与ViewState前面已经说过了防 SQL 注入最有效的方法就是参数化查询这不能偷懒。但作为 ASP.NET 项目还有另外一个容易被忽略的安全点就是 ViewState。Web Forms 的 ViewState 默认会把页面状态序列化后存在隐藏字段里如果站点没有给 ViewState 做加密和签名攻击者有可能针对它做反序列化操作伪造数据甚至执行恶意逻辑。所以上线前一定要做两件事一是给 machineKey 配置独立的验证密钥和解密密钥二是把页面或全局的 ViewState 加密级别设置成 Alwayspages viewStateEncryptionModeAlways enableViewStateMactrue /同时在 Web.config 的 system.web 节点下配置 machineKey让密钥不随应用重启而变化。这样既提高了安全性也避免出现“验证视图状态 MAC 失败”这种间歇性报错。另外上传文件时除了校验扩展名还要校验 ContentType并把上传目录的脚本执行权限关掉防止图片马一类的攻击。安全这块属于“平时用不上出事就后悔”的部分大家一定要在项目上线前就补上。4.5 性能、备份与后续扩展最后提一嘴性能和运维。美妆网站图片多一定要给图片目录配置好 IIS 的静态文件缓存让浏览器缓存图片减少重复请求。数据库方面定期备份是必须的SQL Server Agent 可以设置自动备份计划我习惯每天凌晨做一次完整备份。如果将来想继续演进可以考虑把项目迁移到 ASP.NET Core 上核心业务逻辑包括数据库设计可以完全复用只是 UI 层从 Web Forms 换成 Razor Pages 或者 MVC。这种迁移路径很成熟工作量也没有想象中大。现在我回头看这个项目最大的价值其实不是“我写出了多少行代码”而是通过它把需求分析、数据库建模、事务处理、安全加固这些基本功完整地走了一遍。它们才是以后无论换什么技术栈都用得上的底层能力。如果你正在做类似的项目遇到具体瓶颈也欢迎在评论区把报错信息发出来我们可以一起讨论。
返回列表