简介:小丫软件库是一套面向移动应用开发者与小程序搭建者的开源软件库源码,包含后端管理与iApp客户端两部分,适合希望快速搭建应用分发或软件库类项目的初中级开发者参考。压缩包共184个文件,约17.64MB,以58个PHP后端脚本、38个PNG界面素材为主,并配有JSON配置、JS/CSS/HTML前端页面、字体图标及iApp项目文件,目录组织相对完整,便于按模块查看接口、页面与资源调用关系。已有922人学习下载。读者可获得后端账号配置、客户端导入对接、API与URL地址替换等源码参考,也能借鉴弹窗、日期选择、字体图标等组件的组织方式,用于二次开发或搭建类似软件库应用。
1. 一份软件库源码的可见结构:后端、后台、客户端和小程序
拿到这份软件库源码,我先扫了一遍工程根目录,四个子工程摆得很清楚:Spring Boot 写的软件库后端源码、Vue3 的后台管理、uni-app 的客户端,外加一套部署脚本。它要干的事并不复杂——把团队里的工具、插件、App 安装包集中上架,按分类和标签组织,客户端能搜能下,后台能管版本、看下载量。真正值得看的是它怎么处理“一个软件多个版本”和“下载地址不被随意抓取利用”这两件事,这两块代码写得很实在。适合两类人:一类是想给团队搭个内部分发站的,一类是拿它当应用商店类小程序起手模板的,改改字段就能跑。下面按“代码长什么样 → 怎么跑 → 怎么改”的顺序拆。
2. 技术栈与工程结构:四个子工程怎么摆、配置怎么分环境
2.1 选型:为什么是 Spring Boot 加 uni-app 这套组合
后端是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8 + Redis,鉴权用 JWT,文件存储走一层适配接口(本地磁盘、MinIO、对象存储三选一)。这套组合不算新,但对“软件库”这个场景很贴:软件主表和版本表是一对多,MyBatis-Plus 的分页和条件构造够用;下载计数是典型的高频写,丢给 Redis 扛;安装包是几百兆的大文件,必须能换存储后端,所以代码里把FileStorage抽成了接口。
客户端选 uni-app(Vue3 语法)而不是原生,是这份源码里最务实的一个决定:同一个pages/目录,编译出 H5、微信小程序、Android 包三份产物。软件库这类产品页面结构高度雷同——列表、详情、分类、我的,一套代码三端复用,省下来的工作量比性能损失值钱得多。代价也有,后面第 5 章会讲小程序端装不了包这个关键区别。
后台管理是 Vue3 + Vite + Element Plus,只做三件事:软件 CRUD、版本发布、分类维护。没有引入太重的权限框架,一个sys_user表加角色字段就够了,内部系统没必要上完整的 RBAC。
2.2 目录结构:每个目录负责什么
先把目录摆出来,后面看代码时能对上号:
software-lib/ ├── server/ 后端,Spring Boot 单体 │ ├── src/main/java/com/example/lib/ │ │ ├── controller/ 前台接口 + admin 后台接口 │ │ ├── service/ 发布、签名、计数等业务逻辑 │ │ ├── mapper/ MyBatis-Plus Mapper │ │ ├── storage/ 存储适配层 local / minio / oss │ │ └── common/ 统一返回、全局异常、JWT 拦截器 │ ├── src/main/resources/ │ │ ├── application.yml 公共配置 │ │ ├── application-dev.yml 本地 │ │ └── application-prod.yml 线上 │ └── sql/ schema.sql + seed.sql ├── admin/ 管理后台,Vue3 + Vite + Element Plus │ └── src/views/app/ 软件列表、版本管理、分类管理 ├── client/ 客户端,uni-app(Vue3) │ └── src/pages/ index / detail / category / mine ├── deploy/ │ ├── docker-compose.yml │ └── nginx.conf └── docs/api.md 接口约定storage/这一层值得单独说。接口只有四个方法:put、exists、size、openStream,实现类按storage.type配置注入。本地实现往磁盘写,对象存储实现走 SDK,业务代码完全不知道文件到底在哪。这种写法在二次开发时特别省事——上线后要换成云存储,改一行 yml 就行,不用回头刷新库里几万条记录。
2.3 配置:一份 yml 决定本地和线上
application.yml只放公共项,环境相关的全部用${ENV:默认值}占位,这样本地开发不配环境变量也能跑起来:
server: port: 8080 spring: datasource: url: jdbc:mysql://${MYSQL_HOST:127.0.0.1}:3306/software_lib?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ${MYSQL_USER:root} password: ${MYSQL_PWD:root} redis: host: ${REDIS_HOST:127.0.0.1} port: 6379 servlet: multipart: max-file-size: 500MB # 安装包动辄几百兆,默认 1MB 一定翻车 max-request-size: 520MB storage: type: local # local / minio / oss local-path: /data/pkg public-prefix: /pkg # Nginx 把 /pkg 映射到 /data/pkg security: download-secret: ${DOWNLOAD_SECRET:change-me-in-prod}serverTimezone=Asia/Shanghai这个参数别删。我见过不止一个项目在容器里跑出“发布时间比实际早 8 小时”,最后查出来是 JDBC 连的 UTC、容器又是 UTC,两个都“没错”但加起来就错了。
download-secret是下载签名的密钥,本地随便填,线上必须用环境变量注入并且不要提交到仓库。这个值一旦泄露,等于所有安装包的下载地址都能被随意伪造,后面 3.3 节会讲它是怎么用的。
2.4 从 clone 到登录后台:四步跑起来
依赖只有 MySQL 8 和 Redis,用 compose 拉起来最省事。整个流程按顺序走一遍:
# 1. 起依赖 docker compose -f deploy/docker-compose.yml up -d mysql redis # 2. 建库 + 导表结构 + 灌种子数据(一个管理员账号、两个分类、三条演示软件) mysql -h127.0.0.1 -uroot -proot -e "CREATE DATABASE IF NOT EXISTS software_lib DEFAULT CHARSET utf8mb4;" mysql -h127.0.0.1 -uroot -proot software_lib < server/sql/schema.sql mysql -h127.0.0.1 -uroot -proot software_lib < server/sql/seed.sql # 3. 后端,dev profile 会读 application-dev.yml cd server && mvn clean package -DskipTests java -jar target/software-lib.jar --spring.profiles.active=dev # 4. 后台管理,Vite 已配好到 8080 的代理 cd admin && npm i && npm run dev # http://localhost:5173种子数据里的管理员是admin / 123456,登录后第一件事是改密码——这不是客套话,内部系统最容易被忽略的就是这条。
客户端启动分两种:npm run dev:h5直接在浏览器里调页面,npm run dev:mp-weixin把产物打到dist/dev/mp-weixin,用开发者工具打开那个目录。注意小程序端要在自己的后台把request和downloadFile的合法域名都加上,否则开发者工具能跑、真机一片红,这是最常见的第一次挫败点。
跑起来之后先别急着改代码,用后台传一个小文件、发布一个版本、在 H5 端下载一次,把这条最短链路走通。链路通了,后面任何一处改动出了问题,都能快速排除环境因素。
3. 后端核心链路:软件上架、版本发布与下载地址签名
3.1 数据模型:主表和版本表为什么必须分开
软件库的数据模型最怕的就是把“软件”和“版本”揉进一张表。揉进去的后果是:改一次简介要复制一遍历史版本,查最新版得写一堆GROUP BY,下载统计也没法按版本拆。这份源码分得很干脆:
CREATE TABLE `app_info` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL COMMENT '软件名称', `package_name` VARCHAR(128) NOT NULL COMMENT '包名/唯一标识', `category_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, `icon_url` VARCHAR(255) NOT NULL DEFAULT '', `summary` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '一句话简介', `description` TEXT, `developer` VARCHAR(64) NOT NULL DEFAULT '', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1上架 2下架', `download_total` BIGINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_package` (`package_name`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件主表'; CREATE TABLE `app_version` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `app_id` BIGINT UNSIGNED NOT NULL, `version_name` VARCHAR(32) NOT NULL COMMENT '展示用,如 2.3.1', `version_code` INT NOT NULL COMMENT '排序用,单调递增', `file_key` VARCHAR(255) NOT NULL COMMENT '存储层相对路径,不对外暴露', `file_size` BIGINT NOT NULL DEFAULT 0, `file_md5` CHAR(32) NOT NULL DEFAULT '', `channel` VARCHAR(32) NOT NULL DEFAULT 'official', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待发布 1已发布 2已下架', `publish_time` DATETIME NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_app_version_channel` (`app_id`, `version_code`, `channel`), KEY `idx_app_status` (`app_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='版本表';几个关键参数值得说清楚。version_code是整数、version_name是字符串,前者只用来排序和比较,后者只用来展示——这一条分不清,就会出现“1.10.0 排在 1.9.0 前面”这种经典错误。file_key存的是相对路径而不是完整 URL,换存储后端时不用刷数据。channel字段现在看起来多余,但它是后面做灰度发布的落点,留着不亏。唯一索引uk_app_version_channel直接把“同软件同渠道版本号重复”这个脏数据挡在数据库层,比在 Service 里查一遍再插入靠谱。
3.2 上架与版本发布:一个接口要做对三件事
发布接口是整个后端最容易被写糙的地方。很多人只做“插一条记录”,结果线上出现同一版本号发两次、库里记录了但文件没落盘、发布完主表还是下架状态。这份源码的写法是把三件事放在一个事务里:
@Service @RequiredArgsConstructor public class AppVersionService { private final AppVersionMapper versionMapper; private final AppInfoMapper appInfoMapper; private final FileStorage storage; @Transactional(rollbackFor = Exception.class) public Long publish(Long appId, VersionPublishDTO dto) { AppInfo app = appInfoMapper.selectById(appId); if (app == null) { throw new BizException("软件不存在"); } // 1. 版本号必须严格递增,否则客户端会把它当成"降级",更新逻辑直接失效 Integer maxCode = versionMapper.selectMaxCode(appId, dto.getChannel()); if (maxCode != null && dto.getVersionCode() <= maxCode) { throw new BizException("version_code 必须大于当前最大版本 " + maxCode); } // 2. 文件必须已经落到存储层,防止"只写库不落盘"造成 404 if (!storage.exists(dto.getFileKey())) { throw new BizException("安装包未上传完成,请重新上传"); } AppVersion v = new AppVersion(); v.setAppId(appId); v.setVersionName(dto.getVersionName()); v.setVersionCode(dto.getVersionCode()); v.setFileKey(dto.getFileKey()); v.setFileSize(storage.size(dto.getFileKey())); // 大小以存储层为准,不信前端传的 v.setFileMd5(dto.getFileMd5()); v.setChannel(StringUtils.hasText(dto.getChannel()) ? dto.getChannel() : "official"); v.setChangelog(dto.getChangelog()); v.setStatus(1); v.setPublishTime(LocalDateTime.now()); versionMapper.insert(v); // 3. 新版本发布后把主表拉回上架,并冗余最新版本号供列表页直接展示 appInfoMapper.updateStatusAndLatest(appId, 1, v.getVersionName()); return v.getId(); } }参数上有两个细节:fileSize从存储层现取而不是用前端传来的值,避免前端传错或造假;channel缺省为official,保证老接口调用不炸。@Transactional把三次写操作绑在一起,任何一步失败都回滚,不会留下“版本表有记录但主表没更新”的中间态。
这里还有个隐含约定:文件是先上传、后发布的。后台上传接口返回file_key,前端拿到file_key再调发布接口。顺序反过来的话,storage.exists这层兜底就没意义了。
3.3 下载地址签名:把 file_key 藏起来
如果下载接口直接返回/pkg/2024/06/xxx.apk,等于把文件目录结构公开了,谁都能遍历、爬取,还能盗链。这份源码的做法是返回一个带时效的签名 URL:
private static final String SECRET = "${security.download-secret}"; public String buildDownloadUrl(Long versionId, Long userId) { long expireAt = System.currentTimeMillis() / 1000 + 300; // 5 分钟有效 String raw = versionId + ":" + userId + ":" + expireAt; String sign = HmacUtils.hmacSha256Hex(SECRET, raw); return "/api/download/" + versionId + "?uid=" + userId + "&exp=" + expireAt + "&sign=" + sign; } public boolean verify(Long versionId, Long userId, long exp, String sign) { if (System.currentTimeMillis() / 1000 > exp) { return false; // 过期就拒绝,客户端重新申请即可 } String expect = HmacUtils.hmacSha256Hex(SECRET, versionId + ":" + userId + ":" + exp); // 定长比较,别用 equals,避免时序侧信道 return MessageDigest.isEqual(expect.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8)); }签名原文里带了userId,意味着 A 用户拿到的链接发给 B 用户,B 用户也能用——但如果场景要求“一人一链”,把 IP 或设备号也拼进raw就行,改动量很小。5 分钟有效期是个折中:太短,弱网下客户端还没开始下载就过期了;太长,链接被转发出去基本收不回来。实际项目里我一般会在下载接口外面再挂一层限流,同一 IP 每分钟 N 次,两个措施一起用才算完整。
真正返回文件流的那一步,走的是storage.openStream(fileKey),配合Content-Disposition设成附件下载。整个过程中file_key从没出现在响应体里,这是这套设计的核心。
3.4 下载计数:Redis 先扛,定时落库
下载量是典型的高频写低频读。每下一次就UPDATE app_info SET download_total = download_total + 1,几百并发就能看到行锁排队。这份源码的路径是:Redis 做去重和累加,定时任务批量落库。
public void record(Long appId, Long versionId, String ip) { String day = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // 同一 IP 对同一版本一天只记一次,重试和刷新不会把数据刷爆 String dedupKey = "dl:dedup:" + day + ":" + versionId + ":" + ip; Boolean first = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofDays(1)); if (!Boolean.TRUE.equals(first)) { return; } redisTemplate.opsForHash().increment("dl:count:" + day, String.valueOf(appId), 1); redisTemplate.opsForHash().increment("dl:version:" + day, String.valueOf(versionId), 1); } @Scheduled(cron = "0 5 * * * ?") // 每小时第 5 分钟落一次 public void flush() { String day = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); Map<Object, Object> counts = redisTemplate.opsForHash().entries("dl:count:" + day); counts.forEach((appId, c) -> appInfoMapper.incrDownload( Long.valueOf(appId.toString()), Long.valueOf(c.toString()))); // 关键:rename 而不是 del,整点前后新写入的增量不会被误清 redisTemplate.rename("dl:count:" + day, "dl:count:flushed:" + day + ":" + System.currentTimeMillis()); }setIfAbsent加 24 小时过期这一句是去重的全部,少了它,用户点一次下载失败重试三次,计数就涨三倍。flush里用rename不用del是个小技巧:如果先读再删,读和删之间进来的增量会被直接丢掉;rename是原子的,读到的快照另存一份,新数据继续写原来的 key。
被 rename 出去的 key 也别一直留着,加个定时清理,只保留最近 7 天的 flushed 记录,否则 Redis 内存会慢慢被这些历史流水吃满。
4. 客户端与小程序端:列表、详情、下载三步的落地写法
4.1 列表页:先渲染缓存,再静默刷新
列表页的体验差异主要来自弱网。这份源码的写法是缓存优先:
const PAGE_SIZE = 20 export default { data() { return { list: [], page: 1, hasMore: true, loading: false } }, onLoad() { const cache = uni.getStorageSync('home_list') // 10 分钟内的缓存先铺上去,首屏不会白 if (cache && Date.now() - cache.time < 10 * 60 * 1000) { this.list = cache.data } this.load(true) }, onReachBottom() { if (this.hasMore && !this.loading) this.load(false) }, methods: { async load(reset) { this.loading = true if (reset) this.page = 1 const res = await uni.request({ url: `${this.$baseUrl}/api/app/list`, data: { page: this.page, size: PAGE_SIZE, categoryId: this.categoryId }, header: { 'X-Client': 'miniapp' } }) const rows = res.data.data.records || [] this.list = reset ? rows : this.list.concat(rows) this.hasMore = rows.length === PAGE_SIZE // 返回不足一页就认为到底了 this.page += 1 this.loading = false if (reset) { uni.setStorageSync('home_list', { time: Date.now(), data: this.list }) } } } }hasMore用“返回条数是否等于页大小”判断,比让后端多返回一个total更省事,代价是最后一页刚好满页时会多发一次空请求,可以接受。X-Client这个头后端会读,用来区分下载渠道统计,客户端不加也能跑,加了数据更准。
缓存只缓存第一页,翻页数据不落本地——列表更新频繁,缓存越多越容易出现“点进去发现版本已经下架”的尴尬。
4.2 详情页与多版本选择
详情页的核心是版本列表。后端已经按version_code倒序返回,前端不要再自作聪明按version_name排一遍:
computed: { sortedVersions() { // 前端只做稳定兜底,排序依据仍然是 version_code return this.versions.slice().sort((a, b) => b.versionCode - a.versionCode) }, latest() { return this.sortedVersions[0] } }, methods: { async download(v) { const { data } = await uni.request({ url: `/api/app/download-url/${v.id}` }) // #ifdef APP-PLUS const task = uni.downloadFile({ url: data.url }) task.onProgressUpdate(p => { this.progress = p.progress }) task.promise.then(res => plus.runtime.install(res.tempFilePath, { force: false })) // #endif // #ifdef MP-WEIXIN // 小程序沙箱装不了原生包,只能复制链接交给外部浏览器 uni.setClipboardData({ data: data.url }) uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' }) // #endif } }#ifdef条件编译是 uni-app 里最实用的语法,同一份download方法在不同端走完全不同的实现,不用维护两套页面。plus.runtime.install的force: false表示不强制覆盖安装,用户已装高版本时不会被降级。
onProgressUpdate拿到的进度只在 App 端有效,小程序端downloadFile也有onProgressUpdate,但因为它只负责把文件存到临时目录,装不了,做进度条意义不大,所以直接走复制链接。
4.3 小程序端的三个差异:包体、域名、下载落地
第一是包体。uni-app 编译到小程序有 2MB 主包限制,客户端里如果塞了大图或者引入了完整 UI 库,很容易超。常见做法是把分类页、我的页拆成分包,主包只留列表和详情。
第二是域名白名单。request合法域名和downloadFile合法域名是分开配的,只配前者的话,列表能出来、点下载报错。而且必须是 https,且证书链完整——开发者工具会放宽校验,真机不会,所以一定要在真机上点一遍。
第三是下载落地。小程序沙箱里拿不到安装权限,这是平台限制,改代码绕不过去。源码里的处理是复制链接引导到外部浏览器,如果产品要求“点一下就能装”,那只能走 App 端或 H5,不要在小程序上死磕。
5. 避坑与排查:上线前后最容易翻车的五个点
一、后台显示上传成功,客户端下载 404
现象:版本记录里file_key有值,点下载返回 Nginx 的 404 页面。
原因:storage.local-path和nginx.conf里location /pkg的alias指向的不是同一个目录。本地开发时后端直接读磁盘,所以没事;一进容器,后端写到容器内的/data/pkg,Nginx 挂的是宿主机另一个路径,两边对不上。
解决:把/data/pkg统一挂成 volume,Nginx 用alias /data/pkg/;,后端只存相对 key(如2024/06/xxx.apk),拼 URL 时用public-prefix。改完在容器里ls一次确认文件真的在。
二、小程序开发者工具下载正常,真机失败
现象:工具里点下载有反应,真机点了一直转圈,vConsole 里errMsg是downloadFile:fail url not in domain list。
原因:只配了request合法域名,没配downloadFile合法域名;或者域名是 http,工具默认不校验,真机强制 https。
解决:小程序后台把两个域名都加上,全站上 https,证书用完整链(不是只配叶子证书)。真机调试时打开 vConsole 看具体 errMsg,比拼猜测快得多。
三、版本列表顺序错乱,1.10.0 排在 1.9.0 后面
现象:后台按发布时间倒序没问题,客户端详情页版本顺序是乱的。
原因:某一处按version_name做了字符串排序,字典序里"1.10.0" < "1.9.0"。
解决:所有排序、比较、更新判断一律用version_code,version_name只用于展示。发布接口里那句version_code递增校验就是防止这类数据进来的最后一道防线,别图省事关掉。
四、下载量比实际日志多一个数量级
现象:后台统计显示某软件下载 8000 次,Nginx access log 里实际请求不到 800 次。
原因:客户端下载失败会重试,用户也会反复点,每次都调计数接口;去重 key 如果用了永久不过期的,第二天同 IP 就被永久拦掉了,反过来又少算。
解决:setIfAbsent必须带过期时间(按天去重就设 24 小时),维度用ip + versionId,别只用ip——同一个办公楼出口 IP 一样,只用 IP 会把整个公司算成一个人。Redis 计数和落库之间那段用rename,别用del。
五、上传 300MB 安装包直接 502
现象:后台上传大文件,进度条走到一半报 502,后端日志里没有异常。
原因:三层限制叠加——Nginxclient_max_body_size默认 1MB,Springmultipart.max-file-size默认 1MB,网关读超时默认 60 秒。任何一层没放开都会挂,而且报错位置完全不同。
解决:三处一起改,Nginx 的client_max_body_size、Spring 的两个 multipart 参数、网关的proxy_read_timeout。文件超过 500MB 的话,别硬传,改成前端分片或者直传对象存储,后端只收file_key。
6. 二次开发:加一个插件市场频道并把灰度发布跑通
这份源码最容易改出价值的地方,是channel字段。它现在默认official,看起来只是个标记,实际上可以撑起一整条灰度链路。
改造思路分三步。第一步,给app_info加一个app_type字段(app/plugin),列表接口按类型过滤,插件市场频道就出来了,页面结构完全复用现有的列表页和详情页,只是分类换成插件分类。第二步,把channel用起来:后台发布时可以选择official、beta、internal,客户端请求列表和下载地址时带上自己的渠道,后端按渠道过滤版本。第三步,加一张channel_whitelist表,记录哪些用户能看internal渠道,这样内部测试包就不会流到外面。
渠道过滤那段逻辑,我一般写成这样:
public List<AppVersion> listVersions(Long appId, Long userId, String channel) { // 非 official 渠道必须过白名单,否则统一降级到 official if (!"official".equals(channel) && !whitelistMapper.exists(userId, channel)) { channel = "official"; } return versionMapper.selectByAppAndChannel(appId, channel, 1); }exists查的是channel_whitelist,参数是用户和渠道,返回布尔值。这段代码的价值在于“降级”这个动作:用户拿着一个无效渠道进来,不报错,直接给正式版,体验上没有断点,排查时也不会因为 403 一头雾水。
验证方式很简单,不需要写测试用例:拿两个账号,一个加白名单一个不加,用同一个channel=beta请求列表接口,看返回的版本号是否不同;再对拿到 beta 的账号调下载地址接口,确认签出来的 URL 能下到 beta 包。两个账号结果一致,说明白名单没生效,去查userId是不是取错了(很多项目在这一步取的是 token 里的sub,但表里存的是自增 id)。
还有一个改动量更小但收益很高的点:给app_version加min_os和force_update两个字段,客户端启动时拉一次“检查更新”接口,返回{latestCode, forceUpdate, downloadUrl},比让用户自己去列表里翻版本靠谱得多。这个接口一旦上线,version_code严格递增这件事就从“建议”变成了“必须”,因为客户端的更新判断完全依赖它。
从那以后我每次接这类软件库项目,都先把version_code的递增规则和file_key的不外泄这两件事钉死,再动手写页面——顺序反过来,后面补的每一个坑都要花三倍时间。希望帮到你。
本文还有配套的精品资源,点击获取