资讯详情

JSP垃圾回收积分商城系统:积分流水、幂等结算与兑换双扣减

📅 2026/10/27 22:54:12 | 华诺云谱 👁 阅读
JSP垃圾回收积分商城系统:积分流水、幂等结算与兑换双扣减
先说个容易被忽略的事实这几年我接触过的基层信息化小项目里JSP 出现的频率远比技术圈讨论的要高。尤其是社区回收点、废品中转站这类场景——办公电脑是七八年前的机器浏览器还停留在某个国产内核的老版本网络时断时续运维预算基本为零。在这种环境里JSP 那套“服务端渲染、一个 war 包丢进容器就能跑、前端零构建”的特性反而成了难得的优点。基于 JSP 实现垃圾回收积分商城系统说白了就是把两件事缝在一起一头是回收业务居民把纸箱、塑料瓶、旧衣物送到服务点按品类和重量折算成积分另一头是积分商城积分能在里面换洗衣液、抽纸、大米这些日用品。听起来简单真正动起手来难的不是页面怎么写而是积分这个“钱”怎么记才不出错。我见过太多类似项目页面做得挺漂亮上线三个月账就平不了最后靠人工 Excel 对账。这篇内容适合三类人想拿一个完整业务闭环练手 JavaWeb 的同学、接了类似小项目需要快速落地的开发者、以及被“积分/余额”这类账户模型坑过的同行。下面我不讲教科书式的分层架构只讲这套系统里真正会咬人的地方。1. 为什么这套系统适合用 JSP 收口而不是硬上前后端分离1.1 运行环境的现实约束比技术潮流更有话语权我先把选型这件事说透因为很多人一上手就想套 Vue 加 Spring Boot结果部署阶段直接卡死。回收点的实际条件是一台装着老版本运行时的工控机或者普通台式机屏幕分辨率 1366×768操作人员是四五十岁的阿姨只会“点开浏览器、输入地址、点按钮”这三步。这种条件下前后端分离带来的收益几乎为零成本却实实在在需要额外装 Node 环境打包、需要配静态资源服务、需要处理跨域、需要保证前端路由在旧浏览器上不崩。而 JSP 的方案是——一个 war 包扔进 Tomcat 的 webapps 目录启动访问完事。升级版本也就替换一个文件。这个差别在有人帮你运维的环境里不算什么在没人运维的环境里就是生死线。另一个被低估的点是首屏渲染。回收点的网络是那种“能用但会抽风”的状态前后端分离的项目一旦接口超时页面就是一片空白加转圈JSP 因为是服务端拼好 HTML 再吐出来即使慢用户看到的也是完整页面只是慢一点。对不会刷新重试的用户来说这个体验差异很大。所以我的判断标准很朴素如果使用者不会用开发者工具运维没人管那就老老实实服务端渲染。这不是技术保守是场景匹配。1.2 一套能撑住业务的目录结构长什么样JSP 项目最容易烂的地方是目录。我见过把所有 jsp 平铺在 webapp 根目录下的三十多个文件挤在一起找goods_list.jsp要翻半天。下面这套结构是我反复用过、比较顺手的webapp/ ├── WEB-INF/ │ ├── web.xml │ ├── lib/ # 依赖 jar别用 Maven 之外的野路子 │ └── views/ # 所有 jsp 藏这里禁止外部直接访问 │ ├── common/ │ │ ├── header.jsp │ │ └── footer.jsp │ ├── recycle/ # 回收业务 │ │ ├── order_add.jsp │ │ └── order_list.jsp │ ├── mall/ # 积分商城 │ │ ├── goods_list.jsp │ │ └── order_confirm.jsp │ └── admin/ ├── static/ │ ├── css/ │ ├── js/ │ └── img/ └── index.jsp # 只做跳转把 jsp 全部塞进WEB-INF/views是个关键动作。因为WEB-INF目录浏览器无法直接访问所有页面必须经过 Servlet 转发才能到达。这样做的好处是入口唯一任何一次页面访问都必然经过控制层权限校验、参数预处理、登录拦截才有地方挂。否则用户直接敲goods_list.jsp就能绕过登录这类漏洞在小项目里极其常见。提示index.jsp只保留一行jsp:forward page/home/不要在里面写业务它的唯一职责是把根路径请求交给 Servlet。1.3 JSP 只做展示这件事必须用纪律守住JSP 最诱人的地方是能直接写 Java 代码% %一裹什么逻辑都能塞。这正是它名声变差的原因。我的做法是给自己定三条硬规矩第一JSP 里不出现Connection、不出现 SQL。所有数据在 Servlet 里准备好通过request.setAttribute传过去。第二JSP 里不出现if (user null)这种判断登录拦截统一放在 Filter 里做。第三金额、积分这类数字的格式化统一走 JSTL 的fmt:formatNumber不在页面里写String.format。举个最典型的对比。反面写法是在页面上直接查数据% ListGoods list GoodsDao.queryAll(); // 绝对不要在 JSP 里干这个 for (Goods g : list) { out.print(g.getName()); } %正面写法是 Servlet 转发JSP 只负责渲染// GoodsListServlet.java protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { ListGoods goods goodsService.listOnSale(); req.setAttribute(goodsList, goods); req.getRequestDispatcher(/WEB-INF/views/mall/goods_list.jsp).forward(req, resp); }c:forEach items${goodsList} varg div classcard span${g.name}/span spanfmt:formatNumber value${g.pricePoints} pattern#,##0/ 积分/span /div /c:forEach第二段代码看着笨但它在半年后你回头改需求时能救你一命——因为页面里没有任何业务逻辑需要理解改动只发生在 Java 侧。这条纪律看着简单能坚持的项目不到一半。2. 把“扔一袋废纸箱”翻译成数据模型三条主线必须分开业务建模这一步做错后面全是补丁。这个系统里真正跑起来的主线只有三条回收单、积分流水、兑换订单。三者的关系必须理清楚。2.1 回收单业务发生的“事实记录”回收单记录的是一次物理世界里真实发生的事某人某时在某服务点交了一袋东西过秤多少公斤什么品类。品类和单价的配置我建议单独建表不要写死在代码里。原因是积分标准会变比如塑料瓶从 8 积分/公斤调到 10 积分/公斤如果写死在 Java 常量里改一次就要重新打包部署。做成配置表之后运营在后台改个数字就完事。CREATE TABLE t_recycle_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 纸类/塑料/金属/织物/玻璃, points_per_kg INT NOT NULL COMMENT 每公斤折算积分, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用 ); CREATE TABLE t_recycle_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号 R日期序列, user_id BIGINT NOT NULL, category_id INT NOT NULL, weight_kg DECIMAL(8,2) NOT NULL COMMENT 过秤重量,保留两位, points_per_kg INT NOT NULL COMMENT 下单时快照单价, total_points INT NOT NULL COMMENT 结算积分 weight * points_per_kg, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待结算 1已结算 2已作废, settle_time DATETIME NULL, operator_id BIGINT NOT NULL COMMENT 录入的操作员, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_user_time (user_id, create_time) );这里有个细节值得单独说points_per_kg必须在回收单里存一份快照。很多人图省事结算时去关联品类表取单价结果品类调价之后历史单据的金额全跟着变了月底对账时发现去年十二月的报表和打印出来的小票对不上——这种事故我见过不止一次。快照字段虽然冗余但它是业务事实的一部分不能省。另外weight_kg用DECIMAL(8,2)别用DOUBLE。浮点数在累加时会出现0.10.20.30000000000000004这种情况积分是要乘单价的误差会被放大。称重保留两位小数已经足够顺手把乘法结果用整数处理// 先按“克”放大成整数再算避免浮点误差 int totalPoints (int) Math.round(weightKg.doubleValue() * pointsPerKg);2.2 积分账户余额字段只能当缓存流水才是真相这是整个系统里最重要的一个认知。新手最常见的做法是用户表加一个points字段回收了就兑换了就-。这个做法在单机低并发下能用但一旦出问题就无法追溯——用户说我明明有 3000 积分你系统显示 2000你拿什么证明正确的模型是双表一张流水表记录每一笔变动一条记录对应一次业务一张账户表存当前余额纯粹为了查询快属于缓存性质。CREATE TABLE t_point_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, biz_type TINYINT NOT NULL COMMENT 1回收入账 2兑换出账 3人工调整 4冲正, biz_no VARCHAR(32) NOT NULL COMMENT 关联业务单号,幂等的关键, change_points INT NOT NULL COMMENT 正数入账,负数出账, balance_after INT NOT NULL COMMENT 变动后余额快照, remark VARCHAR(128), create_time DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_no), -- 同一业务只能记一次账 KEY idx_user (user_id, create_time) ); CREATE TABLE t_user_account ( user_id BIGINT PRIMARY KEY, balance INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME );uk_biz这个唯一索引是整个模型的保险丝。它保证同一张回收单不管被结算请求打到多少次流水表里都只能有一条记录。数据库层面挡住的错误比代码里写十个if都可靠。balance_after看起来冗余但排查问题的时候极其有用。用户投诉“我的积分少了两百”你把流水按时间列出来一眼就能看出哪一笔开始对不上不用去反推。2.3 商品与兑换订单库存和积分是两笔账商城侧的表结构相对常规但有一点要注意——商品要有“上架状态”和“库存”两个独立字段不能合并。运营想临时下架一个商品但保留库存数据合并字段就做不到了。CREATE TABLE t_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, cover_img VARCHAR(128), price_points INT NOT NULL COMMENT 兑换所需积分, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, sort_no INT NOT NULL DEFAULT 0 ); CREATE TABLE t_exchange_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, goods_name VARCHAR(64) NOT NULL COMMENT 下单快照, price_points INT NOT NULL COMMENT 下单快照, qty INT NOT NULL DEFAULT 1, total_points INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待核销 1已完成 2已取消, pickup_code VARCHAR(16) NOT NULL COMMENT 线下自提码, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) );goods_name和price_points同样是快照。理由和回收单一样商品改名、调价之后历史订单必须保持原样否则用户翻记录会一脸懵。三条主线理清楚之后页面就只是这三张表的不同视角而已。回收端是“写回收单 触发入账”商城端是“读商品 写出库单 触发扣减”管理端是“查流水 查订单 处理异常”。逻辑一下子清爽了。3. 积分入账这条链路并发问题藏在你想不到的地方这一段是我最想展开的因为它是这类系统唯一真正有技术难度的地方也是最容易上线后炸的地方。3.1 一张回收单被结算两次是怎么发生的先说一个真实场景。操作员阿姨录完一张回收单点了“确认结算”页面转圈她以为没点上又点了一次。第一次请求刚好走到一半两次请求同时到达服务端于是请求 A 读到status0判断“未结算”开始处理请求 B 也读到status0同样判断“未结算”也开始处理两个请求各自给账户加了积分各自写了一条流水结果就是这位居民白拿了一倍积分。如果当天有五十个人遇到这种情况账就平不了了。这个问题的根因不是“用户手抖”而是检查与更新之间没有原子性。先查询状态 → 再判断 → 再更新这个模式在并发下必然出问题因为查询和更新之间有一个时间窗口。修法有几种我最推荐的是用一条带条件的 UPDATE 完成“检查更新”UPDATE t_recycle_order SET status 1, settle_time NOW() WHERE id #{orderId} AND status 0;然后在 Java 里看影响行数int affected stmt.executeUpdate(); if (affected 0) { // 说明这张单已经被别人结算过了或者状态不对 return Result.fail(该回收单已结算请勿重复操作); }数据库在执行这条 UPDATE 时会对该行加锁两个并发请求只有第一个能看到status0第二个进来时status已经是 1匹配不上影响行数为 0。这是最简单也最可靠的幂等手段不需要引入任何分布式组件。3.2 事务边界划在哪儿账户、流水、单据必须同生共死幂等解决的是“不重复”但还有“不残缺”的问题。一次结算要动三张表回收单状态、账户余额、积分流水。这三步必须在一个事务里任何一步失败全部回滚。Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 第一步条件更新回收单顺便完成幂等 int n updateOrderStatus(conn, orderId); // status 0 - 1 if (n 0) { conn.rollback(); return Result.fail(该回收单已结算); } // 第二步账户加积分 int m addBalance(conn, userId, totalPoints); if (m 0) { conn.rollback(); return Result.fail(积分账户异常); } // 第三步读回最新余额写快照 int after queryBalance(conn, userId); insertFlow(conn, userId, 1, orderNo, totalPoints, after); conn.commit(); return Result.ok(结算成功本次入账 totalPoints 积分); } catch (Exception e) { if (conn ! null) conn.rollback(); log.error(结算失败 orderNo{}, orderNo, e); return Result.fail(结算失败请重试); } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } }这里有个细节第二步用的是balance balance ?这种自增写法不是“先查出来加完再写回”。前者是数据库层面的原子操作后者在并发下会丢失更新两个请求读到同一个旧值都加了一次最后只生效一次。至于balance_after的读取虽然是事务内查询但因为前面的UPDATE已经持有该行的锁其他事务进不来读到的就是自己刚写的值不会串。注意MySQL 的默认隔离级别是 RR可重复读在同一个事务里第一次SELECT之后后续SELECT读的是快照。所以读余额这一步必须放在 UPDATE 之后如果放在事务开始就查读到的会是旧值。这个坑我踩过一次排查了两个小时。3.3 一次“积分翻倍”的完整排查链路讲个我实际处理过的问题把排查思路摊开因为这种问题以后你大概率也会遇到。现象某天运营反馈有十几个用户的积分比预期多了整整一倍时间集中在下午两点到两点半。第一步先看流水表。按用户和时间把这些人的流水列出来SELECT biz_type, biz_no, change_points, balance_after, create_time FROM t_point_flow WHERE user_id 1024 ORDER BY create_time DESC LIMIT 20;结果发现同一个biz_no回收单号有两条记录change_points相同但create_time相差不到一秒。这时候第一反应应该是唯一索引uk_biz没生效第二步验证唯一索引。查表结构发现索引确实建了。那为什么能插进去两条再看biz_no两次的值不一样——一张是R20240612001另一张是R20240612001末尾多了一个空格。第三步追到源头。看回收单生成逻辑单号是用System.currentTimeMillis()拼的而两次请求时间戳不同所以生成了两个不同的单号。也就是说阿姨点两次“新增”系统真的创建了两张回收单而不是同单重复结算。第四步定位真正的漏洞。问题出在页面的提交按钮没有防重复。第一次点击后按钮没禁用第二次点击又发了一个POST /recycle/add服务端老老实实建了新单。修复分两层前端点击后立即disabled这是治标后端在创建回收单时增加一个**提交令牌token**校验这是治本。令牌的实现很简单进入录入页时生成一个 UUID 存进 session同时放进表单隐藏域提交时比对无论成功失败都从 session 里删掉。这样第二次提交找不到令牌直接拒绝。这套排查走下来核心经验是看到重复数据先别急着改代码先确认重复的到底是“同一业务”还是“两次业务”。这两种情况的修法完全不同——前者是幂等问题后者是防重提交问题。4. 兑换环节的双扣减库存和积分谁先动顺序有讲究回收端是“只加不减”逻辑相对简单。商城兑换就麻烦了因为它同时要扣两样东西商品库存、用户积分。任何一边扣失败都会出现“扣了积分没拿到货”或者“发了货没扣积分”的烂账。4.1 先扣库存还是先扣积分我的选择是先库存这个问题网上答案不一我按自己的实践说理由。先扣库存的好处是库存是全局共享资源冲突概率高早点扣能早点暴露问题。积分是单用户资源冲突概率低。把高冲突的操作放在前面能让事务持锁时间更短也更容易在第一时间返回“已售罄”。流程是这样-- 第一步条件扣库存stock qty 保证不超卖 UPDATE t_goods SET stock stock - #{qty} WHERE id #{goodsId} AND status 1 AND stock #{qty}; -- 第二步条件扣积分balance total 保证不透支 UPDATE t_user_account SET balance balance - #{total}, version version 1 WHERE user_id #{userId} AND balance #{total}; -- 第三步写兑换订单 INSERT INTO t_exchange_order (...) VALUES (...); -- 第四步写积分流水负数 INSERT INTO t_point_flow (...) VALUES (...);四条语句在一个事务里前三步任何一步影响行数为 0 就回滚。第二步如果返回 0说明积分不够回滚之后库存会自动还回去——这就是为什么必须在同一个事务里如果分开两个接口调用库存扣了积分不够就得写补偿逻辑复杂度翻倍。stock #{qty}这个条件很多人会忘记写只写stock stock - #{qty}。后者在库存为 0 时会变成负数虽然不会报错但数据就烂了。有了这个条件库存不足时影响行数为 0代码里判断一下就能给出友好提示。4.2 悲观锁还是乐观锁这个场景其实都不太需要有人会问要不要加SELECT ... FOR UPDATE。我的看法是在条件 UPDATE 已经能解决问题的情况下不要额外引入锁。原因很实在。加FOR UPDATE会让事务持锁时间变长回收点这种并发量几十的小系统倒无所谓但它会带来死锁风险——如果两个事务分别锁了商品行和账户行顺序不一致就可能互相等待。而条件 UPDATE 本身就是原子的且锁的范围就是那一行写完立刻释放简单得多。乐观锁version字段在这个场景主要用于账户表的更新作用是防止“读-改-写”模式下的丢失更新。但我上面用的balance balance - ?本身就是原子的version更多是给后续可能的缓存刷新用属于可选动作。真正需要乐观锁的是那种“先查库存算总价再更新库存”的流程而我们这里没有。一句话总结能用一条 SQL 表达的条件更新就别拆成查询加更新。4.3 JSP 表单重复提交的三种拦法兑换按钮是用户最容易连点的地方因为大家都怕“抢不到”。三层防护我一般全上第一层前端禁用。表单onsubmit时把按钮置灰并改文案function submitOnce(form, btn) { if (btn.disabled) return false; btn.disabled true; btn.value 处理中...; return true; }这层只能挡住正常用户挡不住脚本或者刷新重发。第二层令牌校验。进入确认页时生成 token 存 session表单带隐藏域提交Servlet 里比对后立即从 session 移除。这是主力。// 生成 String token UUID.randomUUID().toString(); session.setAttribute(exchangeToken, token); req.setAttribute(token, token); // 校验 String submitted req.getParameter(token); String cached (String) session.getAttribute(exchangeToken); session.removeAttribute(exchangeToken); // 无论成败都删 if (cached null || !cached.equals(submitted)) { resp.sendRedirect(req.getContextPath() /mall/list?msgrepeat); return; }第三层数据库唯一约束。兑换订单号用“用户ID 商品ID 秒级时间戳”生成加唯一索引。极端情况下前两层都被绕过数据库这一层还能兜住。三层都上的成本很低但能省掉后面大量的“用户说扣了积分没收到货”的客服工单。5. 上线之后才暴露的问题以及补救手段开发和上线是两回事。这套系统上线后我遇到过的问题基本都集中在数据一致性和操作留痕上跟功能本身关系不大。5.1 每天凌晨跑一次对账比任何监控都管用我的习惯是写一个定时任务每天凌晨两点跑一遍全量校验-- 校验一账户余额是否等于流水累加 SELECT a.user_id, a.balance, IFNULL(SUM(f.change_points), 0) AS flow_sum FROM t_user_account a LEFT JOIN t_point_flow f ON f.user_id a.user_id GROUP BY a.user_id, a.balance HAVING a.balance IFNULL(SUM(f.change_points), 0);这条 SQL 应该永远返回空集。一旦有记录说明某次操作没走完整事务需要人工介入。跑完之后把结果写进一张t_check_log表连续几天都是 0 才算稳定。校验二是核对回收单和流水的一对一关系-- 已结算的回收单必须有且只有一条对应的入账流水 SELECT o.order_no FROM t_recycle_order o LEFT JOIN t_point_flow f ON f.biz_type 1 AND f.biz_no o.order_no WHERE o.status 1 GROUP BY o.order_no HAVING COUNT(f.id) 1;这两条 SQL 我建议写在项目文档最显眼的位置。它们不解决问题但能第一时间告诉你“出问题了”比等用户投诉强太多。5.2 脏数据怎么补冲正而不是删改发现数据错了新手的第一反应是UPDATE改回去。这是大忌。改完之后流水和余额对不上你连问题是什么时候发生的都查不出来了。正确做法是冲正写一条相反方向的流水把错误抵消掉同时在备注里写清楚冲正原因和关联的原流水 ID。INSERT INTO t_point_flow (user_id, biz_type, biz_no, change_points, balance_after, remark, create_time) VALUES (1024, 4, REV-20240612-001, -500, 2500, 冲正:回收单R20240612001重复入账, NOW());同时更新账户余额biz_type用 4冲正区分开。这样整个账户的变动历史是完整可追溯的任何时候都能从头算一遍验证余额。审计也好、复盘也好都有据可查。5.3 操作留痕谁改的比改了什么更重要小项目最容易省掉的就是操作日志但我吃过亏。有一次一个用户的积分突然少了两千查流水显示是“人工调整”但没人承认操作过。因为当时的日志只记了“调整 -2000”没记操作人。后来我强制加了一张操作表CREATE TABLE t_operate_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, operator_id BIGINT NOT NULL, module VARCHAR(32) NOT NULL COMMENT recycle/mall/account, action VARCHAR(32) NOT NULL COMMENT settle/exchange/adjust, target_no VARCHAR(32) COMMENT 业务单号, detail VARCHAR(512) COMMENT 关键参数JSON, ip VARCHAR(32), create_time DATETIME NOT NULL, KEY idx_time (create_time) );关键是ip和detail两个字段。detail里存变更前后的值用简单 JSON 拼一下就行不需要引入序列化框架。查询的时候按时间倒序一拉谁在什么时候干了什么一目了然。这套日志的成本是一个 AOP 或者手工埋点收益是出问题时不用猜。我个人觉得这是性价比最高的一块投入。关于页面权限我的做法很土但有效在t_user表加一个role字段1 居民 / 2 操作员 / 3 管理员登录成功后把 role 放进 sessionFilter 里对/admin/*路径做一次判断。别去搞复杂的 RBAC 五张表这个规模的系统用不上反而增加了维护负担。到最后我想说的是这类系统的技术含量确实不高但它对数据严谨性的要求一点都不低 —— 因为积分在用户心里就是钱。我做完这个项目最大的体会是页面写得丑一点没关系操作员多等两秒也没关系但账绝对不能错。每次动手改积分相关的逻辑我都会先把前面那两条对账 SQL 跑一遍确认改完之后还是空集才敢发布。这个习惯帮我省掉了无数次半夜被电话叫起来的麻烦。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