跳至正文
StackBug
返回

多种设计模式在知识点卡片查询中的组合应用

背景

最近接手了教育资源平台的知识点卡片查询模块,刚开始看到代码的时候头都大了 - 各种学段学科的查询逻辑都写在一起,一个方法几百行,充斥着各种 if-else 判断。每次要新增一个学科支持,都得小心翼翼地在原有代码里插入逻辑,生怕改坏了其他功能。

决定重构这块,刚开始想的是用策略模式,后来发现单纯的策略模式还不够,最终组合使用了策略模式 + 工厂模式 + 模板方法模式。

涉及的设计模式分析

这个实现其实用到了三种设计模式的组合:

1. 策略模式(Strategy Pattern)

不同学段学科有不同的查询和处理逻辑,但都实现相同的接口。每个具体策略类负责一种学段学科组合的处理逻辑。

2. 模板方法模式(Template Method Pattern)

AbstractKnowledgeCardStrategy抽象类定义了查询的骨架流程:参数验证 → 教材定位 → 数据查询 → 结果处理。通用步骤在抽象类中实现,差异化步骤留给子类。

3. 工厂模式(Factory Pattern)

KnowledgeCardService根据学段学科参数自动选择对应的策略实例,充当了简单工厂的角色。Spring 容器的依赖注入也起到了工厂的作用。

业务背景

我们的教育资源平台需要支持多个学段和学科的知识点卡片查询:

每个学段学科组合都有其独特的数据处理逻辑,但整体查询流程基本一致,适合用多种设计模式组合来处理。

系统架构设计

核心组件关系图

核心接口定义

public interface KnowledgeCardStrategy {
    /**
     * 查询知识点卡片数据
     */
    IPage<?> getKnowledgeCard(YgResourceReq req);

    /**
     * 获取策略标识
     */
    String getStrategyKey();

    /**
     * 检查是否支持指定的学段学科组合
     */
    boolean supports(String period, String subject);
}

抽象模板类的设计(模板方法模式)

AbstractKnowledgeCardStrategy是整个体系的核心,它体现了模板方法模式的思想:定义算法骨架,将可变部分延迟到子类实现。

核心流程设计

public abstract class AbstractKnowledgeCardStrategy implements KnowledgeCardStrategy {

    @Override
    public final IPage<?> getKnowledgeCard(YgResourceReq req) {
        // 1. 参数验证
        validateRequest(req);

        // 2. 获取章节子树信息(通过复杂的多表关联查询)
        String chapterId = getChapterIdFromRequest(req);

        // 3. 子树节点获取 BooksStruct list 通用步骤
        List<BooksStruct> booksStructList = queryBooksStructList(chapterId);

        // 4. 查询并处理知识点卡片数据(完全由子类实现)
        return queryAndProcessKnowledgeCards(booksStructList, req);
    }
}

通用步骤的实现

1. 教材定位逻辑

protected String getChapterIdFromRequest(YgResourceReq req) {
    // req.chapterId 是母树ID,需要找到它绑定的子树ID

    try {
        // 步骤1:通过请求参数定位具体教材
        BooksSubParam bookParam = buildBooksSubParam(req);
        BooksSub booksSub = booksSubService.getFirstBooksSub(bookParam);

        if (booksSub != null) {
            // 步骤2:查询该教材对应的章节树
            LambdaQueryWrapper<ChapterTreeSub> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(ChapterTreeSub::getDelFlag, "0")
                    .like(ChapterTreeSub::getAppModId, booksSub.getAppModuleId())
                    .eq(ChapterTreeSub::getGrade, booksSub.getGrade())
                    .eq(ChapterTreeSub::getSubject, booksSub.getSubject())
                    .eq(ChapterTreeSub::getPress, booksSub.getPress())
                    .eq(ChapterTreeSub::getVolume, booksSub.getVolume())
                    .orderByAsc(ChapterTreeSub::getSort);

            List<ChapterTreeSub> chapterTrees = chapterTreeSubService.list(wrapper);

            // 步骤3:通过 bc_res_chapter 表查找母树与子树的绑定关系
            for (ChapterTreeSub chapterTree : chapterTrees) {
                LambdaQueryWrapper<BcResChapter> resChapterWrapper = new LambdaQueryWrapper<>();
                resChapterWrapper.eq(BcResChapter::getResourceType, 3) // 3-子树章节
                                .eq(BcResChapter::getResourceId, chapterTree.getId())
                                .eq(BcResChapter::getChapterId, req.getChapterId());

                if (bcResChapterMapper.selectCount(resChapterWrapper) > 0) {
                    return chapterTree.getId();
                }
            }
        }
    } catch (Exception e) {
        // 异常处理逻辑
    }
    return null;
}

