简介:这是一套基于Spring Boot与Vue 3构建的AI聊天应用完整工程,面向Java后端、Vue前端开发者及RAG技术实践者,解决多模型接入、检索增强对话与全栈协同开发等实际问题。资源包含2000个文件,主体为1759个JavaScript/TypeScript逻辑文件、86个JSON配置与API定义、70个Markdown文档(含部署指南、模型接入说明、RAG实现原理)、19个Java后端核心类及15个Vue组件,整体15.77MB,结构清晰,便于按模块快速定位。已有162人学习下载,覆盖环境搭建、API调用、RAG流程调试、新模型扩展及常见故障日志分析等关键环节,配套详尽的配置说明、项目结构图解与MIT开源许可,可直接复用于AI应用原型开发或教学演示。
1. 为什么用 Spring AI Alibaba + Milvus + Vue 3 搭 AI 聊天应用,不是“堆技术”,而是解决三个真实卡点
你试过在本地跑一个带知识库的 AI 聊天界面吗?不是调 API 玩玩 demo,而是真要上线、能查自己 PDF、能记住上下文、响应不卡顿、部署不靠云厂商托管——这时候你会发现:光靠 LangChain + OpenAI 官方 SDK,连「上传文档→切块→向量化→存检索→召回→拼 prompt→流式返回」这一条链路都容易翻车。Spring AI Alibaba 填了 Spring 生态里大模型接入的空白(尤其国内用户对通义千问、Qwen、百川等模型的原生适配),Milvus 则是少有的、能在单机跑通千万级向量检索且支持余弦/内积/欧氏距离灵活切换的开源向量数据库(不是 Chroma 那种玩具级,也不是 Pinecone 那种黑匣子 SaaS);Vue 3 的 Composition API 和 Pinia 状态管理,让前端能干净地处理 streaming SSE 流、历史会话折叠、引用溯源高亮这些“非功能需求”。这不是炫技组合,而是把「模型接入稳、向量检索快、前端交互顺」三件事,在国产化技术栈下真正串通的一套最小可行闭环。适合正在做内部知识助手、客服问答系统、或教育类对话产品的 Java/Vue 工程师,尤其当你被要求“不用公网模型、不依赖境外服务、能私有部署”时——这套方案就是你手头最硬的落地选项。
2. 搭建后端:Spring AI Alibaba 接入 Qwen + Milvus 向量库的最小可运行配置
2.1 为什么选 Spring AI Alibaba 而不是直接写 RestTemplate 调通义 API
Spring AI Alibaba 是阿里开源的 Spring Boot 原生适配器,它不是简单封装 HTTP 请求,而是把大模型能力抽象成ChatClient、EmbeddingClient、RetrievalAugmentor三类 Bean,和 Spring 的自动装配、条件化配置、AOP 日志、健康检查天然融合。比如你定义一个@Bean ChatClient qwenClient(),它自动注入QwenChatModel,并帮你处理 token 计费上报、流式响应解析、重试策略、超时熔断——而自己写 HTTP Client,光是处理 SSE 流中data: {...}的换行和 JSON 解析就足够写两百行胶水代码。更重要的是,它内置了MilvusVectorStore的标准实现,你只需配好连接参数,RetrievalAugmentor就能自动完成「用户问题 → embedding → Milvus 查询 → top-k 文档 → 注入 prompt」全流程,无需手写向量转换、ID 映射、元数据过滤逻辑。
提示:Spring AI Alibaba 当前最新稳定版是
0.10.0(2024 年 7 月发布),已支持 Qwen2-7B-Instruct、Qwen2-VL、Baichuan2 等主流国产模型。注意它不是Spring AI 官方项目(Spring AI 由 VMware 维护,侧重通用抽象),而是阿里针对国内模型生态做的深度定制,二者包名、配置项、依赖坐标完全不同。
2.2 初始化 Milvus Standalone 模式:5 分钟跑通本地向量库
Milvus Standalone 是官方推荐的开发/测试模式,单进程、零依赖、Docker 一键拉起,比集群版省掉 80% 的运维成本。它默认使用 SQLite 存储元数据、RocksDB 存储向量索引,完全满足中小规模知识库(<500 万向量)的毫秒级响应需求。关键不是“能不能跑”,而是“怎么配才不踩坑”。
# 拉取官方镜像(注意:必须用 v2.4.9 或更高,低版本不支持 Spring AI Alibaba 的 vector store 协议) docker run -d \ --name milvus-standalone \ -p 19530:19530 \ -p 9091:9091 \ -v $(pwd)/milvus-data:/var/lib/milvus \ --ulimit nofile=65536:65536 \ --restart=always \ milvusdb/milvus:v2.4.9 \ --standalone启动后验证是否就绪:
curl http://localhost:19530/system/healthz # 返回 {"status":"healthy"} 即成功注意:
--ulimit nofile是必须项!否则 Milvus 在高并发插入时会报Too many open files错误,这是 Standalone 模式最常被忽略的硬性限制。另外,/var/lib/milvus目录务必挂载到宿主机,否则容器重启后所有 collection 数据丢失——这不是 bug,是设计如此。
2.3 Spring Boot 项目集成:application.yml 关键配置项详解
以下配置基于 Spring Boot 3.3.x + Spring AI Alibaba 0.10.0 + Milvus Java SDK 2.4.9:
# application.yml spring: ai: alibaba: # Qwen 模型接入配置(以 dashscope 为例,需申请 API Key) dashscope: api-key: ${DASHSCOPE_API_KEY:your_api_key_here} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 指定模型 ID,必须与 dashscope 控制台开通的模型一致 model: qwen2-7b-instruct # 流式响应必须开启,否则前端无法实现打字效果 stream: true # 设置合理的 timeout,避免长 prompt 导致线程阻塞 connect-timeout: 10s read-timeout: 60s # 向量存储配置:Milvus vectorstore: milvus: # Milvus 地址,Standalone 默认为 localhost:19530 uri: http://localhost:19530 # collection 名称,建议按业务域命名,如 "kb_customer_support" collection-name: kb_qa_docs # 向量维度必须与 embedding 模型输出严格一致 # Qwen2 的 text-embedding-v2 默认输出 1024 维 vector-dimension: 1024 # 余弦相似度是语义检索最常用指标,Milvus 支持但需显式指定 metric-type: COSINE # 创建索引类型,IVF_FLAT 是平衡精度与速度的首选 index-type: IVF_FLAT # IVF 分桶数,经验值:数据量 / 1000 ~ 数据量 / 10000 nlist: 1000 # 检索时返回 top-k 结果,默认 3,可根据业务调整 search-k: 3 # 自定义 embedding client(Qwen 自带 embedding 模型) qwen-embedding: model: text-embedding-v2 dimensions: 1024这段配置背后有三个关键逻辑:
metric-type: COSINE决定了 Milvus 计算相似度的方式——它不是简单的点积,而是归一化后的余弦值(范围 [-1,1]),值越接近 1 表示语义越相近。很多新手误设为L2(欧氏距离),导致召回结果完全偏离预期;nlist: 1000是 IVF 索引的核心参数:太小(如 100)会导致分桶过粗,召回率下降;太大(如 10000)则索引构建慢、内存占用高,实测 50 万文档时nlist=1000是精度与速度的甜点;collection-name必须全局唯一,且不能含下划线以外的特殊字符(如-、.),否则 Spring AI Alibaba 初始化时会抛InvalidCollectionNameException——这个错误日志极不友好,只提示“failed to create collection”,实际原因藏在 Milvus server 日志里。
3. 构建知识库管道:从 PDF 到 Milvus 向量库的端到端 ETL 脚本
3.1 文档解析:为什么不用 PyPDF2,而选 pdfplumber + unstructured
PyPDF2 对扫描件、表格、多栏排版的 PDF 解析效果极差,经常漏文字、错顺序。pdfplumber能精确提取每一页的文本坐标、字体、行高,配合unstructured的PartitionStrategy.FAST模式,可自动识别标题、段落、列表、表格结构,并保留原始语义层级。这对后续 chunking(分块)至关重要——如果 chunk 切在标题和正文中间,检索时就会召回“半截答案”。
# ingest_pdf.py import pdfplumber from unstructured.partition.auto import partition from unstructured.chunking.title import chunk_by_title def parse_pdf_to_text(pdf_path: str) -> str: """用 pdfplumber 提取原始文本,再用 unstructured 清洗结构""" with pdfplumber.open(pdf_path) as pdf: full_text = "" for page in pdf.pages: # 提取页面文本(保留换行和空格) text = page.extract_text(x_tolerance=2, y_tolerance=2) if text: full_text += text + "\n\n" # unstructured 进一步结构化(识别标题、列表、表格) elements = partition(text=full_text, strategy="fast") # 按标题层级分块,避免跨章节切分 chunks = chunk_by_title(elements, max_characters=500, new_after_n_chars=300) return [chunk.text for chunk in chunks] # 示例调用 chunks = parse_pdf_to_text("manual.pdf") print(f"共解析出 {len(chunks)} 个语义块,平均长度 {sum(len(c) for c in chunks)//len(chunks)} 字符")这段脚本输出的是纯文本块列表,每个块长度控制在 300~500 字,且保证“一个完整段落”或“一个 FAQ 条目”不被切开。这是高质量 RAG 的前提——不是越多 chunk 越好,而是每个 chunk 都能独立表达一个完整语义单元。
3.2 向量化与入库:用 Qwen Embedding 模型生成向量
Spring AI Alibaba 提供了EmbeddingClient抽象,但生产环境必须自己控制 batch size 和 retry 逻辑,否则单次上传 1000 个 chunk 可能因网络抖动失败,导致整个知识库构建中断。
// IngestService.java @Service public class IngestService { private final EmbeddingClient embeddingClient; private final MilvusVectorStore vectorStore; public void ingestDocuments(List<String> chunks, String sourceId) { // 批处理:每 32 个 chunk 一组,避免 OOM 和 API 限流 final int batchSize = 32; for (int i = 0; i < chunks.size(); i += batchSize) { List<String> batch = chunks.subList(i, Math.min(i + batchSize, chunks.size())); // 调用 Qwen embedding 模型(同步阻塞,需配置合理 timeout) List<Embedding> embeddings = embeddingClient.embed(batch); // 构建 Milvus 实体:id、向量、原文、元数据 List<InsertParam.Field> fields = new ArrayList<>(); fields.add(new InsertParam.Field("id", IntStream.range(0, batch.size()).boxed().collect(Collectors.toList()))); fields.add(new InsertParam.Field("vector", embeddings.stream().map(Embedding::getEmbedded).collect(Collectors.toList()))); fields.add(new InsertParam.Field("text", batch)); fields.add(new InsertParam.Field("source_id", Collections.nCopies(batch.size(), sourceId))); // 执行批量插入(Milvus Java SDK 2.4.9+ 支持) vectorStore.insert(fields); } } }关键参数说明:
batchSize=32:Qwen text-embedding-v2 的最大 batch size 是 32,超限会返回400 Bad Request;source_id是业务字段,用于后续按文档来源过滤(例如只检索“用户手册”不查“API 文档”);vectorStore.insert()底层调用 Milvus 的insert接口,它默认启用auto_id=true,所以id字段其实是自增整数,这里传IntStream.range只是为了占位,实际会被覆盖——这点文档没写清楚,但源码里MilvusVectorStore的insert方法会忽略id字段,改用 Milvus 自动生成的主键。
3.3 验证向量质量:用 Milvus CLI 查看余弦相似度分布
入库完成后,别急着联调前端,先用 Milvus 自带的 CLI 工具验证向量是否真的“有意义”。执行以下命令,查询一个已知关键词的向量,并查看其 top-3 相似结果:
# 进入 Milvus 容器 docker exec -it milvus-standalone bash # 启动 milvus_cli milvus_cli # 连接本地 Milvus connect --host 127.0.0.1 --port 19530 # 查看 collection 信息 describe collection kb_qa_docs # 手动构造一个 query 向量(用 Qwen embedding 模型生成) # 假设你要查 "如何重置密码" # 先用 Python 脚本生成该 query 的向量(同上文 embeddingClient.embed()) # 得到 1024 维浮点数组,复制为 [0.12, -0.45, ..., 0.88] # 执行近似查询(注意:cosine 相似度 > 0.7 才算高相关) query --collection kb_qa_docs \ --output-fields text,source_id \ --filter "source_id == 'manual_v2'" \ --vector-field vector \ --vector-value "[0.12,-0.45,...,0.88]" \ --topk 3 \ --metric-type COSINE观察返回结果的distance字段:
- 如果多数
distance在0.65~0.85区间,说明向量质量良好; - 如果大量
distance < 0.5,可能是 chunk 太短(如单句)、embedding 模型未 fine-tune、或文档噪声太大; - 如果
distance全是0.999,大概率是所有向量被错误归一化成了单位向量(Qwen embedding 默认已归一化,无需二次处理)。
4. 前端交互:Vue 3 实现流式响应、历史会话、引用溯源的三件套
4.1 使用 fetch + ReadableStream 处理 SSE 流,替代 axios
axios 默认不支持原生 SSE 流式解析,强行用responseType: 'stream'会触发 CORS 预检失败。Vue 3 最佳实践是直接用原生fetch+ReadableStream,手动解析data:块:
// composables/useChat.ts export function useChat() { const messages = ref<ChatMessage[]>([]) const isLoading = ref(false) const sendMessage = async (content: string) => { isLoading.value = true messages.value.push({ role: 'user', content }) try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: content }) }) if (!response.body) throw new Error('No stream body') const reader = response.body.getReader() let accumulatedText = '' while (true) { const { done, value } = await reader.read() if (done) break // SSE 格式:data: {"delta":"hello","finish_reason":null}\n\n const text = new TextDecoder().decode(value) const lines = text.split('\n').filter(l => l.trim().startsWith('data:')) for (const line of lines) { try { const jsonStr = line.replace('data: ', '').trim() if (jsonStr === '[DONE]') continue const data = JSON.parse(jsonStr) accumulatedText += data.delta || '' // 实时更新消息(注意:Vue 3 响应式需在 nextTick 中更新) nextTick(() => { const lastMsg = messages.value[messages.value.length - 1] if (lastMsg.role === 'assistant') { lastMsg.content = accumulatedText } else { messages.value.push({ role: 'assistant', content: accumulatedText }) } }) } catch (e) { console.warn('SSE parse error:', e) } } } } finally { isLoading.value = false } } return { messages, isLoading, sendMessage } }这段代码的关键在于:
response.body.getReader()获取流读取器,避免一次性加载全部响应;TextDecoder().decode(value)将 Uint8Array 转为字符串,再按\n拆解 SSE 块;nextTick确保 DOM 更新时机正确,否则 Vue 3 的响应式系统可能丢失更新。
4.2 会话状态管理:Pinia 持久化 + 时间戳分组
聊天记录不能只存在内存里,否则刷新页面就丢。Pinia 支持persist插件,但要注意:
- 不要持久化整个
messages数组(含二进制 blob、函数引用等不可序列化对象); - 只存
role、content、timestamp、sourceIds(引用的文档 ID 列表)四个字段; - 用
timestamp毫秒值做分组依据,每 30 分钟新建一个会话(避免单个 session 过大)。
// stores/chat.ts import { defineStore } from 'pinia' import { persist } from 'pinia-plugin-persist' export interface ChatMessage { role: 'user' | 'assistant' content: string timestamp: number sourceIds?: string[] // 来源文档 ID 列表,用于前端高亮引用 } export const useChatStore = defineStore('chat', { state: () => ({ sessions: [] as { id: string; title: string; messages: ChatMessage[] }[], }), actions: { addMessage(sessionId: string, msg: ChatMessage) { const session = this.sessions.find(s => s.id === sessionId) if (session) { session.messages.push(msg) } }, createNewSession() { const now = Date.now() const id = `session_${Math.floor(now / (30 * 60 * 1000))}` const title = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) this.sessions.push({ id, title, messages: [] }) } }, plugins: [persist({ key: 'chat-store', storage: localStorage, // 只持久化必要字段,过滤掉不可序列化内容 serializer: { serialize: (state) => JSON.stringify(state), deserialize: (str) => { const parsed = JSON.parse(str) return { sessions: parsed.sessions?.map((s: any) => ({ id: s.id, title: s.title, messages: s.messages?.map((m: any) => ({ role: m.role, content: m.content, timestamp: m.timestamp, sourceIds: m.sourceIds })) })) } } } })] })注意:
persist插件默认用JSON.stringify,但Date对象会被转成字符串,Map/Set会丢失。所以ChatMessage类型必须是 plain object,不能含Date实例——一律用timestamp: number替代。
4.3 引用溯源高亮:用正则匹配 sourceIds 并渲染<cite>标签
后端在生成回答时,会把召回的文档 ID(如manual_v2#p12)写入sourceIds字段。前端拿到后,需在content中定位原文位置并高亮:
<!-- components/ChatMessage.vue --> <template> <div :class="['message', `role-${msg.role}`]"> <div v-if="msg.sourceIds && msg.sourceIds.length > 0" class="sources"> <span v-for="sid in msg.sourceIds" :key="sid" class="source-tag"> {{ sid.split('#')[0] }}<small>{{ sid.split('#')[1] }}</small> </span> </div> <div class="content" v-html="highlightCitations(msg.content, msg.sourceIds)" /> </div> </template> <script setup lang="ts"> import { computed } from 'vue' const props = defineProps<{ msg: ChatMessage }>() // 简单正则匹配:假设后端在回答中插入了类似 [ref:manual_v2#p12] 的标记 const highlightCitations = (text: string, sourceIds: string[] = []) => { if (!sourceIds.length) return text return sourceIds.reduce((acc, sid) => { // 将 [ref:manual_v2#p12] 替换为带 tooltip 的 cite 标签 const regex = new RegExp(`\\[ref:${sid}\\]`, 'g') return acc.replace(regex, `<cite class="citation" title="来源:${sid}">${sid}</cite>` ) }, text) } </script> <style scoped> .citation { background: #e6f7ff; color: #1890ff; padding: 0 4px; border-radius: 2px; font-size: 0.85em; cursor: pointer; } .citation:hover { background: #bae7ff; } </style>这个方案不依赖后端返回 HTML 片段,而是前端主动解析标记——既安全(防 XSS),又可控(样式、tooltip、点击行为全由前端定义)。[ref:xxx]格式是约定俗成的轻量标记,比返回<a href="#doc-xxx">更简洁。
5. 避坑指南:Spring AI Alibaba + Milvus + Vue 3 项目中最常见的 4 个血泪错误
5.1 现象:Spring Boot 启动时报NoSuchBeanDefinitionException: No qualifying bean of type 'ChatClient'
原因:spring-ai-alibaba-spring-boot-starter依赖未正确引入,或application.yml中spring.ai.alibaba.dashscope.api-key为空导致自动配置被跳过。Spring AI Alibaba 的ChatClientAutoConfiguration有@ConditionalOnProperty("spring.ai.alibaba.dashscope.api-key"),若 key 为空,整个 auto-config 就不生效,自然没有ChatClientBean。
解决:检查pom.xml是否包含以下依赖(注意版本号必须匹配):
<dependency> <groupId>com.alibaba.spring.ai</groupId> <artifactId>spring-ai-alibaba-spring-boot-starter</artifactId> <version>0.10.0</version> </dependency>并在application.yml中确保spring.ai.alibaba.dashscope.api-key有真实值(哪怕临时填xxx,也不能留空)。
5.2 现象:Milvus 查询永远返回空结果,但describe collection显示有 1000 条数据
原因:MilvusVectorStore默认在插入后不自动创建索引,而search操作要求索引已就绪。Standalone 模式下,索引构建是异步的,insert返回成功不代表索引可用。
解决:插入完成后,显式调用createIndex:
// 插入后立即创建索引(生产环境建议异步触发) vectorStore.createIndex("vector", IndexParam.builder() .indexType(IndexType.IVF_FLAT) .metricType(MetricType.COSINE) .params(Map.of("nlist", 1000)) .build());或者更稳妥的做法:在应用启动时检查 collection 状态,若无索引则创建:
if (!vectorStore.hasIndex("vector")) { vectorStore.createIndex("vector", ...); }5.3 现象:Vue 前端收到 SSE 流,但messages数组不更新,或更新延迟严重
原因:fetch的ReadableStream读取是微任务,而 Vue 3 的响应式更新在宏任务队列。若在reader.read()回调中直接修改ref,可能触发多次triggerRef,导致性能下降甚至死循环。
解决:用queueMicrotask包裹更新逻辑,确保在同一个 microtask 队列中批量更新:
queueMicrotask(() => { const lastMsg = messages.value[messages.value.length - 1] if (lastMsg.role === 'assistant') { lastMsg.content = accumulatedText } else { messages.value.push({ role: 'assistant', content: accumulatedText }) } })5.4 现象:上传 PDF 后,Milvus 中text字段显示为乱码(如\u0000\u0000\u0000)
原因:pdfplumber提取的文本含不可见控制字符(如\x00、\u200b),而 Milvus 的varchar字段对 null byte 敏感,插入时被截断。
解决:清洗文本后再入库:
def clean_text(text: str) -> str: # 移除 null byte 和 zero-width space text = text.replace('\x00', '').replace('\u200b', '') # 移除连续空白符,保留单个空格 text = re.sub(r'\s+', ' ', text) return text.strip() # 在 parse_pdf_to_text 中调用 chunks = [clean_text(c) for c in chunks]6. 进阶技巧:用 Milvus 的expr过滤 + Vue 的v-memo优化首屏加载速度
6.1 动态元数据过滤:让知识库支持“按产品线/按权限/按时间”精准召回
Milvus 的expr参数支持类似 SQL 的布尔表达式,结合source_id字段,可实现业务级过滤。例如,客服系统需区分“企业版用户”和“个人版用户”的知识库:
// 后端:根据用户角色动态生成 expr String expr = "source_id like 'enterprise_%' and doc_status == 'published'"; List<Document> results = vectorStore.similaritySearch( query, SearchRequest.builder() .expr(expr) // 关键:传入过滤表达式 .topK(3) .build() );expr支持的操作符:==,!=,>,<,>=,<=,in,not in,like(支持%通配),以及and/or/not逻辑组合。注意:like只支持前缀匹配('enterprise%'),不支持中间匹配('%enterprise%'),若需全文模糊匹配,得用fulltext索引(需额外配置)。
6.2 Vue 首屏性能优化:用v-memo缓存历史会话 DOM
聊天界面滚动到底部时,旧消息频繁重渲染是性能杀手。Vue 3.4+ 的v-memo可以精准控制组件缓存:
<!-- ChatHistory.vue --> <template> <div class="history"> <div v-for="session in sessions" :key="session.id" v-memo="[session.messages.length, session.id]" > <h3>{{ session.title }}</h3> <div class="messages"> <ChatMessage v-for="(msg, idx) in session.messages" :key="`${session.id}-${idx}`" :msg="msg" /> </div> </div> </div> </template>v-memo="[session.messages.length, session.id]"表示:只要session.messages.length和session.id不变,整个<div>就复用 DOM,不重新渲染。这比v-if/v-show更细粒度,比keep-alive更轻量——因为 keep-alive 会保留整个组件实例(含事件监听器、定时器),而v-memo只缓存 DOM 结构。
6.3 参数对比表:Milvus 向量检索关键参数调优指南
| 参数名 | 可选值 | 推荐值(50 万文档) | 影响 | 如何验证 |
|---|---|---|---|---|
metric-type | COSINE,IP,L2 | COSINE | 决定相似度计算方式,COSINE对语义检索最友好 | 查看distance分布,>0.7 为优 |
index-type | IVF_FLAT,IVF_PQ,HNSW | IVF_FLAT | IVF_FLAT精度最高,IVF_PQ内存省 75%,HNSW查询快但建索引慢 | nq=1000时测 P99 延迟 |
nlist | 100 ~ 10000 | 1000 | 分桶数,太小召回率低,太大内存高 | search_k=3时测 recall@3 |
nprobe | 1 ~nlist | 10 | 查询时搜索的分桶数,越大越准越慢 | nprobe=10vsnprobe=50对比 recall@3 |
search-k | 1 ~ 100 | 3 | 返回 top-k 结果,前端展示通常 3 条足够 | 用户点击率统计,>3 条几乎无人看 |
我的习惯是:先用
nlist=1000,nprobe=10,search-k=3跑通流程;再用milvus_benchmark工具压测,逐步调nprobe看 recall@3 是否达标(目标 ≥ 0.85);最后根据 P99 延迟决定是否降nprobe或换IVF_PQ。从来不用HNSW——它在 Standalone 模式下内存泄漏严重,这是 Milvus 2.4.x 的已知 issue。
希望帮到你。
本文还有配套的精品资源,点击获取