zhou zhou
2 天以前 03c3e3cfc1262e26a218a4b8340c0a53ca3065c6
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/BaseController.java
@@ -1,16 +1,27 @@
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
@@ -105,5 +116,189 @@
        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;
    }
}