2. BooksStruct 查询通用逻辑

protected List<BooksStruct> queryBooksStructList(String chapterId) {
    if (StrUtil.isBlank(chapterId)) {
        return new ArrayList<>();
    }

    // 通过章节ID查询关联的 BooksStruct 列表
    return booksStructService.lambdaQuery()
            .eq(BooksStruct::getChapterParentId, chapterId)
            .eq(BooksStruct::getDelFlag, "0")
            .orderByAsc(BooksStruct::getSort)
            .list();
}

策略枚举的设计

为了更好地管理学段学科组合及其对应的策略实现,我们设计了一个枚举类来统一管理:

@Getter
@AllArgsConstructor
public enum KnowledgeCardStrategyEnum {
    // 小学学科策略
    PRIMARY_CHINESE("1", "01", "小学语文", "primary_chinese"),
    PRIMARY_MATH("1", "02", "小学数学", "primary_math"),
    PRIMARY_ENGLISH("1", "03", "小学英语", "primary_english"),
    PRIMARY_MORAL("1", "04", "小学道德与法治", "primary_moral"),
    PRIMARY_SCIENCE("1", "10", "小学科学", "primary_science"),

    // 初中学科策略
    MIDDLE_CHINESE("2", "01", "初中语文", "middle_chinese"),
    MIDDLE_MATH("2", "02", "初中数学", "middle_math"),
    MIDDLE_ENGLISH("2", "03", "初中英语", "middle_english"),
    MIDDLE_PHYSICS("2", "06", "初中物理", "middle_physics"),
    MIDDLE_CHEMISTRY("2", "08", "初中化学", "middle_chemistry"),

    // 高中学科策略
    HIGH_CHINESE("3", "01", "高中语文", "high_chinese"),
    HIGH_MATH("3", "02", "高中数学", "high_math"),
    HIGH_PHYSICS("3", "06", "高中物理", "high_physics");

    /**
     * 学段编码 (1=小学, 2=初中, 3=高中)
     */
    private final String periodCode;

    /**
     * 学科编码 (01=语文, 02=数学, 03=英语等)
     */
    private final String subjectCode;

    /**
     * 学段学科描述
     */
    private final String description;

    /**
     * Spring组件名称 (用于@Component注解)
     */
    private final String componentName;

    /**
     * 根据学段和学科编码查找对应的策略
     */
    public static Optional<KnowledgeCardStrategyEnum> findByCode(String periodCode, String subjectCode) {
        return Arrays.stream(values())
                .filter(strategy -> strategy.periodCode.equals(periodCode)
                                 && strategy.subjectCode.equals(subjectCode))
                .findFirst();
    }

    /**
     * 获取策略标识(格式: period_subject)
     */
    public String getStrategyKey() {
        return periodCode + "_" + subjectCode;
    }

    /**
     * 检查是否支持指定的学段学科组合
     */
    public boolean supports(String periodCode, String subjectCode) {
        return this.periodCode.equals(periodCode) && this.subjectCode.equals(subjectCode);
    }
}

具体策略的实现

小学语文策略示例

@Component("primary_chinese")
public class PrimaryChineseKnowledgeCardStrategy extends AbstractKnowledgeCardStrategy {

    private static final KnowledgeCardStrategyEnum STRATEGY_CONFIG =
        KnowledgeCardStrategyEnum.PRIMARY_CHINESE;

    @Override
    public String getStrategyKey() {
        return STRATEGY_CONFIG.getStrategyKey();
    }

    @Override
    public boolean supports(String period, String subject) {
        return STRATEGY_CONFIG.supports(period, subject);
    }

