| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.baomidou.mybatisplus.core.metadata.OrderItem; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.common.utils.Utils; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.CursorPageParam; |
| | | import com.vincent.rsf.server.common.domain.CursorPageResult; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.system.entity.User; |
| | | import org.springframework.security.core.Authentication; |
| | | import org.springframework.security.core.GrantedAuthority; |
| | | import org.springframework.security.core.context.SecurityContextHolder; |
| | | |
| | | import java.lang.reflect.Field; |
| | | import java.util.ArrayList; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.function.Consumer; |
| | | |
| | | /** |
| | | * Created by vincent on 1/30/2024 |
| | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 通用游标分页实现。 |
| | | * |
| | | * <p>这个方法的目标不是替代所有分页,而是把“单字段、倒序、向后翻页”的游标分页 |
| | | * 收敛成一套统一实现,避免各个 controller 重复写下面这些样板逻辑:</p> |
| | | * <ul> |
| | | * <li>buildParam</li> |
| | | * <li>忽略前端传入的 orderBy</li> |
| | | * <li>buildWrapper(true) 构建通用筛选</li> |
| | | * <li>cursor 条件</li> |
| | | * <li>按固定字段倒序</li> |
| | | * <li>多查一条判断 hasNext</li> |
| | | * <li>截断结果并生成 nextCursor</li> |
| | | * </ul> |
| | | * |
| | | * <p>适用前提:</p> |
| | | * <ul> |
| | | * <li>游标字段是单一字段</li> |
| | | * <li>游标字段的值稳定、可比较,并且能映射成 Long</li> |
| | | * <li>分页方向固定为“按该字段倒序,向更小的值翻页”</li> |
| | | * </ul> |
| | | * |
| | | * <p>参数说明:</p> |
| | | * <ul> |
| | | * <li>{@code map}:原始请求参数</li> |
| | | * <li>{@code paramClass}:游标参数类型,通常传 {@link CursorPageParam}</li> |
| | | * <li>{@code entityClass}:实体类,用于 PageParam 条件构建和 condition 模糊查询</li> |
| | | * <li>{@code service}:MyBatis-Plus 的 IService,负责执行 page 查询</li> |
| | | * <li>{@code cursorField}:实体字段名,不是数据库列名,例如传 {@code id}</li> |
| | | * <li>{@code defaultPageSize}:当前接口的默认分页大小</li> |
| | | * <li>{@code wrapperConsumer}:可选的额外 where 条件扩展钩子</li> |
| | | * <li>{@code recordsConsumer}:可选的结果后处理钩子,例如补充 createBy$/updateBy$</li> |
| | | * </ul> |
| | | */ |
| | | protected <T, U extends CursorPageParam> CursorPageResult<T> cursorPage( |
| | | Map<String, Object> map, |
| | | Class<U> paramClass, |
| | | Class<T> entityClass, |
| | | IService<T> service, |
| | | String cursorField, |
| | | int defaultPageSize, |
| | | Consumer<QueryWrapper<T>> wrapperConsumer, |
| | | Consumer<List<T>> recordsConsumer |
| | | ) { |
| | | // 允许 controller 传 null,内部统一兜底成空 map, |
| | | // 这样 buildParam 不需要每个调用方自己先判空。 |
| | | U baseParam = buildParam(map == null ? new HashMap<>() : map, paramClass); |
| | | |
| | | // 游标分页不允许客户端自定义排序, |
| | | // 否则“上一页最后一条作为下一页游标”的前提会被破坏。 |
| | | baseParam.setOrderBy(null); |
| | | |
| | | // pageSize 允许从请求里带入,但非法值(null、0、负数)统一回退到接口默认值。 |
| | | int pageSize = resolveCursorPageSize(baseParam.getPageSize(), defaultPageSize); |
| | | |
| | | // controller 传的是实体字段名,例如 "id" / "poId", |
| | | // 这里统一转成数据库列名并补反引号,避免每个业务自己手写 SQL 片段。 |
| | | String cursorColumn = resolveCursorColumn(cursorField); |
| | | |
| | | // 先复用系统现有的 PageParam + buildWrapper(true) 机制, |
| | | // 保留原来的条件解析、时间范围、condition 模糊搜索等能力。 |
| | | PageParam<T, U> pageParam = new PageParam<>(baseParam, entityClass); |
| | | QueryWrapper<T> wrapper = pageParam.buildWrapper(true); |
| | | |
| | | // 给业务预留额外 where 条件的扩展点; |
| | | // 如果某个接口除了通用筛选外,还要拼接额外限制,可以在这里补。 |
| | | if (wrapperConsumer != null) { |
| | | wrapperConsumer.accept(wrapper); |
| | | } |
| | | |
| | | // 游标分页的核心条件: |
| | | // 当前约定是“按 cursorField 倒序查看更旧的数据”,所以条件固定为 < cursor。 |
| | | if (baseParam.getCursor() != null) { |
| | | wrapper.lt(cursorColumn, baseParam.getCursor()); |
| | | } |
| | | |
| | | // 强制按游标字段倒序排序,保证每一页的数据顺序稳定。 |
| | | wrapper.orderByDesc(cursorColumn); |
| | | |
| | | // 多查一条是游标分页判断 hasNext 的常见做法: |
| | | // 实际要 20 条,就查 21 条;多出来那一条只用来判断是否还有下一页。 |
| | | Page<T> queryPage = new Page<>(1L, pageSize + 1L, false); |
| | | List<T> records = service.page(queryPage, wrapper).getRecords(); |
| | | List<T> pageRecords = Cools.isEmpty(records) ? new ArrayList<>() : new ArrayList<>(records); |
| | | |
| | | // 如果查出来的数量大于 pageSize,说明至少还有下一页。 |
| | | boolean hasNext = pageRecords.size() > pageSize; |
| | | if (hasNext) { |
| | | // 只把真正需要返回给前端的 pageSize 条数据留在当前页。 |
| | | pageRecords = new ArrayList<>(pageRecords.subList(0, pageSize)); |
| | | } |
| | | |
| | | // 给业务侧一个“结果出库前处理”的机会。 |
| | | // 典型场景是批量补充用户名、字典文本、缓存字段等, |
| | | // 这样公共分页逻辑不关心业务细节,但业务也不需要回到 controller 自己重写分页。 |
| | | if (recordsConsumer != null && !Cools.isEmpty(pageRecords)) { |
| | | recordsConsumer.accept(pageRecords); |
| | | } |
| | | |
| | | CursorPageResult<T> result = new CursorPageResult<>(); |
| | | result.setRecords(pageRecords); |
| | | result.setPageSize(pageSize); |
| | | result.setHasNext(hasNext); |
| | | // nextCursor 只有在还有下一页时才有意义; |
| | | // 约定取“当前页最后一条记录”的游标字段值。 |
| | | result.setNextCursor(hasNext ? extractCursorValue(pageRecords, cursorField) : null); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 统一解析当前接口实际使用的 pageSize。 |
| | | * |
| | | * <p>只要前端没传、传了 0、或者传了负数,就回退到 controller 传入的默认值。</p> |
| | | */ |
| | | private int resolveCursorPageSize(Integer pageSize, int defaultPageSize) { |
| | | if (pageSize == null || pageSize <= 0) { |
| | | return defaultPageSize; |
| | | } |
| | | return pageSize; |
| | | } |
| | | |
| | | /** |
| | | * 把实体字段名转换成数据库列名。 |
| | | * |
| | | * <p>例如:</p> |
| | | * <ul> |
| | | * <li>{@code id -> `id`}</li> |
| | | * <li>{@code poId -> `po_id`}</li> |
| | | * </ul> |
| | | * |
| | | * <p>这样 controller 调用时只需要关心 Java 字段名,不需要自己拼 SQL。</p> |
| | | */ |
| | | private String resolveCursorColumn(String cursorField) { |
| | | return "`" + Utils.toSymbolCase(cursorField, '_') + "`"; |
| | | } |
| | | |
| | | /** |
| | | * 从当前页最后一条记录中提取 nextCursor。 |
| | | * |
| | | * <p>这里使用反射而不是额外定义接口,目的是降低接入成本: |
| | | * 只要实体里存在同名字段,就能直接复用通用方法。</p> |
| | | * |
| | | * <p>支持的字段值类型:</p> |
| | | * <ul> |
| | | * <li>{@link Long}</li> |
| | | * <li>其他 {@link Number}</li> |
| | | * <li>可转成 Long 的字符串</li> |
| | | * </ul> |
| | | * |
| | | * <p>如果字段不存在、为空、或无法转成 Long,则返回 null。</p> |
| | | */ |
| | | private <T> Long extractCursorValue(List<T> records, String cursorField) { |
| | | if (Cools.isEmpty(records)) { |
| | | return null; |
| | | } |
| | | T lastRecord = records.get(records.size() - 1); |
| | | if (lastRecord == null || Cools.isEmpty(cursorField)) { |
| | | return null; |
| | | } |
| | | Field field = Cools.getField(lastRecord.getClass(), cursorField); |
| | | if (field == null) { |
| | | return null; |
| | | } |
| | | boolean accessible = field.isAccessible(); |
| | | try { |
| | | field.setAccessible(true); |
| | | Object value = field.get(lastRecord); |
| | | if (value instanceof Long) { |
| | | return (Long) value; |
| | | } |
| | | if (value instanceof Number) { |
| | | return ((Number) value).longValue(); |
| | | } |
| | | if (value instanceof String && !((String) value).trim().isEmpty()) { |
| | | return Long.parseLong(((String) value).trim()); |
| | | } |
| | | } catch (IllegalAccessException | NumberFormatException ignored) { |
| | | return null; |
| | | } finally { |
| | | field.setAccessible(accessible); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | } |
| | | |