地区与 IP 库
这篇解决什么
省市区下拉、收货地址、登录 IP 落城市,都读同一套内存库,不建地区表。读完能改 AreaUtils / IPUtils、area.csv / ip2region.xdb,以及 /admin-api/system/area。
默认端口 48080,管理端前缀 /admin-api。菜单在系统管理 → 地区管理,路由 /system/area。
示意图:页面只调地区接口;省市区在 AreaUtils,IP 先查 xdb 再回到 Area。
代码位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| IP Starter | 启动时装 CSV 和 xdb 进内存 | ruoyi-office/yudao-framework/yudao-spring-boot-starter-biz-ip/ |
| 管理端接口 | 中国地区树、按 IP 查城市 | yudao-module-system-server/.../admin/ip/AreaController.java |
| 用户端接口 | 同样出树,@PermitAll | .../controller/app/ip/AppAreaController.java |
| 管理端页面 | 树表 +「IP 查询」弹窗 | ruoyi-office-vben/apps/web-antd/src/views/system/area/ |
| 前端 API | getAreaTree / getCityAreaTree / getAreaByIp | web-antd/src/api/system/area/index.ts |
| 级联组件 | 表单里绑一个 areaId | web-antd/src/components/area/area-cascader.vue |
| 菜单 | id 2083,组件 system/area/index | system_menu |
system、CRM、HRM、会员、商城交易都直接依赖这个 Starter。单体入口随 yudao-module-system-server 带上,不必在 yudao-server 再写一行。
地区
area.csv 大约 3879 行(含表头共 3880 行),列是 id,name,type,parentId。AreaUtils 静态块读完后挂父子,缓存在 Map<Integer, Area>。根节点是编号 0 的「全球」,中国是 1。
注释里的行政区划来源是 Administrative-divisions-of-China。改库只改这份 CSV,不要另做一张 system_area。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
Area | 内存节点:id / name / type / parent / children | .../ip/core/Area.java |
ID_GLOBAL | 0,全球根 | 同上 |
ID_CHINA | 1,中国 | 同上 |
AreaTypeEnum | 1 国家、2 省份、3 城市、4 区县 | .../ip/core/enums/AreaTypeEnum.java |
area.csv | 启动只读一次 | .../starter-biz-ip/src/main/resources/area.csv |
Area 没有 parentId 字段。CSV 要扫两遍:先建节点,再按第 4 列挂到父节点。