    @Override
    protected IPage<?> queryAndProcessKnowledgeCards(List<BooksStruct> booksStructList,
                                                     YgResourceReq req) {
        // 从 BooksStruct 列表中提取 structId
        List<String> structIds = booksStructList.stream()
                .map(BooksStruct::getId)
                .collect(Collectors.toList());

        if (CollUtil.isEmpty(structIds)) {
            return new Page<>();
        }

        // 基于 structId 查询 MongoDB 中的小学语文结构化数据
        Criteria criteria = new Criteria();
        criteria.where("structId").in(structIds);
        Query query = new Query(criteria);

        List<MongoBooksStructChinesePri> mongoData =
            mongoTemplate.find(query, MongoBooksStructChinesePri.class);

        // 处理和转换数据为小学语文特定的格式
        List<GetStructKnowledgeCardPriChineseVo> result = processChineseData(mongoData);

        IPage<Object> pageResult = new Page<>(1, 10000);
        pageResult.setRecords((List<Object>) (Object) result);
        return pageResult;
    }

    private List<GetStructKnowledgeCardPriChineseVo> processChineseData(
            List<MongoBooksStructChinesePri> mongoData) {
        // 具体的数据处理逻辑...
        return new ArrayList<>();
    }
}

中学数学策略示例

@Component("middle_math")
public class MiddleMathKnowledgeCardStrategy extends AbstractKnowledgeCardStrategy {

    private static final KnowledgeCardStrategyEnum STRATEGY_CONFIG =
        KnowledgeCardStrategyEnum.MIDDLE_MATH;

    @Override
    public String getStrategyKey() {
        return STRATEGY_CONFIG.getStrategyKey();
    }

    @Override
    public boolean supports(String period, String subject) {
        return STRATEGY_CONFIG.supports(period, subject);
    }

    @Override
    protected String getChapterIdFromRequest(YgResourceReq req) {
        // 中学数学有特殊的章节ID处理逻辑
        return "1";
    }

    @Override
    protected IPage<?> queryAndProcessKnowledgeCards(List<BooksStruct> booksStructList,
                                                     YgResourceReq req) {
        // 中学数学有知识点和题型两种业务类型
        List<Object> result = new ArrayList<>();

        // 从 BooksStruct 中提取 structId
        List<String> structIds = booksStructList.stream()
                .map(BooksStruct::getId)
                .collect(Collectors.toList());

        if (CollUtil.isNotEmpty(structIds)) {
            // 处理知识点类型(businessType = "1")
            result.addAll(processKnowledgeType(structIds));

            // 处理题型/考点类型(businessType = "4")
            result.addAll(processMethodType(structIds));
        }

        // 返回组合结果
        IPage<Object> pageResult = new Page<>(1, 10000);
        pageResult.setRecords(result);
        return pageResult;
    }

    private List<GetStructKnowledgeCardMidMathKnowVo> processKnowledgeType(List<String> structIds) {
        // 查询 MongoBooksStructMathMid 中的知识点数据
        Criteria criteria = new Criteria();
        criteria.where("structId").in(structIds)
                .and("knowledge").exists(true);
        Query query = new Query(criteria);

        List<MongoBooksStructMathMid> mongoData =
            mongoTemplate.find(query, MongoBooksStructMathMid.class);

        // 转换为 GetStructKnowledgeCardMidMathKnowVo 格式
        return mongoData.stream()
                .flatMap(data -> data.getKnowledge().stream())
                .map(this::convertToKnowledgeVo)
                .collect(Collectors.toList());
    }

    private List<GetStructKnowledgeCardMidMathMethodVo> processMethodType(List<String> structIds) {
        // 查询 MongoBooksStructMathMid 中的方法数据
        Criteria criteria = new Criteria();
        criteria.where("structId").in(structIds)
                .and("method").exists(true);
        Query query = new Query(criteria);

        List<MongoBooksStructMathMid> mongoData =
            mongoTemplate.find(query, MongoBooksStructMathMid.class);

        // 转换为 GetStructKnowledgeCardMidMathMethodVo 格式
        return mongoData.stream()
                .flatMap(data -> data.getMethod().stream())
                .map(this::convertToMethodVo)
                .collect(Collectors.toList());
    }
}

工厂模式的体现

Spring Bean 管理(工厂模式)

这里用到了工厂模式的思想。KnowledgeCardService相当于一个简单工厂,根据输入参数选择合适的策略:

@Service
public class KnowledgeCardService {

