ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

五级行政区划数据实战:树形建模、清洗导入与地址选择器

五级行政区划数据实战:树形建模、清洗导入与地址选择器

简介:面向地域分析、地图服务与本地化业务开发者的全国五级行政区划数据集合,覆盖省、市、区县、乡镇及村委会层级,可解决应用中的标准行政区划编码与层级关联需求。压缩包为rar格式,共1个文件,主体是可在MySQL中创建并填充行政区划表的SQL脚本,包体约7.97MB,内含近77万条记录,字段通常涉及省、市、区县、乡镇、村等层级信息,便于直接导入后做关联查询。已有1074人学习下载,适合需要快速搭建地域基础表的中初级开发者参考。数据可用于地图位置检索、按区域展示天气、人口与经济统计、物流配送范围划分以及电商附近商家推荐等场景;配合建表语句和数据插入语句,读者能较快得到结构统一、层级完整的行政区划底表,并在此基础上叠加业务数据做筛选、统计与本地化服务开发。

1. 别急着建表:一套五级区划数据真正难的是“最后一公里”

地址录入页做到乡镇和村委会这一层,最常见的卡点不是下拉框样式,而是库里的层级断档:省、市、区县都有,再往下就只剩自由输入。全国5级行政区划数据把省、市、区县、乡镇、村委会将近77万条串成树,做地址选择器、配送范围、门店覆盖和客户地址清洗时,六级地区数据的口径也常在这里对齐。它适合后端、前端和做数据清洗的开发者,但别急着建表,先看清层级、编码和查询方式,后面才不会被“最后一公里”拖住。

2. 数据结构拆解:从省级到村委会的层级模型与字段设计

这套数据的第一价值不是“多”,而是“能挂成树”。77万条放在一张表里不算大,真正决定后面好不好用的是 parent_id 怎么落、区划代码怎么保真、层级怎么判断。我见过不少实现把省市区县做成三张表,到了乡镇和村委会又临时加一张,结果前端每选一级就要换一个接口,后端每加一层都要改代码。更稳的做法是一张主表加 level 字段,所有层级共用一套查询,前端只按 parent_id 懒加载。

2.1 五级层级与“六级地区数据”的口径对齐

先对齐口径。省、市、区县、乡镇、村委会是五级,资源标题写的是5级行政区划数据。有人嘴里说的“六级地区数据”,通常是把村委会下面的村民小组、网格、片区也算一级;也有人把“省、市、区县、街道/乡镇、社区/村、小区/村组”叫六级。口径不一致,表结构就会打架。我的处理方式是主表只承载资源包里真实存在的五级,第六级用扩展表挂上去,不硬塞进主表,也不假装原始数据已经覆盖。

level含义parent_id 指向常见用途
1省级0地址选择器第一屏
2地市级省级 id城市筛选、配送范围
3区县级地市级 id门店覆盖、行政区统计
4乡镇/街道区县级 id最后一公里配送
5村委会/社区乡镇 id地址补全、村级归属

这张表里没有放“第六级”,因为资源包的主干到村委会为止。要做六级地区数据,单独建扩展表更干净:扩展表只存村小组、网格、片区,parent_id 指向主表的村委会 id。这样主表保持稳定,后续换数据源也不会把五级结构搅乱。

2.2 核心表字段与层级编码设计

主表我一般会按下面这样建。字段看着多,但每个都有明确用途:id 给内部关联用,region_code 保住原始编码,parent_id 建树,path 做子树查询,full_name 给前端展示,pinyin 和 initial 给搜索兜底。原始包不一定带拼音和全称,可以在导入后补,别在业务查询时临时拼。

