当前位置: 首页 > news >正文

SpringAI+DeepSeek大模型应用开发——4 对话机器人

目录​​​​​​​

​​​​​​​​​​​​​​项目初始化

pom文件

配置模型

ChatClient

同步调用

流式调用

日志功能

对接前端

解决跨域

会话记忆功能

ChatMemory

添加会话记忆功能

会话历史

管理会话id

保存会话id

查询会话历史

完善会话记忆

定义可序列化的Message

方案一:定期持久化

方案二:自定义ChatMemory

方案三:Cassandra

​​​​​​​项目初始化

创建一个新的SpringBoot工程,勾选Web、MySQL驱动、Ollama:

pom文件

主要引入的依赖如下

<properties><java.version>17</java.version><spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope>
</dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.22</version>
</dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId><version>3.5.10.1</version>
</dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency></dependencies>
</dependencyManagement>

SpringAI完全适配了SpringBoot的自动装配功能,而且给不同的大模型提供了不同的starter,比如:

<!--Anthropic-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-anthropic-spring-boot-starter</artifactId>
</dependency><!--Azure OpenAI-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-azure-openai-spring-boot-starter</artifactId>
</dependency><!--DeepSeek-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency><!--Hugging Face-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-huggingface-spring-boot-starter</artifactId>
</dependency><!--Ollama-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency><!--OpenAI-->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

配置模型

在配置文件中配置模型的参数信息,以Ollama为例:

spring:application:name: heima-aiai:ollama:base-url: http://localhost:11434chat:model: deepseek-r1:7bopenai:base-url: https://dashscope.aliyuncs.com/compatible-modeapi-key: 填百炼大模型平台自己的api keychat:options:model: qwen-plusembedding:options:model: text-embedding-v3dimensions: 1024

ChatClient

  • ChatClient中封装了与AI大模型对话的各种API,同时支持同步式或响应式交互;

  • 在使用之前,需要声明一个ChatClient;在ai.config包下新建一个CommonConfiguration类:

  • 系统预设:在SpringAI中,设置System信息非常方便,不需要在每次发送时封装到Message,而是创建ChatClient时指定即可;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class CommonConfiguration {// 注意参数中的model就是使用的模型,这里用了Ollama,也可以选择OpenAIChatModel@Beanpublic ChatClient chatClient(OllamaChatModel model) {  return ChatClient.builder(model) // 创建ChatClient工厂,利用它可以自由选择模型、添加各种自定义配置.defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build()).defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。") //系统预设 .build(); // 构建ChatClient实例}
}
同步调用

定义一个Controller,在其中接收用户发送的提示词,然后把提示词发送给大模型,交给大模型处理,拿到结果后返回;

package com.shisan.ai.controller;import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {private final ChatClient chatClient;// 请求方式和路径不要改动,将来要与前端联调@RequestMapping("/chat")public String chat(@RequestParam String prompt) {return chatClient.prompt(prompt)   // 传入user提示词.call()    // 同步调用请求,会等待AI全部输出完才返回结果.content(); //返回响应内容}
}

启动项目,在浏览器中访问:http://localhost:8080/ai/chat?prompt=你好

流式调用
  • 同步调用需要等待很长时间页面才能看到结果,用户体验不好。为了解决这个问题,可以改进调用方式为流式调用;

  • 使用了WebFlux技术实现流式调用;修改ChatController中的chat方法:

