
1. 从“京淘day03”这个标题里能读出什么看到“CGB2009-京淘项目day03”这个标题第一反应是这是一套体系化课程或训练营里的第三天内容项目代号“京淘”大概率是一个模拟电商平台的教学项目。CGB2009可能是班级编号或课程批次号day03则明确指向第三天的进度节点。这种命名方式在IT技能培训领域非常常见——用一个完整的电商项目贯穿始终每天推进一个模块学员跟着节奏从零搭建出一个具备核心功能的系统。电商项目作为教学载体有个天然优势业务逻辑足够复杂能覆盖技术栈的绝大多数核心知识点同时又足够直观每个人都是电商平台的用户理解需求不需要额外成本。第三天的内容通常处于什么位置按照常见的课程编排节奏第一天一般是环境搭建和项目初始化第二天开始进入基础模块开发第三天往往是一个关键转折点——要么是核心业务逻辑的深入要么是某个重要技术组件的引入。结合“京淘”这个项目名称和电商场景第三天大概率涉及商品管理、订单处理、用户认证或数据持久化这类核心模块的推进。关键词和摘要描述虽然为空但从标题本身可以推断出几个核心方向这是一个教学性质的实战项目有明确的进度规划技术栈大概率围绕Java生态展开国内IT培训电商项目的主流选择涉及后端开发、数据库操作、接口设计等典型任务。适合的读者群体包括正在跟着课程推进的学员、想通过电商项目练手的开发者、需要参考教学项目设计思路的讲师以及想了解电商系统核心模块实现方式的初级工程师。接下来的内容我会基于电商教学项目的常见实践把第三天可能涉及的核心技术点、实操步骤、容易踩的坑以及背后的设计逻辑逐一拆解。需要说明的是由于原始项目正文为空以下内容是基于“一个合格的电商教学项目在第三天应该包含什么”这一合理推断来展开的我会在涉及具体技术选型的地方说明为什么这样选择以及在实际操作中可能遇到什么情况。2. 第三天通常卡在哪个环节核心模块的推进逻辑2.1 为什么第三天往往是“分水岭”带过项目的人都有一个体会第一天大家兴致勃勃配环境第二天照着文档敲代码还算顺利到了第三天问题开始集中爆发。原因很简单——前两天的内容大多是独立的、线性的操作比如装JDK、配Maven、建数据库、写一个简单的增删改查。这些任务彼此之间耦合度低出了问题也容易定位。但第三天通常要开始做模块之间的联调或者引入新的技术组件复杂度陡然上升。以电商项目为例如果第一天是搭建工程骨架第二天是完成商品列表的查询接口那么第三天很可能要处理商品分类的树形结构、商品图片的上传与存储、或者商品规格参数的动态渲染。这些任务不再是单纯的CRUD而是涉及递归查询、文件IO、数据结构转换等更复杂的逻辑。学员在这个阶段最容易出现的情况是单个知识点都懂但组合起来就不知道从哪下手。另一个常见情况是第三天开始引入前端交互。前两天的接口用Postman测一测就完事了第三天可能要对接页面涉及跨域、参数序列化、日期格式、分页封装等问题。这些在真实开发中都是家常便饭但对初学者来说每一个都可能卡住半天。2.2 电商项目第三天最可能涉及的技术点基于电商教学项目的通用设计第三天大概率围绕以下几个方向展开商品分类管理。电商平台的商品分类通常不是简单的扁平列表而是多级树形结构。比如“家用电器”下面有“大家电”“厨房电器”再下面才是具体品类。实现树形结构有两种常见方案一种是数据库存parent_id递归查询另一种是用路径枚举或嵌套集。教学项目通常选第一种因为逻辑直观但递归查询在数据量大时性能堪忧第三天如果涉及这个一定会遇到“怎么一次性查出所有层级”的问题。商品图片上传。电商项目离不开图片。第三天很可能要做一个图片上传接口涉及文件存储路径规划、文件名生成策略、图片格式校验、大小限制等。如果项目用了分布式文件存储或对象存储服务还要处理SDK集成和配置管理。这里有个经典坑本地开发时图片存在项目目录下部署到服务器后路径变了图片全挂。商品规格与SKU。这是电商系统里最绕的一块。同一件商品有不同的颜色、尺码每种组合对应一个SKU价格和库存可能都不一样。第三天如果推进到这里前端要动态生成规格选择器后端要设计SKU表结构还要处理规格值的组合逻辑。很多学员在这一步开始怀疑人生因为表关系突然变复杂了。分页与条件查询。商品列表页通常需要支持按分类筛选、按价格排序、按关键词搜索还要分页。第三天可能要把这些条件组合起来写一个动态SQL。MyBatis的动态SQL标签这时候就派上用场了但if和choose嵌套多了容易写错测试的时候条件组合覆盖不全就会漏bug。2.3 判断第三天进度的几个信号如果你正在跟这个项目可以通过几个信号判断自己是否跟上了节奏能不能独立写出一个带分页和条件筛选的查询接口能不能解释清楚为什么分类表要设计成自关联能不能在不看文档的情况下配置好文件上传的路径和大小限制如果这些都能做到说明第三天的核心内容已经掌握如果卡在某个点上大概率是前两天的某个基础没打牢比如MyBatis的映射规则没搞清楚或者Spring的依赖注入理解不到位。3. 商品分类树形结构的实现与递归查询优化3.1 为什么分类表要设计成自关联电商平台的分类天然具有层级关系。最顶层是“图书”“服装”“数码”这样的大类每个大类下面有子类子类下面可能还有更细的划分。这种层级关系在关系型数据库里怎么表达最直接的方式就是自关联——分类表里加一个parent_id字段指向同一张表里的另一条记录。顶级分类的parent_id设为0或null。这种设计的优点是结构简单插入和移动节点都方便。缺点是查询整棵树的时候需要递归。比如要查“数码”下面所有层级的分类得先查parent_id等于数码ID的记录再拿这些记录的ID去查下一层直到没有子节点为止。在代码里就是一个递归函数在数据库里就是多次查询或者用递归CTE。教学项目通常不会用递归CTE因为MySQL 8.0之前不支持而且对初学者来说理解成本高。更常见的做法是在Java代码里递归组装。具体来说先一次性查出所有分类记录然后在内存里根据parent_id构建树。这样做的好处是数据库只查一次递归在内存里完成速度很快。缺点是如果分类数据量特别大比如上万条内存占用会比较高但电商平台的分类通常也就几百条完全没问题。3.2 一次性查询加内存组装的完整实现假设分类表的结构是这样的CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id INT DEFAULT 0, sort_order INT DEFAULT 0, status TINYINT DEFAULT 1 );要构建树形结构第一步是把所有有效分类查出来ListCategory allCategories categoryMapper.selectAll();然后在Service层做组装。核心思路是用一个Map把id和分类对象映射起来然后遍历列表把每个分类挂到父分类的children列表里public ListCategory buildTree(ListCategory categories) { MapInteger, Category map new HashMap(); for (Category c : categories) { map.put(c.getId(), c); c.setChildren(new ArrayList()); } ListCategory roots new ArrayList(); for (Category c : categories) { if (c.getParentId() 0) { roots.add(c); } else { Category parent map.get(c.getParentId()); if (parent ! null) { parent.getChildren().add(c); } } } return roots; }这段代码看起来简单但有几个细节容易出错。第一children列表必须在第一次遍历时就初始化否则后面add的时候会空指针。第二如果某个分类的parent_id指向了一个不存在的记录比如父分类被删了但子分类没清理map.get会返回null这时候要么跳过要么把它当成根节点处理具体看业务需求。第三排序问题——数据库查出来的顺序不一定符合展示要求如果每个层级需要按sort_order排序得在组装前先对列表排序或者在组装后对children列表单独排序。3.3 递归查询的替代方案与性能考量如果非要在数据库层面做递归MySQL 8.0以上可以用WITH RECURSIVEWITH RECURSIVE category_tree AS ( SELECT * FROM category WHERE parent_id 0 UNION ALL SELECT c.* FROM category c INNER JOIN category_tree ct ON c.parent_id ct.id ) SELECT * FROM category_tree;这种方式的好处是数据库直接返回树形结果Java代码不用递归。但缺点也很明显每次查询都要执行递归CTE如果分类层级深、数据量大性能不如一次性查出来在内存组装。而且很多教学项目用的MySQL版本可能低于8.0根本用不了这个语法。还有一种方案是路径枚举在表里加一个path字段存类似“1/5/12”这样的路径。查某个节点的所有子孙时用LIKE 1/5/%就能搞定。这种方案的查询效率很高但插入和移动节点时要更新所有子孙的path维护成本高。教学项目一般不会用因为理解起来更绕。实际开发中分类数据的特点是读多写少而且总量不大。所以最务实的做法就是一次性查全量、内存组装、加缓存。缓存可以用Redis或者本地缓存分类变动时清一下缓存就行。这样每次请求分类树的时候数据库压力几乎为零。注意如果分类数据量确实很大比如超过5000条内存组装的方式会导致每次请求都传输大量数据。这时候可以考虑按层级懒加载前端点开某个节点时再请求子分类。但电商平台的分类通常不会这么大全量加载完全可行。4. 商品图片上传的路径规划与常见故障4.1 文件存储路径的设计原则图片上传看起来简单但路径设计不好后期迁移和维护会很痛苦。教学项目里常见的做法是在项目目录下建一个upload文件夹图片按日期分目录存储比如upload/2025/01/15/xxx.jpg。这样做的好处是避免单个目录下文件过多导致文件系统性能下降同时也方便按时间清理旧文件。但这里有个坑如果项目打包成jar运行项目目录下的文件在重启后可能会丢失取决于部署方式。更稳妥的做法是把上传目录配置成外部路径通过配置文件指定比如file: upload-path: /data/upload/ access-url: http://localhost:8080/upload/upload-path是文件实际存储的物理路径access-url是前端访问图片的URL前缀。这样开发和部署时只需要改配置代码不用动。文件名生成策略也很关键。直接用原文件名会面临重名覆盖的问题而且中文文件名在某些环境下会乱码。常见的做法是用UUID加原扩展名String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.)); String newFilename UUID.randomUUID().toString() extension;这样生成的文件名唯一不会冲突。但缺点是丢失了原文件名的信息如果业务需要根据文件名搜索就不太方便。折中方案是用时间戳加随机数或者用商品ID加序号。4.2 上传接口的完整实现与参数校验一个健壮的上传接口至少要做以下几件事校验文件类型、限制文件大小、处理空文件、返回可访问的URL。以Spring Boot为例核心代码大概长这样PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件为空); } String contentType file.getContentType(); if (!allowedTypes.contains(contentType)) { return Result.error(不支持的文件类型); } if (file.getSize() maxSize) { return Result.error(文件超过大小限制); } // 生成路径和文件名保存文件 // 返回访问URL }allowedTypes通常包括image/jpeg、image/png、image/gif、image/webp。maxSize一般设2MB到5MB看业务需求。这里有个细节contentType是浏览器传过来的可以伪造所以严格来说还应该检查文件头魔数。但教学项目一般不做这么严知道有这个风险就行。保存文件的时候目录不存在要先创建File dest new File(uploadPath datePath newFilename); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest);transferTo方法在Spring Boot里可以直接用但要注意如果目标文件已存在会抛异常。用UUID生成文件名基本不会重复所以问题不大。4.3 图片访问不到排查这几个方向图片上传后访问不到是最常见的问题排查思路可以按以下顺序来第一检查物理文件是否真的写入了。去服务器上看upload目录下有没有对应的文件如果没有说明保存逻辑有问题可能是路径配置错了或者权限不够。第二检查静态资源映射是否配置了。Spring Boot默认只能访问classpath下的static目录外部目录需要手动配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }注意addResourceLocations里的路径要以file:开头而且结尾要带斜杠否则会拼接错误。第三检查返回的URL是否正确。如果access-url配置的是localhost前端在其他机器上访问就会失败。部署到服务器后要改成服务器的IP或域名。第四检查跨域问题。如果前端和后端不在同一个域图片请求可能被浏览器拦截。这时候要么配置CORS要么用Nginx做反向代理把图片请求转发到后端。提示开发阶段可以用浏览器的开发者工具看Network面板图片请求返回的是404还是403能快速定位是路径问题还是权限问题。5. 商品规格与SKU电商项目里最绕的一块5.1 规格、规格值、SKU三者的关系先理清概念。一件T恤有颜色和尺码两个规格颜色有红、蓝、黑三个值尺码有S、M、L三个值。这些规格和规格值的组合就形成了SKU比如“红色-S”“红色-M”“蓝色-L”等一共9个SKU。每个SKU有自己的价格、库存、甚至独立的图片。数据库设计通常需要三张表规格表specification、规格值表spec_value、SKU表sku。规格表存“颜色”“尺码”这样的维度规格值表存“红”“蓝”“S”“M”这样的具体值SKU表存组合后的结果并且关联商品ID。SKU表里怎么存规格组合常见做法是用一个字段存规格值的ID组合比如“1,4”表示颜色ID为1、尺码ID为4。查询的时候用FIND_IN_SET或者LIKE来匹配。这种设计简单但不够规范而且查询效率一般。更规范的做法是加一张SKU规格关联表每个SKU和规格值的关系存一行。但这样查询时要多一次join教学项目通常选简单的方案。5.2 前端动态生成规格选择器的逻辑前端拿到规格和规格值后要渲染成可点击的标签。用户每选一个规格值就要判断哪些SKU是有效的。比如用户选了“红色”那么尺码里只有存在“红色-S”“红色-M”“红色-L”的才可选其他尺码要置灰。这个逻辑的核心是维护一个已选规格值的集合然后遍历所有SKU看哪些SKU包含当前已选的所有规格值。如果某个SKU不包含那么它里面那些还没选的规格值就不能选。实现的时候可以用一个二维数组或者对象来记录可选状态每次选择变化时重新计算。这里有个性能问题如果规格多、规格值多SKU数量会爆炸。比如3个规格各5个值就是125个SKU。每次点击都遍历125个SKU在浏览器里其实还好但如果规格更多就要考虑优化。常见的优化是提前建立规格值到SKU的索引点击时直接查索引。5.3 后端SKU数据的存储与查询后端接收到SKU数据时通常是一个列表每个元素包含规格值组合、价格、库存等信息。保存的时候要注意事务因为SKU表和商品表要同时写入。如果某个SKU保存失败整个商品发布都要回滚。查询的时候商品详情页需要返回所有SKU及其规格信息。一种做法是查SKU表然后根据规格值ID去查规格值名称再组装返回。另一种做法是连表查询一次性把规格值名称也查出来。教学项目里连表查询更常见因为SQL写起来直观但要注意结果集去重因为一个SKU可能对应多个规格值join之后会产生多行。SELECT s.id, s.price, s.stock, sv.name AS spec_value_name, sp.name AS spec_name FROM sku s LEFT JOIN sku_spec_value ssv ON s.id ssv.sku_id LEFT JOIN spec_value sv ON ssv.spec_value_id sv.id LEFT JOIN specification sp ON sv.spec_id sp.id WHERE s.product_id ?这个查询返回的结果需要按SKU ID分组在Java里组装成嵌套结构。如果SKU数量多这个查询的结果集可能很大要考虑分页或者懒加载。6. 动态SQL与分页查询的实战细节6.1 MyBatis动态SQL的常见写法与陷阱商品列表查询通常需要支持多个可选条件分类ID、关键词、价格区间、排序方式。用MyBatis的XML写动态SQL是最常见的做法select idselectByCondition resultTypeProduct SELECT * FROM product where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if AND status 1 /where ORDER BY choose when testsort price_ascprice ASC/when when testsort price_descprice DESC/when otherwisecreate_time DESC/otherwise /choose /select这里有几个容易踩的坑。第一where标签会自动处理第一个AND但如果所有if都不成立where标签会变成空SQL就变成SELECT * FROM product ORDER BY...这是合法的。但如果where标签里只有AND没有其他条件就会出错。第二LIKE CONCAT的写法在MySQL里没问题但在其他数据库可能要改。第三ORDER BY用choose的时候如果sort参数是用户输入的要防止SQL注入最好用枚举值映射而不是直接拼字符串。6.2 分页查询的两种实现方式对比分页有两种做法内存分页和数据库分页。内存分页是查出所有数据然后在Java里截取数据量小的时候没问题数据量大了内存扛不住。数据库分页是用LIMIT offset, size只查当前页的数据。教学项目里通常用PageHelper插件它能在不修改SQL的情况下自动加LIMIT。原理是拦截MyBatis的查询先查总数再查当前页。用起来很简单PageHelper.startPage(pageNum, pageSize); ListProduct list productMapper.selectByCondition(condition); PageInfoProduct pageInfo new PageInfo(list);但PageHelper有个坑startPage之后必须紧跟查询语句中间不能插入其他数据库操作否则分页会应用到错误的查询上。另外如果查询本身很慢查总数会更慢因为count语句可能走不了索引。不用插件的话手写分页也不复杂SELECT * FROM product WHERE ... LIMIT #{offset}, #{pageSize} SELECT COUNT(*) FROM product WHERE ...offset (pageNum - 1) * pageSize。这种方式更可控但代码量多一些。6.3 条件组合的测试覆盖策略动态SQL的bug往往出现在条件组合的边界情况。比如只传分类ID不传关键词、传了关键词但分类ID为空、价格区间只传最小值不传最大值、所有条件都不传只分页。测试的时候至少要覆盖这些组合测试场景分类ID关键词价格区间预期结果仅分类有值空无返回该分类下商品仅关键词空有值无返回名称匹配商品分类关键词有值有值无返回交集仅价格下限空空有下限返回价格大于等于下限仅价格上限空空有上限返回价格小于等于上限全条件有值有值有区间返回精确匹配无条件空空无返回所有上架商品每一条都要实际请求接口验证不能只看SQL觉得没问题。我见过太多人SQL写得漂亮一测就发现某个条件没生效原因是if标签的test表达式写错了比如把! null写成了! 或者参数名和Java对象属性名不一致。7. 第三天之后的路该怎么走跟到第三天其实已经跨过了电商项目最基础的门槛。后面的内容无非是在这个骨架上不断加肉——购物车、订单、支付、秒杀、搜索。技术栈会越来越复杂但核心思路是一样的先理清业务关系再设计数据结构然后写接口最后联调测试。有几个习惯越早养成越好。第一每写一个接口先用Postman或者curl测通再对接前端不要等前端说有问题才去查。第二数据库表设计好之后先手动插入几条测试数据把查询SQL在客户端里跑一遍确认结果符合预期再写Java代码。第三遇到报错先看日志日志里通常有完整的堆栈信息比在网上搜“某某异常怎么解决”快得多。第三天卡住的人很多是因为前两天的基础不扎实。比如MyBatis的resultMap没搞明白导致字段映射不上或者Spring的注解没理解导致Bean注入失败。这时候与其硬着头皮往下走不如花半天时间把前面的内容重新过一遍。电商项目的知识点是层层递进的前面欠的债后面一定要还。最后说一个实际开发中的经验分类树、图片上传、SKU这些模块看起来是独立的但它们在商品详情页会汇聚到一起。商品详情页要展示分类路径、商品图片、规格选择器、SKU价格库存任何一个模块出问题页面就崩了。所以第三天之后一定要留时间做一次完整的联调把各个模块串起来跑通。这个过程比单独开发每个模块更能暴露问题也更能提升对系统的整体理解。