### 前置 #### 课程大纲 * **课程介绍** - AI智能化云盘大课:后端分布式大项目+结合AI大模型智能体开发+业务应用 - 众多新技术+后端业务领域超多解决方案+AI大模型多案例应用场景落地 - 多语言开发:云盘板块采用Java开发后端项目,AI智能化板块采用Python+LangChain框架+大模型开发; - AI大模型Agent开发和主流解决方案和三方类库等多案例实战 - **项目核心技术体系** - 后端业务全新技术:SpringBoot3.X全家桶+JDK21+超多实用java生态类库+大文件传输处理 - AI大模型全新技术:Python3.1X+FastAPI框架+全新LangChain框架+向量数据库Milvus+多个大模型 - 智能化云盘 多数一线大厂正在研发的业务领域,全网首个后端业务+AI大模型一起的项目教程 - LLM大模型支持在线大模型调用和本地私有化部署,包括不限于ChatGLM、GPT-4、通义千问、LLaMa等 - 后端业务开发板块:打造私有化云盘,大文件上传、秒传、下载、在线分享等核心模块 - AI大模型Agent板块 - LLM大模型Prompt工程、RAG知识库构建、Agent智能体开发 - Memory长短期记忆、LCEL、Tools自定义工具、MaaS模型服务搭建等 * **核心业务模块应用场景** * 业务应用类似阿里/百度云盘、NAS等,支持多类型文件存储和处理,支持多类型存储架构 - 基于云盘存储文件,结合LLM大模型,开发多个Agent智能体,应用多个业务场景,包括不限于 - 从0到1讲解AI大模型基础+项目实战,拓展前端/后端工程师必备的人工智能知识和应用实战 - 智能机器人Chat助理:长短期记忆的个人助理、智能客服、智能销售顾问等 - 企业问答知识库: 知识库检索内容问答、自定义上传到知识库、在线解析URL地址、实时联网搜索等 - 文档AI助手:文档概要总结、内容进行分段总结、AIGC营销内容生产等 image-20241217115445442 - **AI大模型的行业解决方案和案例库参考** - https://page.dingtalk.com/wow/dingtalk/default/dingtalk/I0HfYX4QStBIpLgxnZQe - https://wolai.dingtalk.com/jVUBREtv4JHXRnBSWRaWj6 - https://bigmodel.cn/ #### 高频问题解答 * **问题:大模型是直接调用API吗,就是调用通义千问或者文心一言接口吗?** - ,LLM大模型只是简单调用API?那如何和后端业务+数据库数据联动? - 架构也和微服务类似,LLM重试机制、兜底降级机制等等怎么做?敏感数据敢上传外部? - 比如 - 某一次失败之后应该怎么处理,还有日志生成、管理资源、性能优化、准确性等等 - 这一些都是很关键的,靠普通的API是解决不了的,这个就是大课的部分解决方案,还有更多!!!! - 通义千问或者文心一言都是一个基层模型底座,这些大模型类似我们的操作系统,不是商业应用程序 - 类似我们会基于操作系统上开发App软件;那AI应用就是基于这些大模型作为底座,开发上层的商业智能化应用 - 比如 - 公司需要做智能知识库、行业智能客服、智慧政务、AI律师、AI客服等,那就没法用这些平台 - 因为你公司不可能把敏感数据上传上去,而且也没法做到; - 比如律师行业,医疗行业,财税行业等专业领域知识都是。 - 所以直接调用外部的API完全不一样,像通义千问等只是通用大模型,适合个人提升效率啥的,这个很容易。 - 但是达不到商用级别,也难和公司的业务结合一起; - 像很多公司都是有沉淀很多历史的资料,文档记录,案例等,而且又敏感,不能上传外部的LLM平台 - 所以都需要私有化部署,针对公司本身所处的行业进行深度定制和优化,结合常规的后端和前端项目整合一起 * **问题:学完这个大课,可以开发怎么样的项目和应用呢?** - AI文档助手 - 你可以给一堆专业文档,包括word文档、PDF等,让AI工具帮你生成 文档总结做周报、季度汇报等 - 给公司培训的的时候,可以从网上寻找很多资料,但是杂乱分散,可以让AI帮你整理和汇总,排版清晰 - 让AI帮你写多类型跳槽简历、毕业论文 - 企业知识库 - 将企业的各类知识资源进行智能化归类、整合,形成一套问题与答案的集合 - 【企业内部知识共享】作为企业内部的知识共享平台,帮助员工快速获取所需知识,提高团队协作效率 - 【客户服务】AI企业问答知识库可以为客户提供快速准确的解答服务 理解客户的问题并给出相应的答案 - 【员工培训】AI企业问答知识库还可以作为员工培训的平台,根据员工的个人需求进行定制化培训 - 私人AI助理 - 聊天与陪伴:私人AI助理可以陪伴用户聊天、讲笑话、玩小游戏等,提供轻松愉快的休闲娱乐体验。 - 个性化推荐:根据用户的喜好和行为习惯,推荐音乐、电影、书籍等娱乐内容。 - 健康管理:监测家庭成员的健康状况,提供运动、饮食建议,甚至可以协助医生进行诊断 - 特定领域智能聊天机器人 - 通过给AI一系列资料,单独训练特定领域,然后让帮我们做出决策 - 比如 - 各个大公司财报和历史股票行情信息,让AI汇总和给出指导建议 - 给出医院检查报告等,AI训练可以给出诊断和建议 - 给出特定领域销售部门的日常话术和专业知识, 充当智能客服 * **问题:什么是AI大模型应用,什么是AI大模型底层原理?课程是重点讲解哪块?** - AI大模型应用开发 - 就是我们用的很多人工智能工具,比如【智能美颜相机】【智能客服机器人】 - LLM应用层面很多很多:企业问答、智能律师、智慧政务、税务等 - 多数公司都是开发这类应用产品,包括App,网站等,使用人员和市场需求最多,90%占比 - AI大模型底层原理 - 就是为啥他的更加智能,采用什么数学算法,为啥更加智能,刨根问底 - 需要高学历,需要看很多行业英文论文、高等数学知识、算法原理等,岗位和市场需求少 10%占比 - 如果个人喜欢编写代码,实现具体的功能,且想要快速看到应用效果,则AI大模型应用开发 - 如果你对算数学模型、机器学习等有很好的基础,有精力进行深入研究则可以学习AI大模型底层原理 - 我们这个课程对于侧重AI大模型应用开发,如果你是0基础,之前是前端/后端/测试/大数据等背景则推荐 - 建议优先学习AI大模型应用开发,然后在进一步学习LLM算法方面知识提升 * **问题:后端业务+LLM大模型课程对电脑配置有什么要求,常规几千块的电脑能学不?** - 常规电脑即可学习,虽然后端项目涉及多个中间件,LLM大模型训练和私有化部署等需要用到大量硬件资源 - 课程会教采用云服务器和三方算力平台解决这类问题,几十块就可以搞定,所以不用担心。 - 课程会讲多个LLM大模型,封装成底层,容易切换不同的大模型,包括在线和离线私有化部署的大模型 - LLM大模型参数有几亿和几十亿、几百亿等参数规模,这个是需要比较大的算力资源 - 学习的时候可以使用少点参数进行练习,生产的时候可以根据公司需要选择不同级别的参数规模,结合硬件 - 这些都需要掌握,不同的级别的项目才好根据情况进行选择 #### 技术栈要求和内容安排 * **大课技术栈概览** * 基础工具环境:AI大模型编码插件+JDK21+IDEA旗舰版+VSCode+Python3.1X+Linux服务器 * 后端高并发技术:新版SpringBoot3.X+MybatisPlus+Lombok+Hutool+Mysql8.X+多个开源工具包 * 中间件+存储技术:Redis7.X+Kafak3.X-Kraft架构|RabbitMQ+分布式文件存储MinIO或OSS存储引擎 * 前后端分离架构下的 Vue3+ AntDesign+ Nginx网关+多个前端开源组件 (提供完整代码) * 超多AI大模型+模型库应用:新版GPT/ChatGLM/通义千问等+Huggingface/ModelScope等 * AI大模型技术:FastAPI框架+全新LangChain框架+向量数据库Milvus+多个大模型高频类库等 * LLM框架组件:Model+Prompt+Agent+Chains+Memory+Indexes+RAG+ReAct等 * DevOps上线部署:Jenkins CICD + 阿里云Git仓库+ 阿里云ECS 服务器+ Docker容器编排调度 * ....更多精彩 * **内容安排说明** * 前置必备技术栈:SpringBoot + Mysql +Redis + Kafka|RabbitMQ + Docker +Linux * 其他新技术栈:Python + LangChain + FastAPI + Milvus +MinIO 等大课里面会讲 ### 需求文档和架构图 #### 为什么技术Leader需要掌握产品需求文档 * 核心:有些不懂技术的产品经理没法编写特定领域的项目需求文档 * **技术知识缺乏**: * 特定领域的项目可能需要特定的技术知识。 * 如果产品经理缺乏相关技术背景,难以理解技术实现的复杂性和可行性,从而难以准确描述技术需求。 * **沟通障碍** * 产品经理需要与技术团队紧密合作,以确保需求的可实现性。 * 如果产品经理不懂技术,他们可能难以与技术团队有效沟通,导致需求文档中的技术细节不准确或不完整。 * **风险评估不足**: * 不懂技术的产品经理可能无法准确评估技术实现的风险,这可能导致项目在实施过程中遇到预料之外的问题。 * **需求优先级判断失误**: * 技术背景可以帮助产品经理判断哪些需求对项目成功最为关键。 * 缺乏技术背景的产品经理可能难以做出正确的优先级排序。 * **一份合格的产品需求文档(多数内容有即可,不同团队要求大体类似)** ``` ## 1. 标题页 - **产品名称**:[产品名称] - **版本/修订号**:[版本号] - **编制日期**:[编制日期] - **编制人**:[编制人姓名] - **审核人**:[审核人姓名] ## 2. 目录 - 根据文档内容创建目录,方便快速跳转到各个部分。 ## 3. 引言 ### 3.1 目的 - 简要说明编写此文档的目的。 ### 3.2 背景 - 描述产品的背景信息,包括市场机会、业务需求等。 ### 3.3 定义 - 对文档中使用的专业术语或缩写词进行定义。 ## 4. 产品概述 ### 4.1 产品愿景 - 描述产品的长远目标和愿景。 ### 4.2 产品目标 - 明确产品的短期和长期目标。 ### 4.3 用户和市场 - 描述目标用户群体和市场定位。 ## 5. 功能需求 ### 5.1 功能列表 - 列出产品需要实现的所有功能。 ### 5.2 功能描述 - 对每个功能进行详细描述,包括用户故事或用例。 ## 6. 非功能需求 ### 6.1 性能要求 - 描述产品的性能标准,如响应时间、并发用户数等。 ### 6.2 安全要求 - 列出产品必须满足的安全标准。 ### 6.3 可用性要求 - 描述产品的易用性和可访问性要求。 ### 6.4 法律和标准 - 指出产品需要遵守的法律、法规和行业标准。 ## 7. 技术和开发约束 - 列出技术栈、开发平台、第三方服务等技术约束。 ## 8. 项目计划 - 提供产品开发的时间线和里程碑。 ## 9. 预算和资源 - 概述项目的预算和所需资源。 ## 10. 风险评估 - 识别项目可能面临的风险,并提出相应的缓解措施。 ## 11. 附件 - 包括市场调研报告、竞品分析、用户访谈记录等支持文档。 ``` #### AI智能化云盘需求文档说明 image-20241217151318969 #### 架构图的作用和绘制技巧 - 什么是架构图 - 架构图 = 架构 + 图 - 用图的形式把系统架构展示出来,配上简单的文案 - 一图胜千言,解决沟通障碍,给不同的【业务方】看懂 - 业务方很多,不同人看到角度不一样,你让【产品经理】看 【物理部署视图】他看得懂? * 架构图是给人看的,这些人我们习惯称为【业务方、客户】,有哪些人? - 人员 - 上级:你的公司Leader(晋升汇报)、老板、外部投资人 - 团队内:产品、运营、测试、技术、运维同学 - 外部:最终系统使用的用户 - 好比阿里这边评定绩效,有一项就是业务方评分 - 你做的外部用户的活动系统,测试同学会进行测试,太多bug肯定就不行 - 你做的给运营同学使用的系统,不能提升她运营的效率,业务方是否满意? * 为什么要搞出这么多个架构图?用一个图不行吗? - 一开始确实是一个图表示系统架构设计 - 但是业务方很多,不同人看到角度不一样,你让软件用户看物理部署视图?他看得懂? - 要明确沟通交流面向的客户 - 开发人员、运维人员、项目经理、软件最终用户、客户 - 避免在一张图中展示所有细节,根据受众的需要简化信息,突出关键组件和关系。 - 不同架构视图承载不同的架构设计决策,支持不同的目标和用途 - 架构图也不能太多(过度文档化)维护更新起来成本大 - 不同架构图应该使用哪种方法来画? - 可以用的表示法和工具很多,没有太多的限制,把握对应的视图关注点才是关键 - Xmind、EdrawMax、PPT、PowerDesigner - OmniGraffle、Visio、Process On - 开始阶段不要陷入过度设计中,没那么多需求不一定要那么多图(你是否有那么多客户) * 常见架构图作用对比 * 产品/应用/产品业务架构 - 表达业务是如何开展的,服务于业务目标,通过描绘业务上下层关系,简单的业务视图降低业务系统的复杂 - 是对整个系统实现的总体架构 , 应用架构和**系统架构**很大类似 - 一方面承接业务架构的落地,一方面影响技术选型 - 注意:一般应用架构图【不加入太多技术框架和实现】 - 下面这个是什么架构图(产品架构图-方便技术和产品沟通,图片阿里云官方网站VOD视频点播) ![img](./img/p411278-4421764.png) * 技术架构 - 应用架构本身只关心需要哪些应用系统,不关心在整个项目中你需要使用哪些技术 - 技术架构则是实现应用架构的承接方,识别技术需求,进行技术选型,描述技术之间的关系 - 解决的问题包括 - 技术层面的分层、开发语言、框架的选择 - 通信技术、存储技术的选择、非功能性需求的技术选择等 #### 教你画架构图 - 在画架构图之前,想清楚3个问题,架构图想表达什么?有什么用?给谁看? - 表达是业务系统之间的关系,梳理业务结构 - 将复杂的业务逻辑简单化,降低理解难度,更方便业务方理解 - 给业务方查看,业务相关干系人 - 业务架构图 - 表达业务是如何开展的,服务于业务目标,通过描绘业务上下层关系,简单的业务视图降低业务系统的复杂度,提高客户理解度 - 图中【尽量不出现技术】的字眼,不同架构图的读者是不同的,确保能看懂。 - 架构图中模块的划分粒度,一定要合适,既不能太宽泛,也不能太细粒度 - 无技术背景人员可参与实现的讨论,向技术人员描述解决方案核心要做什么,必须实现的关键是什么 - 明白一个点 - 先有业务,再有系统,微服务/系统/中心 是类似概念 - 系统是来实现业务的,比如电商业务里面A系统、B系统 * 业务架构类型 - 上中下结构:用户展现层-业务平台层-公共能力层-数据存储层-基础资源层 - 案例一(图片来源-阿里云数字政府) ![image-20241217155520041](./img/image-20241217155520041.png) - 左中右结构:上游产业 - 业务平台- 下游产业 - 相对较少用,就是倒置过去 * 画图三步走(**不同架构图通用法则**) - 分层 - 业务按照层级进行划分,各个层级属于独立的版块 - 下层为上层提供服务能力支撑,比如:laaS / PaaS / SaaS - 分模块 - 同层级中进行小归类;属于平行关系,可以独立存在 - 理清架构图类型、业务要全面、专业术语一致、图形清晰美观、颜色类型划分合理 - 不同颜色可以表示当下要做的,未来要做的 - 分功能 - 独立功能划分出来,即业务入口 - 业务方重点关注的功能点,可以认为是微服务划分 ![image-20241217155622979](./img/image-20241217155622979.png) * 如何判断架构图的好和坏? - 业务抽象设计的合理性,是否满足高内聚、低耦合的要求,不能太宽泛,也不能太细粒度 - 层级划分目标系统边界,自下而上 或 由上而下,一般包括 基础设施、数据层、应用层、用户层四个层次 - 使用清晰的布局,确保组件之间的连接线不交叉,易于跟踪。 - 使用颜色和样式来区分不同类型的组件,但不要过度使用,以免分散注意力。 - 纵向分层 上层依赖于下层越底层,越是基础服务;横向并列关系,级别相同 - 理清架构图类型、业务要全面、专业术语一致、图形清晰美观、颜色类型划分合理 - 最重要是:**你的业务方能 满意+看懂!!!** #### AI智能化云盘应用架构图讲解 * 什么是应用架构图 - 是对整个系统实现的总体架构 , 应用架构和**系统架构**很大类似 - 一方面承接业务架构的落地,一方面影响技术选型 * 注意:一般应用架构图【不加入太多技术框架和实现】 - 作用 - 根据业务场景 对系统进分层,指出开发的原则、系统各个层次的应用服务 - 业务方 - **研发人员,各层级架构师,各层级技术管理者** - 分类 - 多系统应用架构,用来分层次说明不同系统间的业务逻辑关系、系统边界等,比如 分布式、微服务 - 单系统应用架构,用来分层次说明系统的组成模块和功能点之间的业务逻辑关系,比如单体应用 - 常规分层 - 表示-展现层:负责用户体验 - 业务-服务层:负责业务逻辑 - 数据-访问层:负责数据库存取 * 画图三步走 - 分层 - 业务按照层级进行划分,各个层级属于独立的版块 - 下层为上层提供服务能力支撑,比如:laaS / PaaS / SaaS - 分模块 - 同层级中进行小归类;属于平行关系,可以独立存在 - 理清架构图类型、业务要全面、专业术语一致、图形清晰美观、颜色类型划分合理 - 不同颜色可以表示当下要做的,未来要做的 - 分功能 - 独立功能划分出来,即业务入口 - 业务方重点关注的功能点,可以认为是微服务划分 * 新一代AI智能化云盘应用架构图(找bug) image-20241217180704525 #### AI智能化云盘技术架构图和作业提交 * 什么是技术架构 - 应用架构本身只关心需要哪些应用系统,不关心在整个项目中你需要使用哪些技术 - 技术架构则是实现应用架构的承接方,识别技术需求,进行技术选型,描述技术之间的关系 - 解决的问题包括 - 技术层面的分层、开发语言、框架的选择 - 通信技术、存储技术的选择、非功能性需求的技术选择等 - 案例 image-20241217160721765 * 新一代AI智能化云盘技术选型(**下面只是部分技术栈**) * 基础工具环境:AI大模型编码插件+JDK21+IDEA旗舰版+VSCode+Python3.1X+Linux服务器 * 后端高并发技术:新版SpringBoot3.X+MybatisPlus+Lombok+Hutool+Mysql8.X+多个开源工具包 * 中间件+存储技术:Redis7.X+Kafak3.X-Kraft架构+分布式文件存储MinIO或OSS存储引擎 * 前后端分离架构下的 Vue3+ AntDesign+ Nginx网关+多个前端开源组件 (提供完整代码) * 超多AI大模型+模型库应用:新版GPT/ChatGLM/通义千问等+Huggingface/ModelScope等 * AI大模型技术:FastAPI框架+全新LangChain框架+向量数据库Milvus+多个大模型高频类库等 * LLM框架组件:Model+Prompt+Agent+Chains+Memory+Indexes+RAG+ReAct等 * DevOps上线部署:Jenkins CICD + 阿里云Git仓库+ 阿里云ECS 服务器+ Docker容器编排调度 ### 开发环境搭建 #### AI编码插件 AI会淘汰程序员? * AI技术的发展一定程度上改变我们程序员的工作方式,例如自动化一些重复性任务,辅助程序员进行代码审查和优化等 * 也可以编写包括中等程度的CURD、算法等;但AI很难完全替代程序员,可以很大程度辅助我们工程师 * 程序员在创造力、人际沟通、适应新技术、解决复杂问题以及法律责任等方面具有不可替代的优势,AI背锅? * 如果程序员不懂技术,你能判断AI写的代码上生产环境?出问题你可以排查? AI编码插件对比 * CodeGeeX(清华大学+智谱AI) * 地址:https://codegeex.cn/ * 优点: * 多语言代码生成模型,支持代码生成与补全、自动添加注释、代码翻译以及智能问答等功能 * 支持多种主流编程语言,并适配多种主流IDE * 对于个人开发者完全免费,国内开发,无需额外连接VPN * 缺点 * 对于复杂的场景,AI工具可能提供错误的答案 * 通义灵码(阿里) * 地址:https://tongyi.aliyun.com/lingma * 优点: * 基于通义大模型,提供代码智能生成、研发智能问答能力 * 支持行级/函数级实时续写,自然语言生成代码 * 生成单元测试,支持多种测试框架。 * 支持多种主流编程语言 * 缺点 * 单元测试生成功能表现一般 * 高级功能需要付费 * GitHub Copilot * 地址:https://github.com/features/copilot/ * 优点: * 根据提示自动生成代码,提高开发效率 * 学习项目中的代码风格,获取足够多的上下文,并根据其生成代码 * 支持多种编程语言,适用范围广 * 缺点: * 可能存在隐私问题 * 功能收费,对于个人开发者成本较高 * **其他比较牛的(都需要科学上网):** * **Cursor、Claude** * 能够完成复杂的任务,并且可以与其他系统集成,支持多种应用场景,包括独立开发程序 * **一个是目前适配AI最好的代码编辑器,一个是目前AI编程能力最强的大模型。** #### SpringBoot3.X本地开发环境创建 技术版本 * Maven-3.9以上: `mvn -version` * JDK-21版本(LTS版本 主流应该是26到28年) * 新版IDEA-旗舰版 * 框架版本-SpringBoot3.X 项目创建 ycloud-aipan * 快速创建地址:https://start.spring.io/ ```xml org.springframework.boot spring-boot-starter-parent 3.2.4 ``` #### 依赖初始化 * 项目依赖配置添加 ```xml 21 1.12.730 3.5.6 5.8.27 2.8.0 2.0.42 8.0.27 ``` * 工程依赖配置 **最佳建议:把这章这集的代码导入到你们IDEA里面,进行构建** ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-aop mysql mysql-connector-java ${mysql.version} org.projectlombok lombok 1.18.30 org.springframework.boot spring-boot-starter-test test com.amazonaws aws-java-sdk-s3 ${aws-java-sdk-s3.version} io.jsonwebtoken jjwt 0.12.3 com.baomidou mybatis-plus-spring-boot3-starter ${mybatisplus.version} com.baomidou mybatis-plus-generator ${mybatisplus.version} org.apache.velocity velocity-engine-core 2.0 cn.hutool hutool-all ${hutool-all.version} com.alibaba fastjson ${fastjson.version} com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 org.springframework.boot spring-boot-maven-plugin org.apache.maven.plugins maven-compiler-plugin 3.1 ${java.version} ${java.version} org.apache.maven.plugins maven-surefire-plugin 2.19.1 true ``` #### Linux操作系统EOL解决方案 * 操作系统停止维护EOL(End of Life) * 大家也知道很多生产环境操作系统都是使用CentOS,尤其是互联网公司 * 但是CentOS官方在24年尾的时候停止了支持,这个就涉及到切换系统 * 建议 * Linux大体是类似的,迁移需要周期,常规25到28年还会是多数公司的首选CentOS * 所以大家还是需要掌握这个主流的系统;如果新项目则可以选择其他操作系统 * 常见的 CentOS 替代方案,包括 AlmaLinux、Rocky Linux、Oracle Linux、Ubuntu 和 Debian * Rocky Linux9.X以上 ,推荐2核4G或4核8G #### Docker镜像加速+软件安装 * 软件安装 * Docker-ce社区版本 * Mysql8.X * 可视化工具自己选择 * Redis7.X * 可视化工具下载地址 * https://gitee.com/qishibo/AnotherRedisDesktopManager * https://github.com/qishibo/AnotherRedisDesktopManager 安装脚本 ```shell ————————Docker-ce社区版本———————— #运行以下命令,下载docker-ce的yum源。 sudo wget -O /etc/yum.repos.d/docker-ce.repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo #运行以下命令,安装Docker。 sudo yum -y install docker-ce #执行以下命令,检查Docker是否安装成功。 sudo docker -v #执行以下命令,启动Docker服务,并设置开机自启动。 sudo systemctl start docker sudo systemctl enable docker #执行以下命令,查看Docker是否启动。 sudo systemctl status docker #配置Docker镜像加速 ([ -f /etc/docker/daemon.json ] || mkdir -p /etc/docker) && echo '{ "registry-mirrors" : [ "https://docker.m.daocloud.io", "https://noohub.ru", "https://huecker.io", "https://dockerhub.timeweb.cloud" ] }' > /etc/docker/daemon.json && sudo systemctl restart docker && sleep 1 && docker info | grep -A 4 "Registry Mirrors" # ————————Mysql8.X安装———————— #创建目录 mkdir -p /home/data/mysql/ #创建配置文件 touch /home/data/mysql/my.cnf #部署 docker run \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=xdclass.net168 \ -v /home/data/mysql/conf:/etc/mysql/conf.d \ -v /home/data/mysql/data:/var/lib/mysql:rw \ -v /home/data/mysql/my.cnf:/etc/mysql/my.cnf \ --name xdclass_mysql \ --restart=always \ -d mysql:8.0 # ————————Redis7.X———————— docker run -itd --name xdclass-redis -p 6379:6379 -v /mydata/redis/data:/data redis:7.0.8 --requirepass abc123456 ``` #### 纳入阿里云Git版本控制 基于git协议的代码仓库 - github 全球最大同性交友社区 - gitee 开源中国 - gitlab 开源的git仓库平台,阿里等大厂就是基于这个搭建 - codeup 阿里云上的免费git仓库 * 地址:https://codeup.aliyun.com/ * 配置ssh * 纳入管理 #### 项目规范说明和工具类封装 响应工具、通用工具、Json工具、对象拷贝工具、枚举状态码、全局异常处理 - 响应工具 ```java /** * 响应类 */ @Data @AllArgsConstructor @NoArgsConstructor public class JsonData { /** * 状态码 0 表示成功 */ private Integer code; /** * 数据 */ private Object data; /** * 描述 */ private String msg; /** * 获取远程调用数据 * * @param typeReference 数据类型的引用 * @param 泛型类型 * @return 返回解析后的对象 */ public T getData(Class typeReference) { return JSON.parseObject(JSON.toJSONString(data), typeReference); } /** * 成功,不传入数据 * * @return 返回一个状态码为0的JsonData对象 */ public static JsonData buildSuccess() { return new JsonData(0, null, null); } /** * 成功,传入数据 * * @param data 成功时返回的数据 * @return 返回一个JsonData对象,其中包含状态码0和传入的数据 */ public static JsonData buildSuccess(Object data) { return new JsonData(0, data, null); } /** * 失败,传入描述信息 * * @param msg 失败时的描述信息 * @return 返回一个JsonData对象,其中包含状态码-1和传入的描述信息 */ public static JsonData buildError(String msg) { return new JsonData(-1, null, msg); } /** * 自定义状态码和错误信息 * * @param code 自定义的状态码 * @param msg 自定义的错误信息 * @return 返回一个JsonData对象,其中包含传入的状态码和错误信息 */ public static JsonData buildCodeAndMsg(int code, String msg) { return new JsonData(code, null, msg); } /** * 自定义状态码和错误信息 * * @param codeEnum 自定义的状态码枚举 * @return 返回一个JsonData对象,其中包含传入的状态码枚举对应的状态码和错误信息 */ public static JsonData buildResult(BizCodeEnum codeEnum) { return JsonData.buildCodeAndMsg(codeEnum.getCode(), codeEnum.getMessage()); } /** * 判断当前JsonData对象是否表示成功 * * @return 如果状态码为0,则返回true,表示成功;否则返回false,表示失败 */ public boolean isSuccess() { return code == 0; } } ``` - 通用工具 ```java package org.ycloud.aipan.util; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.io.PrintWriter; @Slf4j public class CommonUtil { /** * 响应json数据给前端 * * @param response HttpServletResponse对象,用于向客户端发送响应 * @param obj 需要转换为json格式的对象 */ public static void sendJsonMessage(HttpServletResponse response, Object obj) { // 设置响应内容类型为json,并指定字符编码为utf-8 response.setContentType("application/json; charset=utf-8"); try (PrintWriter writer = response.getWriter()) { // 将对象转换为json字符串并写入响应输出流 writer.print(JsonUtil.obj2Json(obj)); // 刷新缓冲区,确保数据被发送到客户端 response.flushBuffer(); } catch (IOException e) { // 捕获并记录异常信息 log.warn("响应json数据给前端异常:{}", e.getMessage()); } } /** * 根据文件名称获取文件后缀 * * @param fileName 文件名 * @return 文件后缀名 */ public static String getFileSuffix(String fileName) { // 从文件名中提取后缀名 return fileName.substring(fileName.lastIndexOf(".") + 1); } /** * 根据文件后缀,生成文件存储路径:年/月/日/uuid.suffix 格式 * * @param fileName 文件名 * @return 生成的文件存储路径 */ public static String getFilePath(String fileName) { // 获取文件后缀名 String suffix = getFileSuffix(fileName); // 生成文件在存储桶中的唯一键 return StrUtil.format("{}/{}/{}/{}.{}", DateUtil.thisYear(), DateUtil.thisMonth() + 1, DateUtil.thisDayOfMonth(), IdUtil.randomUUID(), suffix); } } ``` - Json工具 ```java @Slf4j public class JsonUtil { // 创建一个ObjectMapper对象,用于处理JSON数据的序列化和反序列化 private static final ObjectMapper MAPPER = new ObjectMapper(); // 静态代码块,用于初始化ObjectMapper对象的配置 static { //设置可用单引号 MAPPER.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); //序列化的时候序列对象的所有属性 MAPPER.setSerializationInclusion(JsonInclude.Include.ALWAYS); //反序列化的时候如果多了其他属性,不抛出异常 MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); //下划线和驼峰互转 //mapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); //如果是空对象的时候,不抛异常 MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); //取消时间的转化格式,默认是时间戳,可以取消,同时需要设置要表现的时间格式 MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); MAPPER.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); } /** * 获取ObjectMapper对象 * * @return ObjectMapper对象 */ public static ObjectMapper get() { return MAPPER; } /** * 将对象转换为JSON字符串 * * @param obj 要转换的对象 * @return JSON字符串 */ public static String obj2Json(Object obj) { String jsonStr = null; try { jsonStr = MAPPER.writeValueAsString(obj); } catch (JsonProcessingException e) { log.error("json格式化异常", e); } return jsonStr; } /** * 将JSON字符串转换为对象 * * @param jsonStr 要转换的JSON字符串 * @param beanType 目标对象的类型 * @return 转换后的对象 */ public static T json2Obj(String jsonStr, Class beanType) { T obj = null; try { obj = MAPPER.readValue(jsonStr, beanType); } catch (Exception e) { log.error("json格式化异常", e); } return obj; } /** * 将JSON数据转换为对象列表 * * @param jsonData 要转换的JSON数据 * @param beanType 目标对象的类型 * @return 转换后的对象列表 */ public static List json2List(String jsonData, Class beanType) { JavaType javaType = MAPPER.getTypeFactory().constructParametricType(List.class, beanType); try { // 使用ObjectMapper将JSON数据转换为对象列表 return MAPPER.readValue(jsonData, javaType); } catch (Exception e) { log.error("json格式化异常", e); } // 返回空列表 return new ArrayList<>(0); } } ``` - 对象拷贝工具 ```java /** * SpringBeanUtil 工具类,提供了对象属性复制的功能。 */ public class SpringBeanUtil { /** * 复制属性 * * @param 目标对象类型 * @param source 源对象 * @param target 目标对象类型 * @return 复制后的目标对象 */ public static T copyProperties(Object source, Class target) { try { T t = target.getConstructor().newInstance(); BeanUtils.copyProperties(source, t); return t; } catch (Exception e) { throw new RuntimeException(e); } } /** * 复制一份具有相同属性的列表 * * @param sourceList 源列表 * @param target 目标对象的类型 * @param 目标对象的类型 * @return 复制后的目标列表 */ public static List copyProperties(List sourceList, Class target) { ArrayList targetList = new ArrayList<>(); sourceList.forEach(source -> { T t = copyProperties(source, target); targetList.add(t); }); return targetList; } /** * 复制属性 * * @param source 源对象 * @param target 目标对象 */ public static void copyProperties(Object source, Object target){ BeanUtils.copyProperties(source,target); } } ``` - 枚举状态码 ```java @Getter @AllArgsConstructor public enum BizCodeEnum { /** * 账号 */ ACCOUNT_REPEAT(250001, "账号已经存在"), ACCOUNT_UNREGISTER(250002, "账号不存在"), ACCOUNT_PWD_ERROR(250003, "账号或者密码错误"), ACCOUNT_UNLOGIN(250004, "账号未登录"), /** * 文件操作相关 */ FILE_NOT_EXISTS(220404, "文件不存在"), FILE_RENAME_REPEAT(220405, "文件名重复"), FILE_DEL_BATCH_ILLEGAL(220406, "文件删除参数错误"), FILE_TYPE_ERROR(220407, "文件类型错误"), FILE_CHUNK_TASK_NOT_EXISTS(230408, "分片任务不存在"), FILE_CHUNK_NOT_ENOUGH(230409, "分片数量不匹配,合并不够"), FILE_STORAGE_NOT_ENOUGH(240403, "存储空间不足"), FILE_TARGET_PARENT_ILLEGAL(250403, "目标父级目录不合法"), SHARE_CANCEL_ILLEGAL(260403, "取消分享失败,参数不合法"), SHARE_CODE_ILLEGAL(260404, "分享码不合法"), SHARE_NOT_EXIST(260405, "分享不存在"), SHARE_CANCEL(260406, "分享已取消"), SHARE_EXPIRED(260407, "分享已过期"), SHARE_FILE_ILLEGAL(260408, "分享的文件不合规"); private final int code; private final String message; } ``` - 全局异常处理 ```java /** * 业务异常类,继承自 RuntimeException * 用于封装业务逻辑中的异常信息 */ @Data public class BizException extends RuntimeException { /** * 异常代码 */ private int code; /** * 异常消息 */ private String msg; /** * 异常详细信息 */ private String detail; /** * 构造函数,使用自定义的异常代码和消息 * * @param code 异常代码 * @param message 异常消息 */ public BizException(Integer code, String message) { // 调用父类构造函数,设置异常消息 super(message); // 设置异常代码 this.code = code; // 设置异常消息 this.msg = message; } /** * 构造函数,使用 BizCodeEnum 枚举中的异常代码和消息 * * @param bizCodeEnum 业务代码枚举 */ public BizException(BizCodeEnum bizCodeEnum) { // 调用父类构造函数,设置异常消息 super(bizCodeEnum.getMessage()); // 设置异常代码 this.code = bizCodeEnum.getCode(); // 设置异常消息 this.msg = bizCodeEnum.getMessage(); } /** * 构造函数,使用 BizCodeEnum 枚举中的异常代码和消息,并包含原始异常的详细信息 * * @param bizCodeEnum 业务代码枚举 * @param e 原始异常 */ public BizException(BizCodeEnum bizCodeEnum, Exception e) { // 调用父类构造函数,设置异常消息 super(bizCodeEnum.getMessage()); // 设置异常代码 this.code = bizCodeEnum.getCode(); // 设置异常消息 this.msg = bizCodeEnum.getMessage(); // 设置异常详细信息 this.detail = e.toString(); } } ``` ```java /** * 自定义异常处理器 * 用于捕获并处理全局异常,返回统一的JSON格式响应 */ @ControllerAdvice @Slf4j public class CustomExceptionHandler { /** * 处理所有异常的方法 * * @param e 捕获到的异常对象 * @return JsonData对象,包含错误码和错误信息 */ @ExceptionHandler(value = Exception.class) @ResponseBody public JsonData handler(Exception e){ // 判断异常是否为业务异常 if(e instanceof BizException bizException){ // 记录业务异常日志 log.error("[业务异常]",e); // 返回业务异常的错误码和错误信息 return JsonData.buildCodeAndMsg(bizException.getCode(),bizException.getMsg()); }else { // 记录系统异常日志 log.error("[系统异常]",e); // 返回系统异常的错误信息 return JsonData.buildError("系统异常"); } } } ``` ### 存储引擎MinIO和AWS-S3常规API实战 #### 分布式文件存储行业解决方案和技术选型分析 * 背景说明 * 数据爆炸的时代,产生的数据量不断地在攀升,基本都离不开文件存储 - 存储单位从KB、MB、GB、TB、PB到ZB级别的数据,图片、文档、素材、静态化页面、长短视频、安装包等一系列文件 * 业务应用内存储 - 传统的javaweb项目, 文件数量达到一定后占据大量的内存、磁盘和带宽, 无法满足海量请求的业务 - 开发容易-扩容难 * 分布式文件系统(Distributed File System) - 海量数据对存储提出了新的要求,从而诞生了分布式文件存储 - 文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连 - 扩容容易-开发难 * 目前业界比较多的解决方案 * 免费:MinIO * 官网:https://minio.org.cn/ * 是一个高性能、分布式的对象存储系统,完全兼容Amazon S3协议 * 学习成本低,安装运维简单,主流语言的客户端整合都有, 号称最强的对象存储文件服务器 * 提供简单的Web界面和广泛的API支持,方便集成和开发 * 适用于各种规模的部署,从个人小型项目到大型企业级应用 * 提供数据加密功能、访问控制、身份验证功能 * 具有高可用性,可以在分布式环境中运行,并自动处理数据的冗余和复制 * 高度可扩展性,可以根据需求增加更多的存储节点或容量来扩展存储规模 * 花钱:云厂商 - 阿里云OSS、七牛云、亚马逊云 * 面试官:智能化云盘如何选型哪类存储呢,自建或者云厂商如何思考,为啥选择这个? * 选云厂商理由 * 优点:开发简单,功能强大,容易维护(不同网络下图片质量、水印、加密策略、扩容、加速) * 缺点:要钱, 个性化处理,未来转移比较复杂,不排除有些厂商会提供一键迁移工具 - 选开源MinIO的理由 - 优点:功能强大、可以根据业务做二次的定制,新一代分布式文件存储系统,容器化结合强大,更重要的是免费 - 缺点:自己需要有专门的团队进行维护、扩容等 * 推荐答案 * 参考一:由于平台业务特殊性,多数企业会考虑【私有化】部署,因此如果绑定外部对象存储,则迁移麻烦 * 参考二:公司现有的分布式文件存储基建平台采用的是MinIO,技术团队也比较熟悉,也满足业务需求 #### Docker容器化部署分布式文件存储MinIO实战 * 部署MinIO实战 ```shell mkdir -p /minio/data chmod 777 /minio/data docker run \ -d --restart=always \ --name minio \ --hostname minio-server \ -p 9000:9000 \ -p 9001:9001 \ -v /app/docker/minio/data:/bitnami/minio/data \ -e MINIO_ROOT_USER="minio_root" \ -e MINIO_ROOT_PASSWORD="minio_123456" \ -e MINIO_DEFAULT_BUCKETS="bucket" \ -e "MINIO_SERVER_URL=http://39.108.115.28:9000" \ bitnami/minio:2023.12.7 ``` * 端口说明 * 9000端口是用于内部访问,比如通过SpringBoot接口间接访问MinIO * 9001端口是用于外部访问,即通过浏览器访问 * 安装实战 * 网络安全组开放端口 9000, 9001 * 访问:ip+9001端口 * 操作 * 界面登录 * 文件上传下载 * **疑惑点:那么多存储引擎,是否有行业标准接口协议呢?类似JDBC一样,可以对接多个数据库** #### SpringBoot3.X整合MinIO存储原生方案 * 需求 * SpringBoot3.X整合MinIO文件上传开发实战,采用原生方案 * 编码实战 * 项目增加依赖 ```xml io.minio minio 8.3.7 ``` * 配置文件 ```yaml # minio配置 minio: endpoint: http://39.108.115.28:9000 access-key: minio_root access-secret: minio_123456 bucket-name: ai-pan ``` * 配置类 ```java @Data @Component @ConfigurationProperties(prefix = "minio") public class MinioConfig { @Value("endpoint") private String endpoint; @Value("access-key") private String accessKey; @Value("access-secret") private String accessSecret; @Value("bucket-name") private String bucketName; // 预签名url过期时间(ms) private Long PRE_SIGN_URL_EXPIRE = 60 * 10 * 1000L; } ``` * 测试文件上传 ```java @PostMapping("/upload") public JsonData upload(@RequestParam("file") MultipartFile file) { return JsonData.buildSuccess(minioService.upload(file)); } @Override public String upload(MultipartFile file) { // 获取上传文件名 String filename = CommonUtil.getFilePath(file.getOriginalFilename()); try { InputStream inputStream = file.getInputStream(); minioClient.putObject(PutObjectArgs.builder() .bucket(minioConfig.getBucketName()) .object(filename) .stream(inputStream, file.getSize(), -1) .contentType(file.getContentType()) .build()); } catch (Exception e) { throw new BizException(BizCodeEnum.FILE_REMOTE_UPLOAD_FAILED,e); } return minioConfig.getEndpoint() + "/" + minioConfig.getBucketName() + "/" + filename; } ``` #### AWS-S3通用存储协议介绍和项目依赖配置 * 什么是Amazon S3 * Amazon S3(Amazon Simple Storage Service)是亚马逊提供的一种对象存储服务,行业领先的可扩展性、数据可用性和性能 * 就类似阿里云OSS、七牛云OSS、MinIO等多个存储服务一样 * Amazon S3协议 * 是Amazon Simple Storage Service(简称Amazon S3)的接口规范 * 它是一种基于HTTP协议的RESTful API,用于访问Amazon Web Services(AWS)提供的对象存储服务 * S3-API: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/API/API_Operations_Amazon_Simple_Storage_Service.html * 支持阿里云OSS、七牛云OSS(对象存储服务) * 在一定程度上与Amazon S3协议兼容,可以使用S3 API来操作OSS多数操作 * 存在一些差异,如ACL权限定义、存储类型处理,需要单独处理 * 支持MinIO * 兼容Amazon S3协议的对象存储服务器,它提供了与Amazon S3完全相同的S3 API兼容性 * 在公共云、私有云中 ,MinIO支持广泛的S3 API,包括S3 Select和AWS Signature V4,复杂的查询和身份验证 。 * Amazon S3构建的应用程序可以无缝迁移到MinIO,无需任何代码更改 * 如何用? * 项目添加依赖,配置相关底层存储即可 * 是亚马逊提供的官方软件开发工具包,用在Java程序与Amazon Simple Storage Service(S3)进行交互 * AWS Java SDK for S3提供了创建S3客户端、上传、下载、列出、复制、删除S3存储桶中的对象等功能 ```xml com.amazonaws aws-java-sdk-s3 ${aws-java-sdk-s3.version} ``` * 代码配置 ```java /** * 配置类,用于定义Bean并配置Amazon S3客户端 */ @Configuration public class AmazonS3Config { // 注入Minio配置类,用于获取访问密钥和Endpoint等信息 @Resource private MinioConfig minioConfig; /** * 创建并配置Amazon S3客户端 * * @return AmazonS3 实例,用于与Amazon S3服务进行交互 */ @Bean(name = "amazonS3Client") public AmazonS3 amazonS3Client() { // 设置连接时的参数 ClientConfiguration config = new ClientConfiguration(); // 设置连接方式为HTTP,可选参数为HTTP和HTTPS config.setProtocol(Protocol.HTTP); // 设置网络访问超时时间 config.setConnectionTimeout(5000); config.setUseExpectContinue(true); // 使用Minio配置中的访问密钥和秘密密钥创建AWS凭证 AWSCredentials credentials = new BasicAWSCredentials(minioConfig.getAccessKey(), minioConfig.getAccessSecret()); // 设置Endpoint AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder .EndpointConfiguration(minioConfig.getEndpoint(), Regions.US_EAST_1.name()); // 使用以上配置创建并返回Amazon S3客户端实例 return AmazonS3ClientBuilder.standard() .withClientConfiguration(config) .withCredentials(new AWSStaticCredentialsProvider(credentials)) .withEndpointConfiguration(endpointConfiguration) .withPathStyleAccessEnabled(true).build(); } } ``` #### AWS-S3通用存储案例接口测试和封装实战 - 案例代码测试-Bucket相关操作 ```java @SpringBootTest @Slf4j class AmazonS3ClientTests { @Autowired private AmazonS3Client amazonS3Client; /** * 判断bucket是否存在 */ @Test public void testBucketExists() { boolean bucketExist = amazonS3Client.doesBucketExist("ai-pan1"); log.info("bucket是否存在:{}", bucketExist); } /** * 创建bucket */ @Test public void testCreateBucket() { String bucketName = "ai-pan1"; Bucket bucket = amazonS3Client.createBucket(bucketName); log.info("bucket:{}", bucket); } /** * 删除bucket */ @Test public void testDeleteBucket() { String bucketName = "ai-pan1"; amazonS3Client.deleteBucket(bucketName); } /** * 获取全部bucket */ @Test public void testListBuckets() { for (Bucket bucket : amazonS3Client.listBuckets()) { log.info("bucket:{}", bucket.getName()); } } /** * 根据bucket名称获取bucket详情 */ @Test public void testGetBucket() { String bucketName = "ai-pan1"; Optional optionalBucket = amazonS3Client.listBuckets().stream().filter(bucket -> bucketName.equals(bucket.getName())).findFirst(); if (optionalBucket.isPresent()) { log.info("bucket:{}", optionalBucket.get()); } else { log.info("bucket不存在"); } } } ``` - 案例代码测试-文件相关操作 ```java /** * 上传单个文件,直接写入文本 */ @Test public void testUploadFile() { PutObjectResult putObject = amazonS3Client.putObject("ai-pan", "test1.txt", "hello world11"); log.info("putObject:{}", putObject); } /** * 上传单个文件,直接写入文本 */ @Test public void testUploadFile2() { amazonS3Client.putObject("ai-pan", "test2.txt", new File("/Users/xdclass/Desktop/dpan.sql")); } /** * 上传文件 包括文件夹路径 不带斜杠 都一样 */ @Test public void testUploadFileWithDir1() { amazonS3Client.putObject("ai-pan", "aa/bb/test3.txt", new File("/Users/xdclass/Desktop/dpan.sql")); } /** * 上传文件 包括文件夹路径 带斜杠 都一样 */ @Test public void testUploadFileWithDir2() { amazonS3Client.putObject("ai-pan", "/a/b/test4.txt", new File("/Users/xdclass/Desktop/dpan.sql")); } /** * 上传文件,输入流的方式 带上文件元数据 */ @Test @SneakyThrows public void testUploadFileWithMetadata() { try (FileInputStream fileInputStream = new FileInputStream("/Users/xdclass/Desktop/dpan.sql");) { ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentType("text/plain"); amazonS3Client.putObject("ai-pan", "/meta/test5.txt", fileInputStream, objectMetadata); } } /** * 上传文件,输入流的方式 带上文件元数据 */ @Test @SneakyThrows public void testUploadFileWithMetadata2() { try (FileInputStream stream = new FileInputStream("/Users/xdclass/Desktop/dpan.sql");) { byte[] bytes = IOUtils.toByteArray(stream); ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentType("text/plain"); ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); // 上传 amazonS3Client.putObject("ai-pan", "/meta/testIO.txt", byteArrayInputStream, objectMetadata); } } /** * 获取文件 */ @Test @SneakyThrows public void testGetObject() { try (FileOutputStream fileOutputStream = new FileOutputStream(new File("/Users/xdclass/Desktop/test5.txt"));) { S3Object s3Object = amazonS3Client.getObject("ai-pan", "/meta/test5.txt"); s3Object.getObjectContent().transferTo(fileOutputStream); } } /** * 删除文件 */ @Test public void testDeleteObject() { amazonS3Client.deleteObject("ai-pan", "/meta/test5.txt"); } /** * 生成文件访问地址 */ @Test public void testGeneratePresignedUrl() { // 预签名url过期时间(ms) long PRE_SIGN_URL_EXPIRE = 60 * 10 * 1000L; // 计算预签名url的过期日期 Date expireDate = DateUtil.offsetMillisecond(new Date(), (int) PRE_SIGN_URL_EXPIRE); // 创建生成预签名url的请求,并设置过期时间和HTTP方法, withMethod是生成的URL访问方式 GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest("ai-pan", "/meta/test5.txt") .withExpiration(expireDate).withMethod(HttpMethod.GET); // 生成预签名url URL preSignedUrl = amazonS3Client.generatePresignedUrl(request); // 输出预签名url System.out.println(preSignedUrl.toString()); } ``` ### 存储引擎-设计模式案例实战和AI代码一键优化 #### 策略模式设计模式应用-文件存储引擎抽取方案 * 策略模式(Strategy Pattern) - 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换 - **定义共享接口**:首先定义共享接口,接口规定了所有支持的算法必须遵循的规则。 - **实现具体策略**:为这个接口提供多种不同的实现,每个实现代表一个具体的算法或行为。 - 比如 - 淘宝天猫双十一,正在搞活动有打折的、有满减的、有返利的等等,这些算法只是一种策略,并且是随时都可能互相替换的, - 我们就可以定义一组算法,将每个算法都封装起来,并且使它们之间可以互换 - **优点**: - **算法的封装**:策略模式将算法封装在独立的策略类中,使得算法可以独立于使用它们的客户端变化。 - **易于扩展**:新增算法时,只需新增一个实现了共享接口的策略类,无需修改原有代码。 - **简化单元测试**:可以单独对每个策略进行单元测试。 **缺点**: - **客户端需要知道所有策略类**:客户端需要了解所有策略类的存在,以便能够选择合适的策略。 - **增加系统复杂性**:如果策略类数量过多,可能会增加系统的复杂性。 - **角色** - Context上下文:屏蔽高层模块对策略、算法的直接访问,封装可能存在的变化【不复杂可以去除】 - Strategy策略角色:抽象策略角色,是对策略、算法家族的抽象,定义每个策略或算法必须具有的方法和属性 - ConcreteStrategy具体策略角色:用于实现抽象策略中的操作,即实现具体的算法 image-20241220112247685 * 应用场景 - 外出旅游,选择骑自行车、坐汽车、飞机等,每一种旅行方式都是一个策略 - 如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么可以使用策略模式 - 不希望暴露复杂的、与算法有关的数据结构,那么可以使用策略模式来封装算法 * 为什么要抽象存储引擎接口 * 将文件存储引擎的接口抽象出来,具体实现可以多种,提高系统的灵活性和可维护性。 * 允许我们根据不同的需求和环境(如开发、测试、生产)灵活切换不同的存储解决方案 * 优点 - **灵活性和可扩展性**:通过定义一个统一的存储接口,我们可以在不修改客户端代码的情况下引入新的存储解决方案。 - **解耦**:将存储逻辑从业务逻辑中解耦,使得存储引擎的变化不影响业务逻辑。 - **易于测试**:可以针对接口编写单元测试,而不必依赖具体的存储实现。 - **代码复用**:多个项目可以共享相同的存储接口,提高代码复用率。 - **简化维护**:统一的接口使得维护和更新存储逻辑变得更加简单。 * 缺点 - **复杂性增加**:需要额外定义接口和可能的抽象类,增加了系统的复杂性。 - **性能考虑**:接口调用可能引入额外的性能开销,尤其是在接口频繁调用的情况下。 - **实现一致性**:确保所有存储策略实现都遵循相同的接口规范,需要严格的代码审查和测试。 * 注意 * **其实aws-java-sdk-s3本身就是封装好了,支持多个存储的,为啥我们又要加一层呢???** * 假想下 * 万一我以后不用aws-java-sdk-s3,那岂不是四处要修改aws-java-sdk-s3的API方法 * 但如果我加了一层,其他地方使用的话,后续修改换别的SDK,我只需要修改我自己封装的那层即可 #### SpringBoot3.X整合MinIO存储AWS-S3封装 封装存储引擎接口设计【常规版】 * * 定义一个名为StorageEngine的接口,包含多个方法 * 可以跟进需求,实现`StorageEngine`接口的不同存储策略 - **LocalFileStorageEngine**:使用本地文件系统作为存储。 - **S3StorageEngine**:使用Amazon S3作为存储。 - **DatabaseStorageEngine**:使用数据库存储文件元数据和内容。 - **MinIOStorageEngine**:使用MinIO存储文件内容。 - ... * 使用策略模式的优势 - **客户端代码与存储实现解耦**:客户端代码只需与`StorageEngine`接口交互,不需要关心具体的存储细节。 - **易于切换存储策略**:根据不同的业务需求或环境(开发、测试、生产)灵活切换不同的存储策略。 - **支持A/B测试**:可以同时运行多个存储策略,进行性能和效果比较。 * 抽取文件操作相关接口 StoreEngine ```java public interface StoreEngine { /*=====================Bucket相关===========================*/ /** * 检查指定的存储桶是否存在于当前的存储系统中 * * @param bucketName 存储桶的名称 * @return 如果存储桶存在,则返回true;否则返回false */ boolean bucketExists(String bucketName); /** * 删除指定名称的存储桶 * * @param bucketName 存储桶的名称 * @return 如果存储桶删除成功,则返回true;否则返回false */ boolean removeBucket(String bucketName); /** * 创建一个新的存储桶 * * @param bucketName 新存储桶的名称 */ void createBucket(String bucketName); /** * 获取当前存储系统中的所有存储桶列表 * * @return 包含所有存储桶的列表 */ List getAllBucket(); /*===================文件处理相关=============================*/ /** * 列出指定桶中的所有对象 * * @param bucketName 桶名称 * @return 包含桶中所有对象摘要的列表 */ List listObjects(String bucketName); /** * 判断文件是否存在 */ boolean doesObjectExist(String bucketName, String objectKey); /** * 将本地文件上传到指定桶 * * @param bucketName 桶名称 * @param objectKey 上传后对象的名称 * @param localFileName 本地文件的路径 * @return 上传是否成功 */ boolean upload(String bucketName, String objectKey, String localFileName); /** * 将multipart文件上传到指定桶 * * @param bucketName 桶名称 * @param objectKey 上传后对象的名称 * @param file 要上传的multipart文件 * @return 上传是否成功 */ boolean upload(String bucketName, String objectKey, MultipartFile file); /** * 从指定桶中删除对象 * * @param bucketName 桶名称 * @param objectKey 要删除的对象的名称 * @return 删除是否成功 */ boolean delete(String bucketName, String objectKey); /*===================下载相关=============================*/ /** * 获取指定对象的下载URL * * @param bucketName 桶名称 * @param remoteFileName 对象的名称 * @param timeout URL的有效时长 * @param unit URL有效时长的时间单位 * @return 对象的下载URL */ String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit); /** * 将指定对象下载到HTTP响应中 * * @param bucketName 桶名称 * @param objectKey 对象的名称 * @param response HTTP响应对象,用于输出下载的对象 */ void download2Response(String bucketName, String objectKey, HttpServletResponse response); } ``` * 实现文件存储引擎操作相关接口StoreEngine ```java @Component @Slf4j public class MinioFileStoreEngine implements StoreEngine { @Resource private AmazonS3Client amazonS3Client; @Override public boolean bucketExists(String bucketName) { return amazonS3Client.doesBucketExistV2(bucketName); } @Override public boolean removeBucket(String bucketName) { try { if (bucketExists(bucketName)) { List objects = listObjects(bucketName); if (!objects.isEmpty()) { return false; } amazonS3Client.deleteBucket(bucketName); return !bucketExists(bucketName); } } catch (Exception e) { log.error("errorMsg={}", e.getMessage()); return false; } return false; } @Override public void createBucket(String bucketName) { if (bucketExists(bucketName)) { log.info("Bucket {} already exists.", bucketName); return; } try { Bucket bucket = amazonS3Client.createBucket(bucketName); log.info("Bucket {} created.", bucketName); } catch (Exception e) { log.error("errorMsg={}", e.getMessage()); } } @Override public List getAllBucket() { return amazonS3Client.listBuckets(); } @Override public List listObjects(String bucketName) { if (bucketExists(bucketName)) { ListObjectsV2Result result = amazonS3Client.listObjectsV2(bucketName); return result.getObjectSummaries(); } return List.of(); } @Override public boolean doesObjectExist(String bucketName, String objectKey) { return amazonS3Client.doesObjectExist(bucketName, objectKey); } @Override public boolean upload(String bucketName, String objectName, String localFileName) { try { File file = new File(localFileName); amazonS3Client.putObject(bucketName, objectName, file); return true; } catch (Exception e) { log.error("errorMsg={}", e.getMessage()); return false; } } @Override public boolean upload(String bucketName, String objectKey, MultipartFile file) { try { ObjectMetadata objectMetadata = new ObjectMetadata(); objectMetadata.setContentLength(file.getSize()); objectMetadata.setContentType(file.getContentType()); amazonS3Client.putObject(bucketName, objectKey, file.getInputStream(), objectMetadata); return true; } catch (Exception e) { log.error("errorMsg={}", e.getMessage()); return false; } } @Override public boolean delete(String bucketName, String objectKey) { try { amazonS3Client.deleteObject(bucketName, objectKey); return true; } catch (Exception e) { log.error("errorMsg={}", e); return false; } } @Override public String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit) { try { Date expiration = new Date(System.currentTimeMillis() + unit.toMillis(timeout)); return amazonS3Client.generatePresignedUrl(bucketName, remoteFileName, expiration).toString(); } catch (Exception e) { log.error("errorMsg {}", e); return null; } } @Override @SneakyThrows public void download2Response(String bucketName, String objectKey, HttpServletResponse response) { S3Object s3Object = amazonS3Client.getObject(bucketName, objectKey); response.setHeader("Content-Disposition", "attachment;filename=" + objectKey.substring(objectKey.lastIndexOf("/") + 1)); response.setContentType("application/force-download"); response.setCharacterEncoding("UTF-8"); IOUtils.copy(s3Object.getObjectContent(), response.getOutputStream()); } } ``` * **思考:上面代码有什么问题?哪里可以优化的** #### AI大模型编码效能提升-一键优化代码案例实战 * 上述潜在问题与风险 * 异常处理不一致: * 多个方法中使用了不同的异常处理逻辑,部分方法直接捕获 Exception,而没有具体处理特定的异常类型。这可能导致隐藏 * 潜在的错误信息。 * 异常日志记录不完整,只记录了 e.getMessage(),而没有记录完整的堆栈信息,不利于调试。 * 资源未关闭: * 在 download2Response 方法中,s3Object.getObjectContent() 返回的输入流没有关闭,可能会导致资源泄漏。 * 硬编码的响应头: * download2Response 方法中的响应头设置是硬编码的,缺乏灵活性和可配置性。 * 空返回值: * 多个方法在异常情况下返回 null 或 false,这可能会导致调用方需要额外的空值检查,增加了复杂性。 * 缺少边界条件检查: * upload 方法中没有对 localFileName 和 file 进行有效性检查,可能会导致 NullPointerException。 * S3 客户端实例化: * amazonS3Client 的实例化方式未明确,如果每次调用都创建新实例,可能会导致性能问题。 * 更多.... * **AI一键优化代码案例实战** * **注意** * **并非AI优化的代码可以直接使用,关系到Prompt编写、上下文等,务必要结合实际情况和代码审查再使用** * **可以辅助工程师更好的优化代码和发现问题,提高程序的健壮性** > /optimize 补充接口文档和参数注释,优化代码,统一异常和日志打印,不要使用自定义异常,出错的话log记录即可 ### AI智能化云盘数据库设计和逆向工程 #### AI智能化云盘文件存储设计和核心关系 * **思考:文件存储,如果老板让去负责,你会如何设计?假如你没接触过这个领域,看同行竞品** * 百度网盘 image-20241224104021085 * 智能云盘 image-20241224104920314 * 云盘存储相关设计说明 * 任何文件都有一个唯一标识,我们统一命名为 **identifier**,同个文件产生的标识是不变的 * 唯一标识(identifier)可以采用多个方案,也有对应的类库 * 哈希函数(如MD5、SHA-256) * 优点: * 唯一性:理论上 不同的文件内容会产生不同的哈希值,保证了标识的唯一性。 * 快速计算:哈希函数可以快速计算出文件的哈希值。 * 安全性:对于SHA-256等哈希算法,抗碰撞性较强,不易被篡改。 * 缺点: * 安全性问题:对于MD5,由于其抗碰撞性较弱,已经不推荐用于安全敏感的应用。 * 存储和比较:哈希值需要存储和比较,对于非常大的文件系统,这可能会增加存储和计算开销。 * 基于内容的指纹(如SimHash、Locality-Sensitive Hashing) * 优点: * 相似性检测:适用于检测相似或重复的文件,可以容忍文件内容的微小变化。 * 减少存储:通过减少哈希值的位数来减少存储需求。 * 缺点: * 计算复杂性:相比于简单的哈希函数,这些算法可能需要更复杂的计算。 * 误判率:在某些情况下可能会有误判,即不同的文件产生相同的指纹。 * 文件元数据组合 * 优点: * 简单易实现:通过文件的大小、创建时间、修改时间等元数据生成标识。 * 快速检索:基于元数据的检索通常很快。 * 缺点: * 非唯一性:不同的文件可能具有相同的元数据,特别是在文件被复制或修改的情况下。 * 不稳定性:文件的元数据(如修改时间)可能会改变,导致标识失效 * 方案:采用MD5, 相关标识可以前端和后端保持一定规则,前端上传的时候生成标识传递给后端 #### 账号表-文件表和关联关系表设计说明 * 三个关键表说明 * **account表**:存储用户的基本信息,如用户名、密码、头像等。这是用户身份验证和个性化设置的基础。 * **file表**:存储文件的元数据,包括文件名、大小、后缀、唯一标识符(MD5)等。主要用于跟踪文件的属性和文件的唯一性 * **account_file表**: * 存储用户与文件之间的关系,包括文件的层级结构(文件夹和子文件),以及文件的类型和大小等信息。 * 这个表允许一个用户有多个子文件和文件夹,并且可以表示文件的层级关系 * 如果没有`account_file`表, * 每个用户都重复上传,随着文件数量的增加,没有`account_file`表来组织文件结构,`file`表会变得非常大,性能问题 * 无法有效地表示文件和文件夹的层级结构 * 实现文件的移动、复制、删除等操作会变得复杂,因为没有一个明确的结构来跟踪文件的层级和用户关系 * 权限管理也会变得更加复杂,因为没有一个清晰的结构来定义哪些文件可以被哪些用户访问。 ![image-20241224112405905](./img/image-20241224112405905.png) * 智能化云盘设计的3个表理解清楚 * 账号表 * 记录账号相关基础信息 * 关键字段 ``` id 即后续用的 account_id username password role 用户角色 COMMON, ADMIN ``` * 账号文件关系表 * 记录对应账号下的文件和文件夹、关系等 * 关键字段 ``` id account_id 账号ID is_dir 是否是目录,0不是文件夹,1是文件夹 parent_id 上层文件夹ID,顶层文件夹为0 file_id 文件ID,真正存储的文件 file_name 文件名和实际存储的文件名区分开来,可能重命名 ``` * 文件表 * 记录文件相关的物理存储信息 ``` id 即file_id account_id 哪个账号上传的 file_name 文件名 object_key 文件的key, 格式 日期/md5.拓展名,比如 2024/11/13/921674fd-cdaf-459a-be7b-109469e7050d.png identifier 唯一标识,文件MD5 ``` #### AI智能化云盘数据库设计和字段说明 * 数据库ER图设计(**后续还有调整相关表结构**) ![image-20241224113826448](./img/image-20241224113826448.png) * 导入建表语句 #### 智能化云盘数据库逆向工程配置生成 * 配置数据库 ```java public class MyBatisPlusGenerator { public static void main(String[] args) { String userName = "root"; String password = "xx"; String serverInfo = "127.0.0.1:3306"; String targetModuleNamePath = "/"; String dbName = "ycloud-aipan"; String[] tables = { "account", "file","account_file","file_chunk", "file_suffix","file_type", "share", "share_file", "storage" }; // 使用 FastAutoGenerator 快速配置代码生成器 FastAutoGenerator.create("jdbc:mysql://"+serverInfo+"/"+dbName+"?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&tinyInt1isBit=true", userName, password) .globalConfig(builder -> { builder.author("everyone") // 设置作者 .commentDate("yyyy-MM-dd") .enableSpringdoc() .disableOpenDir() //禁止打开输出目录 .dateType(DateType.ONLY_DATE) //定义生成的实体类中日期类型 DateType.ONLY_DATE 默认值: DateType.TIME_PACK .outputDir(System.getProperty("user.dir") + targetModuleNamePath + "/src/main/java"); // 指定输出目录 }) .packageConfig(builder -> { builder.parent("org.ycloud.aipan") // 父包模块名 .entity("model") //Entity 包名 默认值:entity .mapper("mapper") //Mapper 包名 默认值:mapper .pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + targetModuleNamePath + "/src/main/resources/mapper")); // 设置mapperXml生成路,默认存放在mapper的xml下 }) .dataSourceConfig(builder -> {//Mysql下tinyint字段转换 builder.typeConvertHandler((globalConfig, typeRegistry, metaInfo) -> { if (JdbcType.TINYINT == metaInfo.getJdbcType()) { return DbColumnType.BOOLEAN; } return typeRegistry.getColumnType(metaInfo); }); }) .strategyConfig(builder -> { builder.addInclude(tables) // 设置需要生成的表名 可变参数 .entityBuilder()// Entity策略配置 .enableFileOverride() // 开启生成Entity层文件覆盖 .idType(IdType.ASSIGN_ID)//主键策略 雪花算法自动生成的id .enableLombok() //开启lombok .logicDeleteColumnName("del")// 说明逻辑删除是哪个字段 .enableTableFieldAnnotation()// 属性加上注解说明 .formatFileName("%sDO") //格式化生成的文件名称 .controllerBuilder().disable()// Controller策略配置,这里不生成Controller层 .serviceBuilder().disable()// Service策略配置,这里不生成Service层 .mapperBuilder()// Mapper策略配置 .enableFileOverride() // 开启生成Mapper层文件覆盖 .formatMapperFileName("%sMapper")// 格式化Mapper文件名称 .superClass(BaseMapper.class) //继承的父类 .enableBaseResultMap() // 开启生成resultMap, .enableBaseColumnList() // 开启生成Sql片段 .formatXmlFileName("%sMapper"); // 格式化xml文件名称 }) .templateConfig(builder -> { // 不生成Controller builder.disable(TemplateType.CONTROLLER,TemplateType.SERVICE,TemplateType.SERVICE_IMPL); }) .execute(); // 执行生成 } } ``` ### 账号模块开发和Knife4j接口文档配置 #### Knife4j接口文档工具 * 什么是Knife4j * 一个为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui。 * 提供了新的Web页面,更符合使用习惯和审美;补充了一些注解,扩展了原生Swagger的功能; * 是一个更小巧、轻量且功能强悍的接口文档管理工具 * 核心功能 * **文档说明**:详细列出接口文档的说明,包括接口地址、类型、请求示例、请求参数、响应示例、响应参数、响应码等信息。 * **在线调试**:提供在线接口联调功能,自动解析当前接口参数,返回接口响应内容、headers、响应时间、响应状态码等信息。 * **接口搜索**:提供强大的接口搜索功能,支持按接口地址、请求方法、接口描述等关键字进行搜索。 * **接口过滤**:提供接口过滤功能,可以根据接口分组、接口标签、接口地址等条件进行过滤。 * **自定义主题**:支持自定义主题,定制个性化的API文档界面。 * **丰富的扩展功能**:如接口排序、接口分组、接口标签等,进一步丰富了API文档管理的功能。 * 配置实战 * 添加依赖 ```xml com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 ``` * 创建配置类 ```java /** * Knife4j配置 ,默认是下面 *

