资讯动态

Java C/S进销存系统详细设计:库存并发、事务一致性与验收

发布时间:2026/10/26 10:28:28 来源:尧图企业网站定制
简介面向中小企业信息化开发学习者与 Java 桌面应用入门者这份进销存系统资料以 C/S 架构为主线串联采购管理、销售管理、库存管理与财务对账四大业务模块并配有需求分析与详细设计文档可用于课程设计、毕业设计选题参考与业务流程建模练习。压缩包共 252 个文件、约 1.63MB主体为 68 个 java 源码与 166 个 class 编译文件另含 4 份 doc 需求与设计文档、1 份 sql 建库脚本、properties 配置、jar 依赖与界面截图。整体按 MVC 分包组织源码覆盖客户端窗体、DAO 数据访问与数据库连接管理文档侧给出业务流程图、用例图、接口与表结构设计可对照理解从需求到落库的完整链路。目前已有 201 人学习适合希望打通 C/S 项目结构、补齐设计文档写作与数据库建模能力的读者。1. 需求先立住一套 C/S 进销存要回答的四个问题接手一套进销存 java cs 的活儿第一件事不是画登录界面而是把需求文档里最容易被跳过的几个问题先问出来一张采购单从草稿到入库中间允许改几次价库存是被单据直接改还是只能由流水推导两个人同时给同一商品出库谁的数算数月结之后发现上月单据录错是红冲还是改原单这四个问题答不上来详细设计文档写得再厚也只是空壳。这套方案面向要交完整毕设或课程设计的人也面向要给小型商贸公司搭一套真能用系统的从业者客户端管录入和展示服务端管事务和校验数据库把每一笔库存变动留下痕迹。下面按需求、架构、实现、避坑、验收的顺序拆开每一章都能对应到详细设计文档里的具体小节。2. 总体架构设计C/S 分层、模块边界与通信方式2.1 三层结构与包结构划分C/S 进销存最常见的坏味道是「客户端什么都干」窗体里直接拼 SQL算完库存再把结果发给服务端存一下。这种写法在单机演示里跑得飞快一旦两台客户端同时出库或者操作员在提交途中关掉窗口数据就开始飘。我一般把职责切成三层客户端只保留表示层和「瘦业务」——输入校验、金额格式化、必填项提示真正的库存加减、单据审核、权限判断全部放在服务端。服务端再分业务层和数据访问层DAO 只负责 SQL 和结果映射不允许出现业务判断。包结构按这个思路落下来大概是这样com.example.erp ├── client # 客户端只依赖 common │ ├── ui # JFrame / JPanel / TableModel │ ├── ctrl # 按钮事件、页面跳转 │ └── net # SocketClient封装请求响应 ├── server # 服务端 │ ├── bootstrap # 启动、端口监听、线程池 │ ├── dispatch # 指令路由 Handler │ ├── service # 单据流转、库存计算、报表 │ ├── dao # JDBC / MyBatis 映射 │ └── security # 登录会话、权限点校验 └── common # 两端共享 ├── dto # Request / Response / 各业务 DTO └── util # JsonCodec、BigDecimal 工具common单独抽出来的原因是客户端要反序列化响应体如果 DTO 各写一份字段名对不上就会在运行时炸出空指针而且这种错误编译器不报只能靠联调时一个个对。DTO 里只放数据和简单的自校验方法绝不放数据库连接和业务逻辑否则客户端一打包就把服务端依赖带出去了。再往下是模块划分。进销存的功能看着多归到服务端其实就四块基础资料商品、供应商、客户、仓库、单据采购、销售、退货、调拨、库存现存量、流水、盘点、报表期间汇总、进销存明细。每块一个 Service互相之间只通过接口调用不跨层直接摸对方的 DAO。这样做的好处是详细设计文档里画模块图时边界清楚答辩时被问到「采购模块和库存模块怎么耦合」能直接答「采购审核后调库存服务的入库方法不直接写库存表」。2.2 通信方式选型Socket 直连与 HTTP 的取舍带 cs 标签的项目通信层绕不开一个选择走 Socket 自定义协议还是走 HTTP JSON。两者都能用区别在于部署形态和调试成本。Socket 长连接适合局域网内固定几台收银机、客户端数量可控的场景服务端能主动推送库存变动HTTP 更适合以后想换浏览器端、或者要走反向代理的场合但每次请求都要建连客户端还得处理超时重试。课程设计里通常选 Socket因为「C/S 架构」这个词本身就指向它而且实现一套长度前缀的报文协议能把 TCP 粘包半包这件事讲清楚答辩时是个加分项。报文我一般定成「4 字节长度 JSON 体」指令码放在 JSON 里服务端按指令码路由。核心分发代码如下// 服务端指令分发一个连接一个线程按 cmd 路由到对应 Handler public class RequestDispatcher implements Runnable { private final Socket socket; private final MapString, Handler routes new HashMap(); public RequestDispatcher(Socket socket) { this.socket socket; // 指令码是白名单客户端传什么就只能命中这里注册过的处理器 routes.put(purchase.create, new PurchaseCreateHandler()); routes.put(stock.query, new StockQueryHandler()); routes.put(sale.checkout, new SaleCheckoutHandler()); } Override public void run() { try (DataInputStream in new DataInputStream( new BufferedInputStream(socket.getInputStream())); DataOutputStream out new DataOutputStream( new BufferedOutputStream(socket.getOutputStream()))) { while (true) { int len in.readInt(); // 先读长度解决粘包半包 if (len 0 || len 8 * 1024 * 1024) break; // 8MB 上限挡住畸形包 byte[] body new byte[len]; in.readFully(body); // 必须读满不能只读一次 Request req JsonCodec.decode(body, Request.class); Response resp; try { Handler h routes.get(req.getCmd()); resp (h null) ? Response.fail(未知指令) : h.handle(req); } catch (BizException e) { resp Response.fail(e.getMessage()); // 业务异常回包不断连接 } catch (Exception e) { resp Response.fail(服务端异常请联系管理员); } byte[] payload JsonCodec.encode(resp); out.writeInt(payload.length); // 响应同样带长度头 out.write(payload); out.flush(); } } catch (IOException e) { // 客户端正常断开也会走到这里记录一下即可不要打印整段堆栈刷屏 } finally { closeQuietly(socket); } } }这段代码有三个点值得在详细设计文档里单独写一段。readInt加readFully是处理 TCP 流的正确姿势只调一次read拿到的字节数可能不够一条完整报文长度上限 8MB 是防畸形包和误传大文件超过就直接断连业务异常BizException转成普通响应返回连接继续复用只有 IO 异常才退出循环。指令码用白名单注册而不是「根据类名反射调用」避免客户端构造一个指令就让服务端加载任意类。客户端这一侧对应封装一个SocketClient对外只暴露call(cmd, req, respType)一个方法内部维护连接、超时和重连。重连不要做成死循环我一般限三次三次失败弹框提示检查服务端地址比无限重试卡住界面强。2.3 数据表设计与核心字段口径表设计里最关键的不是字段多而是口径统一。库存到底存不存「可用量」流水要不要记变动前后的值单据号能不能重复这三件事定不下来后面报表一定对不上。给一套能直接落地的核心表-- 库存表一行代表一个 SKU 在一个仓库的数量version 做乐观锁 CREATE TABLE t_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- 现存量 locked_qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- 已审核未发货的占用量 version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_sku_wh (sku_id, warehouse_id) ); -- 库存流水只追加不更新任何库存变动都必须在这里留痕 CREATE TABLE t_stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, biz_type VARCHAR(16) NOT NULL, -- PURCHASE_IN / SALE_OUT / ADJUST / RETURN biz_no VARCHAR(32) NOT NULL, -- 来源单据号对账靠它 change_qty DECIMAL(18,4) NOT NULL, -- 入库为正出库为负 before_qty DECIMAL(18,4) NOT NULL, after_qty DECIMAL(18,4) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_sku_time (sku_id, created_at), UNIQUE KEY uk_biz (biz_no, biz_type, sku_id) -- 同一单据同一商品只能入账一次 );locked_qty单独存一列是为了把「现存量」和「可用量」分开可用量等于qty - locked_qty销售开单审核时先占用量实际发货再扣现存量这样不会出现单子审核了货却已经被别人卖掉的情况。流水表记before_qty、after_qty看着冗余但排查库存差异时是黑匣子——能直接定位到是哪一张单把数量带偏的不用拿一堆单据重新加减。uk_biz这个唯一索引是幂等兜底客户端重复提交同一张单第二次插入直接报唯一键冲突服务端捕获后返回「单据已入账」比在代码里查一遍再插入更可靠。单据号我一般用「前缀 yyyyMMdd 4 位流水」比如CG202405130007。流水号从一张t_seq表里按日期取取号用UPDATE ... SET cur cur 1再查回来放在同一个事务里。不推荐用时间戳拼随机数一来不好看二来并发下真会撞。提示金额、数量一律用DECIMALJava 侧对应BigDecimal不要用double存单价。double的精度问题在汇总报表里会放大成几分钱的差对账时很难解释。3. 核心业务实现采购入库到销售出库的单据流转3.1 单据状态机与主从表结构单据类业务最容易写散的地方是状态判断。if (status 1) ... else if (status 2)这种代码一旦超过三处就会出现「已审核的单据还能改数量」这种漏洞。我的做法是把状态迁移集中到一个枚举里Service 里只调用check()不写裸的判断。public enum DocStatus { DRAFT, // 草稿可改可删 SUBMITTED, // 已提交等待审核 APPROVED, // 已审核库存已占用 PARTLY_DONE, // 部分执行部分发货 / 部分入库 DONE, // 已完成 RED; // 已红冲 // 合法迁移表集中维护新增状态只改这里 private static final MapDocStatus, SetDocStatus NEXT Map.of( DRAFT, EnumSet.of(SUBMITTED), SUBMITTED, EnumSet.of(APPROVED, DRAFT), APPROVED, EnumSet.of(PARTLY_DONE, DONE, RED), PARTLY_DONE, EnumSet.of(DONE, RED) ); public void check(DocStatus target) { SetDocStatus allowed NEXT.getOrDefault(this, Set.of()); if (!allowed.contains(target)) { throw new BizException(单据状态不允许从 this 变更为 target); } } }主从表结构上t_bill存单头单号、类型、往来单位、日期、总额、状态、制单人t_bill_item存明细商品、数量、单价、金额、已执行数量。明细里的「已执行数量」是部分发货的关键销售单审核后可以分三次发货每次发货更新明细的执行数全部执行完才把单头置为DONE。这一列如果不设计就只能靠汇总流水反推性能和可读性都差。单据审核这个动作要落三件事状态迁移、库存占用、写操作日志。三件事必须在同一个事务里任何一步失败全部回滚。我见过把日志写在事务外的写法结果单据回滚了日志却留下一条「审核成功」事后查问题时把人带沟里。3.2 库存扣减的两种写法与并发控制库存扣减是整套系统的技术核心也是答辩最容易被追问的地方。常见两种写法先查再改SELECT出来判断够不够再UPDATE或者带条件直接更新UPDATE ... WHERE qty n。前者在并发下必然超卖因为查和改之间有窗口后者把判断交给数据库靠行锁和条件保证原子性我一般用这种。-- 出库扣减条件里带库存判断影响行数为 0 说明库存不足 UPDATE t_stock SET qty qty - #{n}, version version 1 WHERE sku_id #{skuId} AND warehouse_id #{warehouseId} AND qty - locked_qty #{n};Service 层的调用顺序也有讲究写反了会出现「流水有记录、库存没扣」的鬼数据public void saleOut(long billId, long operatorId) throws Exception { tx(conn - { Bill bill billDao.loadForUpdate(conn, billId); // 先锁单头防重复执行 bill.getStatus().check(DocStatus.DONE); for (BillItem item : bill.getItems()) { int rows stockDao.deduct(conn, item.getSkuId(), bill.getWarehouseId(), item.getQty()); if (rows 0) { throw new BizException(商品 item.getSkuName() 库存不足); } BigDecimal after stockDao.queryQty(conn, item.getSkuId(), bill.getWarehouseId()); flowDao.insert(conn, item.getSkuId(), bill.getWarehouseId(), SALE_OUT, bill.getBillNo(), item.getQty().negate(), after.add(item.getQty()), after); // 变动前 变动后 出库量 } billDao.updateStatus(conn, billId, DocStatus.DONE, operatorId); return null; }); }参数上注意两点loadForUpdate用的是SELECT ... FOR UPDATE锁住单头防止两个线程同时执行同一张单扣减 SQL 的#{n}是BigDecimal千万不要在 Java 里先转成double再传。流水里的before_qty用「变动后 出库量」反推比再查一次数据库少一次往返但前提是扣减和查询在同一个事务里读到的就是刚更新的值。高并发场景还可以在t_stock上加version做乐观锁重试但小型进销存的并发量通常到不了那个级别行锁足够。真正需要防的是「同一张单被执行两次」这个靠单头行锁加状态机就够了。3.3 客户端界面与后台线程模型Swing 客户端最典型的翻车现场是点一下查询整个窗口白屏几秒。原因是网络请求写在了事件分发线程EDT里EDT 被阻塞重绘就停了。规矩很简单所有可能超过 50 毫秒的操作——网络请求、大批量导出、文件读写——统统放到后台线程结果回 EDT 渲染。// 查询按钮网络请求放后台结果回 EDT 更新表格 queryBtn.addActionListener(e - { queryBtn.setEnabled(false); // 防重复点击 new SwingWorkerListStockRow, Void() { Override protected ListStockRow doInBackground() throws Exception { // 这里不在 EDT可以放心阻塞 StockQueryResp resp client.call(stock.query, buildReq(), StockQueryResp.class); return resp.getRows(); } Override protected void done() { try { tableModel.setRows(get()); // done() 由 EDT 回调可直接操作组件 } catch (Exception ex) { JOptionPane.showMessageDialog(frame, 查询失败 rootMsg(ex)); } finally { queryBtn.setEnabled(true); } } }.execute(); });doInBackground跑在后台线程池done由 EDT 回调这是SwingWorker帮你处理好的线程切换比手动SwingUtilities.invokeLater少写一层嵌套。rootMsg要把ExecutionException拆开取getCause()否则弹出来永远是java.util.concurrent.ExecutionException看不出真实原因这种错误提示等于没提示。表格模型继承AbstractTableModel数据用ListStockRow存setRows里先fireTableDataChanged()再刷新状态栏。不要在getValueAt里做格式化之外的事情那个方法会被频繁调用一旦里面查数据库滚动条都能卡住。4. 数据一致性与权限事务边界、角色矩阵与报表口径4.1 事务边界与跨表写入顺序事务这东西写对了没人夸写错了全是血泪。C/S 项目里最常见的问题是事务开在客户端——客户端提交一张单服务端分三次请求分别写单头、明细、库存中间断网就留下半张单。正确的边界只有一个服务端每个业务指令一个事务客户端的一次call对应服务端的一个原子操作。// 统一事务模板回调里执行业务异常自动回滚 public T T tx(TxCallbackT cb) throws Exception { Connection conn DataSourceHolder.get(); boolean oldAuto conn.getAutoCommit(); conn.setAutoCommit(false); try { T result cb.doInTx(conn); conn.commit(); return result; } catch (Exception e) { conn.rollback(); // 业务异常、SQL 异常、运行时异常都回滚 throw e; } finally { conn.setAutoCommit(oldAuto); conn.close(); // 连接归还池不是真关闭 } }写入顺序建议固定成「单头 → 明细 → 库存 → 流水 → 日志」。这个顺序不是随意的单头先写能拿到自增主键明细挂在外键上库存和流水放最后是因为它们是最容易失败的两步库存不足、唯一键冲突放在后面失败时前面写的东西回滚代价最小。反过来先扣库存再写单头一旦单头插入失败库存已经动了回滚虽然也能救回来但锁持有时间变长并发下容易互相等。隔离级别用数据库默认的READ_COMMITTED就够别去调SERIALIZABLE那会把整套系统的并发打回单线程。真正需要串行的地方用SELECT ... FOR UPDATE精确锁住那一行比放大隔离级别划算得多。4.2 角色权限矩阵与菜单控制权限分两层菜单层决定看不看得到服务端决定干不干得成。只做前者等于没做因为客户端是可以被绕过的。角色我一般分四类配一张矩阵表写进详细设计文档角色基础资料采购单销售单库存调整报表用户管理管理员增删改查全权限全权限可操作全部可操作采购员只读建单/提交只读无采购类无销售员只读只读建单/提交无销售类无仓管员只读审核/入库审核/发货可操作库存类无服务端的校验放在分发层统一做不要撒在每个 Handler 里// 分发前统一鉴权指令码 - 权限点会话里没有就拒绝 String cmd req.getCmd(); if (!session.hasPerm(cmd)) { return Response.fail(无权限执行该操作); }session是登录时建立的存在服务端Maptoken, Session里客户端每次请求带 token。权限点直接复用指令码省掉一层映射role_perm表里存「角色 → 指令码」的多对多关系改权限只改数据不改代码。这样做还有一个好处新增一个功能只要在权限表里给它配好角色不用重新发版客户端。4.3 报表统计口径与对账表报表对不上十有八九是口径没定死。库存报表要区分三个数现存量qty、锁定量locked_qty、可用量qty - locked_qty。期间出入库汇总必须从流水表出不能从单据表出因为单据可能红冲红冲单在单据表里是另一条记录直接在单据表上SUM会把红冲量算成两倍。-- 某商品某期间的进销存明细期初 入库 - 出库 期末 SELECT sku_id, SUM(CASE WHEN created_at #{begin} THEN change_qty ELSE 0 END) AS open_qty, SUM(CASE WHEN created_at #{begin} AND created_at #{end} AND change_qty 0 THEN change_qty ELSE 0 END) AS in_qty, SUM(CASE WHEN created_at #{begin} AND created_at #{end} AND change_qty 0 THEN -change_qty ELSE 0 END) AS out_qty, SUM(CASE WHEN created_at #{end} THEN change_qty ELSE 0 END) AS close_qty FROM t_stock_flow WHERE sku_id #{skuId} GROUP BY sku_id;这条 SQL 的close_qty是「所有变动累加到期末」和「期初 入库 - 出库」在数学上恒等但如果流水有遗漏或重复两个算法会算出不同的数正好用来互相校验。验收时我会专门跑一遍这个恒等式对不上就说明流水写入有问题比翻单据快得多。注意报表查询的时间参数统一用服务端时间不要用客户端传上来的时间。客户端机器时间被改过报表就会莫名其妙少一天数据这种问题排查起来非常费劲。5. 避坑与排查C/S 进销存最容易翻车的五处5.1 库存与单据相关的三处坑库存和流水差几分钱。现象是库存表显示 100流水累加出来 99.98对账对到怀疑人生。原因基本是金额或数量用了double累加过程中精度丢失或者某次扣减写了两条流水。解决方式是把所有数量金额字段统一成DECIMAL(18,4)加BigDecimal比较时用compareTo不用equalsequals会比较 scale1.0和1.00判不相等流水表加(biz_no, biz_type, sku_id)唯一索引重复写入直接失败。同一张单入了两次库。现象是库存翻倍查流水发现同一单号有两条一模一样的记录。原因是客户端超时重发或者操作员连点了两下审核按钮。服务端如果只做了「查一下有没有入过账再插入」在高并发下两个线程会同时查到「没有」。解决靠数据库唯一索引兜底插入冲突捕获后返回「该单据已入账」不要抛异常给客户端客户端侧按钮点击后立刻置灰请求返回再恢复。已审核的单据还能改数量。现象是审核过的采购单被修改库存却没跟着变单据和库存对不上。原因是修改入口只判断了「是不是本人创建」没判断状态。解决办法是把状态校验放进UPDATE的WHERE条件里UPDATE t_bill SET qty ? WHERE id ? AND status DRAFT影响行数为 0 就提示「当前状态不可修改」。这比先查再改可靠因为查和改之间没有窗口。5.2 通信与部署相关的两处坑界面卡死任务管理器显示程序未响应。现象是点查询后窗口白屏几秒后恢复数据量大时直接卡死。原因前面提过网络请求或大批量数据渲染跑在了 EDT 上。排查方式是打开 Java 控制台看线程栈如果 EDT 停在SocketInputStream.read或 JDBC 调用上基本可以确诊。解决办法是把阻塞操作全部挪进SwingWorker或独立线程池表格分页加载一次只查 200 行滚动到底再拉下一页。跑了一上午客户端连不上服务端。现象是早上正常下午陆续有客户端报连接超时重启服务端才好。原因通常是连接没释放客户端每次请求新建一个 Socket用完忘了关或者服务端线程池满了新连接排队。排查时在服务端打印当前活跃连接数看着它一路涨就是泄漏。解决办法是客户端全局复用一个长连接加心跳包每 30 秒发一个空指令服务端设读超时超时未收到心跳就主动关闭线程池用固定大小加有界队列队列满了直接拒绝并返回「服务繁忙」比无限堆积把内存撑爆强。6. 验收与答辩怎么证明这套进销存真的跑通了验收这件事我的习惯是不看功能列表打勾而是走一条完整的业务链路让数据自己说话。具体做法是准备一份验收脚本从建商品、建供应商开始一路走到采购入库、销售出库、退货、盘点最后出三张报表每一步都记下预期值和实际值。答辩时按这个顺序演示评委看到的是数据在流动而不是一个个孤立的界面。验收项操作通过标准采购入库建单 100 件审核并入库库存 100流水一条金额与单价一致并发出库两个客户端同时出库 60 件只有一个成功另一个提示库存不足重复提交同一采购单连点两次审核库存只加一次第二次提示已入账红冲对已入库单做红冲库存回到原值流水新增反向记录权限隔离销售员账号调库存调整指令服务端返回无权限数据库无变化报表对账期初 入库 - 出库等于期末且与库存表现值一致演示时有个小技巧提前在数据库里造一批带小数点的单价比如 19.99、0.1跑完汇总看总额是不是精确到分。评委里只要有一个做过财务系统的看到金额没有浮点误差印象分就稳了。反过来如果演示到一半库存对不上别急着切界面当场把那笔流水的before_qty、after_qty调出来说明是哪一步写入的能救回不少分——这恰恰证明你知道系统内部长什么样。答辩被问得最多的是「并发怎么处理」和「事务边界在哪」。回答时别背概念直接指代码库存扣减的UPDATE ... WHERE qty n、单头的SELECT ... FOR UPDATE、服务端每个指令一个事务。再补一句「客户端不参与事务」基本就问到头了。从那以后我每次交付前都强制走一遍这份验收清单哪怕时间紧也要把并发和重复提交这两项跑完因为这两处翻车概率最高而且一旦出问题演示现场根本圆不回来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