进销存Java C/S实战:需求拆解、库存扣减与通信协议避坑

发布时间:2026/10/26 10:28:11
进销存Java C/S实战:需求拆解、库存扣减与通信协议避坑

简介:这份进销存 Java C/S 系统资料包面向计算机专业课程设计、毕业设计以及初学 Java 桌面开发的学习者,围绕采购、销售、库存、财务四条核心业务线,提供一套可运行参考的完整实现。压缩包共 252 个文件、约 1.63MB,以 68 个 java 源文件和 166 个 class 编译文件为主体,另含 4 份 doc 需求与详细设计文档、sql 建库脚本、properties 配置及少量界面截图,源码、文档与数据脚本配套齐全。已有 201 人学习下载。读者可借 MVC 分层与 controller、model、view 的包结构理解客户端与服务器端的职责划分,通过商品、客户、供应商等模块的 DAO 类掌握 JDBC 增删改查写法,借助 Swing 窗体体会 C/S 界面搭建过程,再结合需求文档中的业务流程图、用例图与数据库表结构设计,把从需求分析到编码实现的链路完整走一遍,适合需要一处可对照、可拆解的进销存项目范例的读者。

1. 拿到「进销存 java cs」先别急着点运行

一个标着「进销存 java cs」的压缩包,多数人第一反应是双击主类看登录框弹不弹出来,卡在数据库连接那一步之后就放弃了,压缩包里那两份需求文档和详细设计文档连打开都没打开。这其实是一套 Java 客户端/服务端架构的进销存项目包:客户端承担开单、查询、打印和小键盘快捷键,服务端承担业务校验、事务和落库,配套的需求文档与详细设计文档把「进货—销售—库存—往来账」这条主链路拆到了字段和状态级别。真正解决问题的地方是单据流转怎么设计、库存扣减放在哪一层、C/S 通信协议怎么定这类卡人的环节,一个能跑起来的 Demo 本身说明不了什么。它适合两类人:正在做课程设计或毕业设计、需要一份能对照着抄结构的技术方案的同学;以及要给中小商贸公司做轻量定制、手里缺一份基线详细设计的开发者。中间层最容易出错的地方,后面会用具体代码和踩坑记录一条条拆开。

2. 需求文档怎么拆成能写进代码的模块

2.1 需求文档里真正能落进代码的只有四类条目

翻需求文档最常见的翻车方式是通读一遍,觉得懂了,打开 IDE 却不知道第一行写什么。原因是需求写的是业务语言,代码要的是结构语言,中间缺一层翻译。我一般会拿三支不同颜色的笔过一遍文档,只标四类东西:名词、动词、约束词、角色。

名词是会变成表或字段的东西,比如商品、供应商、客户、仓库、单据头、单据行、结算方式。动词会变成用例或接口,比如开单、审核、作废、调拨、盘点、退货。约束词是「必须」「不得」「只能」「不超过」这类定语的落点,一般变成校验代码或者数据库约束。角色会变成权限点和菜单,比如收银员只能开单不能审核,仓管只能看库存不能改单价。

举个具体转换,需求原文写「销售人员可以开具销售单,销售单审核后自动扣减库存,已审核的单据不允许修改」。这句话拆出来是四个落地物:

原文片段落地物落到哪
销售单实体 sale_order + sale_order_item表结构、DAO
开具用例 SALE_CREATE服务端 service 方法、客户端开单界面
审核后自动扣减库存状态机 + 库存扣减逻辑audit() 方法、事务边界
已审核不允许修改约束更新语句带 status 条件,或 service 层前置判断

标完之后你会得到一张用例清单和一张实体清单,这两张清单就是后面所有工作的锚点。详细设计文档如果写得规范,里面应该已经有模块划分和接口签名,可以直接拿来对我的清单;对不上的地方通常就是文档跟代码脱节的位置,恰恰是最需要警惕的地方。

还有一类东西要单独拎出来,就是单据状态。进销存系统的复杂度八成集中在状态流转上,草稿、已审核、已出库、已作废,每个状态能做什么操作、能退回哪个状态,必须画成一张状态机表,不能靠代码里的 if 分支口口相传。这张表后面会反复用到。