    private final Map<String, KnowledgeCardStrategy> strategies;

    // Spring 自动注入所有策略实现(IoC容器充当工厂角色)
    public KnowledgeCardService(List<KnowledgeCardStrategy> strategyList) {
        this.strategies = strategyList.stream()
                .collect(Collectors.toMap(
                    KnowledgeCardStrategy::getStrategyKey,
                    Function.identity()
                ));
    }

    // 简单工厂方法:根据参数选择策略
    public IPage<?> getKnowledgeCard(YgResourceReq req) {
        String strategyKey = req.getPeriod() + "_" + req.getSubject();
        KnowledgeCardStrategy strategy = strategies.get(strategyKey);

        if (strategy == null) {
            throw new BusinessException("不支持的学段学科组合: " + strategyKey);
        }

        return strategy.getKnowledgeCard(req);
    }

    /**
     * 获取所有支持的学段学科组合
     */
    public List<String> getSupportedCombinations() {
        return new ArrayList<>(strategies.keySet());
    }
}

控制器层调用

@RestController
@RequestMapping("/api/knowledge-card")
@Api(tags = "知识点卡片查询接口")
public class KnowledgeCardController {

    @Autowired
    private KnowledgeCardService knowledgeCardService;

    @PostMapping("/query")
    @ApiOperation("查询知识点卡片")
    public Result<IPage<?>> queryKnowledgeCard(@RequestBody @Valid YgResourceReq req) {
        try {
            IPage<?> result = knowledgeCardService.getKnowledgeCard(req);
            return Result.success(result);
        } catch (BusinessException e) {
            return Result.error(e.getMessage());
        }
    }

    @GetMapping("/supported-combinations")
    @ApiOperation("获取支持的学段学科组合")
    public Result<List<String>> getSupportedCombinations() {
        List<String> combinations = knowledgeCardService.getSupportedCombinations();
        return Result.success(combinations);
    }
}

三种模式的协作关系

这三种设计模式在系统中是这样协作的:

  1. 工厂模式:KnowledgeCardService根据请求参数(学段+学科)选择对应的策略实例
  2. 策略模式:每个学段学科组合都有独立的策略实现,满足相同接口
  3. 模板方法模式:AbstractKnowledgeCardStrategy定义通用流程,子类实现差异化逻辑
Client Request

KnowledgeCardService (Factory)  ←── 选择策略

ConcreteStrategy (Strategy)     ←── 执行策略

AbstractStrategy (Template)     ←── 模板流程

设计优势

1. 扩展容易了很多

之前新增一个学科支持,得在好几个地方改代码,还容易漏掉。现在就简单多了:

第一步:枚举里加个配置

// 比如要加高中物理
HIGH_PHYSICS("3", "06", "高中物理", "high_physics"),

第二步:写个实现类

@Component("high_physics")
public class HighPhysicsKnowledgeCardStrategy extends AbstractKnowledgeCardStrategy {

    private static final KnowledgeCardStrategyEnum STRATEGY_CONFIG =
        KnowledgeCardStrategyEnum.HIGH_PHYSICS;

    @Override
    protected IPage<?> queryAndProcessKnowledgeCards(List<BooksStruct> booksStructList,
                                                     YgResourceReq req) {
        // 高中物理的具体逻辑
        return processPhysicsData(booksStructList, req);
    }
}

就这两步,其他代码完全不用动。Spring 会自动扫描到新策略并注册,工厂会自动识别,模板流程也不用改。

2. 三种模式各司其职

这样分工明确,出问题知道去哪找,改需求也知道动哪个文件。

3. 测试和维护轻松了

每个策略都是独立的,单元测试好写多了:

@ExtendWith(SpringExtension.class)
@MockitoExtension
class PrimaryChineseKnowledgeCardStrategyTest {

    @InjectMocks
    private PrimaryChineseKnowledgeCardStrategy strategy;

    @Mock
    private MongoTemplate mongoTemplate;

    @Test
    void should_support_primary_chinese() {
        // 测试策略支持逻辑
        assertTrue(strategy.supports("1", "01"));
        assertFalse(strategy.supports("2", "01"));
        assertFalse(strategy.supports("1", "02"));
    }