// 注意看返回值,是Flux<String>,也就是流式结果,另外需要设定响应类型和编码,不然前端会乱码
@RequestMapping(value = "/chat", produces = "text/html;charset=UTF-8")
public Flux<String> chat(@RequestParam(String prompt) {return chatClient.prompt(prompt).stream() // 流式调用.content();
}
日志功能

默认情况下,AI交互时是不记录日志的,我们无法得知SpringAI 组织的提示词到底长什么样,这样不方便我们调试。

SpringAI基于AOP机制实现与大模型对话过程的增强、拦截、修改等功能,所有的增强通知都需要实现Advisor接口;Spring提供了一些Advisor的默认实现,来实现一些基本的增强功能:

  • SimpleLoggerAdvisor:日志记录的Advisor;

  • MessageChatMemoryAdvisor:会话记忆的Advisor;

  • QuestionAnswerAdvisor:实现RAG的Advisor;

当然,也可以自定义Advisor,具体可以参考:Advisors API 

添加日志功能

@Configuration
public class CommonConfiguration {// 注意参数中的model就是使用的模型,这里用了Ollama,也可以选择OpenAIChatModel@Beanpublic ChatClient chatClient(OllamaChatModel model) {  return ChatClient.builder(model) // 创建ChatClient工厂,利用它可以自由选择模型、添加各种自定义配置.defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build()).defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。") //系统预设.defaultAdvisors(new SimpleLoggerAdvisor(),new MessageChatMemoryAdvisor(chatMemory)).build(); // 构建ChatClient实例}
}

日志级别

#配置 application.yaml即可,重启项目,再次聊天就能在IDEA的运行控制台中看到AI对话的日志信息了
logging:level:org.springframework.ai: debugcom.itheima.ai: debug

对接前端

npm运行

进入spring-ai-protal文件夹(该文件夹要放在非中文目录下),然后执行cmd命令:

# 安装依赖
npm install
# 运行程序
npm run dev

启动后,访问http://localhost:5173即可看到页面:

nginx运行

若不关心源码,进入spring-ai-nginx文件夹(该文件夹要放在非中文目录下),然后执行cmd命令

# 启动Nginx
start nginx.exe
# 停止
nginx.exe -s stop
  • 启动后,访问http://localhost:5173即可看到页面。

解决跨域

前后端在不同端口,存在跨域问题,因此需要在服务端解决cors问题;在ai.config包中添加一个MvcConfiguration类:

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOrigins("*").allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS").allowedHeaders("*").exposedHeaders("Content-Disposition");}
}

测试

会话记忆功能

前面讲过,让AI有会话记忆的方式就是把每一次历史对话内容拼接到Prompt中,一起发送过去,这种方式比较挺麻烦;

如果使用了SpringAI,并不需要自己拼接,SpringAI自带了会话记忆功能,可以把历史会话保存下来,下一次请求AI时会自动拼接,非常方便。

ChatMemory

会话记忆功能同样是基于AOP实现,Spring提供了一个MessageChatMemoryAdvisor的通知,可以像之前添加日志通知一样添加到ChatClient即可;不过,要注意的是,MessageChatMemoryAdvisor需要指定一个ChatMemory实例,也就是会话历史保存的方式;

ChatMemory接口声明如下:

public interface ChatMemory {// TODO: consider a non-blocking interface for streaming usagesdefault void add(String conversationId, Message message) {this.add(conversationId, List.of(message));}// 添加会话信息到指定conversationId的会话历史中void add(String conversationId, List<Message> messages);// 根据conversationId查询历史会话List<Message> get(String conversationId, int lastN);// 清除指定conversationId的会话历史void clear(String conversationId);
}

可以看到,所有的会话记忆都是与conversationId有关联的,也就是会话Id,将来不同会话Id的记忆自然是分开管理的;

目前,在SpringAI中有两个ChatMemory的实现:

  • InMemoryChatMemory:会话历史保存在内存中;

  • CassandraChatMemory:会话保存在Cassandra数据库中(需要引入额外依赖,并且绑定了向量数据库,不够灵活);

目前选择用InMemoryChatMemory来实现。

添加会话记忆功能

CommonConfiguration配置类中添加

@Bean
public ChatMemory chatMemory() {return new InMemoryChatMemory();
}@Beanpublic ChatClient chatClient(AlibabaOpenAiChatModel model, ChatMemory chatMemory) {return ChatClient.builder(model).defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build()).defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。").defaultAdvisors(new SimpleLoggerAdvisor(),    //记录日志new MessageChatMemoryAdvisor(chatMemory)  //添加会话记忆功能).build();}

现在聊天会话已经有记忆功能了,不过现在的会话记忆还是不完善的,接下来的章节还会继续补充

会话历史

会话记忆:是指让大模型记住每一轮对话的内容,不至于前一句刚问完,下一句就忘了;

会话历史:是指要记录总共有多少不同的对话;

以DeepSeek为例,页面上的会话历史:

ChatMemory中,会记录一个会话中的所有消息,记录方式是以conversationId为key,以List<Message>为value,根据这些历史消息,大模型就能继续回答问题,这就是所谓的会话记忆;