2.2 为什么这类系统选 C/S 而不是 B/S:把代价讲清楚

需求文档和详细设计文档放在一起看,能看出这套东西选的是 C/S:客户端是桌面程序,服务端是常驻进程,两者之间走自定义协议或 RPC。很多人会问,现在都 2025 年了为什么不用浏览器。答案不在技术先进与否,而在使用场景。

收银台这个位置有几个硬约束:要接小票打印机、标签打印机、扫码枪、钱箱,浏览器调这些外设要么装插件要么走本地服务,部署成本反而更高;操作员一天要开几百单,键盘快捷键和焦点流转的响应速度直接影响效率,桌面端的本地控件在这方面比网页稳;门店网络经常抖,客户端可以在断网时先把单子存本地,恢复后补传,浏览器做这件事要额外引入 Service Worker 和本地存储,复杂度并不低。

代价也很明确:发版要一台台机器更新,端口要在防火墙上开,跨网段和跨公网访问很麻烦,客户端一旦被反编译,业务逻辑就暴露了。所以选择 C/S 的前提是「局域网内、终端数量可控、外设依赖重」。如果客户是多门店跨公网、还要手机端查库存,那这套架构就不该硬上,老老实实走 B/S 加一台本地打印服务更省事。这个判断在动手改代码之前就要做,做完再决定是复用还是重写。

顺便说一个设计上的红线:客户端绝对不能直连数据库。有些课程设计的包图省事,客户端里直接写 JDBC,账号密码写死在配置里,这在演示时没事,一旦放到真实门店就是灾难。正确做法是客户端只认协议,所有 SQL 都在服务端。这一点在详细设计文档的部署视图里一般会体现,看不到就要自己补。

2.3 把用例映射到包结构:一张对照表定骨架

拿到用例清单之后,下一步是把它们塞进包结构。这套包的常见分层是四层:common 放协议和 DTO,client 放界面和通信封装,server 放 handler、service、dao,外加一个 db 放脚本和迁移。我一般会先画一张对照表,确认每个用例都能找到归属,找不到归属的就是骨架缺了一块。

包职责典型类不该放什么
common.protocol帧格式、请求响应对象、错误码Message、Request、Response任何业务规则
client.ui界面、事件绑定、输入校验SaleOrderFrame、StockPanel数据库访问
client.net连接管理、请求发送、超时重试Connection、RequestDispatcher业务判断
server.handler解帧、分发、异常转错误码SaleOrderHandler事务细节
server.service业务规则、事务边界SaleOrderService界面相关逻辑
server.daoSQL、结果集映射StockDao、SaleOrderDao业务规则

对照的时候有个小技巧:把每个用例写成「谁在什么界面触发什么动作,服务端哪个方法接住,落哪几张表」。写不出来的那条,就是需求文档里没写清楚、详细设计文档里也没覆盖的空白点。这些空白点在做课设时可以直接按常见做法补齐,比如退货单默认走「红字单据 + 反向库存」而不是直接改原单,这样审计留痕清楚,也避免单据金额被悄悄改掉。

3. 库存扣减的业务规则:单据表结构与三条链路

3.1 单据头加单据体:为什么不做一张大表

新手最容易踩的建模坑是把一张销售单塞进一张表,商品 ID、数量、单价、金额全堆在同一行,一张单卖三种货就得插三行,单号重复、客户信息重复、审核状态重复。后面要改一次客户地址,三行都得改,改漏一行数据就不一致。

标准做法是单据头 + 单据体两张表。单头存单号、客户、仓库、总金额、状态、制单人、审核时间,一行对应一张单;单行存商品、数量、单价、金额、行号,一行对应一个商品。两张表用 order_id 关联。这样改客户的收货地址只动单头,加一个商品只往单行插一条,审核状态只有一处,状态机才不会分叉。

库存表是第三张核心表,粒度是「商品 + 仓库」,不是「商品」。同一件货放在一号仓和二号仓,可用量必须分开算,调拨才说得通。这张表上带一个 version 字段做乐观锁,后面并发扣减会用到。