* knife4j 访问地址:http://localhost:8080/doc.html * Swagger2.0访问地址:http://localhost:8080/swagger-ui.html * Swagger3.0访问地址:http://localhost:8080/swagger-ui/index.html */ @Slf4j @Configuration public class Knife4jConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("AI智能云盘系统 API") .version("1.0-SNAPSHOT") .description("AI智能云盘系统") .termsOfService("https://www.xxx.net") .license(new License().name("Apache 2.0").url("https://www.xxx.net")) // 添加作者信息 .contact(new Contact() .name("anonymity") // 替换为作者的名字 .email("anonymity@qq.com") // 替换为作者的电子邮件 .url("https://www.xxx.net") // 替换为作者的网站或个人资料链接 ) ); } } ``` * 配置Spring Boot控制台打印 ```java @Slf4j @SpringBootApplication public class CloudApplication { public static void main(String[] args) throws Exception { ConfigurableApplicationContext application = SpringApplication.run(CloudApplication.class, args); Environment env = application.getEnvironment(); log.info("\n----------------------------------------------------------\n\t" + "Application '{}' is running! Access URLs:\n\t" + "Local: \t\thttp://localhost:{}\n\t" + "External: \thttp://{}:{}\n\t" + "API文档: \thttp://{}:{}/doc.html\n" + "----------------------------------------------------------", env.getProperty("spring.application.name"), env.getProperty("server.port"), InetAddress.getLocalHost().getHostAddress(), env.getProperty("server.port"), InetAddress.getLocalHost().getHostAddress(), env.getProperty("server.port")); } } ``` #### 账号注册相关模块接口开发实战 * 需求 * 开发用户注册相关接口,手机号注册 * 内部使用, 不加验证码,如果需要对外则可以加入验证码逻辑 * 用户板块不做复杂权限或者多重校验处理等 * 逻辑说明 * 根据手机号查询是否重复(或者唯一索引) * 密码加密处理 * 保存用户注册逻辑 * 其他逻辑(创建默认的存储空间,初始化根目录) * 编码实战: > 编写`AccountController,AccountRegisterReq,AccountService,AccountConfig`... ```sql CREATE TABLE `account` ( `id` bigint NOT NULL COMMENT 'ID', `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户名', `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '密码', `avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户头像', `phone` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号', `role` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'COMMON' COMMENT '用户角色 COMMON, ADMIN', `del` tinyint DEFAULT '0' COMMENT '逻辑删除(1删除 0未删除)', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `idx_phone_uni` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息表'; ``` #### 头像上传接口开发和MinIO权限配置 * 需求 * 开发头像上传接口,用户注册时候需要把头像url进行上传 * **存储到minio需要可以公开访问,和文件存储分开bucket** * 逻辑说明 * 文件上传接口 * 返回文件访问路径 * **配置minio的头像存储bucket存储权限为public** #### 网盘存储容量设计和根目录初始化配置 * 需求 * **问题一:新用户注册,有默认网盘存储容量,什么时候进行初始化?** * 答案 * 用户注册的时候一并配置相关的初始化内容 * 如果是简单场景:直接调用; 复杂场景:结合消息队列 * 类似场景大家可以思考下还有哪些,各大公司拉新活动折扣 image-20241225144548702 * **问题二:网盘文件存储有个根目录,这个如何进行设计?** * 上传文件的到根目录,这个相关的parent_id是怎么填写? * 答案:参考Linux操作系统,根目录也是一个目录 ![image-20241225143220117](./img/image-20241225143220117.png) * 开发编码实战:创建文件夹 ```java //3.创建默认的存储空间 StorageDO storageDO = new StorageDO(); storageDO.setAccountId(accountDO.getId()); storageDO.setUsedSize(0L); storageDO.setTotalSize(AccountConfig.DEFAULT_STORAGE_SIZE); storageMapper.insert(storageDO); //4.初始化根目录 FolderCreateReq createRootFolderReq = FolderCreateReq.builder() .accountId(accountDO.getId()) .parentId(AccountConfig.ROOT_PARENT_ID) .folderName(AccountConfig.ROOT_FOLDER_NAME) .build(); accountFileService.createFolder(createRootFolderReq); ``` #### 账号登录相关模块设计和开发实战 * 需求 * 开发用户登录模块 * 配置生成JWT * 编码实战 ```java //业务逻辑 public AccountDTO login(AccountLoginReq req) { String encryptPassword = DigestUtils.md5DigestAsHex(( AccountConfig.ACCOUNT_SALT+ req.getPassword()).getBytes()); QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("phone", req.getPhone()).eq("password", encryptPassword); AccountDO accountDO = accountMapper.selectOne(queryWrapper); return SpringBeanUtil.copyProperties(accountDO, AccountDTO.class); } //JWT工具 @Slf4j public class JwtUtil { // JWT的主题 private static final String LOGIN_SUBJECT = "XDCLASS"; /** * token有效期1小时 */ private static final Long SHARE_TOKEN_EXPIRE = 1000L * 60 * 60L; //注意这个密钥长度需要足够长, 推荐:JWT的密钥,从环境变量中获取 private final static String SECRET_KEY = "xdclass.net168xdclass.net168xdclass.net168xdclass.net168"; // 签名算法 private final static SecureDigestAlgorithm ALGORITHM = Jwts.SIG.HS256; // 使用密钥 private final static SecretKey KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes()); // token过期时间,30天 private static final long EXPIRED = 1000 * 60 * 60 * 24 * 7; /** * 生成JWT * @param accountDTO 登录账户信息 * @return 生成的JWT字符串 * @throws NullPointerException 如果传入的accountDTO为空 */ public static String geneLoginJWT(AccountDTO accountDTO) { if (accountDTO == null) { throw new NullPointerException("对象为空"); } // 创建 JWT token String token = Jwts.builder() .subject(LOGIN_SUBJECT) .claim("accountId", accountDTO.getId()) .claim("username", accountDTO.getUsername()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + EXPIRED)) .signWith(KEY, ALGORITHM) // 直接使用KEY即可 .compact(); // 添加自定义前缀 return addPrefix(token); } /** * 校验JWT * @param token JWT字符串 * @return JWT的Claims部分 * @throws IllegalArgumentException 如果传入的token为空或只包含空白字符 * @throws RuntimeException 如果JWT签名验证失败、JWT已过期或JWT解密失败 */ public static Claims checkLoginJWT(String token) { try { log.debug("开始校验 JWT: {}", token); // 校验 Token 是否为空 if (token == null || token.trim().isEmpty()) { log.error("Token 不能为空"); throw new IllegalArgumentException("Token 不能为空"); } token = token.trim(); // 移除前缀 token = removePrefix(token); log.debug("移除前缀后的 Token: {}", token); // 解析 JWT Claims payload = Jwts.parser() .verifyWith(KEY) //设置签名的密钥, 使用相同的 KEY .build() .parseSignedClaims(token).getPayload(); log.info("JWT 解密成功,Claims: {}", payload); return payload; } catch (IllegalArgumentException e) { log.error("JWT 校验失败: {}", e.getMessage(), e); throw e; } catch (io.jsonwebtoken.security.SignatureException e) { log.error("JWT 签名验证失败: {}", e.getMessage(), e); throw new RuntimeException("JWT 签名验证失败", e); } catch (io.jsonwebtoken.ExpiredJwtException e) { log.error("JWT 已过期: {}", e.getMessage(), e); throw new RuntimeException("JWT 已过期", e); } catch (Exception e) { log.error("JWT 解密失败: {}", e.getMessage(), e); throw new RuntimeException("JWT 解密失败", e); } } /** * 给token添加前缀 * @param token 原始token字符串 * @return 添加前缀后的token字符串 */ private static String addPrefix(String token) { return LOGIN_SUBJECT + token; } /** * 移除token的前缀 * @param token 带前缀的token字符串 * @return 移除前缀后的token字符串 */ private static String removePrefix(String token) { if (token.startsWith(LOGIN_SUBJECT)) { return token.replace(LOGIN_SUBJECT, "").trim(); } return token; } } ``` #### 拦截器开发和ThreadLocal传递用户信息 * 需求 * 开发登录拦截器 解密JWT * 传递登录用户信息 * request的attribute传递 * threadLocal传递 * 配置拦截器放行路径开发配置 * ThreadLocal知识点说明 * 全称thread local variable(线程局部变量)功用非常简单,使用场合主要解决多线程中数据因并发产生不一致问题。 * ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某时间访问到的并不是同一个对象 * 注意:ThreadLocal不能使用原子类型,只能使用Object类型 ![image-20241225154054266](./img/image-20241225154054266.png) * 应用场景 * ThreadLocal 用作每个线程内需要独立保存信息,方便同个线程的其他方法获取该信息的场景。 * 每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到 * 类似于全局变量的概念 比如用户登录令牌解密后的信息传递(用户权限信息、从用户系统获取到的用户名、用户ID) * 编码实战 * 开发登录拦截器 解密JWT ```java @Component public class LoginInterceptor implements HandlerInterceptor { public static ThreadLocal threadLocal = new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 处理OPTIONS请求 if (HttpMethod.OPTIONS.toString().equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpStatus.NO_CONTENT.value()); return true; } // 从请求头或参数中获取token String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { token = request.getParameter("token"); } // 如果token存在,解析JWT if (StringUtils.isNotBlank(token)) { Claims claims = JwtUtil.checkLoginJWT(token); if (claims == null) { // 如果token无效,返回未登录的错误信息 CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN)); return false; } // 从JWT中提取用户信息 Long accountId = Long.valueOf( claims.get("accountId")+""); String userName = (String) claims.get("username"); // 创建 AccountDTO 对象 AccountDTO accountDTO = AccountDTO.builder() .id(accountId) .username(userName) .build(); // 将用户信息存入 ThreadLocal threadLocal.set(accountDTO); return true; } // 如果没有token,返回未登录的错误信息 CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN)); return false; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 清理 ThreadLocal 中的用户信息 threadLocal.remove(); } } ``` * 配置拦截器放行路径开发配置 ```java @Configuration @Slf4j public class InterceptorConfig implements WebMvcConfigurer { @Resource private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) //添加拦截的路径 .addPathPatterns("/api/account/*/**","/api/file/*/**","/api/share/*/**") //排除不拦截 .excludePathPatterns("/api/account/*/register","/api/account/*/login","/api/account/*/upload_avatar", "/api/share/*/check_share_code","/api/share/*/visit","/api/share/*/detail_no_code","/api/share/*/detail_with_code"); } } ``` #### 首页前后端交互逻辑和账号详情接口开发 * 需求 * 网盘存储首页进入,会触发哪些请求? image-20241224104920314 * 逻辑说明 * 步骤一 * 进入首页需要先获取用户的根目录文件夹ID * 通过根目录文件夹ID去获取对应的文件列表 * 步骤二 * 首页需要显示用户的存储空间 * 编码实战 ```java public AccountDTO queryDetail(Long accountId) { //账号详情 AccountDO accountDO = accountMapper.selectById(accountId); AccountDTO accountDTO = SpringBeanUtil.copyProperties(accountDO, AccountDTO.class); //存储信息 StorageDO storageDO = storageMapper.selectOne(new QueryWrapper().eq("account_id", accountId)); StorageDTO storageDTO = SpringBeanUtil.copyProperties(storageDO, StorageDTO.class); accountDTO.setStorageDTO(storageDTO); //根文件相关信息 AccountFileDO accountFileDO = accountFileMapper.selectOne(new QueryWrapper() .eq("account_id", accountId) .eq("parent_id", AccountConfig.ROOT_PARENT_ID)); // bug处理 if (accountFileDO != null) { accountDTO.setRootFileId(accountFileDO.getId()); accountDTO.setRootFileName(accountFileDO.getFileName()); } return accountDTO; } ``` #### AI编码-账号注册和登录单元测试生成 * 需求 * 利用AI编写账号注册和登录 * 验证相关接口逻辑 * 单元测试实战 > 操作:复制controller对应的接口,右键,选择生成测试 ### 技术架构图答案+AI接口文档快速生成 * 技术架构图 image-20241230155838690 AI补充接口文档和注释字段操作 * AI补充API接口文档 * `补充knife4j的接口文档配置内容,@Tag @Operation等注解,使用v3` * AI补充字段解释说明 * `补充knife4j接口文档信息,使用@Schema,使用v3,添加参数举例` ### 网盘文件模块基础设计和开发 #### 资源访问安全之web常见越权攻击和防范 * **越权攻击介绍** * 是Web应用程序中一种常见的漏洞,由于其存在范围广、危害 大, 列为Web应用十大安全隐患的第二名 * 指应用在检查授权时存在纰漏,使得攻击者在获得低权限用户账户后,利用一些方式绕过权限检查,访问或者操作其他用户 * 产生原因:主要是因为开发人员在对数据进行增、删、改、查询时对客户端请求的数据过分相信,而遗漏了权限的判定 * 比如网盘里面:分享、转存、查看文件的时候都容易触发 * **水平越权攻击** - 指的是攻击者通过某种手段获取了与自己权限相同的其他账户的访问权限。 - 用户A能够访问用户B的账户信息,尽管他们都是普通用户,但A不应该能够访问B的数据。 - 技术实现方式 - **参数篡改**: - 攻击者通过修改请求中的用户ID参数,尝试访问其他同级别用户的资源。 - 在电商系统中,用户A通过修改订单ID参数,尝试查看或修改用户B的订单信息。 - **会话劫持**: - 攻击者通过某种方式获取了其他用户的会话信息,从而冒充该用户进行操作,这可能导致水平越权问题。 - **利用前端安全漏洞**: - 如果前端安全措施不当,攻击者可能会通过修改前端显示的界面元素,如隐藏的URL或参数,来访问其他用户的数据。 * **水平越权攻击的防范**: - **权限验证**:确保每次数据访问都进行严格的权限验证。 - **数据隔离**:不同用户的数据应该在数据库层面进行隔离。 - **会话管理**:使用安全的会话管理机制,如HTTPS、Token等。 * **垂直越权攻击** - 指的是攻击者通过某种手段获取了更高权限的账户的访问权限。 - 普通用户获取了管理员账户或者更高的权限。 - 技术实现方式 - **权限配置错误**: - 由于系统配置不当,普通用户能够执行管理员级别的操作,例如通过修改请求中的权限参数来提升权限。 - **利用系统漏洞**: - 攻击者利用系统或应用程序的漏洞提升权限,例如通过SQL注入攻击来执行管理员级别的数据库操作。 - **多阶段功能滥用**: - 在多阶段功能实现中,如果后续阶段不再验证用户身份,攻击者可能通过抓包修改参数值,实现越权操作,如修改任意用户密码 - **垂直越权攻击的防范**: - **最小权限原则**:用户和系统组件应该只拥有完成其任务所必需的最小权限。 - **权限审查**:定期审查权限设置,确保没有不必要的权限提升。 - **安全编码**:遵循安全编码实践,避免常见的安全漏洞,如SQL注入、跨站脚本(XSS)等。 - **安全审计**:实施安全审计,监控和记录关键操作,以便在发生安全事件时进行追踪。 * 智能化网盘项目里面的避免越权处理方案 * 相关文件数据处理,加入account_id确认 * 角色权限通过role进行确认操作 #### 文件模块开发之查询文件列表接口开发 * 需求 * 网盘存储首页进入,会触发哪些请求?**获取当前用户根目录文件夹** * 根据根目录文件夹查询对应的文件列表 * 进入相关的指定文件夹,查询对应的子文件 image-20241224104920314 * 注意事项 * 查询的时候都需要加入账号相关进行确认 **前面代码相对会简单点,逐步代码封装和抽取就会上升难度,** * 编码实战 ```java @GetMapping("list") public JsonData list(@RequestParam(value = "parent_id")Long parentId){ Long accountId = LoginInterceptor.threadLocal.get().getId(); List list = fileService.listFile(accountId,parentId); return JsonData.buildSuccess(list); } public List listFile(Long accountId, Long parentId) { List accountFileDOList = accountFileMapper.selectList(new QueryWrapper() .eq("account_id", accountId).eq("parent_id", parentId) .orderByDesc("is_dir") .orderByDesc("gmt_create") ); return SpringBeanUtil.copyProperties(accountFileDOList, AccountFileDTO.class); } ``` #### 创建文件夹相关接口设计和开发 * 需求 * 开发网盘里面可以创建文件夹 ![image-20241226151209235](img/image-20241226151209235.png) * 业务逻辑方法梳理(**哪些方法会其他地方复用**) * 检查父文件ID是否存在(抽) * 生成账号文件信息 * 检查文件名是否重复(抽) * 保存相关账号文件夹信息 * 编码实战 ```java @PostMapping("/create_folder") public JsonData createFolder(@RequestBody FolderCreateReq req){ req.setAccountId(LoginInterceptor.threadLocal.get().getId()); fileService.createFolder(req); return JsonData.buildSuccess(); } AccountFileDTO accountFileDTO = AccountFileDTO.builder().accountId(req.getAccountId()) .parentId(req.getParentId()) .fileName(req.getFolderName()) .isDir(FolderFlagEnum.YES.getCode()).build(); return saveAccountFile(accountFileDTO); ``` * 需求 * 处理用户和文件的映射存储,存储文件和文件夹都可以 * 编码实战 ```java /** * 处理用户和文件的映射存储,存储文件和文件夹都可以 *