而会话历史,其实就是每一个会话的conversationId,用它去查询List<Message>,注意,在接下来业务中,以chatId来代conversationId

管理会话id

由于会话记忆是以conversationId来管理的,也就是会话id(以后简称为chatId)将来要查询会话历史,其实就是查询历史中有哪些chatId;因此,为了实现查询会话历史记录,必须记录所有的chatId。

定义一个ai.repository包,然后新建一个ChatHistoryRepository管理会话历史接口:

public interface ChatHistoryRepository {/*** 保存会话记录* @param type 业务类型,如:chat、service、pdf* @param chatId 会话ID*/void save(String type, String chatId);/*** 获取会话ID列表* @param type 业务类型,如:chat、service、pdf* @return 会话ID列表*/List<String> getChatIds(String type);
}

在这个包下继续创建一个实现类InMemoryChatHistoryRepository

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;@Component
@RequiredArgsConstructor
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {private Map<String, List<String>> chatHistory = new HashMap<>();  //存储不同类型的聊天 ID//业务类型,如:chat、service、pdf	//按类型存储聊天 ID,避免重复@Overridepublic void save(String type, String chatId) {List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());if (chatIds.contains(chatId)) {return;}chatIds.add(chatId);}//根据类型返回对应的聊天 ID 列表@Overridepublic List<String> getChatIds(String type) {return chatHistory.getOrDefault(type, List.of());}
}

接下来,修改ChatController中的chat方法,做到以下3点:

  • 添加一个请求参数:chatId,每次前端请求AI时都需要传递chatId;

  • 每次处理请求时,将chatId存储到ChatRepository;

  • 每次发请求到AI大模型时,都传递自定义的chatId;

保存会话id

接下来,修改ChatController中的chat方法,做到以下3点:

  • 添加一个请求参数:chatId,每次前端请求AI时都需要传递chatId

  • 每次处理请求时,将chatId存储到ChatRepository;

  • 每次发请求到AI大模型时,都传递自定义的chatId;

private final ChatClient chatClient;
private final ChatHistoryRepository chatHistoryRepository;
//流式调用
@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
public Flux<String> chat(@RequestParam("prompt") String prompt,@RequestParam("chatId") String chatId,@RequestParam(value = "files", required = false) List<MultipartFile> files) {// 1.保存会话id(如果存在会直接返回)chatHistoryRepository.save("chat", chatId);// 2.请求模型if (files == null || files.isEmpty()) {// 没有附件,纯文本聊天return textChat(prompt, chatId);} else {// 有附件,多模态聊天return multiModalChat(prompt, chatId, files);}
}//纯文本聊天
private Flux<String> textChat(String prompt, String chatId) {return chatClient.prompt()  //请求模型.user(prompt)      //预设//通过AdvisorContext,也就是以key-value形式存入上下文.advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)) .stream().content();
}

查询会话历史

定义一个新的Controller,专门实现回话历史的查询。包含两个接口:

  • 根据业务类型查询会话历史列表(将来有3个不同业务,需要分别记录历史。可以自己扩展成按userId记录,根据UserId查询)

  • 根据chatId查询指定会话的历史消息;

private final ChatHistoryRepository chatHistoryRepository;
private final ChatMemory chatMemory;//根据业务类型查询会话历史列表
@GetMapping("/{type}")
public List<String> getChatIds(@PathVariable("type") String type) {return chatHistoryRepository.getChatIds(type);
}
//根据chatId查询指定会话的历史消息
@GetMapping("/{type}/{chatId}")
public List<MessageVO> getChatHistory(@PathVariable("type") String type, @PathVariable("chatId") String chatId) {List<Message> messages = chatMemory.get(chatId, Integer.MAX_VALUE);if(messages == null) {return List.of();}//由于Message并不符合页面的需要,所以需要自己定义一个VOreturn messages.stream().map(MessageVO::new).toList();
}
@NoArgsConstructor
@Data
public class MessageVO {private String role;private String content;public MessageVO(Message message) {switch (message.getMessageType()) {case USER: role = "user";  break;case ASSISTANT:role = "assistant";break;default:role = "";break;}this.content = message.getText();}
}

重启服务,现在AI聊天机器人就具备会话记忆和会话历史功能了!

完善会话记忆