往来账部分一般单独一张 account_flow,按客户或供应商记应收应付的增减,跟单据一对多关联。这部分在需求文档里经常只有一句话「系统应支持往来账查询」,实际实现要自己补规则:销售审核时增加应收,收款时减少应收,退货时反向冲减,作废时整笔红冲。

3.2 建表 SQL 与索引怎么留后悔药

下面这段是这套系统里最核心的三张表,字段和索引都是按真实使用频次定的,可以照着改:

-- 销售单头:一行一张单,状态只有一处 CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '单号,业务唯一,由服务端生成', customer_id BIGINT NOT NULL COMMENT '客户ID', warehouse_id BIGINT NOT NULL COMMENT '发货仓库', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已审核 2已作废', total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '单据总额,落库时由行金额汇总', created_by BIGINT NOT NULL COMMENT '制单人', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, audited_at DATETIME NULL COMMENT '审核时间,未审核为 NULL', UNIQUE KEY uk_order_no (order_no), KEY idx_customer_status (customer_id, status), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售单头'; -- 销售单行:一行一个商品 CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, line_no INT NOT NULL COMMENT '行号,从1开始,界面显示用', goods_id BIGINT NOT NULL, qty DECIMAL(18,3) NOT NULL COMMENT '数量,支持小数,称重商品要用', price DECIMAL(18,4) NOT NULL COMMENT '成交单价,保留4位防抹零误差', amount DECIMAL(18,2) NOT NULL COMMENT '行金额 = qty * price,落库前四舍五入', KEY idx_order (order_id), KEY idx_goods (goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售单行'; -- 库存:粒度是商品+仓库,不是商品 CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, qty DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '可用数量', version INT NOT NULL DEFAULT 0 COMMENT '每次变更+1,排查时能看出被改过几次', UNIQUE KEY uk_goods_wh (goods_id, warehouse_id), KEY idx_wh (warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存';

几个参数值得单独说。数量用 DECIMAL(18,3) 而不是 INT,是因为散装和称重商品一定会有 0.5 公斤这种量,用整数迟早要改表。单价保留 4 位是因为批发场景里经常有 3.75 这种价格,只留 2 位在做整单折扣时会累积出几毛钱的误差,对账时解释不清。金额保留 2 位并且落库前统一四舍五入,避免客户端和服务端各算一遍算出两个数。

索引这块,uk_goods_wh 是唯一索引,同时也是扣库存时的定位索引,一举两得。idx_customer_status 是为「查某个客户所有未审核单据」这个高频查询准备的,进销存里对账页面天天要用。idx_created_at 支持按日期范围拉日报,没有它,单量上到十万级之后日报页面会转圈转到人怀疑人生。

提示:单号不要用自增 ID 拼,也不要用 UUID。常见做法是「前缀 + yyyyMMdd + 4 位当日流水」,流水用一个独立的序列表加行锁生成,既保证唯一又能看出日期,客服排查时一眼定位。

3.3 扣减库存的三种时机与事务边界

库存什么时候扣,是个业务决策而不是技术决策。常见三种:开单就扣、审核才扣、出库才扣。开单就扣的问题是一堆草稿单把库存占满,实际没发货;出库才扣的问题是审核到出库之间有一段超卖窗口,收银台看得到库存但其实已经被别人订走。中小商贸最常见的选择是审核即扣,配合「占用量」字段做更细的控制,这套包里用的就是审核即扣。

事务边界要卡在「一张单」上:审核动作开始,锁单头、逐行扣库存、改状态,全部成功才提交,任何一行库存不足就整单回滚。下面这段是服务端审核方法的核心,注释标了每一步为什么这么写:

// 审核销售单:锁单据 -> 逐行扣库存 -> 改状态,全程一个事务 @Transactional(rollbackFor = Exception.class) public void audit(long orderId, long operatorId) { // 1. 行锁住单头,挡住两个收银台同时点审核 SaleOrder order = saleOrderDao.selectForUpdate(orderId); if (order == null || order.getStatus() != 0) { throw new BizException("单据不存在或已处理"); } List<SaleOrderItem> items = saleOrderItemDao.listByOrder(orderId); if (items.isEmpty()) { throw new BizException("空单据不能审核"); } // 2. 逐行扣减,SQL 里带 qty >= ? 的守卫条件,扣不动就回滚 for (SaleOrderItem it : items) { int affected = stockDao.decrease(it.getGoodsId(), order.getWarehouseId(), it.getQty()); if (affected == 0) { throw new BizException("库存不足,商品ID=" + it.getGoodsId()); } } // 3. 改状态时再带一次 status=0 条件,重复审核会返回 0 行 int rows = saleOrderDao.audit(orderId, operatorId); if (rows == 0) { throw new BizException("单据状态已变更,请刷新"); } }

对应的扣减 SQL 长这样,重点是 WHERE 里那个 qty >= #{qty},它把「判断够不够」和「扣减」合并成一条原子语句,避免了先查再改的竞态:

<update id="decrease"> UPDATE stock SET qty = qty - #{qty}, version = version + 1 WHERE goods_id = #{goodsId} AND warehouse_id = #{warehouseId} AND qty >= #{qty} </update>

参数上要注意两点。第一,affected == 0 有两种可能:库存不够,或者这条商品在目标仓库根本没有库存记录。两种都当库存不足处理,提示语可以区分,但处理方式都是回滚。第二,第 3 步的状态更新不能省,它既是一次幂等校验,也是一次乐观锁;如果两个请求都通过了第 1 步的行锁(比如中间有事务隔离级别的差异),这一步会拦住后到的那个。

事务隔离级别用默认的 READ COMMITTED 就够,别去动 REPEATABLE READ 之上的设置,调高了会引入间隙锁,扣库存时容易跟别的单子互相等,表现为偶发超时。

4. Java C/S 通信层:协议帧与线程模型怎么撑住几十个收银台

4.1 长度前缀加 JSON 报文的帧格式

C/S 通信最容易犯的错是把对象序列化成 JSON 直接往流里写,然后对端 read 一次就当读完。TCP 是字节流,不保证一次 read 拿到一个完整消息,也不保证两个消息不粘在一起。单机测试永远正常,一上真实网络就开始解析报错,就是这个问题。

解决方案是定帧:每条消息前面加固定长度的头,告诉对端这条消息有多长。这套包里用的是最朴素也最稳的方案,4 字节长度 + 1 字节类型 + JSON body:

public final class FrameCodec { // 帧结构:4字节body长度(大端) + 1字节消息类型 + body(UTF-8 JSON) public static byte[] encode(byte type, byte[] body) { ByteBuffer buf = ByteBuffer.allocate(5 + body.length); buf.putInt(body.length); // 长度只算 body,不含 5 字节头本身 buf.put(type); // 1=登录 2=业务请求 3=业务响应 9=心跳 buf.put(body); return buf.array(); } public static Message decode(InputStream in) throws IOException { DataInputStream din = new DataInputStream(new BufferedInputStream(in)); int len = din.readInt(); // 阻塞读满 4 字节,读不满会抛 EOF if (len <= 0 || len > 1024 * 1024) { // 上限 1MB,挡住脏包和恶意长度 throw new IOException("非法帧长度: " + len); } byte type = din.readByte(); byte[] body = new byte[len]; din.readFully(body); // 必须 readFully,read 可能只读到一半 return new Message(type, new String(body, StandardCharsets.UTF_8)); } }

参数上,1MB 的上限是按最坏情况估的:一次批量导入商品或者拉一张几百行的单据,JSON 通常不会超过 200KB,留 5 倍余量足够,再大就该改成服务端分页返回。类型字节留了扩展空间,心跳单独一个类型,方便服务端识别长连接是否还活着。

编码统一用 UTF-8,并且从数据库到 JDBC 连接串再到帧编码,三处必须一致。这块出问题的表现是中文商品名变问号或者乱码,排查时按链路一段段核对,先看数据库表的字符集,再看连接串有没有 characterEncoding=utf8,最后看这里的 StandardCharsets。

4.2 服务端线程模型:一连接一线程还是线程池

门店场景的连接数很好估:一个店三到五个收银台,加上仓库和后台,撑死二十个连接。这个量级用 BIO 加线程池完全够,没必要上 NIO,Netty 在这里是过度设计,还增加调试成本。

做法是主线程只负责 accept,接到的连接丢进固定大小的线程池,每个连接在线程里跑一个阻塞读循环:

ExecutorService pool = Executors.newFixedThreadPool(32, r -> { Thread t = new Thread(r, "conn-worker"); t.setDaemon(true); return t; }); ServerSocket server = new ServerSocket(9527); while (running) { Socket socket = server.accept(); socket.setSoTimeout(30_000); // 读超时,防止半死连接长期占线程 socket.setTcpNoDelay(true); // 关 Nagle,收银台点一下就要响应 socket.setKeepAlive(true); pool.submit(() -> handle(socket)); // 一个连接一个阻塞读循环 }

线程池大小 32 是按「最大连接数 × 1.5」估的,连接数超过线程数时新连接会排队,但不会崩。setSoTimeout 设 30 秒,配合客户端每 20 秒发一次心跳,能及时回收掉那些客户端崩溃后没关闭的僵尸连接。setTcpNoDelay 一定要开,否则小报文会被 Nagle 算法攒着发,收银员点保存之后界面要等一两百毫秒才返回,体感很差。

每个连接的 handle 方法里做三件事:循环解帧、按类型分发到 handler、把响应按同样的帧格式写回。异常要在这里捕获并转成错误响应,绝不能让异常把循环打断,否则一个坏请求就把整条连接废了。

4.3 客户端请求封装与超时重试

客户端这一侧的核心是「请求和响应怎么对上」。因为一条连接上可能同时有多个请求在飞,必须给每个请求编个号,响应回来时按号找回调:

private final AtomicLong seq = new AtomicLong(0); private final Map<Long, CompletableFuture<Response>> pending = new ConcurrentHashMap<>(); public Response call(String method, Map<String, Object> params) throws Exception { long reqId = seq.incrementAndGet(); // 单连接内自增,够用且不重复 CompletableFuture<Response> future = new CompletableFuture<>(); pending.put(reqId, future); try { send(reqId, method, params); // 写帧,写操作要加锁 return future.get(5, TimeUnit.SECONDS); // 5秒没回就当超时 } finally { pending.remove(reqId); // 无论成败都清理,防止内存泄漏 } }

超时时间设 5 秒是权衡结果:正常业务请求在局域网内 100 毫秒内返回,超过 1 秒就该怀疑服务端卡住了;但批量导入这种操作可能要两三秒,所以留到 5 秒。超过 5 秒直接抛超时,界面提示「服务端无响应,请重试」。

重试这里有个血泪经验:查询类请求可以自动重试一次,写入类请求绝对不能盲目重试。因为超时只代表你没收到响应,不代表服务端没执行。审核单据的请求超时后自动重发,很可能导致重复扣库存。正确的做法是给每个写请求带一个客户端生成的 requestId,服务端用一张 request_log 表做去重,同一个 requestId 第二次进来直接返回上次的结果。这套幂等机制在详细设计文档里如果没写,一定要自己补上,它是断网重连场景的唯一保险。

5. 避坑与排查:上线前必须过的五道坎

5.1 库存扣成负数:先查再改的经典竞态

现象是月底盘点发现某个商品库存是 -3,但系统里没有任何一步提示过库存不足。原因基本都是在服务层写成「先 select 查库存,判断够不够,再 update 扣减」,两个收银台同时卖最后一件货,两边都查到 1,都判断通过,都执行扣减,库存变 -1。

解决就是把判断和扣减合并成一条带条件的 UPDATE,也就是 3.3 节里那条WHERE qty >= #{qty},然后看 affected rows,等于 0 就回滚。改完之后还要做一次数据修复,把负库存的单据找出来人工核对,不能直接把负数改成 0,那样账就永远对不上了。

5.2 客户端界面假死:在事件线程里做网络请求

现象是点「保存」之后整个窗口卡住,转圈,Windows 上还会提示「未响应」,但服务端日志显示请求早就处理完了。原因是 Swing 的事件分发线程(EDT)里直接调了阻塞的网络请求,界面重绘被堵死。

解决是把网络调用挪到后台线程,用 SwingWorker 或者自己起一个线程池,结果回到 EDT 里再更新界面。这条规则要写进团队的编码约定里,凡是 call() 之类的阻塞方法,都不允许出现在按钮的 actionPerformed 里。同理,客户端里也不要写 JDBC,一旦数据库慢查询,整个界面就跟着卡。

5.3 金额差几分钱:float 和 BigDecimal 混着用

现象是对账单上单据总额跟明细行加总差 0.01,怎么查都查不出来,换一台机器结果还不一样。原因是某处用了 double 或 float 存金额,二进制浮点表示不了 0.1 这类十进制小数,累加几十行之后误差就显出来了。

解决是全链路统一 BigDecimal,数据库用 DECIMAL,Java 用 BigDecimal,JSON 序列化时转成字符串而不是数字,避免反序列化时又被转成 double。四舍五入统一用 RoundingMode.HALF_UP,并且规定好「先算行金额、再汇总单头」的顺序,不允许两处各自算一遍。这条在需求文档里通常不会写,但它是财务类系统的基本功。

5.4 断网重连后单据重复入账

现象是门店网络闪断一次,恢复后同一个单号在系统里出现了两条,库存被扣了两次。原因是客户端超时后自动重发,服务端没有任何去重机制。

解决是双层防护:客户端给每个写请求生成 requestId,随请求一起发;服务端用 request_log 表记录 requestId 和响应结果,唯一索引冲突就说明是重复请求,直接返回上次的结果。另外单号的生成也要放到服务端做,客户端只负责展示,不能自己拼单号,否则两台机器离线开单再同步时单号会撞车。

5.5 中文乱码与换行符:编码链路要一段段核

现象是商品名存进数据库变成问号,或者打印出来的小票上中文变成方块,还有从 Excel 导入的数据每行末尾多一个不可见字符导致匹配失败。原因是编码在某一环断了:数据库表不是 utf8mb4、JDBC 连接串没指定字符集、帧编码用了平台默认字符集、或者导入的 CSV 带 BOM。

排查顺序固定下来:先看表的字符集,再看连接串,再看帧编解码,最后看文件读取。BOM 问题用 UTF-8 读取时手动跳过前三个字节,或者在导入前用文本工具另存为无 BOM。换行符问题统一在读取后做 trim 和去掉 \r,不要指望数据源是干净的。

6. 用详细设计文档跑通一条销售出库链路

文档看得再多,不跑一条完整链路都是纸上谈兵。我习惯挑「销售出库」这一条,因为它同时穿过权限、校验、事务、并发四个面,跑通了基本就能判断这套包能不能用。

具体四个验证点,按顺序做:

验证点怎么验期望结果
权限边界用收银员账号调审核接口返回无权限,单据状态不变
开单校验提交数量为 0、单价为负的单据服务端拒绝,客户端有明确提示
事务回滚一张单里第二行商品库存不足第一行库存也不被扣,单据仍是草稿
并发幂等两个线程同时审核同一张单只有一个成功,库存只扣一次

第三个验证点最容易被忽略。很多实现是先扣第一行、再扣第二行,第二行失败抛异常,但事务注解加在了错误的位置(比如加在 DAO 上,或者被同类内部调用绕过代理),导致第一行的扣减已经提交。验证方法很直接:准备一张两行的单据,让第二行库存不足,点审核,然后去查第一行的库存有没有变。变了就是事务边界没配对。

第四个点用两个浏览器窗口或者两台客户端同时点,看服务端日志里是不是只有一个请求真正执行了 update。如果两个都成功,回去检查 3.3 节里的 selectForUpdate 和状态更新条件是不是漏了。

跑完这条链路,再回头翻详细设计文档,你会发现文档里那些当时觉得啰嗦的状态迁移表和接口时序说明,恰好对应着你刚才验证的每一个点。从那以后我每次拿到这类带文档的项目包,都先把状态机抄到纸上,对着代码逐条打勾,勾不上的地方就是坑,先记下来再动手改。希望帮到你。

本文还有配套的精品资源,点击获取