    @Test
    void should_return_correct_strategy_key() {
        // 测试策略键生成
        assertEquals("1_01", strategy.getStrategyKey());
    }

    @Test
    void should_process_knowledge_cards_correctly() {
        // 准备测试数据
        List<BooksStruct> booksStructList = Arrays.asList(
            createMockBooksStruct("struct1"),
            createMockBooksStruct("struct2")
        );
        YgResourceReq req = createMockRequest();

        // 模拟MongoDB查询结果
        when(mongoTemplate.find(any(Query.class), eq(MongoBooksStructChinesePri.class)))
                .thenReturn(createMockMongoData());

        // 执行测试
        IPage<?> result = strategy.queryAndProcessKnowledgeCards(booksStructList, req);

        // 验证结果
        assertNotNull(result);
        assertFalse(result.getRecords().isEmpty());
        verify(mongoTemplate).find(any(Query.class), eq(MongoBooksStructChinesePri.class));
    }

    @Test
    void should_handle_empty_books_struct_list() {
        // 测试空列表处理
        YgResourceReq req = createMockRequest();
        IPage<?> result = strategy.queryAndProcessKnowledgeCards(new ArrayList<>(), req);

        assertNotNull(result);
        assertTrue(result.getRecords().isEmpty());
    }
}

性能优化考虑

1. 策略缓存优化

@Component
public class KnowledgeCardStrategyCache {

    private final Map<String, KnowledgeCardStrategy> strategyCache = new ConcurrentHashMap<>();
    private final ApplicationContext applicationContext;

    public KnowledgeCardStrategyCache(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @PostConstruct
    public void initCache() {
        // 应用启动时预热缓存
        Arrays.stream(KnowledgeCardStrategyEnum.values())
              .parallel()
              .forEach(config -> {
                  try {
                      KnowledgeCardStrategy strategy =
                          applicationContext.getBean(config.getComponentName(), KnowledgeCardStrategy.class);
                      strategyCache.put(config.getStrategyKey(), strategy);
                  } catch (Exception e) {
                      log.warn("Failed to load strategy: {}", config.getComponentName(), e);
                  }
              });

        log.info("Loaded {} knowledge card strategies", strategyCache.size());
    }

    public KnowledgeCardStrategy getStrategy(String strategyKey) {
        return strategyCache.get(strategyKey);
    }
}

2. 数据库查询优化

// 批量查询 BooksStruct,减少数据库交互
protected List<BooksStruct> queryBooksStructList(List<String> chapterIds) {
    if (CollUtil.isEmpty(chapterIds)) {
        return new ArrayList<>();
    }

    // 使用IN查询替代多次单独查询
    return booksStructService.lambdaQuery()
            .in(BooksStruct::getChapterParentId, chapterIds)
            .eq(BooksStruct::getDelFlag, "0")
            .orderByAsc(BooksStruct::getSort)
            .list();
}

// MongoDB查询优化
protected List<MongoBooksStructMath> queryMongoData(List<String> structIds,
                                                   List<String> businessIds) {
    // 构建复合索引查询
    Criteria criteria = new Criteria();
    criteria.andOperator(
        Criteria.where("structId").in(structIds),
        Criteria.where("example._id").in(businessIds)
    );

    Query query = new Query(criteria);
    // 只查询需要的字段,减少网络传输
    query.fields()
         .include("structId")
         .include("example")
         .exclude("_id");

    return mongoTemplate.find(query, MongoBooksStructMath.class);
}

3. 结果缓存策略

@Service
public class CachedKnowledgeCardService {

    private final KnowledgeCardService knowledgeCardService;
    private final RedisTemplate<String, Object> redisTemplate;

    @Cacheable(value = "knowledge-card", key = "#req.hashCode()", unless = "#result.records.isEmpty()")
    public IPage<?> getKnowledgeCard(YgResourceReq req) {
        return knowledgeCardService.getKnowledgeCard(req);
    }

    @CacheEvict(value = "knowledge-card", allEntries = true)
    public void clearCache() {
        // 清除所有缓存
    }
}

监控和日志

1. 策略执行监控

@Aspect
@Component
public class KnowledgeCardStrategyMonitor {

    private final MeterRegistry meterRegistry;