本系统截图:系统管理 → 地区管理,树根是省级(含直辖市、港澳)。
接口只走中国子树,国外国家行在 CSV 里,但 /tree 不会列出来。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
GET /system/area/tree | AreaUtils.getChinaAreaTree(maxType),再转 AreaNodeRespVO | AreaController |
maxType | 1~4,按 AreaTypeEnum 截断;薪酬城市传 3 | 同上;前端 getCityAreaTree() |
AreaNodeRespVO | id / name / type / children,不含 parent | .../admin/ip/vo/AreaNodeRespVO.java |
App /system/area/tree | 同样的树,免登录 | AppAreaController |
List<Area> tree = AreaUtils.getChinaAreaTree(maxTypeEnum);
return success(BeanUtils.toBean(tree, AreaNodeRespVO.class));getChinaAreaTree 会拷一份树。maxType 等于当前节点类型时不再挂孩子,所以 maxType=3 只到地级市。
AreaUtils
| 名称 | 说明 | 仓库路径 |
|---|---|---|
getArea(id) | 按编号取节点 | .../ip/core/utils/AreaUtils.java |
format(id) | 从当前节点拼到省,空格分隔 | 同上 |
format(id, "/") | 自定义分隔符,给 Excel、展示用 | 同上 |
parseArea("省/市/区") | 按 / 逐级找孩子 | 同上 |
getByType | 按类型滤全表,例如全部省份 | 同上 |
getParentIdByType | 朝阳区向上找到省 110000 | 同上 |
getAreaName | 只要本节点名字 | 同上 |
getAreaNodePathList | 展开成「省/市/区」路径列表 | 同上 |
format 遇到父节点是全球或中国就停,所以 format(1) 是「中国」,format(110105) 是「北京市 北京市 朝阳区」,前面不带「中国」。编号不存在返回 null。单测在 AreaUtilsTest。
AreaUtils.getArea(110100); // 北京市,类型城市
AreaUtils.format(110105); // 北京市 北京市 朝阳区
AreaUtils.format(110105, "/"); // 北京市/北京市/朝阳区
AreaUtils.parseArea("北京市/北京市/朝阳区"); // id = 110105
AreaUtils.getParentIdByType(110105, AreaTypeEnum.PROVINCE); // 110000业务表只存 areaId
CRM 客户、会员地址、商城收货地址都存整数编号,展示时再 AreaUtils.format。不要把「省市区」字符串写进业务表当主键。
会员地址 Convert 就是这样回填名称:
@Named("convertAreaIdToAreaName")
default String convertAreaIdToAreaName(Integer areaId) {
return AreaUtils.format(areaId);
}这段在 yudao-module-member-server 的 AddressConvert。CRM 列表同样调用 AreaUtils.format(customerVO.getAreaId())。
操作日志函数名 getArea 也走 format,类是 AreaParseFunction。
前端怎么选
| 名称 | 说明 | 仓库路径 |
|---|---|---|
AreaCascader | 拉完整树,v-model 只回写最后一级 areaId | web-antd/src/components/area/ |
表单设计器 AreaSelect | level 用 AreaLevelEnum 控制深度 | web-antd/src/components/form-create/components/area-select.vue |
| 薪酬城市 | getCityAreaTree(),maxType=3 | views/hrm/salary/_shared/form-helpers.ts |
| 移动端 | 同一套 /system/area/tree | ruoyi-office-uniapp/src/api/system/area/index.ts |
CRM 客户 / 线索 / 联系人、会员用户、商城订单和自提门店的表单都挂了 AreaCascader。
两套「层级」数字不一样
后端 AreaTypeEnum:国家 1、省 2、市 3、区 4。前端 AreaLevelEnum:省 1、市 2、区 3,只给表单设计器截层级。getAreaTree(maxType) 必须传后端那套,薪酬城市传 3 才是市。
IP
IPUtils 启动时把 ip2region.xdb(约 4MB)读进 Searcher.newWithBuffer。依赖是 org.lionsoul:ip2region 2.7.0。这份 xdb 的检索结果是地区编号字符串,不是默认的「国家|省|市」文本,所以能直接 Integer.parseInt 再进 AreaUtils。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
ip2region.xdb | 精简库,检索值对齐 Area.id | .../starter-biz-ip/src/main/resources/ip2region.xdb |
getAreaId(ip) | 127.0.0.1 这种点分地址 | .../ip/core/utils/IPUtils.java |
getAreaId(long) | Searcher.checkIP 转成的长整型 | 同上 |
getArea(...) | 先拿编号,再 AreaUtils.getArea | 同上 |
| 单测 | 120.202.4.50 → 420600 襄阳市 | IPUtilsTest |
Integer areaId = IPUtils.getAreaId("120.202.4.50"); // 420600
Area area = IPUtils.getArea("120.202.4.50"); // 襄阳市管理端「IP 查询」调 GET /system/area/get-by-ip?ip=。查不到返回「未知」,查到则 AreaUtils.format。

本系统截图:地区管理点「IP 查询」,输入公网地址后地址栏回填格式化地名。
登录日志、操作日志里的 userIp 只是字符串,来自 ServletUtils.getClientIP(),不会自动转城市。要落地区,自己再调 IPUtils.getArea(ip)。取客户端 IP 见 工具类。
内网 IP 不要当城市
127.0.0.1、10.*、192.168.* 在 xdb 里经常对不上正式区划,getArea 会得到 null,接口就返回「未知」。演示请用单测里的公网段。
导入导出
Excel 读「北京市/北京市/朝阳区」这种路径时,用 AreaConvert 调 parseArea,再把 area.getId() 转成字段类型。路径对不上会打 error 日志并得到 null。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
AreaConvert | 单元格文字 → areaId | yudao-spring-boot-starter-excel/.../convert/AreaConvert.java |
| CRM 下拉 | getAreaNodePathList(中国.children) | .../crm/.../AreaExcelColumnSelectFunction.java |
模板列必须用 / 拼接,和 format(id, "/") 一致。空格版 format(id) 解析不了。导入导出总览见 Excel 导入导出。
扩展时注意
改行政区划:替换 area.csv 后跑 AreaUtilsTest。直辖市会同时出现「北京市 / 北京市」,format 会打出两个「北京市」,这是数据如此,不要当成 bug 去重。
改 IP 库:xdb 的值必须仍是 Area.id。换官方原版 ip2region 库会把 Integer.parseInt 直接打崩。两份资源都随 Starter 打进 jar,更新后要重新编译该模块。
AreaUtils / IPUtils 都在静态块里 init,失败会抛 RuntimeException 让进程起不来。CSV 缺列、父子 id 相同、xdb 读不到,都属于启动失败,不是业务空数据。
统计按省聚合时,先 getParentIdByType(areaId, PROVINCE),再对 getByType(PROVINCE) 做补零。CRM 客户画像、会员地区统计都是这个写法。
配置与操作
管理员在 系统管理 → 地区管理 浏览树,没有单独的「导入地区」页。数据在 yudao-spring-boot-starter-biz-ip 启动时从 CSV / xdb 载入内存。
改库文件要换包并重启,页面保存不会热更新 IP 库。查询 IP 走 GET /system/area 的按 IP 接口。开启见 系统功能设计。截图见上文列表和查询。