目前,会话记忆是基于内存,重启服务就没了;如果要持久化保存,这里提供了3种办法:

  1. 依然是基于InMemoryChatMemory,但是在项目停机时,或者使用定时任务实现自动持久化;

  2. 自定义基于Redis的ChatMemory

  3. 基于SpringAI官方提供的CassandraChatMemory,同时会自动启用CassandraVectorStore。

定义可序列化的Message

前面的两种方案,都面临一个问题,SpringAI中的Message类未实现Serializable接口,也没提供public的构造方法,因此无法基于任何形式做序列化。所以必须定义一个可序列化的Message类,方便后续持久化。定义一ai.entity.po包,新建一个Msg类:

@NoArgsConstructor
@AllArgsConstructor
@Data
public class Msg {MessageType messageType;String text;Map<String, Object> metadata;List<AssistantMessage.ToolCall> toolCalls;//将SpringAI的Message转为我们的Msgpublic Msg(Message message) {this.messageType = message.getMessageType();this.text = message.getText();this.metadata = message.getMetadata();if(message instanceof AssistantMessage am) {this.toolCalls = am.getToolCalls();}}//实现将我们的Msg转为SpringAI的Messagepublic Message toMessage() {return switch (messageType) {case SYSTEM -> new SystemMessage(text);case USER -> new UserMessage(text, List.of(), metadata);case ASSISTANT -> new AssistantMessage(text, metadata, toolCalls, List.of());default -> throw new IllegalArgumentException("Unsupported message type: " + messageType);};}
}

方案一:定期持久化

接下来,将SpringAI提供的InMemoryChatMemory中的数据持久化到本地磁盘,并且在项目启动时加载;

本方案中,采用Spring的生命周期方法,在项目启动时加载持久化文件,在项目停机时持久化数据

也可以考虑使用定时任务完成持久化,项目启动加载的方案;

修改ai.repository.InMemoryChatHistoryRepository类,添加持久化功能:

//项目启动时
@PostConstruct
private void init() {// 1.初始化会话历史记录this.chatHistory = new HashMap<>();// 2.加载本地会话历史和会话记忆FileSystemResource historyResource = new FileSystemResource("chat-history.json");FileSystemResource memoryResource = new FileSystemResource("chat-memory.json");if (!historyResource.exists()) {return;}try {// 会话历史Map<String, List<String>> chatIds = this.objectMapper.readValue(historyResource.getInputStream(), new TypeReference<>() {});if (chatIds != null) {this.chatHistory = chatIds;}// 会话记忆Map<String, List<Msg>> memory = this.objectMapper.readValue(memoryResource.getInputStream(), new TypeReference<>() {});if (memory != null) {memory.forEach(this::convertMsgToMessage);  //转成message}} catch (IOException ex) {throw new RuntimeException(ex);}
}private void convertMsgToMessage(String chatId, List<Msg> messages) {this.chatMemory.add(chatId, messages.stream().map(Msg::toMessage).toList());
}@PreDestroy
private void persistent() {String history = toJsonString(this.chatHistory);String memory = getMemoryJsonString();FileSystemResource historyResource = new FileSystemResource("chat-history.json");FileSystemResource memoryResource = new FileSystemResource("chat-memory.json");try (PrintWriter historyWriter = new PrintWriter(historyResource.getOutputStream(), true, StandardCharsets.UTF_8);PrintWriter memoryWriter = new PrintWriter(memoryResource.getOutputStream(), true, StandardCharsets.UTF_8)) {historyWriter.write(history);memoryWriter.write(memory);} catch (IOException ex) {log.error("IOException occurred while saving vector store file.", ex);throw new RuntimeException(ex);} catch (SecurityException ex) {log.error("SecurityException occurred while saving vector store file.", ex);throw new RuntimeException(ex);} catch (NullPointerException ex) {log.error("NullPointerException occurred while saving vector store file.", ex);throw new RuntimeException(ex);}
}private String getMemoryJsonString() {Class<InMemoryChatMemory> clazz = InMemoryChatMemory.class;try {Field field = clazz.getDeclaredField("conversationHistory");field.setAccessible(true);Map<String, List<Message>> memory = (Map<String, List<Message>>) field.get(chatMemory);Map<String, List<Msg>> memoryToSave = new HashMap<>();memory.forEach((chatId, messages) -> memoryToSave.put(chatId, messages.stream().map(Msg::new).toList()));return toJsonString(memoryToSave);} catch (NoSuchFieldException | IllegalAccessException e) {throw new RuntimeException(e);}
}private String toJsonString(Object object) {ObjectWriter objectWriter = this.objectMapper.writerWithDefaultPrettyPrinter();try {return objectWriter.writeValueAsString(object);} catch (JsonProcessingException e) {throw new RuntimeException("Error serializing documentMap to JSON.", e);}
}

