2026/10/27 22:54:01

JSP垃圾回收积分商城系统:积分流水与对账设计

JSP垃圾回收积分商城系统:积分流水与对账设计 1. 先把这个系统的账算清楚需求拆解与整体设计垃圾回收积分商城系统这个词第一次听的人容易把它想成两个独立的东西——一个回收登记本一个积分兑换页。真做起来才会发现它是同一套账的两个方向回收是进账兑换是出账中间那条线必须严丝合缝。我接触这个题目的场景很典型某社区想做一套轻量的激励工具居民把纸箱、塑料瓶、旧衣物送到回收点工作人员称重登记系统按品类折算积分居民用积分去商城换洗衣液、纸巾、米面这类日用品。整套东西用JSP搭原因后面细说但核心矛盾从一开始就很清楚积分的产生和消耗必须可追溯、可对账、不能凭空多也不能莫名少。这篇内容适合三类人看。第一类是正在做课程设计或毕业设计的同学需要一套跑得起来、讲得清楚、结构完整的实现第二类是社区或小型回收站点的工作人员想自己搭一个不花钱、不依赖外部服务的登记兑换系统第三类是做传统Java Web维护的同行手上有一堆JSP工程需要在这套老技术栈上加一个带资金属性的业务模块。后者其实最需要这篇文章因为积分看着不是钱运营两个月之后它就是钱。我先把范围划清楚系统管三件事——回收登记谁、什么时候、送了什么、多重、值多少积分、积分账户当前余额、每一笔变动来自哪里、商城兑换有什么商品、库存多少、谁换了、发货没发货。不管它就会变成台账Excel加微信群喊话一个月之后没人说得清某位居民的余额是怎么来的。1.1 谁在用、用在什么场景角色不多但权限边界必须一开始就切干净。我见过太多同学把这块糊弄过去最后管理员和居民的页面混在一个JSP里靠if判断代码越写越乱。角色主要动作权限边界居民用户查看自己积分、浏览商品、提交兑换、看兑换记录只能读写自己的数据不能看别人余额回收点工作人员录入回收单、称重、确认品类、提交入账可创建积分流水不可修改历史流水不可删单管理员维护品类与积分系数、商品上下架、库存调整、审核兑换、导出对账可配置规则但所有敏感操作留操作日志这个划分有个隐藏好处工作人员只负责录系统负责算。称重是人工的容易出错换算是程序做的不能出错。把这两个动作物理分开出了问题就能定位到底是秤的问题还是规则的问题。我在实际使用中碰到过一次纠纷居民说自己送了一大捆纸板应该有五公斤登记只有三公斤。查下来是工作人员把品类选成了混合废纸而不是纸板系数不一样。系统里留了品类和系数的快照一分钟就解释清楚了。要是当时只有一条加50分的记录这事说不明白。1.2 为什么是JSP技术选型的现实考量先把话说透JSP不是新东西今天做新项目我不会首选它。但在这个具体场景里它有三个实打实的优势这也是我最终没换技术栈的原因。第一是部署成本几乎为零。一台普通服务器装个Tomcat把war包扔进webapps目录就完事不需要Node环境、不需要打包构建链、不需要容器编排。社区这种环境能少一个依赖就少一个运维事故点。第二是页面即文件改一个文案、加一个字段直接在JSP里改完重启就行接手的人哪怕只会写HTML也能维护这对人员流动性大的小团队很关键。第三是和JDBC天然贴合这套系统数据量不大一天几百条回收单用不着ORM的复杂映射手写SQL反而更透明出问题能一眼看到执行的是哪条语句。代价也要讲清楚。JSP的模板能力弱页面复用靠include复杂交互要靠手写JavaScript和Ajax没有成熟的依赖注入和事务管理事务得自己在Servlet里用Connection控制调试体验差报错信息经常指向编译后的Java文件得回头找对应的JSP行号。这些坑后面我会一个个说怎么绕。提示如果这个系统未来要接入小程序或者做多端JSP这层就只能当后台管理页用业务逻辑必须提前抽到Servlet或Service类里页面只做展示。我第一版就是把换算逻辑写在了JSP脚本片段里后来加一个手机端接口等于重写了一遍。1.3 模块划分与数据流向整体是经典的三层但要加一个流水层的概念这是这套系统的灵魂。居民/工作人员浏览器 | JSP 视图层展示 表单 | Servlet 控制层参数校验、权限判断、跳转 | Service 业务层换算、事务、幂等 | DAO 数据访问层JDBC 预编译SQL | MySQL账户表 流水表 商品表 订单表数据流有两个方向一定要在脑子里画清楚。入账方向工作人员提交回收单 → Service 校验重量和品类 → 按规则算出积分 → 开启事务 → 写回收单主表 → 写积分流水 → 更新账户余额 → 提交事务。出账方向居民提交兑换 → Service 检查余额和库存 → 开启事务 → 扣库存带条件更新→ 写兑换订单 → 写积分流水负数→ 更新账户余额 → 提交事务。两个方向共用同一张流水表和同一个余额字段这就是为什么双记账在这里不是可选项而是必需品。余额是结果流水是过程任何时候两者对不上就以流水为准重算余额。2. 数据库设计积分系统的地基数据库这块我只说真踩过的坑。很多教程给的建表语句能跑但跑一个月就出问题问题不在语法在字段类型和约束的设计意图上。2.1 核心表结构与字段取舍先看用户表和品类表这两个是基础字典。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL COMMENT 登录手机号, nick_name VARCHAR(50) NOT NULL, pwd_hash VARCHAR(64) NOT NULL COMMENT 加盐后的摘要, salt VARCHAR(16) NOT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0居民 1工作人员 2管理员, point_balance INT NOT NULL DEFAULT 0 COMMENT 冗余余额以流水为准, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0冻结, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL COMMENT 品类名如纸板, unit_price DECIMAL(10,3) NOT NULL COMMENT 每公斤折算金额, point_rate INT NOT NULL DEFAULT 10 COMMENT 每元折多少积分, min_weight DECIMAL(8,2) NOT NULL DEFAULT 0.1 COMMENT 起收重量, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键决定。point_balance用INT而不是DECIMAL因为积分我坚持只允许整数半分的积分在展示和兑换时全是麻烦取整规则统一放在 Service 里做。unit_price用DECIMAL(10,3)保留三位小数是因为纸类单价常在 0.8 到 1.2 元每公斤之间波动两位小数误差会在批量结算时被放大。t_user里那个point_balance是冗余字段它和流水表存在理论上的不一致风险但必须有。原因很直接商城列表页要显示你的积分还够换几件如果每次都去流水表 sum 一遍首页就会拖慢。这是一个典型的用一致性换性能的取舍代价是要有对账机制兜底第 6 章会给对账 SQL。再看回收单表和流水表。CREATE TABLE t_recycle_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号幂等用, user_id BIGINT NOT NULL, staff_id BIGINT NOT NULL, category_id INT NOT NULL, weight DECIMAL(8,2) NOT NULL, unit_price DECIMAL(10,3) NOT NULL COMMENT 下单时单价快照, point_rate INT NOT NULL COMMENT 下单时倍率快照, point_amount INT NOT NULL COMMENT 本次产生积分, remark VARCHAR(200) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_point_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, change_amount INT NOT NULL COMMENT 正数入账负数出账, balance_after INT NOT NULL COMMENT 变动后余额快照, biz_type TINYINT NOT NULL COMMENT 1回收 2兑换 3人工调整 4撤销, biz_id BIGINT NOT NULL COMMENT 对应业务主键, remark VARCHAR(200) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_id), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_recycle_order里存了unit_price和point_rate的快照这是我认为整个设计里最重要的一个决定。规则是会变的今天纸板 1.2 元每公斤下个月可能调成 1.0 元。如果订单表只存 category_id历史单据的积分就永远跟着最新规则漂移年底一算总账全是解释不清的差额。存快照之后历史就是历史规则调整只影响新单据。t_point_log的uk_biz (biz_type, biz_id)唯一索引是幂等的最后一道闸门。同一张回收单无论因为网络重试还是用户狂点提交按钮被处理多少次第二次插入必然冲突失败事务回滚不会重复加分。这个约束比在代码里写 if 判断可靠得多因为它拦在数据库层代码写漏了也拦得住。2.2 积分流水的设计要点流水表有三条铁律我建议直接抄进项目注释里。第一条流水只增不改不删。任何修正都必须用一条新的反向流水来实现而不是 update 老记录。这跟财务记账是一个道理划掉的账和抹掉的账性质完全不同。撤销一张回收单做法是插入一条biz_type4、金额为负的流水备注里写清撤销原因而不是把原来那条删了。第二条balance_after 必须写。很多实现只记变动金额余额靠实时 sum 计算。这样一旦有并发或者历史数据被人工改过就再也查不清是哪一笔开始错的。有了 balance_after对账就变成一个简单动作取某用户最后一条流水的 balance_after和 t_user.point_balance 比不一致就说明有写入绕过了流水。第三条biz_type 要有明确枚举不要用 0-9 随便编号。我在第三版里加了人工调整这个类型是因为运营中一定会出现这种情况居民来投诉说某天送的十公斤纸板没到账查下来是工作人员操作失误提交失败了那么补账就得走人工调整并留下清晰的备注谁调的、为什么调、依据是什么。没有这个类型补账只能偷偷改余额账就烂了。2.3 索引与并发冲突处理索引不用多这两个必须t_point_log(user_id, create_time)支撑个人明细页倒序分页t_recycle_order(user_id, create_time)支撑个人回收记录。管理员端的对账查询走的是全表聚合数据量到几十万行时建议按 create_time 加范围条件别裸跑全表。并发这块是重点。积分系统的并发冲突只有两处但每处都能让人半夜爬起来处理。第一处是余额更新。绝对不能用先查余额、代码里加、再写回这种三步走因为两个请求同时读到 100都加 50最后写回 150 而不是 200。正确做法是让数据库做原子运算UPDATE t_user SET point_balance point_balance #{changeAmount} WHERE id #{userId} AND point_balance #{changeAmount} 0;末尾那个条件很关键它同时解决了余额不能为负的校验。出账时如果余额不足这条语句影响行数为 0Service 层判断affectedRows 0就抛异常回滚不需要先 select 再判断省一次查询也避开了检查与更新之间的竞态。第二处是库存扣减同理UPDATE t_goods SET stock stock - #{num} WHERE id #{goodsId} AND status 1 AND stock #{num};我见过有人用SELECT ... FOR UPDATE加行锁来做逻辑上没问题但在 InnoDB 下遇到范围条件或者索引没走好容易升级成间隙锁一锁一大片小系统也会卡。用条件更新的方式锁的粒度最小行为最好预测。3. 核心业务实现从投递称重到积分入账这一章是全篇最需要动手的部分。我会把换算规则、入账链路、兑换流程按代码级展开你照着抄能跑通。3.1 回收品类与积分换算规则换算规则看着简单实际是最容易埋雷的地方。我先给一条基础公式本次积分 重量(kg) × 单价(元/kg) × 积分倍率 × 品类系数 最终积分 floor(本次积分) // 向下取整为什么要品类系数因为不同品类的回收价值差异大纸板和旧衣物的处理成本完全不同靠调单价也能实现但单价是给居民看的市场价系数是运营手段两者职责不同。比如旧衣物单价标 0.5 元每公斤给居民看但处理成本高给 0.8 的系数实际按 0.4 元折积分。这样调系数不影响公示的单价运营起来更灵活。取整为什么向下因为如果向上取整居民送 1.01 公斤和 1.99 公斤可能拿到同样的积分容易引起我这明明多送了的争执向下取整则是少给一点不占你便宜的姿态配合 0.1 公斤的称重精度误差可控。这条规则要写在页面显眼位置别让人自己算。具体实现的工具方法public class PointCalculator { private static final BigDecimal ZERO BigDecimal.ZERO; /** * param weight 称重公斤数 * param unitPrice 单价快照 * param rate 积分倍率如 10 表示 1 元 10 分 * param factor 品类系数如 0.8 */ public static int calc(BigDecimal weight, BigDecimal unitPrice, int rate, BigDecimal factor) { if (weight null || weight.compareTo(ZERO) 0) { throw new IllegalArgumentException(重量必须大于 0); } BigDecimal money weight.multiply(unitPrice); BigDecimal points money.multiply(BigDecimal.valueOf(rate)).multiply(factor); // 向下取整避免因四舍五入产生超额发放 return points.setScale(0, RoundingMode.DOWN).intValue(); } }注意全程用BigDecimal不要用double。0.1 0.2 ! 0.3这个经典问题在积分结算里会直接变成每天差几分年底差几百的糊涂账。我第一版图省事用了 double跑了两周对账差了 37 分查了一整天才定位到是浮点误差累积。还要注意参数校验的位置。重量、单价、系数的合法性校验必须放在 Service 入口不能指望页面传什么就是什么。有人用浏览器改前端 maxlength 提交 99999 公斤如果不校验一条记录就能把积分池掏空。3.2 积分入账的完整链路与事务控制JSP 项目没有 Spring 的Transactional事务得自己管。写法上我推荐把事务边界收在 Service 的一个方法里DAO 只负责执行 SQL不碰 commit 和 rollback。public class RecycleService { public void submitRecycle(RecycleDTO dto) throws Exception { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 落回收单带唯一单号重复提交会触发唯一索引冲突 long orderId recycleDao.insert(conn, dto); // 2. 计算积分用单表快照的单价和倍率不用当前配置 int points PointCalculator.calc( dto.getWeight(), dto.getUnitPrice(), dto.getPointRate(), dto.getFactor()); // 3. 更新余额条件更新防负数 int affected userDao.addBalance(conn, dto.getUserId(), points); if (affected 0) { throw new BizException(账户状态异常入账失败); } // 4. 查询变动后余额写入流水快照 int after userDao.queryBalance(conn, dto.getUserId()); pointLogDao.insert(conn, dto.getUserId(), points, after, 1, orderId, 回收入账 dto.getCategoryName()); conn.commit(); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (Exception ignore) {} } throw e; } finally { DBUtil.close(conn); } } }这段代码有几个细节值得停一下。第四步的查询余额放在事务内是因为要拿到本事务内的准确值作为快照如果放到事务外并发场景下拿到的可能是别人的变动结果balance_after就失真了。异常统一往上抛不要在 Service 里吞掉吞掉就意味着用户看到提交成功但积分没到账这种问题最难排查。关于order_no的生成我用的规则是RC yyyyMMdd 6位随机为什么要随机而不是自增因为自增单号在提交前就能猜到配合重复提交测试容易被刷。随机 6 位在单日万级单量下碰撞概率极低真撞了也有唯一索引兜底捕获冲突后重试一次即可。3.3 商城兑换与库存扣减兑换是出账比入账多两个风险点库存和余额都要检查且这两个检查必须是原子的。public void exchange(long userId, int goodsId, int num) throws Exception { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); Goods goods goodsDao.queryForUpdate(conn, goodsId); if (goods null || goods.getStatus() ! 1) { throw new BizException(商品已下架); } int cost goods.getPointPrice() * num; // 先扣库存条件更新扣不到说明库存不足 int stockOk goodsDao.reduceStock(conn, goodsId, num); if (stockOk 0) { throw new BizException(库存不足); } // 再扣积分条件更新扣不到说明余额不足 int balanceOk userDao.addBalance(conn, userId, -cost); if (balanceOk 0) { throw new BizException(积分不足); } int after userDao.queryBalance(conn, userId); long orderId exchangeDao.insert(conn, userId, goodsId, num, cost); pointLogDao.insert(conn, userId, -cost, after, 2, orderId, 兑换 goods.getName() x num); conn.commit(); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (Exception ignore) {} } throw e; } finally { DBUtil.close(conn); } }顺序上我坚持先扣库存、再扣积分。反过来的话如果积分扣完库存扣失败回滚也来得及但日志里会出现大量积分不足之外的干扰信息运营看数据时会误判成用户余额问题。先扣库存能让失败原因更聚焦。还有个经常被忽略的点兑换订单要有状态机。初始是待发放管理员点发货变已发放超时未领取可以变已取消并返还积分。返还积分必须走一条新的正向流水biz_type4备注兑换取消返还而不是把原来那条负数流水删掉。这个流程看着繁琐但它是唯一能让账目始终闭环的做法。4. JSP 层与后端的分工别把逻辑写进页面老项目最典型的腐烂方式就是 JSP 里塞满 Java 脚本片段SQL 直接写在页面上。我接手过一个类似的工程一个 list.jsp 里有两百多行% %改一个字段要在三个地方同步改。所以这一章讲的是纪律。4.1 页面职责边界我给自己定的规矩很简单JSP 里不出现Connection、不出现 SQL、不出现业务计算。它只做三件事——把 request 里的数据渲染出来、提交表单到指定的 Servlet、给出错误提示。渲染用 JSTL 和 EL别用脚本片段% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table classtb trth时间/thth品类/thth重量/thth积分变动/thth余额/th/tr c:forEach items${page.list} varlog tr tdfmt:formatDate value${log.createTime} patternyyyy-MM-dd HH:mm//td td${log.bizName}/td tdfmt:formatNumber value${log.weight} pattern0.00/ kg/td td class${log.changeAmount 0 ? plus : minus} ${log.changeAmount 0 ? : }${log.changeAmount} /td td${log.balanceAfter}/td /tr /c:forEach /tablec:out或者 EL 默认会做 HTML 转义吗老版本不一定取决于配置。备注字段是用户可填的如果你直接${log.remark}输出一个填了script的备注就能在管理员页面执行脚本。稳妥写法是c:out value${log.remark}/或者统一在入库前做转义。这个问题我在一次内部测试中被同事随手试出来了从此养成习惯。分页也不用插件两个参数足够SELECT id, change_amount, balance_after, biz_type, remark, create_time FROM t_point_log WHERE user_id #{userId} ORDER BY id DESC LIMIT #{offset}, #{pageSize};offset (pageNo - 1) * pageSize。数据量大了之后深分页会慢可以优化成记住上一页最后一条 id用WHERE id #{lastId}但日订单几百条的量级没必要提前优化。4.2 会话与权限控制登录态放 session这是 JSP 时代的常规做法。关键是别在每个页面开头都写一遍判断用 Filter 统一拦。public class AuthFilter implements Filter { private static final SetString WHITE new HashSet(Arrays.asList( /login.jsp, /login.do, /css/, /js/, /img/)); Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); String path uri.substring(request.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(req, resp); return; } } Object uid request.getSession().getAttribute(uid); if (uid null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }白名单里那三个静态资源路径一定要加我第一次部署时忘了结果登录页的 CSS 全被拦成 302页面裸奔得像 2003 年的网站。这个坑非常隐蔽因为浏览器控制台只报资源加载失败不告诉你原因。权限细分放在 Servlet 里做不要放在 Filter 里。Filter 只管有没有登录这个角色能不能调这个接口应该由业务判断因为有些接口是居民和工作人员都能访问的用 Filter 配路径规则会越来越复杂。注意session 里只存 uid 和 role不要存整个 User 对象。用户的昵称、余额是会变的存对象会出现改了名字页面还是旧的这种诡异现象。4.3 页面级的三个高频坑坑一是表单重复提交。用户点两次确认兑换可能生成两笔订单扣两次积分。除了前面说的流水唯一索引页面层要用 PRG 模式POST 处理完sendRedirect到结果页而不是forward。这样刷新页面只会重放 GET不会重放 POST。坑二是金额和积分的展示格式。${goods.pointPrice}直接输出 BigDecimal可能显示成100.0000。用fmt:formatNumber统一格式不然页面上会出现一堆看着像 bug 的小数。坑三是表单的中文编码。GET 和 POST 的编码处理不一样POST 靠request.setCharacterEncoding(UTF-8)GET 在部分 Tomcat 版本上要靠 Connector 的URIEncodingUTF-8。我习惯在 web.xml 里直接加一个编码 Filter一次性解决别指望每个 Servlet 都记得写。5. 实操过程从零把系统跑起来这一章写给真要动手的人按顺序执行中间不要跳步。环境我用的是一套很保守的组合兼容性最好。5.1 环境与工程骨架组件版本说明JDK8 或 118 最稳11 需要 Tomcat 9Tomcat8.5 或 9.0别用 10javax.servlet改成了jakarta.servlet老代码全废MySQL5.7 或 8.08.0 注意驱动类名和时区参数驱动mysql-connector-java 8.x与 MySQL 版本对应连接池Druid 或 C3P0别裸用 DriverManager工程目录按 Maven 的标准结构来虽然不一定要用 Maven但目录结构清晰能省很多事src/main/java/com/demo/recycle/ controller/ RecycleServlet, ExchangeServlet, LoginServlet... service/ RecycleService, ExchangeService, PointCalculator dao/ UserDao, RecycleDao, PointLogDao, GoodsDao entity/ User, RecycleOrder, PointLog, Goods util/ DBUtil, PageResult src/main/webapp/ WEB-INF/web.xml WEB-INF/views/*.jsp login.jsp, error.jspJSP 放WEB-INF下好处是浏览器无法直接访问只能通过 Servlet forward 进入避免用户绕过 Controller 直接打开带参数的页面。这个习惯我建议从第一个项目就养成。数据库连接工具类核心是别每次 new 连接public class DBUtil { private static DruidDataSource ds new DruidDataSource(); static { ds.setUrl(jdbc:mysql://127.0.0.1:3306/recycle_db ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalse); ds.setUsername(recycle); ds.setPassword(******); ds.setInitialSize(5); ds.setMaxActive(20); ds.setValidationQuery(SELECT 1); ds.setTestWhileIdle(true); } public static Connection getConnection() throws SQLException { return ds.getConnection(); } public static void close(Connection conn) { if (conn ! null) { try { conn.close(); } catch (SQLException ignore) {} } } }maxActive设 20 是个经验值。社区级别的并发同一秒能有三五个人同时提交就算高峰了20 够用。设太大反而会因为数据库连接数上限被打满影响其他应用。validationQuery和testWhileIdle是为了防止 MySQL 八小时空闲断连导致第二天早上一堆报错这个坑几乎所有 JSP 项目都踩过。5.2 关键页面与接口的落地登录页不多说重点讲回收录入页它是整个系统数据质量的源头。页面要做的不是让工作人员随便填而是引导他填对。品类下拉框的选项从数据库读不要写死在前端。每个选项最好带上当前单价提示工作人员选完能立刻看到纸板 1.20 元/kg避免选错。重量输入框加step0.01和前端范围校验但服务端必须再校验一次前端校验只是体验优化不是安全边界。录入成功后不要直接跳走返回一个带本次积分 38当前余额 412的结果页。这个即时反馈很重要工作人员当场就能念给居民听减少后续查询和争议。我第一版没做这个结果每天都有居民跑来问我昨天送的那些算上了没。商品列表页要考虑库存为 0 和积分不足两种状态的展示。库存为 0 的按钮置灰积分不足的按钮文案改成积分不足比点了之后弹一个错误提示友好得多。这些判断用 EL 表达式在页面上做即可不需要额外接口。管理员的对账页面我给一个最实用的 SQL每天跑一次-- 找出余额与流水不一致的账户 SELECT u.id, u.nick_name, u.point_balance, IFNULL(SUM(l.change_amount), 0) AS log_sum FROM t_user u LEFT JOIN t_point_log l ON l.user_id u.id GROUP BY u.id, u.nick_name, u.point_balance HAVING u.point_balance IFNULL(SUM(l.change_amount), 0);正常情况这条 SQL 应该返回零行。返回了任何一行就说明有写入绕过了流水需要立刻查日志定位。我把它做成一个定时任务每天早上八点执行有结果就发提醒。上线三个月触发过一次查下来是一个旧的测试接口直接 update 了余额属于人为失误但如果没有这个检查等居民发现时账已经乱了两周。5.3 上线前的自测清单我给自己列了一套必测项每次改完核心逻辑都跑一遍十分钟的事能挡住绝大多数线上问题。序号测试项预期结果1同一回收单连点两次提交只产生一条记录积分只加一次2余额 50 时兑换 60 积分的商品提示积分不足库存和余额均不变3库存 1 时两人同时兑换只有一人成功另一人提示库存不足4重量填 0 或负数服务端拒绝给出明确提示5未登录直接访问明细页跳转登录页6兑换后刷新结果页不重复下单7撤销一张回收单生成反向流水余额回退原流水仍在8连续提交 50 单无连接泄漏响应时间稳定第 8 项要用压测工具或者简单的循环脚本做。连接泄漏是 JSP 项目最隐蔽的问题表现是跑了三天突然全站 500重启就好然后三天后再犯。根因就是某个分支忘了 close或者异常路径没走 finally。养成finally里必关连接的习惯比事后排查省太多时间。6. 常见问题与排查实录这一章是我这两年里真实处理过的问题按出现频率排序附排查路径。6.1 问题速查表现象最可能原因排查动作页面提交中文变乱码请求或响应未设 UTF-8检查编码 Filter、Connector 的 URIEncoding、数据库连接串500 报错指向 .java 文件JSP 编译后的临时文件报错去 work 目录找对应行号回头定位 JSP 原文积分突然对不上有写入绕过流水或浮点误差跑第 5 章的对账 SQL定位用户后查该用户流水打开页面很慢几分钟后超时数据库连接耗尽或死锁看连接池监控查SHOW PROCESSLIST找长时间运行的语句部署后 CSS/JS 404静态资源被 Filter 拦截或路径写错检查 Filter 白名单检查是否用了绝对路径定时任务不执行Tomcat 重启后 Timer 失效或未初始化改用监听器初始化或改成外部调度触发时间显示差 8 小时数据库时区与 JVM 时区不一致连接串加serverTimezone服务器统一时区兑换后余额为负用了先查再改的写法改成带条件的原子 update说一个印象最深的排查。某天下午居民反馈兑换失败日志里全是Deadlock found when trying to get lock。我当时的第一反应是库存扣减的行锁冲突查了半小时没找到。后来把死锁日志打出来才看明白一个是兑换事务先扣库存再扣积分另一个是撤销事务先返还积分再回补库存两个事务加锁顺序相反互相等待。解决办法很简单把所有涉及库存 积分的事务统一成同一个加锁顺序先商品行、后用户行。顺序一致死锁就消失了。这个经验很通用凡是涉及多行更新的事务全系统统一加锁顺序比任何重试机制都管用。6.2 几条不太好写进文档的经验第一别相信这次不会有人重复提交。我做过一个小范围试点二十来个人用一周内出现了三次重复兑换全是因为手机网络慢用户以为没点上又点了一次。所以 PRG 模式、唯一索引、按钮置灰这三样我后来一个都不敢省。第二把积分当成钱来对待心态会完全不一样。当它是游戏分数时少几分没人管当它能换一袋米时每一分都有人盯着。所以宁可前期多花两天把流水和对账做扎实也别等运营两个月后再补那时候数据已经脏了补起来要一条条核对工作量是前期的十倍。第三在小系统里简单比先进值钱。我一度想引入缓存中间件来提升商品列表的读取速度后来发现列表页 QPS 不到 5加了纯属给自己找运维负担。最后用应用级的ConcurrentHashMap缓存品类和商品字典配一个手动刷新的管理按钮五分钟写完效果足够。技术选型要看真实压力不要看别人的架构图。第四给运营留一个人工调整入口但一定要留痕。没有这个入口运营遇到客诉只能找开发改数据库那才是真正的风险。有了入口要求填申请单号、调整原因、操作人全部写进流水备注出了问题能追。堵不如疏这话在系统设计里同样成立。第五页面上的每一个数字都要能点进去看明细。这是我后来加的一个交互余额旁边加一个明细链接直达流水列表。加完之后我这个积分怎么算的这类咨询少了一大半。用户不是要跟你争论他只是想看清楚。透明度本身就是最好的客服。最后分享一个我自己一直在用的调试小技巧给每个请求在 Filter 里生成一个六位随机 traceId塞进 MDC 或者直接打进日志。回收入账和兑换涉及多张表的写入出问题时日志是一大片交错输出有了 traceIdgrep一下就能把一次请求的所有 SQL 串起来排查效率提升非常明显。这套系统后来交接给别人的时候我留下的最重要的东西不是代码而是这份日志规范和那张对账 SQL。