CREATE TABLE region ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级id,省级为0', region_code VARCHAR(20) NOT NULL COMMENT '原始区划编码,按字符串保存', region_name VARCHAR(100) NOT NULL COMMENT '当前层级名称', short_name VARCHAR(50) DEFAULT NULL COMMENT '简称,没有就留空', level TINYINT UNSIGNED NOT NULL COMMENT '1省 2市 3区县 4乡镇 5村委会', path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '祖先id路径,如/1/23/456/', full_name VARCHAR(255) DEFAULT NULL COMMENT '全路径名称,便于展示和搜索', pinyin VARCHAR(100) DEFAULT NULL COMMENT '拼音,可选', initial CHAR(1) DEFAULT NULL COMMENT '首字母,可选', sort_no INT NOT NULL DEFAULT 0 COMMENT '同级排序', status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0停用', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_code (region_code), KEY idx_parent (parent_id, status, sort_no), KEY idx_level (level), KEY idx_path (path(64)), KEY idx_name (region_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

这里有个关键选择:parent_id 存内部 id,不存 region_code。原因是有些数据包的上级编码列并不完整,甚至同一批数据里编码长度不统一;内部 id 由导入程序生成,父子关系一旦建好就稳定。region_code 加唯一索引,用来做增量更新和去重。path 存成/1/23/456/这种形式,查某个省下面所有节点时直接path LIKE '/1/%',比递归查询省事。

编码长度要按字符串处理,不要用 INT。区划代码常见做法是分级补位,省级2位、地市4位、区县6位、乡镇9位、村级12位,但不同数据源会有差异,以资源包实际字段为准。用 INT 会丢前导零,Excel 双击打开也会把长编码变成科学计数法,这个坑后面还会单独讲。

2.3 树形结构落库:邻接表、路径表与闭包表怎么选

树形结构常见的三种落库方式,放到77万条这个量级要分开看。邻接表就是 parent_id,优点是写入简单、移动节点方便,缺点是查整棵子树要递归。路径表是在邻接表上多加 path,查子树快,代价是移动父级时要批量更新子节点 path。闭包表单独存 ancestor/descendant 关系,查询最灵活,但77万条全量展开关系表会膨胀得很快,除非业务经常做任意层级祖先查询,否则没必要一上来就上闭包表。

我的选择是邻接表加 path。地址选择器是典型懒加载:点省查市,点市查区县,每次只查直接子节点,走idx_parent足够。运营后台要导出某个省下面所有村,走 path 前缀匹配也比递归 CTE 稳。移动节点的场景在区划数据里很少,真遇到行政区划调整,走增量脚本重算 path,比为了偶尔的移动把整张表做成闭包表划算。

提示:path 里存 id 不存名称,名称会变,id 不变。full_name 可以重算,path 是结构,别混在一起。

3. 跑起来第一步:把原始数据清洗并导入数据库

拿到数据包后不要直接往业务库里灌。先做一份中间清洗表,把字段名、层级、父级编码、前导零这些问题处理掉,再进正式表。77万条不算海量,但字段错一列、层级错一级,后面排查成本会翻倍。我一般分三步:原始文件转统一 CSV,生成 id 和 parent_id,导入 MySQL 后跑完整性校验。

3.1 原始字段识别与清洗脚本

不同来源的列名不一样,有的叫“区划代码”,有的叫 code,有的直接给“名称/上级代码”。先做一个字段别名映射,把原始行转成统一结构。下面这段脚本只做清洗和层级归一,不直接连库,方便你先在本地看结果。

import csv from pathlib import Path RAW = Path("raw.csv") OUT = Path("region_clean.csv") # 常见列名别名,拿到新数据包时优先改这里 FIELD_ALIAS = { "code": ["code", "区划代码", "行政区划代码", "编码"], "name": ["name", "名称", "区划名称", "地名"], "parent_code": ["parent_code", "父级代码", "上级代码", "pcode"], "level": ["level", "层级", "级别"], } LEVEL_MAP = { "省": 1, "省级": 1, "市": 2, "地市": 2, "地级市": 2, "区县": 3, "县": 3, "区": 3, "乡镇": 4, "街道": 4, "镇": 4, "乡": 4, "村": 5, "村委会": 5, "社区": 5, } def pick(row, names): for n in names: if n in row and row[n].strip(): return row[n].strip() return "" def main(): with RAW.open("r", encoding="utf-8-sig", newline="") as fin, \ OUT.open("w", encoding="utf-8", newline="") as fout: reader = csv.DictReader(fin) writer = csv.DictWriter( fout, fieldnames=["code", "name", "parent_code", "level"] ) writer.writeheader() for row in reader: code = pick(row, FIELD_ALIAS["code"]) name = pick(row, FIELD_ALIAS["name"]) parent = pick(row, FIELD_ALIAS["parent_code"]) level_raw = pick(row, FIELD_ALIAS["level"]) if not code or not name: continue level = LEVEL_MAP.get(level_raw) if level is None: # 没有层级列时用编码长度兜底,实际以数据包为准 level = {2: 1, 4: 2, 6: 3, 9: 4, 12: 5}.get(len(code)) writer.writerow({ "code": code, "name": name, "parent_code": parent, "level": level, }) if __name__ == "__main__": main()

这段脚本的重点有三个。第一,encoding="utf-8-sig"处理带 BOM 的 CSV,不然第一列列名可能带不可见字符,后面取不到值。第二,pick按别名顺序取第一个非空值,兼容不同列名。第三,LEVEL_MAP只做常见映射,遇到“街道”“镇”“乡”都归到第4级,“村委会”“社区”归到第5级;如果数据包有更细的分类,另加category字段,不要硬改 level。

清洗完先看两件事:code有没有重复,parent_code能不能在code集合里找到。前者决定唯一索引能不能建,后者决定树会不会断。可以在脚本里加一段统计,也可以在 Excel 里做透视,但别用 Excel 直接保存长编码,容易丢前导零。

3.2 MySQL 建表与批量导入

导入前把local_infile打开,CSV 列顺序和表字段对齐。下面这条LOAD DATA先灌基础字段,path 和 full_name 留到导入后统一回填,避免一边导入一边算路径。

LOAD DATA LOCAL INFILE '/tmp/region_import.csv' INTO TABLE region CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (id, parent_id, region_code, region_name, @level, @sort_no, @status) SET level = @level, sort_no = IFNULL(@sort_no, 0), status = IFNULL(@status, 1), path = '', full_name = region_name;

LOAD DATA比一条条 INSERT 快很多,77万条在普通开发机上几分钟能进完,具体看磁盘和索引。导入前可以先把非唯一索引删掉,灌完再建,能省不少时间。id和parent_id要在 CSV 里提前生成,不要指望数据库按名称自动关联,重名乡镇和重名村会让关联直接翻车。

生成 id 和 parent_id 的脚本可以接在清洗后面:先读全部行,建code -> id字典,再回写parent_id。

import csv rows = list(csv.DictReader(open("region_clean.csv", encoding="utf-8"))) code_to_id = {r["code"]: i for i, r in enumerate(rows, start=1)} for i, r in enumerate(rows, start=1): r["id"] = i r["parent_id"] = code_to_id.get(r["parent_code"], 0) with open("region_import.csv", "w", encoding="utf-8", newline="") as f: writer = csv.DictWriter( f, fieldnames=["id", "parent_id", "code", "name", "level"] ) writer.writeheader() for r in rows: writer.writerow({ "id": r["id"], "parent_id": r["parent_id"], "code": r["code"], "name": r["name"], "level": r["level"], })

这里parent_id=0表示找不到父级,省级节点也应该是0。导入后一定要查parent_id=0且 level>1 的记录,数量不为0就说明有孤儿节点。孤儿节点不一定是数据错,也可能是原始包把某个层级拆到了另一个文件,先定位再决定补父级还是剔除。

path 和 full_name 的回填我一般用 Python 按 level 从小到大跑一遍。父节点先处理,子节点才能拼出完整路径。77万条一次性executemany可能占内存,分批5000条更新更稳。

import pymysql conn = pymysql.connect( host="127.0.0.1", user="demo", password="demo", database="region_db", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cur: cur.execute("SELECT id, parent_id, region_name FROM region ORDER BY level, id") rows = cur.fetchall() path = {0: ""} full = {0: ""} updates = [] for r in rows: pid = r["parent_id"] path[r["id"]] = path.get(pid, "/") + f"{r['id']}/" full[r["id"]] = full.get(pid, "") + r["region_name"] updates.append((path[r["id"]], full[r["id"]], r["id"])) if len(updates) >= 5000: cur.executemany( "UPDATE region SET path=%s, full_name=%s WHERE id=%s", updates ) updates.clear() if updates: cur.executemany( "UPDATE region SET path=%s, full_name=%s WHERE id=%s", updates ) conn.commit() conn.close()

这段代码按level, id排序,保证父节点先出现在结果里。path用 id 拼接,full_name用名称拼接,两者分开维护。updates分批提交,避免一次攒77万条把内存顶上去。跑完后抽查一条村级记录,看path的斜杠数量是不是5段,full_name是不是从省到村的完整名称。

3.3 导入后的完整性与层级校验

导入完成别急着接前端,先跑四类校验:层级数量、孤儿节点、重复编码、path 与 level 是否匹配。下面几条 SQL 可以直接抄。

-- 1. 各层级数量,先看分布是否合理 SELECT level, COUNT(*) AS cnt FROM region GROUP BY level ORDER BY level; -- 2. 孤儿节点:非省级但父级不存在 SELECT COUNT(*) AS orphan_cnt FROM region c LEFT JOIN region p ON c.parent_id = p.id WHERE c.parent_id <> 0 AND p.id IS NULL; -- 3. 重复编码 SELECT region_code, COUNT(*) AS cnt FROM region GROUP BY region_code HAVING cnt > 1; -- 4. path 段数与 level 粗校验 SELECT COUNT(*) AS bad_path_cnt FROM region WHERE level = 5 AND (LENGTH(path) - LENGTH(REPLACE(path, '/', ''))) <> 5;

第2条查出孤儿节点,先按region_code回原始 CSV 找父级编码,再看父级是不是被过滤掉了。第3条查出重复编码,通常是前导零丢失或同一编码在多个文件重复出现。第4条是粗校验,村级 path 应该有5段斜杠;如果对不上,回填脚本的排序或 parent_id 有问题。校验通过后再建业务索引,顺序别反。

4. 查询与接口层:地址选择器、模糊搜索和按层级懒加载

数据导入只是底座,前端真正要的是“点一下省,返回市;点一下市,返回区县;输入关键词,返回带全路径的候选”。接口设计要围绕 parent_id 做,不要每个层级写一个/province、/city接口,否则第六级扩展时又要加一组。一个/regions加 parent_id 和 level 参数,前后端都省事。

4.1 地址选择器的懒加载接口

下面用 FastAPI 写一个最小接口,返回直接子节点,并带has_children给前端判断要不要显示下一级箭头。SQL 里用 EXISTS 子查询,走idx_parent,每次只查一层,77万条也不会慢。

from fastapi import FastAPI, Query from typing import Optional import pymysql app = FastAPI() def get_conn(): return pymysql.connect( host="127.0.0.1", user="demo", password="demo", database="region_db", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor ) @app.get("/regions") def list_regions( parent_id: int = Query(0, ge=0), level: Optional[int] = Query(None, ge=1, le=5), ): sql = """ SELECT r.id, r.parent_id, r.region_name, r.level, EXISTS( SELECT 1 FROM region c WHERE c.parent_id = r.id AND c.status = 1 ) AS has_children FROM region r WHERE r.status = 1 AND r.parent_id = %s """ args = [parent_id] if level is not None: sql += " AND r.level = %s" args.append(level) sql += " ORDER BY r.sort_no, r.id" conn = get_conn() try: with conn.cursor() as cur: cur.execute(sql, args) return cur.fetchall() finally: conn.close()

parent_id=0返回省级,前端第一屏直接调这个。点某个省时把该省 id 传进来,返回地市;再点地市,返回区县。has_children用来控制是否显示“继续选择”按钮,避免前端为了判断有没有下一级再发一次请求。level是可选参数,正常懒加载不需要传,做后台按层级导出时才用。

注意:不要让前端保存区划名称当外键。名称会重名,也会随区划调整变化,业务表存region_code或主表id,展示时再关联full_name。

4.2 模糊搜索:前缀、拼音与“六级地区数据”的扩展

搜索框是另一个高频入口。用户可能输“某镇”“某村”,也可能只输首字母。最稳的 SQL 是前缀匹配加层级排序,别一上来就LIKE '%关键词%',77万条全表扫描在并发下很难看。

SELECT id, region_name, full_name, level FROM region WHERE status = 1 AND ( region_name LIKE CONCAT(%s, '%') OR pinyin LIKE CONCAT(%s, '%') ) ORDER BY level DESC, CHAR_LENGTH(region_name), id LIMIT 20;

LIKE CONCAT(%s, '%')是前缀匹配,能走idx_name;pinyin字段如果建了索引也能走。排序把层级高的放前面,用户搜“某村”时更容易看到村级结果,再用full_name区分同名。真要做“包含搜索”,可以加 MySQL 的 ngram 全文索引,或者把搜索请求打到独立搜索引擎,但那是第二步,不要一开始就把简单查询复杂化。

六级地区数据扩展也在这层处理。主表只到村委会,扩展表region_ext挂村小组、网格、片区,接口多一个source=ext参数即可。下面这张扩展表只存第六级,parent_id 指向主表村委会 id。

CREATE TABLE region_ext ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, parent_id BIGINT UNSIGNED NOT NULL COMMENT '指向region.id的村委会', ext_name VARCHAR(100) NOT NULL, ext_code VARCHAR(20) DEFAULT NULL, sort_no INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_parent (parent_id, status, sort_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这样主表数据包升级时不用动扩展表,扩展表也不会污染五级结构。前端地址选择器在第五级后面多渲染一个“村组/网格”列,有数据就显示,没有就隐藏。

4.3 缓存、分页与热点数据

地址选择器的访问有明显热点:省级、地市、区县这几层被反复查,村级搜索相对分散。我的做法是给直接子节点加 Redis 缓存,key 按region:children:{parent_id}:{level}拼,TTL 设1小时,数据更新时主动删相关 key。根节点和第一、二级缓存命中率很高,能挡掉大部分重复查询。

import json import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) def cache_children(parent_id: int, level: int | None, rows: list): key = f"region:children:{parent_id}:{level or 0}" r.setex(key, 3600, json.dumps(rows, ensure_ascii=False)) def read_children(parent_id: int, level: int | None): key = f"region:children:{parent_id}:{level or 0}" val = r.get(key) return json.loads(val) if val else None

缓存里存接口返回的 JSON,读出来直接返回,不用再查库。数据更新脚本跑完后,按 parent_id 删region:children:{parent_id}:*,或者干脆按前缀扫一遍删掉。分页在区划接口里一般不需要,单层子节点数量有限;搜索接口要限制LIMIT,不要一次返回几百条。

5. 避坑与排查:77万条区划数据最容易翻车的5个地方

区划数据的坑很集中:层级、编码、重名、搜索、更新。下面这5条是我在地址类项目里反复见到的翻车现场,每条按“现象、原因、解决”拆开,照着排查能少走很多弯路。

5.1 层级断档:省下面直接挂区县,或村级找不到父级

现象是前端选完省,市的下拉框空着;或者后台导出某个市下面所有村,结果少了一大片。原因通常有两个:原始数据的parent_code列和code列不是同一套编码,或者导入时按名称关联父子,重名乡镇把关系挂错了。还有一种情况是清洗脚本把parent_code当数字读,前导零丢了,code_to_id字典里找不到父级,parent_id全变成0。

解决方式是先建code -> id的字典,再按parent_code回填parent_id,全程按字符串处理。导入后跑孤儿检查:

SELECT c.id, c.region_code, c.region_name, c.parent_id FROM region c LEFT JOIN region p ON c.parent_id = p.id WHERE c.parent_id <> 0 AND p.id IS NULL LIMIT 50;

查出来的记录回原始 CSV 找父级编码,看是父级缺失还是编码格式不一致。如果是层级列本身缺失,用编码长度兜底只适合结构规整的数据包,最好还是以原始层级列为准。

5.2 重名导致选错:搜索“某村”出现几十条一模一样的结果

现象是用户搜一个村名,下拉框里几十条同名记录,前端只显示region_name,根本分不清是哪个乡镇下面的。原因不是数据重复,而是展示字段太少。区划数据里同名乡镇、同名村很常见,只存名称、只展示名称,一定会选错。

解决方式是接口返回full_name和path,前端展示全路径,业务表存region_code或主表 id。搜索时加层级过滤,比如用户已经选了市,就在该市path前缀下搜,别在全表搜。SQL 可以这样收紧:

SELECT id, region_name, full_name, level FROM region WHERE status = 1 AND path LIKE CONCAT(%s, '%') AND region_name LIKE CONCAT(%s, '%') ORDER BY level DESC, id LIMIT 20;

path前缀把范围限定在已选父级下面,重名概率立刻降下来。

5.3 编码前导零丢失:导入后唯一键冲突或代码对不上

现象是导入时报Duplicate entry,或者导完后发现某些区划代码少了一位。原因多半是 CSV 被 Excel 打开后另存,长编码被当成数字,前导零没了;也可能是 Python 读 CSV 时没指定字符串类型,pandas 自动推断成 int。区划代码是标识,不是数值,不能参与加减乘除。

解决方式是建表用VARCHAR,读文件时全部按字符串处理。用 pandas 的话显式写dtype=str:

import pandas as pd df = pd.read_csv("raw.csv", dtype=str, keep_default_na=False) df["code"] = df["code"].str.strip() df["parent_code"] = df["parent_code"].str.strip()

keep_default_na=False避免空值变成NaN后被写成字符串"nan"。导入前抽查几条长编码,确认位数和原始文件一致,再跑唯一索引。

5.4 搜索接口慢:LIKE '%关键词%'把77万条扫了一遍

现象是地址搜索框输入两个字,接口要一两秒,并发一上来直接超时。原因很直接:前置%让 B-Tree 索引失效,77万条全表扫描,再加上排序和full_name拼接,耗时更高。地址搜索是高频操作,不能靠加机器硬扛。

解决方式是优先做前缀搜索,把LIKE '%关键词%'改成LIKE '关键词%',让idx_name生效。拼音搜索也按前缀走,pinyin LIKE 'mou%'。如果产品一定要包含搜索,再加 ngram 全文索引或独立搜索服务,别在主库上硬扫。用EXPLAIN看执行计划,重点看type是不是ALL,rows是不是接近全表。

EXPLAIN SELECT id, region_name, full_name FROM region WHERE region_name LIKE '某%' LIMIT 20;

执行计划里出现range或ref才算走上索引。搜索接口还要限制返回条数,前端做防抖,输入停顿300毫秒再请求,能挡掉大量无效查询。

5.5 更新数据没有后悔药:truncate 重建导致业务 id 全变

现象是区划数据升级一次,历史订单里的地址关联全乱了,或者缓存里还是旧名称。原因是每次更新都TRUNCATE region再全量导入,自增 id 重新生成,业务表存的老 id 全对不上。区划数据不是日志,不能这么干。

解决方式是用region_code做 upsert,保留已有 id,新增的插入,删除的标停用而不是物理删除。更新前把旧表备份一份,更新后跑孤儿和 path 校验,再按 parent_id 清缓存。下面这条 upsert 按唯一键更新名称、父级和层级:

INSERT INTO region (id, parent_id, region_code, region_name, level, path, full_name, status) VALUES (%s, %s, %s, %s, %s, '', %s, 1) ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), region_name = VALUES(region_name), level = VALUES(level), full_name = VALUES(full_name), status = 1;

父级变了要重算该节点及子节点的path,不能只更新当前行。更新脚本跑完,抽查几个有历史订单的region_code,确认 id 没变、名称已更新、path 还对得上。缓存删除按region:children:*前缀处理,别只删根节点。

6. 二次开发:把静态区划表改成可维护的地址中台

这份数据拿来做地址选择器只是第一步,真要长期用,得把它当成“可维护的地址中台”来改。我的改造顺序是:先加变更日志,再做增量导入,最后把查询接口收口。变更日志表不用复杂,记录region_code、变更类型、旧父级、新父级、变更时间即可,出问题时能回放。

改造点做法验证方式
增量更新按 region_code upsert,保留 id对比更新前后 count 和抽样 id
变更日志记录新增、停用、父级变化回放一条区划调整
接口收口统一/regions和/regions/search前端不再按层级写死接口
第六级扩展单独建 region_ext村委会下能查出村组

增量导入时,先把新数据清洗成同样结构,跑一遍code去重和孤儿检查,再进临时表。临时表和主表按region_code做 diff,新增的插入,消失的标status=0,父级变化的记日志并重算 path。不要直接覆盖主表,也不要每次重建自增 id。

INSERT INTO region_change_log (region_code, change_type, old_parent_id, new_parent_id, changed_at) SELECT n.region_code, 'parent_changed', o.parent_id, n.parent_id, NOW() FROM region_stage n JOIN region o ON o.region_code = n.region_code WHERE o.parent_id <> n.parent_id;

验证方法很土但有效:更新后跑一遍第3章的孤儿检查、重复编码检查和 path 段数检查,再抽三个有历史订单的地址,确认region_code能关联到新名称,id 没漂。缓存按 parent_id 清一遍,前端重新点一轮省市区县乡镇村。

从那以后我每次导区划数据,都先把编码按字符串读、先跑孤儿检查、再让业务接接口,顺序反一次就得花半天找 id。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表