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 */ public class BaseController { public User getLoginUser() { try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { Object object = authentication.getPrincipal(); if (object instanceof User) { return (User) object; } } } catch (Exception e) { System.out.println(e.getMessage()); } return null; } public Long getLoginUserId() { User loginUser = getLoginUser(); return loginUser == null ? null : loginUser.getId(); } public Long getTenantId() { User loginUser = getLoginUser(); return loginUser == null ? null : loginUser.getTenantId(); } public boolean hasAuthority(String authority) { if (authority == null || authority.trim().isEmpty()) { return false; } try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || authentication.getAuthorities() == null) { return false; } for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { if (grantedAuthority != null && authority.equals(grantedAuthority.getAuthority())) { return true; } } } catch (Exception e) { System.out.println(e.getMessage()); } return false; } public T buildParam(Map map, Class clz) { if (!Objects.isNull(map.get("meta"))) { Map meta = (Map) map.get("meta"); meta.keySet().forEach(key -> { map.put(key, meta.get(key)); }); map.remove("meta"); } // 移除筛选条件里面的 $ for (String key : map.keySet()) { Object value = map.get(key); if (key.equals("orderBy")) { String newValue = value.toString().replace("$", ""); map.replace("orderBy", value, newValue); } } T t = null; try { t = clz.getDeclaredConstructor().newInstance(); t.syncMap(map); } catch (Exception e) { e.printStackTrace(); } return t; } public Boolean hasCreateTimeDesc(List orderItems) { if (Cools.isEmpty(orderItems)) { return true; } if (orderItems.size() > 1) { return false; } OrderItem orderItem = orderItems.get(0); if (!orderItem.getColumn().equals("create_time")) { return false; } if (orderItem.isAsc()) { return false; } return true; } /** * 通用游标分页实现。 * *

这个方法的目标不是替代所有分页,而是把“单字段、倒序、向后翻页”的游标分页 * 收敛成一套统一实现,避免各个 controller 重复写下面这些样板逻辑:

*
    *
  • buildParam
  • *
  • 忽略前端传入的 orderBy
  • *
  • buildWrapper(true) 构建通用筛选
  • *
  • cursor 条件
  • *
  • 按固定字段倒序
  • *
  • 多查一条判断 hasNext
  • *
  • 截断结果并生成 nextCursor
  • *
* *

适用前提:

*
    *
  • 游标字段是单一字段
  • *
  • 游标字段的值稳定、可比较,并且能映射成 Long
  • *
  • 分页方向固定为“按该字段倒序,向更小的值翻页”
  • *
* *

参数说明:

*
    *
  • {@code map}:原始请求参数
  • *
  • {@code paramClass}:游标参数类型,通常传 {@link CursorPageParam}
  • *
  • {@code entityClass}:实体类,用于 PageParam 条件构建和 condition 模糊查询
  • *
  • {@code service}:MyBatis-Plus 的 IService,负责执行 page 查询
  • *
  • {@code cursorField}:实体字段名,不是数据库列名,例如传 {@code id}
  • *
  • {@code defaultPageSize}:当前接口的默认分页大小
  • *
  • {@code wrapperConsumer}:可选的额外 where 条件扩展钩子
  • *
  • {@code recordsConsumer}:可选的结果后处理钩子,例如补充 createBy$/updateBy$
  • *
*/ protected CursorPageResult cursorPage( Map map, Class paramClass, Class entityClass, IService service, String cursorField, int defaultPageSize, Consumer> wrapperConsumer, Consumer> 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 pageParam = new PageParam<>(baseParam, entityClass); QueryWrapper 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 queryPage = new Page<>(1L, pageSize + 1L, false); List records = service.page(queryPage, wrapper).getRecords(); List 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 result = new CursorPageResult<>(); result.setRecords(pageRecords); result.setPageSize(pageSize); result.setHasNext(hasNext); // nextCursor 只有在还有下一页时才有意义; // 约定取“当前页最后一条记录”的游标字段值。 result.setNextCursor(hasNext ? extractCursorValue(pageRecords, cursorField) : null); return result; } /** * 统一解析当前接口实际使用的 pageSize。 * *

只要前端没传、传了 0、或者传了负数,就回退到 controller 传入的默认值。

*/ private int resolveCursorPageSize(Integer pageSize, int defaultPageSize) { if (pageSize == null || pageSize <= 0) { return defaultPageSize; } return pageSize; } /** * 把实体字段名转换成数据库列名。 * *

例如:

*
    *
  • {@code id -> `id`}
  • *
  • {@code poId -> `po_id`}
  • *
* *

这样 controller 调用时只需要关心 Java 字段名,不需要自己拼 SQL。

*/ private String resolveCursorColumn(String cursorField) { return "`" + Utils.toSymbolCase(cursorField, '_') + "`"; } /** * 从当前页最后一条记录中提取 nextCursor。 * *

这里使用反射而不是额外定义接口,目的是降低接入成本: * 只要实体里存在同名字段,就能直接复用通用方法。

* *

支持的字段值类型:

*
    *
  • {@link Long}
  • *
  • 其他 {@link Number}
  • *
  • 可转成 Long 的字符串
  • *
* *

如果字段不存在、为空、或无法转成 Long,则返回 null。

*/ private Long extractCursorValue(List 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; } }