简介这是一份面向iOS开发初学者与电商系统开发者的商品三级类目实现示例基于Objective-C与Storyboard构建帮助理解类目树在移动端的前端展示与数据组织方式。压缩包共23个文件约54KB包含7个.m实现文件、4个.h头文件、4个plist配置文件以及storyboard界面文件、xcscheme与pbxproj工程配置、json数据文件等覆盖视图控制器、自定义表格单元格、根视图与AppDelegate等核心模块可直接在Xcode中打开运行。资源围绕大类、中类、小类三层结构展开演示了逐级筛选定位商品的交互逻辑涉及类目树的动态展开、子类目加载与表格复用等常见实现思路便于读者对照代码理解导航优化、数据统计与精准营销背后的分类设计。目前已有672人学习下载适合作为电商类目模块的入门参考与课程练习素材。1. 商品三级类目从一张混乱的 Excel 到可查询的树电商后台里最容易被低估、又最容易翻车的一块数据就是商品三级类目。运营丢过来一张 Excel第一列写着「女装」第二列「连衣裙」第三列「雪纺连衣裙」第四列是商品 ID——看起来人畜无害等你把它灌进系统才发现同一个「连衣裙」在三个父类目下各出现一次商品挂错节点前端筛选直接空结果。商品三级类目本质是一棵深度固定为三层的树一级是行业大类二级是品类三级是叶子节点商品只能挂在三级上。它解决的是「商品放哪儿、怎么被筛出来、怎么统计」这三件事适合做商品中台、搜索推荐、类目运营的从业者。下面我按自己踩过的路径从建表、导入、校验到查询把这条链路讲透。2. 三级类目的数据模型为什么不用邻接表硬扛2.1 三种常见存储方案的取舍做类目树绕不开三种存法邻接表parent_id、路径枚举path、闭包表closure table。邻接表最直观一个 parent_id 字段搞定但查「某三级类目的所有祖先」要递归MySQL 8.0 之前只能靠应用层循环层级一深就是 N1 查询。路径枚举把祖先链存成1/12/103这样的字符串查祖先用 LIKE 前缀匹配写起来爽但改父节点要批量更新所有子孙的 path类目调整时容易漏。闭包表单独一张关系表存所有祖先-后代对查询最快代价是写入放大一个三级节点要插三条关系记录。我一般会这么选类目总量在几千以内、几乎不改结构用路径枚举类目上万、频繁做「查某节点的全部子孙」这种运营统计用闭包表如果只是个小系统邻接表加一层缓存就够了。三级类目深度固定其实路径枚举的维护成本被大大削弱因为最多就三段改父节点的概率也低。2.2 建表语句与字段含义下面这套表结构是我在多个项目里复用过的一级、二级、三级统一放一张表用 level 区分避免三张表 join 到吐血。CREATE TABLE category ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 类目主键, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT 父类目ID一级为0, level TINYINT NOT NULL COMMENT 层级1/2/3, name VARCHAR(64) NOT NULL COMMENT 类目名称, path VARCHAR(128) NOT NULL COMMENT 祖先路径如 1/12/103, sort_no INT NOT NULL DEFAULT 0 COMMENT 同级排序, is_leaf TINYINT NOT NULL DEFAULT 0 COMMENT 是否叶子商品只能挂叶子, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id), KEY idx_parent (parent_id), KEY idx_path (path), KEY idx_level_status (level, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品类目表;path存的是从一级到自己的 ID 链一级类目 path 就是它自己的 id二级是父id/自己id三级是一级id/二级id/自己id。is_leaf冗余一个字段是为了让商品挂载时不用再查子节点直接判断。sort_no控制前端展示顺序运营最爱调这个。idx_path让「查某二级下所有三级」变成WHERE path LIKE 12/%走索引。2.3 商品表怎么关联三级类目商品只挂三级所以商品表里存一个category_id指向 level3 的节点即可。但查询时经常要按一级或二级聚合这时候要么 join 类目表拿 path 再拆要么在商品表冗余cate_l1_id、cate_l2_id、cate_l3_id三个字段。我倾向冗余因为统计场景下 join 类目表再字符串拆分开销大而且类目改名不影响 ID。冗余的代价是类目调整时要同步刷商品这个用异步任务兜底。ALTER TABLE product ADD COLUMN cate_l1_id BIGINT NOT NULL DEFAULT 0 COMMENT 一级类目, ADD COLUMN cate_l2_id BIGINT NOT NULL DEFAULT 0 COMMENT 二级类目, ADD COLUMN cate_l3_id BIGINT NOT NULL DEFAULT 0 COMMENT 三级类目, ADD KEY idx_cate (cate_l1_id, cate_l2_id, cate_l3_id);这三个字段的写入时机在商品创建/编辑时由后端根据cate_l3_id反查 path 拆分填充绝不让前端传否则数据脏得没法救。3. 从 Excel 导入三级类目脚本、校验与幂等3.1 导入前的数据清洗规则运营给的 Excel 十有八九有坑一级类目名带空格、二级类目重名、三级类目为空、同一路径重复行。导入前先跑一遍清洗规则我固定这几条名称 trim 掉首尾空白和全角空格同一父节点下名称去重保留 sort_no 最小的三级为空的行直接丢弃并记日志路径完全重复的行只留一条。这些规则写成脚本比人工核对靠谱。3.2 Python 导入脚本与幂等处理导入最怕重复执行跑两遍就多出一堆重复类目。幂等的做法是以「父ID 名称」作为唯一键存在则更新不存在则插入。下面脚本用 pandas 读 Excel逐行 upsert。import pandas as pd from sqlalchemy import create_engine, text engine create_engine(mysqlpymysql://user:pwd127.0.0.1:3306/shop?charsetutf8mb4) def get_or_create(conn, parent_id, level, name, path_prefix, sort_no): # 以 parent_id name 为唯一键查询保证幂等 row conn.execute( text(SELECT id FROM category WHERE parent_id:p AND name:n), {p: parent_id, n: name} ).fetchone() if row: return row[0] path f{path_prefix}/{name} if path_prefix else name result conn.execute( text(INSERT INTO category(parent_id, level, name, path, sort_no, is_leaf) VALUES(:p, :l, :n, :path, :s, 0)), {p: parent_id, l: level, n: name, path: path, s: sort_no} ) return result.lastrowid df pd.read_excel(category.xlsx).fillna() with engine.begin() as conn: for _, r in df.iterrows(): l1 str(r[一级]).strip() l2 str(r[二级]).strip() l3 str(r[三级]).strip() if not l3: continue # 三级为空直接跳过 id1 get_or_create(conn, 0, 1, l1, , 0) id2 get_or_create(conn, id1, 2, l2, str(id1), 0) id3 get_or_create(conn, id2, 3, l3, f{id1}/{id2}, 0) # 标记叶子节点 conn.execute(text(UPDATE category SET is_leaf1 WHERE id:i), {i: id3})get_or_create是幂等核心先查后插重复跑不会产生脏数据。path用 ID 拼接而不是名称拼接避免类目改名后 path 失效。is_leaf在插入三级后单独更新因为插入时还不知道它是不是叶子。注意这里没做并发控制导入是离线任务单线程跑就够别为了性能上多线程把幂等搞崩。3.3 导入后的完整性校验 SQL导入完必须校验我固定跑这几条 SQL任何一条返回非空都要查。-- 1. 检查是否存在非叶子的三级节点三级必须 is_leaf1 SELECT id, name FROM category WHERE level3 AND is_leaf0; -- 2. 检查是否有孤儿节点parent_id 指向不存在的类目 SELECT c.id, c.name FROM category c LEFT JOIN category p ON c.parent_id p.id WHERE c.parent_id ! 0 AND p.id IS NULL; -- 3. 检查 path 与实际父子关系是否一致三级 path 应为 一级/二级/自己 SELECT id, path FROM category WHERE level3 AND path NOT LIKE %/%/%; -- 4. 检查同一父节点下是否有重名 SELECT parent_id, name, COUNT(*) FROM category GROUP BY parent_id, name HAVING COUNT(*) 1;第一条防的是导入脚本漏标叶子商品挂上去后前端筛不出来。第二条防的是父节点被删导致的悬空。第三条防 path 拼接逻辑写错。第四条防运营 Excel 里同名不同义被合并。这四条跑完干净数据基本可用。4. 三级类目查询与商品挂载把树用起来4.1 查某节点的全部子孙与祖先有了 path查子孙和祖先都是字符串操作不用递归。查「某二级下所有三级」SELECT id, name FROM category WHERE level3 AND path LIKE CONCAT(:l2_id, /%);查「某三级的所有祖先」SELECT id, name, level FROM category WHERE id IN ( SELECT SUBSTRING_INDEX(SUBSTRING_INDEX(:path, /, n), /, -1) FROM (SELECT 1 n UNION SELECT 2 UNION SELECT 3) t );这里用SUBSTRING_INDEX按/拆分 path因为层级固定三层写死 1、2、3 就够了不用动态生成序列。如果层级不固定就得用递归 CTE 或数字辅助表那是另一个话题。4.2 商品挂载时的校验逻辑商品挂三级类目时后端必须校验三件事目标类目存在且 status1目标类目 level3 且 is_leaf1根据目标类目的 path 反查出 l1、l2 冗余到商品表。校验不通过直接拒绝别让脏数据进库。def bind_category(conn, product_id, cate_l3_id): row conn.execute( text(SELECT id, level, is_leaf, status, path FROM category WHERE id:i), {i: cate_l3_id} ).fetchone() if not row: raise ValueError(类目不存在) if row.level ! 3 or row.is_leaf ! 1: raise ValueError(商品只能挂三级叶子类目) if row.status ! 1: raise ValueError(类目已停用) ids row.path.split(/) # [l1, l2, l3] conn.execute( text(UPDATE product SET cate_l1_id:a, cate_l2_id:b, cate_l3_id:c WHERE id:pid), {a: ids[0], b: ids[1], c: ids[2], pid: product_id} )path.split(/)拿到的三段正好对应三级直接写入商品冗余字段。这段逻辑放在商品创建和编辑的同一个事务里避免类目校验通过但商品更新失败导致的不一致。4.3 按一级类目聚合商品数的写法运营最常要的报表是「每个一级类目下有多少商品」。因为商品表冗余了 l1、l2、l3直接 group by 就行不用 join 类目表。SELECT c.name AS l1_name, COUNT(p.id) AS product_cnt FROM category c LEFT JOIN product p ON p.cate_l1_id c.id WHERE c.level 1 AND c.status 1 GROUP BY c.id, c.name ORDER BY product_cnt DESC;用 LEFT JOIN 保证没有商品的类目也显示 0运营看到空类目才知道要补品。如果商品量大这个查询会慢可以在商品表加(cate_l1_id, status)联合索引或者走离线数仓预聚合别在线上库硬扛。5. 三级类目避坑五条血泪记录5.1 现象前端筛选某三级类目返回空但后台明明有商品原因通常是商品挂的是二级类目或者挂的三级类目is_leaf0。早期我没加is_leaf校验运营在后台选类目时选到了二级商品存进去后前端按三级筛自然查不到。解决是挂载时强制校验 level3 且 is_leaf1同时把二级、一级节点在后台选择器里置灰不可选。5.2 现象类目改名后path 里的名称对不上查询错乱这是用名称拼 path 的经典翻车。我早期图省事path 存成女装/连衣裙/雪纺连衣裙结果运营把「女装」改成「女士服装」所有子孙 path 全失效。解决是 path 只存 ID名称只用于展示改名不影响结构。如果历史数据已经是名称 path写迁移脚本按 parent_id 重建。5.3 现象导入脚本跑第二遍类目数量翻倍没有幂等键每次 insert 都新增。解决是以parent_id name做唯一约束或者像 3.2 那样先查后插。更稳的做法是在数据库加唯一索引UNIQUE KEY uk_parent_name (parent_id, name)让数据库兜底脚本重复跑直接报唯一键冲突比应用层判断更可靠。5.4 现象删除一个二级类目底下三级商品全变孤儿物理删除类目是灾难。解决是类目只做逻辑删除status0删除前先检查是否有子节点或关联商品有就拒绝。如果非要物理删必须先迁移商品再删节点且用事务包住。我现在的习惯是类目表永远不物理删停用即可历史订单还要靠它回溯。5.5 现象按一级类目统计商品数结果比实际少多半是商品表的cate_l1_id没填或填错。冗余字段的代价就是可能不一致。解决是加对账任务定期跑product和category的 path 比对发现不一致的修正。另外商品编辑时如果只改了三级类目要确保 l1、l2 一起更新别只更新 l3。6. 三级类目的进阶技巧缓存、批量与对账类目树是典型的读多写少数据全量几千条直接塞进本地缓存最划算。我一般会在应用启动时把整棵树加载成MapLong, Category再构建MapLong, ListLong的父子索引查询子孙、祖先全走内存数据库只用于写入和兜底。缓存刷新用定时任务或类目变更时发消息别每次查询都打库。批量挂载商品时别一条条调bind_category那样 N 个商品就是 N 次类目查询。做法是先按cate_l3_id分组一次性查出所有涉及的类目构建 ID 到 path 的映射再批量 update 商品。下面这个批量校验的写法我常用def batch_bind(conn, items): # items: [(product_id, cate_l3_id), ...] cate_ids {c for _, c in items} rows conn.execute( text(SELECT id, level, is_leaf, status, path FROM category WHERE id IN :ids) .bindparams(idstuple(cate_ids)) ).fetchall() cate_map {r.id: r for r in rows} updates [] for pid, cid in items: c cate_map.get(cid) if not c or c.level ! 3 or c.is_leaf ! 1 or c.status ! 1: continue # 非法挂载跳过并记日志 l1, l2, l3 c.path.split(/) updates.append({a: l1, b: l2, c: l3, pid: pid}) if updates: conn.execute( text(UPDATE product SET cate_l1_id:a, cate_l2_id:b, cate_l3_id:c WHERE id:pid), updates )一次查询拿全部类目一次 executemany 更新全部商品比循环快一个数量级。注意IN :ids的写法依赖 SQLAlchemy 的 bindparams 展开不同驱动语法略有差异用之前先小批量验证。对账任务我固定每周跑一次核心是比对商品冗余字段和类目 path 是否一致SELECT p.id, p.cate_l1_id, p.cate_l2_id, p.cate_l3_id, c.path FROM product p JOIN category c ON p.cate_l3_id c.id WHERE c.path ! CONCAT(p.cate_l1_id, /, p.cate_l2_id, /, p.cate_l3_id);返回的行就是冗余字段和实际类目对不上的商品修正即可。这个查询在商品量大时慢可以加时间范围或分页跑别在业务高峰期执行。最后说个我自己的习惯类目表任何变更不管是导入、改名还是停用都先在一个只读副本上跑一遍校验 SQL确认四条完整性检查全过再推到主库。类目数据是商品体系的骨架骨架歪了后面搜索、推荐、报表全跟着歪。这个习惯帮我省过至少两次线上事故。希望帮到你。本文还有配套的精品资源点击获取