做企业管理软件久了你会发现,单据查询从来不是写个select列表那么简单。这个模块看起来不起眼,但它是所有业务模块的“脸面”——采购、销售、库存、收付款,每一类单据最终都要靠查询入口展示给用户。如果这个模块设计得顺手,后续加需求、出报表、做权限控制都会轻松很多;如果一开始草率了,后面每个迭代都在还技术债。
《看潮企业管理软件》项目开发进行到第17期,这期的主题是单据查询模块的第四次拆解。前面几讲我们已经把基础的单据增删改查、单据审核流和单据打印过了一遍,这次重点落在这个系列里最容易被人忽略却最考验功底的部分:查询逻辑的抽象、分页与统计的性能控制,以及在实际项目中踩出来的那些坑。如果你正在做进销存、ERP或者任何带“单据”概念的管理系统,这篇文章应该能帮你少走不少弯路。
很多新手拿到一个“查询功能”的需求,第一反应是“不就是写个列表接口吗”。真做起来才会发现,业务方会不断追加条件筛选、表格列自定义、数据导出、行级权限、合计行……需求清单越拉越长。这期我就从设计思路、数学原理、代码落地和排障经验四个维度,把单据查询这个看似简单的模块掰开揉碎讲清楚。
1. 单据查询模块的整体定位与设计取舍
1.1 查询模块在整套系统里承担什么角色
“看潮”这个项目我定位成一款给中小型贸易公司用的进销存+财务一体化软件。它不是那种展示型的Demo,而是真的要给业务人员天天用的工具。在这种系统里,单据是业务流转的核心载体:采购订单、采购入库单、销售订单、销售出库单、收款单、付款单、费用单……每张单据都承载着业务数据。
单据查询模块承担的是“把分散在各业务模块的数据重新组织起来,按用户指定的条件快速呈现”的任务。听起来很朴素,但它在实际系统里有三层隐藏价值:
第一,它是数据校验的入口。业务人员需要确认“这批货是不是已经到了”“这张单子客户付款了没有”,查询列表就是第一道信息窗口。第二,它是统计分析的基础。很多报表功能本质上是对查询结果做二次聚合,查询条件的可复用性直接决定了报表开发的效率。第三,它是权限控制的关键阵地。哪些人能看哪些单、能看到哪些字段、能不能看金额,都要在查询链路里做拦截和过滤。
所以我在设计“看潮”的查询模块时,没有按“采购查询”“销售查询”“库存查询”各自独立开发一套,而是先抽象出一套通用的查询机制,再按业务场景做差异化配置。这样做的收益到项目后期非常明显:新的单据类型接入查询模块,只需要配置条件字段、列表字段、排序规则即可,不需要重新写一遍查询逻辑。
1.2 通用查询机制和独立查询接口的取舍
通用查询接口是很多架构师会首先想到的方案:一张配置表定义查询字段和显示字段,前端动态渲染查询表单和表格列,后端根据配置动态拼SQL。这种方案确实灵活,但代价是把业务规则硬编码进了配置数据里,排查问题的时候配置和代码来回跳,调试成本很高。
我在“看潮”项目里采用的是折中路线:列表查询的Controller层单独写,Service层做业务组装,但底层的分页查询工具、条件对象基类、排序规则、统计合计逻辑是全局复用的。也就是说,每个单据类型可以定制自己的查询参数和返回字段,但分页、排序、权限过滤、性能保护这些公共机制是统一收口的。
这么做最直接的好处是:业务适配性和代码可维护性达到了平衡。新来一个开发同事接手某个单据查询的改动,只需要打开对应的QueryDTO和Mapper XML,改动范围清晰可控,不会牵扯到其他模块。而公共的分页逻辑和权限过滤在底层已经处理好了,不会出现“这个页面能查、那个页面不能查”这种规则不一致的问题。
2. 藏在查询逻辑背后的数学思维
2.1 分页的数学本质:偏移量计算与页数边界
分页查询是所有列表功能的基石,但很多人只记住了公式,没有理解背后的数学逻辑。分页的基本模型是:数据集合总共有N条记录,每页展示S条,求第P页的数据是哪几条。
这里涉及两个关键计算:偏移量偏移量 offset = (P - 1) × S,这是数据库LIMIT子句中的起点;总页数 totalPages = ceil(N / S),即向上取整。
如果你忽略掉C语言里整除是向下取整这个细节,用N / S直接作为总页数,那么当N是S的整数倍时结果正确,但当N不是S的整数倍时,最后一页的数据就被吞掉了。我早期就犯过这个错误——写代码时直接用了N % S == 0 这样的判断分支去处理,后来发现代码丑且容易漏边界,改用数学上更严谨的表达式:totalPages = (N + S - 1) / S。这个公式本质上是ceil的整数实现,避免了浮点数转换的性能损耗和精度问题。
还有一个小细节:当页码、每页条数由前端传入时,必须做合法性校验。pageNum小于1要重置为1,pageSize超过上限要截断到最大值。否则恶意请求传一个pageSize=999999,能把数据库内存直接打爆。
分页还有个容易被忽略的数学陷阱是重复数据和漏数据。如果ORDER BY子句指定的排序字段存在并列值,且没有加入唯一字段(如主键id)作为次级排序条件,那么同一批数据在做深分页时,可能被上一次查询和下一次查询重复或者遗漏。这个问题的解决方案不是调整SQL,而是在设计层就确定排序规则:排序字段 + id 兜底。
2.2 日期区间查询为什么要用半开区间
企业管理软件里,日期筛选是最高频的条件。采购单“查询上个月的单据”,销售单“查询本周的出库记录”,几乎每个列表页都要做时间过滤。
最容易出错的写法是:startDate <= order_date <= endDate。表面上看起来没有问题,但order_date如果是DATETIME类型,业务人员选了“2025-03-20”作为结束日期,潜意识里是希望包含3月20日全天。而order_date存储的是'2025-03-20 14:30:00',用<= '2025-03-20'去比较,14:30那条记录就被排除在外了,用户就会反馈“数据少了”。
解决思路是用左闭右开的半开区间 [start, end),也就是数学上说的包含左端点、不包含右端点。SQL写成:
order_date >= #{startDate} AND order_date < DATE_ADD(#{endDate}, INTERVAL 1 DAY)这样day=2025-03-20被转换为'2025-03-21 00:00:00',3月20日一整天(到23:59:59.999)都落入区间内。这个思路的本质是数学上处理连续区间的标准方法:把一个“闭区间”问题转换为“半开区间”问题,既不会漏数据,也不会在跨月、跨年时产生边界重叠。
我还见过一种方案是日期字段统一存DATE类型,只按“天”维度做业务统计,这样就不存在时间边界问题。但对进销存系统来说,单据的创建时间精确到秒是有实际意义的(比如财务对账需要知道同一分钟内发生了什么),所以设计上选择了DATETIME + 半开区间过滤的组合。
2.3 查询条件合法性:集合的完备性与条件组合的克制
从数学角度审视一个查询功能是否“规范”,你可以用集合思想来检验:
- 查询动作本质是对全集做筛选,返回满足所有条件的子集。
- 多条件之间通常是AND关系,也就是数学上的交集概念。
- 同一字段的多值筛选(比如查询多个状态),本质是UNION,对应IN操作。
这些集合语义听起来基础,但用它在心里校验SQL逻辑非常管用。比如一个查询条件里有“单据状态=已审核”和“金额>=1000”两个条件,你可以问自己:返回结果是这两个集合的交集吗?如果是OR关系,那是什么样?在对接业务方需求时,我经常用这种思路和对方确认,能提前消灭大量理解偏差。
再说条件组合的克制。假设一个查询页面有10个筛选字段,每个字段可填可不填,那么理论上有2^10=1024种组合。这个数字看起来不大,但每种组合的SQL执行计划都不同,测试人员根本不可能把所有组合都覆盖到。所以设计上要主动做减法:给查询条件分优先级,高频字段做组合索引,低频字段不参与复杂联动。
具体到“看潮”的单据查询,我把条件字段分成两类:等值条件走索引精确匹配,模糊条件用LIKE且禁止前导%。实际开发中还设定了一个硬性规则:模糊匹配字段最多两个,超过两个的查询需求需先确认是否要引入全文检索,否则性能隐患会在数据量增长后集中爆发。
3. 实操开发:从建表到接口落地
3.1 数据表结构与查询字段设计
“看潮”项目里的单据主表取名business_order,这是所有业务单据的统称。表结构里与查询直接相关的核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| order_no | varchar(32) | 单据编号,业务唯一 |
| order_type | tinyint | 单据类型:1采购,2销售,3收款,4付款 |
| partner_id | bigint | 往来单位(客户/供应商)ID |
| order_date | datetime | 单据日期,业务日期而非创建时间 |
| total_amount | decimal(18,2) | 单据总金额,单位元,保留两位小数 |
| status | tinyint | 单据状态:0草稿,1已提交,2已审核,3已作废 |
| create_by | bigint | 制单人ID |
| create_time | datetime | 创建时间,系统自动填充 |
| remark | varchar(500) | 备注,列表页不展示,详情页展示 |
这里有几个设计细节值得展开。单据编号order_no为什么要单独建字段而不是直接用主键id?因为业务人员习惯用“PO-20250301-001”这种可读编号,直接暴露数据库主键既不利于辨识,也可能被猜到业务量。所以order_no在业务上是唯一键,查询时用等值匹配,配合唯一索引查询性能很好。
partner_id、status、order_type这几个字段是列表页最高频的筛选维度,我建了一个联合索引:(order_type, status, order_date)。为什么order_date放在最后?因为查询条件里它一定是范围条件,而联合索引的匹配规则是“等值条件优先,范围条件靠后”。如果前面两个等值条件能过滤大部分数据,后面范围扫描的压力就很小了。
3.2 查询条件对象与统一分页模型的实现
查询接口入参如果直接接收一堆散落的参数,代码会非常难维护。我习惯的做法是封装一个查询DTO,让每个单据类型继承公共基类。基类定义分页和排序参数,业务子类定义自己的筛选字段:
public class BaseQueryDTO { private Integer pageNum = 1; private Integer pageSize = 20; private String orderBy; private String orderDir = "DESC"; private String keyword; }然后是采购单据的查询参数对象:
public class PurchaseOrderQueryDTO extends BaseQueryDTO { private String orderNo; private Long partnerId; private LocalDate beginDate; private LocalDate endDate; private BigDecimal minAmount; private BigDecimal maxAmount; private Integer status; }用LocalDate接收日期,而不是直接用String,这样在Controller层做参数校验时就能避免“2025-02-30”这种非法日期进入SQL层。
这里有个实操细节:分页参数pageNum和pageSize的默认值放在DTO字段上初始化,而不是等前端传。如果前端没传,前端传的null会把Java字段的默认值覆盖掉吗?不会,因为Spring MVC在没有传参时不会调用setter方法,而是直接忽略,字段保持初始值。但如果前端显式传了pageNum=null,那就要在Controller或全局处理器里做兜底了。我通常会在统一参数校验里加一步处理:
if (dto.getPageNum() == null || dto.getPageNum() < 1) { dto.setPageNum(1); } if (dto.getPageSize() == null || dto.getPageSize() > 100) { dto.setPageSize(20); }这层保护放在全局的查询拦截处理器里,任何查询接口自动生效。
3.3 动态SQL的关键写法与常见误区
“看潮”项目用的是MyBatis Plus的底层能力,但复杂查询我仍然选择手写XML,因为可以精确控制SQL语句,必要时还能做EXPLAIN分析。
查询条件动态拼接的核心逻辑是:每个条件用 判断非空,然后用 标签自动处理首个AND和OR。这里有一个新手经常踩的坑: 标签只能解决“第一个条件前面的AND”问题,如果第一个条件是 里拼出来的且为null,第二个条件前面仍然有AND, 不会自动识别。所以在每个条件内部,我都显式写了AND:
<select id="selectPurchaseOrderPage" resultType="com.kanchao.vo.PurchaseOrderVO"> SELECT o.id, o.order_no, o.order_date, o.total_amount, o.status, p.name AS partner_name, u.real_name AS creator_name FROM business_order o LEFT JOIN base_partner p ON o.partner_id = p.id LEFT JOIN sys_user u ON o.create_by = u.id <where> <if test="query.orderType != null"> AND o.order_type = #{query.orderType} </if> <if test="query.orderNo != null and query.orderNo != ''"> AND o.order_no LIKE CONCAT('%', #{query.orderNo}, '%') </if> <if test="query.partnerId != null"> AND o.partner_id = #{query.partnerId} </if> <if test="query.beginDate != null"> AND o.order_date >= #{query.beginDate} </if> <if test="query.endDate != null"> AND o.order_date < DATE_ADD(#{query.endDate}, INTERVAL 1 DAY) </if> </where> ORDER BY o.order_date DESC, o.id DESC LIMIT #{query.offset}, #{query.pageSize} </select>这里要特别注意几个细节。第一,XML里的小于号<必须转义成<,否则XML解析会报错。我在早期写动态SQL时经常因为忘了转义,浪费大量时间排查。第二,LIKE查询的写法我用了CONCAT('%', #{query.orderNo}, '%'),而不是直接在Java端拼好传进来,这样可以避免SQL注入的隐患(虽然MyBatis的#{}预编译已经处理了大部分风险,但把拼接动作放在SQL层语义更清晰)。第三,LIMIT的offset计算我放在了Service层,而不是XML里写死:
int offset = (dto.getPageNum() - 1) * dto.getPageSize();这个offset本身就是2.1节里那个数学公式,直接乘法算出来就行。
可能有人会问,动态查询结果字段里出现了下单人姓名(u.real_name),为什么不用子查询?因为这里join关联的是用户表,而用户表数据量很小(企业内部就几十号人),联合查询走索引代价极低,完全没必要用标量子查询增加逻辑复杂度。这种“用小表join替代子查询”的习惯,是为了让SQL的执行计划可预期,排查性能问题时不至于两眼一抹黑。
3.4 列表查询与统计合计的联动实现
单据查询页面上,除了表格数据,用户往往还希望看到查出来的这些单据“合计金额是多少”。这个需求如果在前端做,一次只能汇总当前页那几十行,用户要的是所有符合条件的单据总和,所以必须由后端处理。
初版我把统计SQL和列表SQL写在同一个方法里:先count,再selectPage,再selectSum。后来发现这三个查询的条件完全没有变化,可以复用同一段动态SQL的公共片段。MyBatis的SQL片段解决这个问题非常顺手:
<sql id="orderQueryWhere"> <where> <!-- 与上面查询条件的片段相同 --> </where> </sql>然后在三个查询语句里用 引用同一份条件。这样做有一个很实际的好处:以后加了一个查询条件,只需要改一处,列表和统计逻辑同时生效,不会出现“列表按新条件过滤了,合计还是全量”这种诡异问题。
选择完把列表、总条数、总金额一起封装到返回对象里:
public class PageResult<T> { private List<T> list; private long total; private BigDecimal sumAmount; }这个对象是泛型结构,后续任何单据查询都能复用。
这里还要多提一句:合计算法用的是SQL里的SUM聚合,而不是在Java侧遍历list逐个累加。原因很简单,列表查询因为分页只返回了一部分数据,Java侧累加只能得到当页合计,不符合业务语义。而且SQL的SUM是数据库引擎优化过的,精度和性能都有保障。
4. 前端配合与导出等扩展能力
4.1 查询表单与表格联动的交互细节
“看潮”的前端部分基于Vue全家桶实现。单据查询页的基本交互“套路”是:查询表单区域 + 主表格区域 + 分页组件区。这一套交互看起来简单,但联动时的细节不少。
我的做法是:查询表单的字段定义直接放在前端配置数组里,每个字段用type标明类型(input、date-picker、select等),查询按钮触发时把表单值序列化成JSON传给后端。这样新增一个筛选条件时,前后端各加一个配置项即可,不需要改动查询请求的公共逻辑。
联动时机上,“看潮”选择的是“查询按钮手动触发筛选+分页页码变化自动触发查询”组合。没有做成输入关键字后自动防抖查询,因为单据查询对结果准确性要求高,用户更希望自己控制查询时机,而不是每敲一个字符就请求一次接口,误操作还会导致页面闪烁。
表格列的字段也是动态渲染的,但每行后面的“操作”列是固定的。操作列一般包含“详情”“打印”“作废”三个入口。点击详情弹窗加载单据头+单据明细,这里我采用了“按需加载”策略:弹窗打开时才去请求detail接口,而不是在列表查询时一次性把所有明细都带出来。如果数据量大,列表和明细分开查是性能底线。
4.2 导出Excel的异步化处理
查询模块还有一个很容易被忽略的扩展需求:导出。业务人员看到符合条件的单据列表,大概率会点“导出”按钮,希望把结果存成Excel发给别人或留档。
第17期项目开发到这里有个值得记录的教训:最初我把导出做成了同步接口,前端发起请求后,后端当场把Excel生成出来给浏览器下载。数据量小的时候这没问题,但当筛选条件是“最近一年的销售出库单”时,一次性查出几万条记录再生成Excel,接口响应时间飙到十几秒,浏览器经常因为等不及而中断请求。
后来我把导出逻辑改成了异步任务:用户点击导出,后端立刻返回“任务已提交”,前端在页面上显示“正在导出…”的进度提示;后台线程生成Excel并上传到文件服务器(或本地临时目录),完成后往消息表里插入一条记录,前端轮询或者通过WebSocket收到“导出完成”事件,然后下载文件。
异步导出最关键的是控制内存和分页策略。生成大Excel不能一次性把所有数据load到内存里,应该是流式查询,每查出一批数据就写一批到ExcelWriter。这背后的数学/资源管理逻辑是“分治”——把大任务拆成小批次顺序执行,控制内存峰值为固定的batchSize,而不是数据总量。
5. 常见问题排查与性能优化实录
5.1 日期查询丢数据的坑
这个坑在前面2.2节已经提过原理,再补充一个实际排查过程的细节。上线后的某一天,业务人员反馈“3月20号的销售单据在查询界面看不到”。我第一反应是查数据库里有没有那天的单子,发现有,说明问题出在过滤条件。
排查时我先在Navicat里手动执行了SQL,直接写 order_date >= '2025-03-20' AND order_date <= '2025-03-20' 也查出来了数据,心里更奇怪了。后来才发现接口日志里前端传的endDate字段格式是'2025-03-20 23:59:59',而后台的LocalDate类型在序列化解析时,把'2025-03-20 23:59:59'转成了LocalDateTime再反序列化,结果格式直接报错,Spring返回了400,前端却误以为查询结果为空。
这个问题的根源是前端日期组件传值格式与后端接收类型不一致。后来我做了两层修复:第一层,DTO里的endDate改成String,由自己写的解析工具统一转成LocalDate,格式错误时明确提示“结束日期格式不正确”,而不是返回通用400;第二层,前端封装了统一的日期组件,只传yyyy-MM-dd格式。这之后日期查询再也没有出现过边界问题。
5.2 大偏移量分页越查越慢的根因分析
深分页问题在“看潮”的一次真实业务场景中暴露得非常明显。客户公司有5万多张历史单据,业务人员习惯翻到第2000页去核对一个多月前的记录,每次都等接近三十秒。排查时我用EXPLAIN看了执行计划,发现LIMIT 39980, 20 这种写法,数据库需要先扫描并丢弃前面39980行数据,再取20行,扫描行数非常大,索引再好也扛不住。
解决办法是改用游标分页(Keyset Pagination)。但它有个使用限制:不能直接跳转到任意页数,只能通过“上一页/下一页”的方式翻页。这对业务人员来说其实可以接受,因为老用户找单据很少跳跃,都是往后翻。具体实现是记录上一页最后一条记录的id和order_date,下一页查询时带上条件:
WHERE (o.order_date < #{lastOrderDate}) OR (o.order_date = #{lastOrderDate} AND o.id < #{lastId}) ORDER BY o.order_date DESC, o.id DESC LIMIT 20这套逻辑的数学基础是字典序:以(order_date, id)为组合键,下一页就是从上一页的最后一条记录的“下一个”位置开始取。每一页的扫描量被约束在20行左右,性能稳定。
对仍然想支持跳页的场景,我给出的折中方案是限制最大翻页深度:超过100页时提示用户使用筛选条件缩小范围,而不是允许无限翻页。这个限制需要业务方理解,不是为了偷懒,是从根本上避免数据库资源浪费。
5.3 金额字段的精度问题:double、float与BigDecimal
做财务相关功能的同学应该都能脱口而出:金额计算不能用浮点型。这里我再做个补充说明,为什么数学上很自然的0.1 + 0.2 = 0.3在计算机里变成了0.30000000000000004。
根本原因是计算机用二进制表示小数,而0.1的二进制是一个无限循环小数:0.00011001100110011...,浮点数存储时只能截断保留有限位,误差就累积出来了。这个误差在单次计算里肉眼很难发现,但在累加几千次之后,金额差个几分钱都是可能的事。
“看潮”项目里所有金额字段在数据库层统一用decimal(18,2)存储,Java侧用BigDecimal接收和参与计算。前端展示时直接用后端返回的Decimal值,不做加减乘除;如果前端真的需要做一些合计计算,也要求用decimal.js这类库,禁止直接使用JavaScript浮点数运算。
还有一个细节容易被忽略:BigDecimal除法必须指定精度和舍入模式,否则除不尽时会抛ArithmeticException。实际写代码时我统一在这个工具类里定义舍入策略:
public class MoneyUtil { public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, 2, RoundingMode.HALF_UP); } }HALF_UP就是四舍五入,这是财务上最常用的舍入模式。如果你做的是银行系统,可能需要用HALF_EVEN(银行家舍入),这个要看业务规则来定。
5.4 权限过滤与数据隔离的兜底策略
企业管理软件里,单据查询不能漏掉权限控制。如果所有登录用户都能查所有单据,做实施的时候分分钟被客户怼回来:销售A不想让销售B看到自己的客户和报价,仓库管理员不该看到财务的收款金额。
“看潮”的权限模型是:用户属于角色,角色绑定数据范围(全部数据、本部门数据、本人数据、自定义部门数据)。查询时通过AOP切面统一往Mapper注入数据权限条件。
具体实现时,我在查询基类里加了一个字段:
public class BaseQueryDTO { // 数据权限辅助字段,Service层填充 private Long currentUserId; private Long currentDeptId; private Integer dataScope; }Service层根据当前登录用户的dataScope决定拼接什么SQL约束:
- dataScope == 3(本人):AND o.create_by = #{currentUserId}
- dataScope == 2(本部门):AND u.dept_id = #{currentDeptId}
- dataScope == 1(全部):不加约束
这里有个必须注意的细节:数据权限过滤条件属于系统性约束,必须无条件拼接,不能和用户勾选的业务筛选条件混在一个动态 里。否则就会出现用户把“制单人”筛选字段留空时权限过滤一并失效的漏洞。我的做法是权限条件放在 标签的第一行,且不加 判断(查询动作必然有当前登录用户)。
这个坑有过一次深刻的教训:曾经因为权限条件加了 的校验,而前端构造查询时没有传userId,导致管理员账户看到的是全量数据,而普通用户看不到任何数据。后来我改成从服务端上下文里取登录用户,不依赖前端传值,彻底杜绝了越权风险。
5.5 列表页查询缓存与实时性的权衡
单据查询的数据实时性要求比较高,因为业务人员在审核单据之后,立刻就会去查询列表确认状态有没有变。如果在这里加Redis缓存,很容易出现“审核完成了,列表里还是待审核”的脏读问题。所以我在“看潮”里对列表查询默认不加缓存,每次请求直接查MySQL。
那性能压力大的时候怎么办?数据库读写分离,主库写、从库读,业务高峰期把查询流量引到从库上。不过因为“看潮”是单体应用,读写分离就这么一点配置而已。等数据量真的大到需要引入搜索引擎阶段——比如要支持订单全文检索、按收货地址模糊搜索——那才需要引入Elasticsearch来做查询加速。对小规模管理软件来说,MySQL用索引优化就够用了,没必要为了“技术先进”而引入一套ES维护成本。
写在最后的实操体会
单据查询这个模块做到第4期,我最深刻的感受是:真正影响这个模块质量的地方,不是CRUD能不能跑通,而是你有没有把业务边界、数学规律、数据库原理这些东西串起来。
一个典型的例子就是分页。表面上它只是LIMIT后两个数字的事,但只有把它放到数学的“集合分块”视角下理解,你才会主动思考排序稳定性、深分页性能、边界条件这些深水区的问题。编程到了一定阶段,拼的就是这种“用底层规律去解释表层现象”的能力。
如果你正在开发类似的企业管理软件,我的建议是别急着堆功能,先把查询模块的地基打好。把查询条件对象抽象清楚,把分页和合计逻辑统一封装,把权限过滤条件固化到公共链路上,把日期处理和金额精度处理写成工具类……这些工作前期看起来像“浪费时间”,但到第10个、第20个单据查询页面做出来之后,你会发现自己已经领先那些每次都在页面上“复制粘贴改SQL”的同行一大截了。
最后再分享一个小技巧:开发完查询功能,不要只测“正常查询”。每次改完代码,都用几组极端数据自测一下——页码为0、每页条数为0、时间区间跨年、金额字段带负数、模糊条件输入%和_。这些边缘case才是bug最容易藏身的地方,也是体现一个开发者是否靠谱的分水岭。