方案二:自定义ChatMemory

基于Redis来实现自定义ChatMemory;

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

ai.repository包中新建一个RedisChatMemory类:由于使用的是Redis的Set结构,无序的,因此要确保chatId是单调递增的。

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itheima.ai.entity.po.Msg;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;@RequiredArgsConstructor
@Component
public class RedisChatMemory implements ChatMemory {private final StringRedisTemplate redisTemplate;private final ObjectMapper objectMapper;private final static String PREFIX = "chat:";@Overridepublic void add(String conversationId, List<Message> messages) {if (messages == null || messages.isEmpty()) {return;}List<String> list = messages.stream().map(Msg::new).map(msg -> {try {return objectMapper.writeValueAsString(msg);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}).toList();redisTemplate.opsForList().leftPushAll(PREFIX + conversationId, list);}@Overridepublic List<Message> get(String conversationId, int lastN) {List<String> list = redisTemplate.opsForList().range(PREFIX + conversationId, 0, lastN);if (list == null || list.isEmpty()) {return List.of();}return list.stream().map(s -> {try {return objectMapper.readValue(s, Msg.class);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}).map(Msg::toMessage).toList();}@Overridepublic void clear(String conversationId) {redisTemplate.delete(PREFIX + conversationId);}
}

方案三:Cassandra

  • SpringAI官方提供了CassandraChatMemory,但是是跟CassandraVectorStore绑定的,不太灵活;

  • 首先,需要安装一个Cassandra访问,使用Docker安装:

docker run -d --name cas -p 9042:9042  cassandra

在项目中添加cassandra依赖:

<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-cassandra-store-spring-boot-starter</artifactId>
</dependency>

配置Cassandra地址:

spring:cassandra:contact-points: 192.168.150.101:9042local-datacenter: datacenter1
  • 基于Cassandra的ChatMemory已经实现了,其它不变。

  • 注意:多种ChatMemory实现方案不能共存,只能选择其一。

http://www.xdnf.cn/news/2035.html

相关文章:

  • Qt界面卡住变慢的解决方法
  • 常用UI设计工具及平台概览
  • 【Pandas】pandas DataFrame xs
  • 关于视频的一些算法内容,不包含代码等
  • Java 中 Synchronized如何保证可见性
  • html+js+clickhouse环境搭建
  • Java项目——校园社交网络平台的设计与实现
  • 考研单词笔记 2025.04.17
  • 音视频学习 - ffmpeg 编译与调试
  • 【零基础】基于DeepSeek-R1与Qwen2.5Max的行业洞察自动化平台
  • 记录一次生产中mysql主备延迟问题处理
  • python学习—详解word邮件合并
  • Redis List 的详细介绍
  • 方德桌面操作系统V5.0-G23 vim无法复制粘贴内容
  • Java虚拟机(JVM)平台无关?相关?
  • 在Linux下安装Gitlab
  • 2.深入剖析 Rust+Axum 类型安全路由系统
  • 极狐GitLab GEO 功能介绍
  • DAY 47 leetcode 232--栈与队列.用栈实现队列
  • vue3 element-plus中的国际化在onMounted中的写法
  • docker Windows 存放位置
  • 【web考试系统的设计】
  • 零服务器免备案!用Gitee代理+GitHub Pages搭建个人博客:绕过443端口封锁实战记录
  • 基于Flask的漏洞挖掘知识库系统设计与实现
  • 对抗生成进化:基于DNA算法的AIGC检测绕过——让AI创作真正“隐形“
  • 生物信息学技能树(Bioinformatics)与学习路径
  • 04-libVLC的视频播放器:获取媒体信息
  • 【裁员感想】
  • 关于webpack的知识点
  • 《似锦》:画饼之—你画给我我画给你