    @Around("execution(* com.bcbook.modules.yg.strategy.impl.*.*(..))")
    public Object monitorStrategyExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        String strategyName = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        Timer.Sample sample = Timer.start(meterRegistry);
        try {
            Object result = joinPoint.proceed();

            // 记录成功执行
            meterRegistry.counter("knowledge.card.strategy.success",
                               "strategy", strategyName,
                               "method", methodName)
                        .increment();

            return result;
        } catch (Exception e) {
            // 记录失败执行
            meterRegistry.counter("knowledge.card.strategy.error",
                               "strategy", strategyName,
                               "method", methodName,
                               "error", e.getClass().getSimpleName())
                        .increment();
            throw e;
        } finally {
            // 记录执行时间
            sample.stop(Timer.builder("knowledge.card.strategy.duration")
                           .tag("strategy", strategyName)
                           .tag("method", methodName)
                           .register(meterRegistry));
        }
    }
}

2. 结构化日志

@Component
public class KnowledgeCardStrategyLogger {

    private static final Logger log = LoggerFactory.getLogger(KnowledgeCardStrategyLogger.class);

    public void logStrategyExecution(String strategyKey, YgResourceReq req,
                                   long executionTime, int resultCount) {
        log.info("Strategy execution completed. " +
                "strategy={}, period={}, subject={}, chapterId={}, " +
                "executionTime={}ms, resultCount={}",
                strategyKey, req.getPeriod(), req.getSubject(),
                req.getChapterId(), executionTime, resultCount);
    }

    public void logStrategyError(String strategyKey, YgResourceReq req, Exception e) {
        log.error("Strategy execution failed. " +
                 "strategy={}, period={}, subject={}, chapterId={}, error={}",
                 strategyKey, req.getPeriod(), req.getSubject(),
                 req.getChapterId(), e.getMessage(), e);
    }
}

未来扩展方向

1. 动态策略加载

@Service
public class DynamicStrategyLoader {

    public void loadStrategy(String jarPath, String strategyClassName) {
        // 动态加载外部JAR包中的策略实现
        URLClassLoader classLoader = new URLClassLoader(new URL[]{new File(jarPath).toURI().toURL()});
        Class<?> strategyClass = classLoader.loadClass(strategyClassName);

        // 注册到Spring容器
        registerStrategy(strategyClass);
    }

    private void registerStrategy(Class<?> strategyClass) {
        // 动态注册Bean到Spring容器
        DefaultListableBeanFactory beanFactory =
            (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();

        beanFactory.registerBeanDefinition(
            strategyClass.getSimpleName(),
            BeanDefinitionBuilder.rootBeanDefinition(strategyClass).getBeanDefinition()
        );
    }
}

2. 策略组合模式

@Component("combined_strategy")
public class CombinedKnowledgeCardStrategy extends AbstractKnowledgeCardStrategy {

    private final List<KnowledgeCardStrategy> subStrategies;

    @Override
    protected IPage<?> queryAndProcessKnowledgeCards(List<BooksStruct> booksStructList,
                                                     YgResourceReq req) {
        // 并行执行多个子策略
        List<CompletableFuture<IPage<?>>> futures = subStrategies.stream()
                .map(strategy -> CompletableFuture.supplyAsync(
                    () -> strategy.queryAndProcessKnowledgeCards(booksStructList, req)))
                .collect(Collectors.toList());

        // 合并所有子策略的结果
        return mergeResults(futures);
    }
}

总结

整个重构下来,感觉还是很值得的。不是单纯的策略模式,而是策略 + 工厂 + 模板方法三种模式的组合应用,确实适合我们这种”流程相同,细节不同”的场景。

主要收获

  1. 架构更清晰了:三种设计模式各司其职,工厂选策略、模板定流程、策略做实现
  2. 扩展性好了很多:新增学科支持真的快,基本半天就能搞定
  3. 维护成本降低:出问题知道去哪个文件找,改需求也不用担心影响其他学科
  4. 团队协作顺畅:不同人可以同时开发不同学科的策略,不会冲突

适合用的场景

这种多模式组合比较适合:

当然也不是万能的,如果业务逻辑本身就很简单,用这么多设计模式反而会过度设计。


分享到:

上一篇
Spring AI 1.0.3 完全指南:从入门到企业级实践
下一篇
为什么 Elasticsearch 精确查询要用 keyword?