* 1、检查父文件ID是否存在,避免越权 * 2、检查文件名是否重复 * 3、保存文件信息 * * @return */ private Long saveAccountFile(AccountFileDTO accountFileDTO) { //检查父文件ID是否存在 checkParentFileId(accountFileDTO); //存储文件信息 AccountFileDO accountFileDO = SpringBeanUtil.copyProperties(accountFileDTO, AccountFileDO.class); //检查文件名是否重复 processFileNameDuplicate(accountFileDO); accountFileMapper.insert(accountFileDO); return accountFileDO.getId(); } ``` #### 网盘文件重命名相关接口 * 需求 * 开发网盘文件重命名接口,包括文件夹和文件一样适用 image-20241226170055385 * 业务逻辑方法梳理 * 文件ID是否存在,避免越权 * 新旧文件名称不能一样 * 也不能用同层文件夹的名称,通过parent_id进行查询 * 编码实战 ```java @Override public void renameFile(FileUpdateReq req) { //文件ID是否存在,避免越权 AccountFileDO accountFileDO = accountFileMapper.selectOne(new QueryWrapper() .eq("id", req.getFileId()) .eq("account_id", req.getAccountId())); if (accountFileDO == null) { log.error("文件ID不存在,请检查:{}", req); throw new BizException(BizCodeEnum.FILE_NOT_EXISTS); } else { //新旧文件名称不能一样 if (Objects.equals(accountFileDO.getFileName(), req.getNewFilename())) { log.error("新旧文件名称不能一样,{}", req); throw new BizException(BizCodeEnum.FILE_RENAME_REPEAT); } else { //同层的文件或者文件夹也不能一样 Long selectCount = accountFileMapper.selectCount(new QueryWrapper() .eq("account_id", req.getAccountId()) .eq("parent_id", accountFileDO.getParentId()) .eq("file_name", req.getNewFilename())); if (selectCount > 0) { log.error("同层的文件或者文件夹也不能一样,{}", req); throw new BizException(BizCodeEnum.FILE_RENAME_REPEAT); } else { accountFileDO.setFileName(req.getNewFilename()); accountFileMapper.updateById(accountFileDO); } } } } ``` #### 接口测试工具-文件夹创建-查询-重命名接口测试 * 接口测试工具 * Apifox和Postman都是流行的API接口管理工具 * 选择哪个工具取决于具体的使用场景和需求 * 接口工具核心功能 * 支持多种HTTP请求方法(如GET、POST、PUT、DELETE等),允许用户设置请求头、请求体、查询参数等 * 环境变量允许用户存储和管理多个环境(如开发、测试、生产环境)的配置信息,便于在不同环境间切换 * 我们采用ApiFox录入相关接口进行测试 * 配置全局环境变量 * 录入相关接口模块 * bug修复 ```JAVA //bug1 /** * 检查父文件是否存在 * @param accountFileDTO */ private void checkParentFileId(AccountFileDTO accountFileDTO) { if(accountFileDTO.getParentId()!=0){ AccountFileDO accountFileDO = accountFileMapper.selectOne( new QueryWrapper() .eq("id", accountFileDTO.getParentId()) .eq("account_id", accountFileDTO.getAccountId())); if(accountFileDO == null){ throw new BizException(BizCodeEnum.FILE_NOT_EXISTS); } } } //bug2 @AllArgsConstructor @NoArgsConstructor public class AccountFileDTO //bug3 private void processFileNameDuplicate(AccountFileDO accountFileDO) { Long selectCount = accountFileMapper.selectCount(new QueryWrapper() .eq("account_id", accountFileDO.getAccountId()) .eq("parent_id", accountFileDO.getParentId()) .eq("is_dir", accountFileDO.getIsDir()) .eq("file_name", accountFileDO.getFileName())); if(selectCount>0){ //处理重复文件夹 if(Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())){ accountFileDO.setFileName(accountFileDO.getFileName()+"_"+System.currentTimeMillis()); }else { //处理重复文件名,提取文件拓展名 String[] split = accountFileDO.getFileName().split("\\."); accountFileDO.setFileName(split[0]+"_"+System.currentTimeMillis()+"."+split[1]); } } } ``` #### Swagger+Apifox 1. 使用AI将源码中的Controller接口和Req对象生成knife4j注释 :参考: AI补充接口文档和注释字段操作 2. 注册Apifox账号,配置 API 访问令牌 3. 在idea中安装Apifox插件,通过插件将对应的接口同步到Apifox。[快速上手 - Apifox 帮助文档](https://docs.apifox.com/doc-5743620) ### 查询文件树接口设计和文件操作进阶 #### 【难点】查询文件树接口应用场景和流程设计讲解 * 什么是文件树和应用场景 * 多层级展示文件夹列表和子文件夹 * 用途包括移动、复制、转存文件 * 开发这个接口有多种方式 * 递归和非递归,我们采用非递归,内存里面操作的方式 * 内存里面操作也有多种实现方式,比如分组或者遍历处理 ![image-20241226170536073](/img/image-20241226170536073.png) * 后端接口协议分析,倒推代码处理逻辑 ```json { "code": 0, "data": [ { "id": 1871837581885325314, "parentId": 0, "label": "全部文件夹", "children": [ { "id": 1871838400252755969, "parentId": 1871837581885325314, "label": "a2", "children": [ { "id": 1872208466167484418, "parentId": 1871838400252755969, "label": "b2", "children": [] }, { "id": 1872208451487420418, "parentId": 1871838400252755969, "label": "b1", "children": [ { "id": 1872208603140870145, "parentId": 1872208451487420418, "label": "c2(1)", "children": [] } }, { "id": 1872208573759770626, "parentId": 1872208451487420418, "label": "c1", "children": [] } ] }, { "id": 1872208480121933825, "parentId": 1871838400252755969, "label": "b3", "children": [] } ] }, { "id": 1871838384587030529, "parentId": 1871837581885325314, "label": "a1", "children": [] } ] } ], "msg": null, "success": true } ``` * 代码逻辑思路 * 查询用户的全部文件夹列表 ![image-20241226173942236](/img/image-20241226173942236.png) * 构建一个Map,key为文件夹ID,value为FolderTreeNodeDTO对象 ![image-20241226174734539](/img/image-20241226174734539.png) * 构建文件夹树,遍历文件夹映射,为每个文件夹找到其子文件夹 ![image-20241226181052870](/img/image-20241226181052870.png) * 返回根节点(parentId为0的节点)过滤出根文件夹即可 ![image-20241226181539405](/img/image-20241226181539405.png) #### 【难点】查询文件树接口编码案例实战 * 编码实战 ```json /** * 获取文件树接口,非递归方式 * 1、查询当前用户的所有文件夹 * 2、拼装文件夹树 * @param accountId * @return */ @Override public List fileTree(Long accountId) { // 查询当前用户的所有文件夹 List folderList = accountFileMapper.selectList(new QueryWrapper() .eq("account_id", accountId) .eq("is_dir", FolderFlagEnum.YES.getCode())); // 拼装文件夹树列表 if (CollectionUtils.isEmpty(folderList)) { return List.of(); } // 构建一个Map,key为文件夹ID,value为FolderTreeNodeDTO对象 Map folderMap = folderList.stream().collect(Collectors.toMap( AccountFileDO::getId, file -> FolderTreeNodeDTO.builder() .id(file.getId()) .label(file.getFileName()) .parentId(file.getParentId()) .children(new ArrayList<>()) .build() )); // 构建文件夹树,遍历文件夹映射,为每个文件夹找到其子文件夹 for (FolderTreeNodeDTO node : folderMap.values()) { // 获取当前文件夹的父ID Long parentId = node.getParentId(); // 如果父ID不为空且父ID在文件夹映射中存在,则将当前文件夹添加到其父文件夹的子文件夹列表中 if (parentId != null && folderMap.containsKey(parentId)) { // 获取父文件夹 FolderTreeNodeDTO folderTreeNodeDTO = folderMap.get(parentId); // 获取父文件夹的子文件夹列表 List children = folderTreeNodeDTO.getChildren(); // 将当前文件夹添加到子文件夹列表中 children.add(node); } } // 返回根节点(parentId为0的节点)过滤出根文件夹即可,里面包括多个 List folderTreeNodeDTOS = folderMap.values().stream() .filter(node -> Objects.equals(node.getParentId(), 0L)) .collect(Collectors.toList()); return folderTreeNodeDTOS; } ``` #### 查询文件树接断点调试和另外一种实现方式 **简介: 查询文件树接断点调试和另外一种实现方式** * 需求 * 断点调试查询文件树接口逻辑 * 编写另外一种文件树实现代码(思考哪种方式好) * 对比不同方式,多数据和少数据的优缺点 * 另一种文件树实现代码 ```json //查询当前用户的所有文件夹 List folderList = accountFileMapper.selectList(new QueryWrapper() .eq("account_id", accountId) .eq("is_dir", FolderFlagEnum.YES.getCode())); //拼装文件夹树列表 if (CollectionUtils.isEmpty(folderList)) { return List.of(); } List folderTreeNodeDTOS = folderList.stream().map(file->{ return FolderTreeNodeDTO.builder() .id(file.getId()) .label(file.getFileName()) .parentId(file.getParentId()) .children(new ArrayList<>()) .build(); }).toList(); //根据父文件夹进行分组 key是当前文件夹ID,value是当前文件夹下的所有子文件夹 Map> folderTreeNodeVOMap = folderTreeNodeDTOS.stream() .collect(Collectors.groupingBy(FolderTreeNodeDTO::getParentId)); for (FolderTreeNodeDTO node : folderTreeNodeDTOS) { List children = folderTreeNodeVOMap.get(node.getId()); //判断列表是否为空 if (!CollectionUtils.isEmpty(children)) { node.getChildren().addAll(children); } } return folderTreeNodeDTOS.stream().filter(node -> Objects.equals(node.getParentId(), 0L)).collect(Collectors.toList()); ``` #### 网盘小文件上传接口设计和开发 * 需求 * 文件上传分三部分接口:小文件上传、大文件上传、文件秒传 * 先开发:小文件上传接口 * 上传到存储引擎 * 保存文件信息 * 保存文件映射关系 * 编码实战 * 上传文件到存储引擎,返回存储的文件路径 ```json private String storeFile(FileUploadReq req) { String objectKey = CommonUtil.getFilePath(req.getFilename()); fileStoreEngine.upload(minioConfig.getBucketName(), objectKey, req.getFile()); return objectKey; } ``` * 保存文件信息 ```json private FileDO saveFile(FileUploadReq req, String storeFileObjectKey) { FileDO fileDO = new FileDO(); fileDO.setAccountId(req.getAccountId()); fileDO.setFileName(req.getFilename()); fileDO.setFileSize(req.getFile() != null ? req.getFile().getSize() : req.getFileSize()); fileDO.setFileSuffix(CommonUtil.getFileSuffix(req.getFilename())); fileDO.setIdentifier(req.getIdentifier()); fileDO.setObjectKey(storeFileObjectKey); fileMapper.insert(fileDO); return fileDO; } ``` * 保存文件映射关系 ```json AccountFileDTO accountFileDTO = AccountFileDTO.builder().fileName(req.getFilename()) .accountId(req.getAccountId()) .fileId(fileDO.getId()) .fileSize(fileDO.getFileSize()) .fileSuffix(fileDO.getFileSuffix()) .parentId(req.getParentId()) .isDir(FolderFlagEnum.NO.getCode()) .fileType(FileTypeEnum.fromExtension(fileDO.getFileSuffix()).name()) .build(); saveAccountFile(accountFileDTO); ``` * 文件枚举 ```json @Getter public enum FileTypeEnum { COMMON("common"), COMPRESS("compress"), EXCEL("excel"), WORD("word"), PDF("pdf"), TXT("txt"), IMG("img"), AUDIO("audio"), VIDEO("video"), PPT("ppt"), CODE("code"), CSV("csv"); private final String type; private static final Map EXTENSION_MAP = new HashMap<>(); static { for (FileTypeEnum fileType : values()) { switch (fileType) { case COMPRESS: EXTENSION_MAP.put("zip", fileType); EXTENSION_MAP.put("rar", fileType); EXTENSION_MAP.put("7z", fileType); break; case EXCEL: EXTENSION_MAP.put("xls", fileType); EXTENSION_MAP.put("xlsx", fileType); break; case WORD: EXTENSION_MAP.put("doc", fileType); EXTENSION_MAP.put("docx", fileType); break; case PDF: EXTENSION_MAP.put("pdf", fileType); break; case TXT: EXTENSION_MAP.put("txt", fileType); break; case IMG: EXTENSION_MAP.put("jpg", fileType); EXTENSION_MAP.put("jpeg", fileType); EXTENSION_MAP.put("png", fileType); EXTENSION_MAP.put("gif", fileType); EXTENSION_MAP.put("bmp", fileType); break; case AUDIO: EXTENSION_MAP.put("mp3", fileType); EXTENSION_MAP.put("wav", fileType); EXTENSION_MAP.put("aac", fileType); break; case VIDEO: EXTENSION_MAP.put("mp4", fileType); EXTENSION_MAP.put("avi", fileType); EXTENSION_MAP.put("mkv", fileType); break; case PPT: EXTENSION_MAP.put("ppt", fileType); EXTENSION_MAP.put("pptx", fileType); break; case CODE: EXTENSION_MAP.put("java", fileType); EXTENSION_MAP.put("c", fileType); EXTENSION_MAP.put("cpp", fileType); EXTENSION_MAP.put("py", fileType); EXTENSION_MAP.put("js", fileType); EXTENSION_MAP.put("html", fileType); EXTENSION_MAP.put("css", fileType); break; case CSV: EXTENSION_MAP.put("csv", fileType); break; default: break; } } } FileTypeEnum(String type) { this.type = type; } public static FileTypeEnum fromExtension(String extension) { if (extension == null || extension.isEmpty() || !isValidExtension(extension)) { return COMMON; } try { return EXTENSION_MAP.getOrDefault(extension.toLowerCase(), COMMON); } catch (NullPointerException e) { // 记录日志 System.err.println("Unexpected null pointer exception: " + e.getMessage()); return COMMON; } } private static boolean isValidExtension(String extension) { // 确保扩展名只包含字母和数字 return extension.matches("[a-zA-Z0-9]+"); } } ``` #### 网盘小文件上传接口测试验证 * 需求 * ApiFox测试文件上传接口 * 测试创建多个文件夹 * 对应的文件上传多个类型的文件 * 测试实战 #### 文件批量移动接口设计和开发 * 需求 * 批量操作对应的文件列表,移动到对应的目录下面 * 需要考虑什么?如何实现相关功能? image-20250102105611605 * 业务逻辑设计(哪些方法会复用) * 检查被转移的文件ID是否合法(复用) * 检查目标文件夹ID是否合法(复用) * 目标文件夹ID必须是当前用户的文件夹,不能是文件 * 要操作(移动、复制)的文件列表不能包含是目标文件夹的子文件夹,递归处理 * 批量转移文件到目标文件夹 * 处理重复文件名 * 更新文件或文件夹的parentId为目标文件夹ID * 编码实战 image-20250102112917137 ```java @Transactional(rollbackFor = Exception.class) public void moveBatch(FileBatchReq req) { //检查被转移的文件ID是否合法 List accountFileDOList = checkFileIdLegal(req.getFileIds(), req.getAccountId()); //检查目标文件夹ID是否合法,需要包括子文件夹 checkTargetParentIdLegal(req); //批量转移文件到目标文件夹 //处理重复文件名 accountFileDOList.forEach(this::processFileNameDuplicate); // 更新文件或文件夹的parentId为目标文件夹ID UpdateWrapper updateWrapper = new UpdateWrapper<>(); updateWrapper.in("id", req.getFileIds()) .set("parent_id", req.getTargetParentId()); int updatedCount = accountFileMapper.update(null, updateWrapper); if (updatedCount != req.getFileIds().size()) { throw new RuntimeException("部分文件或文件夹移动失败"); } } public List checkFileIdLegal(List fileIdList, Long accountId) { List accountFileDOList = accountFileMapper.selectList(new QueryWrapper() .in("id", fileIdList) .eq("account_id", accountId)); if (accountFileDOList.size() != fileIdList.size()) { log.error("文件ID数量不合法,请检查:accountId={},fileIdList={}", accountId, fileIdList); throw new BizException(BizCodeEnum.FILE_DEL_BATCH_ILLEGAL); } return accountFileDOList; } ``` * 需求 * 批量整理对应的文件列表,移动到对应的目录下面 * 需要考虑什么?如何实现相关功能? image-20250102105611605 * 编码实现 image-20250102112809176 * 检查父ID是否合法 ```java private void checkTargetParentIdLegal(FileBatchReq req) { //1、目标文件夹ID 必须是当前用户的文件夹,不能是文件 AccountFileDO targetParentFolder = accountFileMapper.selectOne(new QueryWrapper() .eq("id", req.getTargetParentId()) .eq("account_id", req.getAccountId()) .eq("is_dir", FolderFlagEnum.YES.getCode())); if (targetParentFolder == null) { log.error("目标文件夹不存在,目标文件夹ID:{}", req.getTargetParentId()); throw new BizException(BizCodeEnum.FILE_TARGET_PARENT_ILLEGAL); } /** * 2、要操作(移动、复制)的文件列表不能包含是目标文件夹的子文件夹 * 思路: * 1、查询批量操作中的文件夹和子文件夹,递归处理 * 2、判断是否在里面 */ //查询待批量操作中的文件夹和子文件夹 List prepareAccountFileDOList = accountFileMapper.selectList(new QueryWrapper() .in("id", req.getFileIds()) .eq("account_id", req.getAccountId())); List allAccountFileDOList = new ArrayList<>(); findAllAccountFileDOWithRecur(allAccountFileDOList, prepareAccountFileDOList, false); // 判断allAccountFileDOList是否包含目标夹的id if (allAccountFileDOList.stream().anyMatch(file -> file.getId().equals(req.getTargetParentId()))) { log.error("目标文件夹不能是源文件列表中的文件夹,目标文件夹ID:{},文件列表:{}", req.getTargetParentId(), req.getFileIds()); throw new BizException(BizCodeEnum.FILE_TARGET_PARENT_ILLEGAL); } } ``` #### 文件批量操作-递归接口设计和开发实战 * 什么是递归 * 允许函数调用自身来解决问题。 * 递归的基本思想是将一个复杂的问题分解成更小的、相似的子问题,直到这些子问题足够简单,可以直接解决 * **优点:包括代码简洁和优雅,比如对于某些问题(如树结构遍历、分治算法等)的处理逻辑更加简单** * 缺点:比如可能导致较大的内存消耗(因为每次函数调用都需要在调用栈上保存信息),在某些情况下可能不如迭代方法高效 * 递归通常包含两个主要部分: * **基本情况(Base Case)**:停止的条件,在每个递归调用中,都会检查是否达到了基本情况,如果是则停止递归返回结果。 * **递归步骤(Recursive Step)**:函数调用自身的过程。在这一步中问题被分解成更小的子问题,递归地解决这些子问题。 * 编码设计和逻辑说明 * 遍历文件列表:对传入的 accountFileDOList 进行遍历。 * 判断是否为文件夹:如果当前项是文件夹,则递归获取其子文件,并继续递归处理。 * 添加到结果列表:根据 onlyFolder 参数决定是否只添加文件夹,或者同时添加文件和文件夹 image-20250102113509986 * `findAllAccountFileDOWithRecur` 递归逻辑处理(多个地方会使用,封装方法) ```java @Override public void findAllAccountFileDOWithRecur(List allAccountFileDOList, List accountFileDOList, boolean onlyFolder) { for (AccountFileDO accountFileDO : accountFileDOList) { if (Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())) { //文件夹,递归获取子文件ID List childFileList = accountFileMapper.selectList(new QueryWrapper() .eq("parent_id", accountFileDO.getId())); findAllAccountFileDOWithRecur(allAccountFileDOList, childFileList, onlyFolder); } //如果通过onlyFolder是true只存储文件夹到allAccountFileDOList,否则都存储到allAccountFileDOList if (!onlyFolder || Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())) { allAccountFileDOList.add(accountFileDO); } } //return allAccountFileDOList; } ``` #### 文件批量移动接口测试验证实战 * 需求 * 测试文件批量移动 * bug修复 ![image-20250106111000289](/img/image-20250106111000289.png) * 数据准备 * 创建m1和m2文件夹, * m1文件夹上传多个文件,然后里面创建子文件夹 * 查看m1和m2文件夹相关数据 * 移动m1到m2文件夹 * 查看m1和m2文件夹相关数据