2022年底接到内部需求的时候,我一度怀疑自己是不是要做一个“网盘换皮”的项目。团队积累了几年的一堆Java学习资料、课程视频、面试题文档散落在各人网盘和群文件里,新人入职没人知道从哪开始学,老同事总结的资源也没有一个入口沉淀。聊了两轮之后需求逐渐清晰:不只要一个资源共享库,还要在上面做“学习计划”——每个人可以把资源挂到自己的计划里,跟着进度学、打勾完成。于是就有了这个“Java网络教育资源共享学习计划平台”,后端用Java、前端用Vue3,从0到1我一个人花六周做完,内部已经跑了大半年。这篇文章把整个项目的思路、核心表结构、关键代码和踩坑过程全部翻出来讲一遍,适合正在做Java+Vue3全栈项目,或者打算把零散资料沉淀成体系化学习平台的人参考。
1. 需求拆解和技术选型,为什么最终选了这套组合
1.1 把“像网盘”的功能砍掉一半,剩下来的才是核心
平台功能最初列出来有十几个,我对着一版版原型问“这个功能去掉会怎样”,最后保留的功能只有三类。
第一类是资源侧。资源能上传、下载,能按分类浏览和搜索,能在线预览视频和PDF,能评论和评分。第二类是计划侧。用户能创建学习计划,设置起止时间,能往计划里添加资源作为学习任务,任务有“未开始/进行中/已完成”三种状态,计划整体有一个完成百分比。第三类是用户侧。注册登录、个人信息、我的收藏、我的计划列表。
管理后台我没有一开始就做,而是复用同一个前端,管理员登录后多看到几个管理菜单:分类管理、资源审核、用户管理。后面实际跑起来发现,资源审核这个功能反而最有用,因为上传者多了之后总会有人传重复的或者跟主题无关的文件。
拆完需求我有个很深的感受:很多所谓的“在线学习平台”,学习计划其实就是收藏夹换了个名字。真正的计划应该是有任务拆解、有每项任务的进度、有整体完成判断的,否则用户定了计划也没有驱动力。这个判断直接影响后面表结构的设计,也决定了整个项目跟普通资源分享系统的本质差别。
1.2 Java+Vue3的组合,以及我为什么放弃脚手架
技术选型我几乎没有犹豫,直接定了后端Spring Boot + MyBatis Plus + MySQL + Redis,前端Vue3 + Vite + Pinia + Element Plus。
| 层次 | 选型 | 选它的理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 生态成熟、资料多、招人好招 |
| ORM | MyBatis Plus | 单表CRUD不用写SQL,LambdaQueryWrapper写起来顺手 |
| 鉴权 | Sa-Token | 相比Spring Security配置量小,登录、踢人、注解鉴权开箱即用 |
| 数据库 | MySQL 8.0 | 存储结构化数据,简单可靠 |
| 缓存 | Redis | 首页列表缓存、热点资源统计 |
| 文件存储 | 本地磁盘 + Nginx静态映射 | 内部平台量不大先本地,后续可平滑换MinIO/OSS |
| 前端 | Vue3 + Vite | 组合式API复用逻辑方便,Vite冷启动快 |
| UI | Element Plus | Vue3配套组件库,表格、表单、上传都覆盖 |
为什么不用若依这类现成脚手架?我确实评估过,用脚手架最快十几分钟能起一个后台。但问题是脚手架带了一堆我用不到的东西,比如部门管理、岗位管理、代码生成器日志,删起来反而要小心翼翼。而且那时候若依的Vue3版本还在不断打补丁,同事用TS版本就遇到过vue-tsc过程直接报错的坑,这个后面会专门讲。所以最终选择自己搭,结构清爽,每一行代码都知道是干嘛的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端Java核心模块:表结构、鉴权与文件上传
2.1 六张核心表,资源和学习计划是这样串起来的
数据库设计是这个项目最重要的部分。我最终用了六张表:用户表、分类表、资源表、计划表、计划任务表、资源评论表。
| 表名 | 核心字段 | 职责 |
|---|---|---|
| t_user | user_id, account, password, nickname, role, status | 登录账号与角色 |
| t_category | category_id, parent_id, name, sort | 资源分类,支持两级 |
| t_resource | resource_id, title, summary, category_id, file_url, file_name, file_size, file_type, uploader_id, download_count, avg_score | 资源元数据 |
| t_plan | plan_id, user_id, plan_name, goal, start_date, end_date, status, progress | 用户的学习计划 |
| t_plan_task | task_id, plan_id, resource_id, task_name, sort_order, status, progress, finish_time | 计划里的具体学习任务 |
| t_resource_comment | comment_id, resource_id, user_id, content, score | 资源评价 |
这里有一个设计需要特别说明:计划任务表里存的是resource_id,而不是把资源详情复制一份。好处是资源文件实体只保留一份,上传者更新资源后所有关联任务都能拿到最新文件;坏处是如果资源被删除,所有引用它的计划任务都会变成空引用,所以我在资源删除接口里做了联动检查,凡是存在未完成任务引用的资源不允许物理删除,只能下架。
另外,t_resource表里冗余了avg_score平均值,t_plan表里冗余了progress完成度。这两个字段完全是列表页要展示用的,没必要每次实时算。这是典型的“读多写少用冗余”场景,任务表、评论表每次更新后,service层顺手重算一下写入就行,成本很低。
2.2 Sa-Token统一鉴权,接口全部走统一返回和异常处理
每个接口都重复判断“当前用户是否登录”会很痛苦。我用Sa-Token做登录鉴权,登录成功后会返回一个token,前端存到localStorage,后续所有请求在header里带上token即可。
后端配置:
java复制@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns(
"/api/user/login",
"/api/user/register",
"/api/resource/list",
"/api/resource/detail/**",
"/api/category/list",
"/file/**"
);
}
}
登录接口大致是这样:
java复制@PostMapping("/login")
public Result<UserVO> login(@RequestBody @Valid LoginDTO dto) {
User user = userService.login(dto.getAccount(), dto.getPassword());
// Sa-Token 登录,生成 token
StpUtil.login(user.getUserId());
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
vo.setToken(StpUtil.getTokenValue());
return Result.ok(vo);
}
注意密码不能明文存。我用的BCrypt加密,登录时用matches方法校验。
除了鉴权,我还定义了一个统一的Result对象和全局异常处理器。所有接口不管成功失败都返回同样的JSON结构:code、msg、data。前端只需要在axios拦截器里做一次判断,不需要每个接口单独处理错误分支。
2.3 文件上传:MD5去重和类型白名单,少踩两个生产坑
资源平台的本质就是文件上传处理,这一步最容易出问题。我给的方案是:用原文件内容算MD5当存储名,再配一个类型白名单。
java复制private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"pdf", "mp4", "zip", "rar", "7z", "ppt", "pptx",
"doc", "docx", "md", "txt", "png", "jpg"
);
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file,
@RequestParam("title") String title,
@RequestParam("categoryId") Long categoryId,
@RequestParam("fileType") Integer fileType) {
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null
? originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase()
: "";
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw new BizException("不支持的文件类型:" + ext);
}
String md5 = DigestUtils.md5DigestAsHex(file.getBytes());
String storeName = md5 + "." + ext;
File target = new File(uploadDir, storeName);
if (!target.exists()) {
file.transferTo(target);
}
// 落库:file_url 存 /file/storeName,file_name 存原始文件名
Resource resource = new Resource();
resource.setTitle(title);
resource.setCategoryId(categoryId);
resource.setFileType(fileType);
resource.setFileName(originalFilename);
resource.setFileUrl("/file/" + storeName);
resource.setFileSize(file.getSize());
resource.setUploaderId(StpUtil.getLoginIdAsLong());
resource.setStatus(1);
resourceMapper.insert(resource);
return Result.ok("上传成功");
}
用MD5当存储名最大的好处是同一个文件多人重复上传时,磁盘上只保留一份,后端做一次查询去重就行,既省空间又省流量。上传目录后面由Nginx静态映射成/file/**,下载不需要再走Java应用层,Nginx直接返回文件,性能好很多。
类型白名单这个不能省,尤其平台是面向内部知识库的定位,如果放开可执行文件或网页脚本上传,等于给XSS留后门。业务上就限制成“学习资料常见类型”,视频、文档、压缩包、图片,其他格式一律提示不支持。
3. Vue3前端:工程初始化、页面组件与接口封装
3.1 用Vite初始化工程,开发代理和目录结构一次配好
前端我用npm create vite创建了一个Vue3项目模板。为了减少团队成员的心智负担,没有上TypeScript,用了标准JS。工具链装齐后,项目结构保持得很简单:
code复制edu-frontend/
├── src/
│ ├── api/ # 接口定义
│ ├── assets/ # 静态资源
│ ├── components/ # 通用组件
│ ├── router/ # 路由配置
│ ├── store/ # Pinia状态
│ ├── utils/request.js # axios实例
│ ├── views/ # 页面组件
│ ├── App.vue
│ └── main.js
├── vite.config.js
└── package.json
vite.config.js里最重要的两个点:@路径别名和开发代理。开发阶段前端跑5173端口,后端跑8080,端口不同必然有跨域。我在开发环境用Vite的proxy把/api开头的请求转发到后端,浏览器里看到的是同源请求,不需要后端额外开CORS。
js复制import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
3.2 登录页的点线动态背景,用Canvas做比插件靠谱
登录页如果只是一个表单会很单调,我给它加了一个由粒子构成的点线网络动态背景。实现不复杂:页面上放一个全屏canvas,在onMounted里初始化粒子数组,每个粒子随机方向移动,当两个粒子之间距离小于某个阈值时在这两个点之间画一条半透明线,用requestAnimationFrame持续刷新,组件销毁时用onUnmounted取消动画帧。
js复制let particles = []
let ctx
let rafId
function initParticles(w, h) {
for (let i = 0; i < 80; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.8,
vy: (Math.random() - 0.5) * 0.8,
r: 2
})
}
}
function draw() {
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
for (const p of particles) {
p.x += p.vx
p.y += p.vy
// 边界回弹
if (p.x < 0 || p.x > canvas.value.width) p.vx *= -1
if (p.y < 0 || p.y > canvas.value.height) p.vy *= -1
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
}
// 遍历所有粒子对,距离小于120px则连线
rafId = requestAnimationFrame(draw)
}
onMounted(() => {
ctx = canvas.value.getContext('2d')
initParticles(canvas.value.width, canvas.value.height)
draw()
})
onUnmounted(() => cancelAnimationFrame(rafId))
为什么不直接引一个背景插件?因为这种动画逻辑简单、代码量不大,插件反而增加体积和不确定性,手绘逻辑可控性也更强。如果你直接抄网上的代码,注意循环结束后一定要cancelAnimationFrame,否则页面切走之后动画还在跑,白白占用CPU。
3.3 资源库页面的检索交互,分类树加列表加上传对话框
资源库是平台访问量最大的页面。我采用左树右表的经典布局:左侧是分类树,展开收起用el-tree;右侧是资源表格,展示资源名、类型、大小、上传人、评分、下载次数。搜索框做标题和描述的模糊匹配,关键词变了就重新请求列表。
这个页面有两点要注意。第一,上传功能放在页面右上角,点击后弹出el-dialog,里面是el-upload组件,但el-upload默认提交请求头里不会带token。我的做法是在组件上配:headers属性,从Pinia里取token塞进去。第二,因为是内部平台,上传限定单个文件,上传成功后要刷新列表,不能只弹一个“上传成功”的提示,不然用户看不到自己传的东西排在列表底部之外。
评论和评分我放在资源详情的抽屉里。抽屉从右边滑出来,上面展示文件信息和在线预览,下面是一个评论区,每个评论带星级评分。评分汇总后回写资源的avg_score字段。刚开始我只做了评论没做评分,后来觉得“哪个资料值得看”完全靠运营人工推荐太累。加了评分之后列表排序可以直接用平均分,基本不用人工干预。
3.4 axios统一封装、路由守卫和Pinia状态管理
接口对接我做了统一封装。axios实例放在utils/request.js,请求拦截器里从localStorage拿token塞到header,响应拦截器里统一判断Result.code。非200的code,直接ElMessage提示错误并reject,页面里不用每个请求都写try-catch;如果是401,则清空登录态跳到登录页。
js复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 30000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['satoken'] = token
}
return config
})
request.interceptors.response.use(
res => {
const { code, msg, data } = res.data
if (code === 200) {
return data
}
if (code === 401) {
localStorage.removeItem('token')
router.push('/login')
}
ElMessage.error(msg)
return Promise.reject(new Error(msg))
},
err => {
ElMessage.error(err.message || '网络异常')
return Promise.reject(err)
}
)
路由守卫用来做登录控制。这里有个容易漏的点:路由守卫不能只判断to.path,因为动态传参的详情页路径不一样,要结合meta.requiresAuth判断。我用meta标记哪些页面需要登录,比硬编码路径清爽很多。Pinia里存了当前登录用户、token、侧边栏折叠状态等交互性数据。
4. 学习计划的核心业务链路:从建计划到进度闭环
4.1 创建计划,不要只看眼前这一张表
一个学习计划包含名称、目标描述、开始日期、结束日期四个字段。前端做成一个对话框,用户填完直接提交。后端的创建逻辑反而简单,插入一条plan记录,状态默认0(进行中),进度默认0。
我坚持在这个接口上加了事务注解@Transactional,虽然当前版本的“创建计划”只插入一张表,事务似乎多余。但后面增加了“创建后立即自动添加开场任务”的功能后,事务就派上用场了。先留好坑位,比以后再回头改接口要省事。
4.2 批量绑定资源任务,用事务和排序字段守住数据完整性
计划创建之后,最关键的功能是从资源库选一批资源,一次性绑到计划里成为学习任务。前端在计划详情页提供一个“添加资源”按钮,打开和资源库几乎一样的资源选择弹窗,支持多选,选中后调用批量添加接口。
后端用循环插入,这里必须用事务。如果循环执行到一半遇到重复数据或字段长度超限,事务回滚,不会留下半份任务列表。每个task包含plan_id、resource_id、task_name(默认取资源标题)、sort_order、status=0、progress=0。
java复制@Transactional
public void addTasksFromResource(Long planId, List<Long> resourceIds) {
int sort = planTaskMapper.selectCount(
new LambdaQueryWrapper<PlanTask>().eq(PlanTask::getPlanId, planId));
for (Long resourceId : resourceIds) {
Resource resource = resourceMapper.selectById(resourceId);
PlanTask task = new PlanTask();
task.setPlanId(planId);
task.setResourceId(resourceId);
task.setTaskName(resource.getTitle());
task.setSortOrder(sort++);
task.setStatus(0);
task.setProgress(0);
planTaskMapper.insert(task);
}
}
这里有一个容易忽视的细节:为什么用selectCount获取当前已有任务数作为起始序号,而不是直接用循环变量i?因为在线编辑场景下,用户可能先添加3个任务,再删掉第2个,此时任务的序号是0和2,如果我重新从0开始循环插入,会出现两个0号。用当前总数做起点能避免这个问题,虽然不算完美,但内部平台足够用了。
4.3 进度计算:任务状态变化后,动态刷新计划总进度
这是整个平台最核心的业务逻辑。用户点击任务右侧的“标记完成”按钮,前端调用进度更新接口,后端把对应task的status置为2、progress置为100,同时记录finish_time。更新完之后立刻计算该计划下所有任务的完成率,回填到plan.progress和plan.status。
java复制private void refreshPlanProgress(Long planId) {
List<PlanTask> tasks = planTaskMapper.selectList(
new LambdaQueryWrapper<PlanTask>().eq(PlanTask::getPlanId, planId));
if (tasks.isEmpty()) {
return;
}
long finished = tasks.stream().filter(t -> t.getStatus() == 2).count();
int progress = (int) (finished * 100L / tasks.size());
Plan plan = new Plan();
plan.setId(planId);
plan.setProgress(progress);
plan.setStatus(progress == 100 ? 2 : 1);
planMapper.updateById(plan);
}
这个逻辑本身不难,难在“完成”的判定。我一开始把status=2才算完成,后来发现有些资料不需要完整学完,只当工具参考看某一段也算“学过了”。所以任务详情里我又加了一个手动进度字段,用户可以填0到100的任意值,填到100自动置为完成。这样进度条对成人学习者更友好,不会因为一门课太长而产生挫败感。
前端这里我用el-progress展示计划总进度,任务列表里每行放一个小进度条和“标记完成”按钮,交互简单直接。数据刷新是局部更新,组件内部通过Vue的响应式依赖自动触发,不需要整页刷新。
4.4 首页统计里的一次CompletableFuture实践
首页要给用户展示三个数据:我的计划总数、进行中任务数、已完成任务数,以及一张最近30天学习打卡的柱状图。这三个查询串行执行的话,每打开一次首页大概要等三次数据库查询结束,整体耗时在几十到几百毫秒之间。内部平台并发不高,但每逢周一早上大家集中打开时,接口还是偶尔出现一秒以上的毛刺。
后来我改成用CompletableFuture并行跑三个统计查询,接口总耗时从三次查询之和变成三次查询的最大值。当时顺手踩了一个Java多线程的经典坑:如果直接在controller里往CompletableFuture里塞Spring管理的service,并行线程池默认是ForkJoinPool.commonPool(),在容器环境下它和请求线程共享资源,并发一高反而互相拖慢。我最后给这个统计接口配了一个固定大小的独立线程池,任务结束及时关闭,才算稳住。一句话总结:多线程解决接口响应时间,务必给任务配专属线程池,别裸用默认池。
5. 部署上线、性能优化和四个绕不过去的坑
5.1 Nginx部署前后端分离,history路由和文件上传两个配置一个不能少
整个平台打出来就两个东西:后端一个Spring Boot jar,前端一个纯静态的dist目录。部署时我用Nginx统一入口,前端静态文件、后端/api接口、文件下载/file三个location完全分离。
nginx复制server {
listen 80;
server_name edu.example.com;
client_max_body_size 200m;
location / {
root /var/www/edu-frontend;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /file/ {
alias /data/edu-resources/;
expires 7d;
}
}
配置里有两个点新手很容易漏。第一,try_files那一行如果没有,直接访问一个深层路由比如/plan/detail/12就会404,因为Nginx按路径去找文件找不到,必须让它退回首页让Vue Router接管。第二,client_max_body_size如果不显式设置,默认只有1m,学习平台动不动传几百MB的课程视频,不调到200m,上传到一半会被Nginx直接切断。
5.2 资源列表加Redis缓存,热数据不再每次打库
资源列表是平台流量最大的接口。列表支持分类筛选、关键字搜索、分页,查询条件组合非常多,如果每次都直接查MySQL,压力会随着资源量增长越来越难看。我的方案是给最热的“全部资源+空搜索”首页列表做Redis缓存,key带上分页参数,比如resource:list:page:1:size:20,缓存5分钟。
注意缓存不是只加读,还要处理写。新增资源、删除资源、资源被锁定之后,必须把列表缓存清空,否则用户看到的还是旧数据。我在资源service里封装了一个clearResourceListCache(),在insert、delete后调用,用Redis的keys匹配resource:list:*然后逐个删除。
java复制Set<String> keys = redisTemplate.keys("resource:list:*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
内部平台数据量不大,keys命令能接受。如果将来资源量上去了,建议改用SCAN迭代删除,避免阻塞线上。
5.3 大文件上传超时、vue-tsc构建报错、pxtorem对ECharts无效
先讲大文件上传。这个坑几乎每个做资源平台的人都会遇到。现象是上传100MB以上的视频时,前端等很久最后报“网络错误”,后端日志根本没有请求进来。排查链路是:先看Nginx配置,client_max_body_size从1m改成200m;再看Spring Boot配置,spring.servlet.multipart.max-file-size默认只有1MB,也要调大;最后看前端axios的timeout,30秒可能不够,需要设成0或一个很大的值。三层都改完,大文件上传才稳定。
第二个坑是vue-tsc构建报错。如果项目用了TypeScript,尤其基于若依这类脚手架,经常在npm run build时被大量类型错误卡住。常见原因不是业务代码,而是依赖包版本升级导致的类型不兼容,比如Element Plus某个组件的类型定义变化。我的处理思路是:先甄别报错来源,如果纯第三方依赖的类型问题,就把构建脚本里的vue-tsc --noEmit去掉,先用vite build保证产物能正常出;业务代码里的类型错误则不能绕过,要逐个修。等依赖版本稳定后再把类型检查加回构建链。
第三个坑是pxtorem对ECharts无效。这是个挺容易被误解的问题,很多人给移动端页面接了postcss-pxtorem,发现ECharts图表在手机上总是超出屏幕或者被缩放。原因很简单:ECharts是在canvas上绘制的,尺寸由JavaScript读取的像素宽度决定,CSS把px转成rem不影响canvas内部的渲染逻辑。如果要做响应式,应该监听resize事件,重新获取容器尺寸并调用chart.resize(),而不是指望CSS做适配。
5.4 若依Vue3+TS脚手架的报错避坑建议
如果你决定用若依做基础平台——它毕竟省了很多权限管理代码——那有几个实际踩过的点可以注意。最常见的是在npm run build阶段vue-tsc报错,逮着某个.vue文件说类型不匹配,但你看代码又觉得完全没问题。这类报错大概率是某个第三方插件对defineOptions的类型支持不到位,或者是某个库的@types版本冲突。
我的建议是:新项目用若依的Vue3版本时,先把ESLint和vue-tsc的版本固定成官方脚手架默认版本,不要上来就升级;遇到无法理解的类型报错,先跑npm run type-check看完整错误栈,再决定是修代码还是调整构建脚本。还是那句话,脚手架只是起点,别让它反过来绑架你的项目。
6. 项目做完之后的几点真心话
这个平台从需求确认到上线,前后六周,工作量最大其实不是写代码,而是资源整理和分类。技术上的东西反而在我预期之内。做完这个项目之后我最大的变化,是开始用“数据关系”去思考功能:一个学习计划落到数据库里就是plan和task两张表,一个资源共享就是resource表加一个文件引用,所有花里胡哨的页面,背后都是增删改查。
最后给和我一样从前端转Java的同学一点路线参考:先花两周把Java基础语法过一遍,重点是面向对象、集合、异常、IO、多线程;然后直接上手Spring Boot,不要一上来啃大部头,做一个能跑起来的注册登录demo比读十章书有用。这个资源共享学习计划平台就是一个很好的练手项目,规模不大但覆盖了登录鉴权、文件上传、复杂搜索、前后端联调、部署上线,走完一遍你会对全栈开发的完整链路有实际体感。
如果你也想搭一个类似平台,我的忠告是:先想清楚“学习计划”到底怎么定义,再动数据库表。表结构一旦定了,后面改起来牵一发动全身。在这个基础上继续做的话,可以加一个每日签到和积分体系,把“计划”往“督学”方向推,用户的学习动机会强不少。
