2026-10-04 08:56:40 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 全国行政区划(省 / 地级市 / 区县三级)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户 2026-10-04 第 4 条:「我们的地址,应该也是这样的,由:国家、省、市、区和详细地址组成。
|
|
|
|
|
|
* 如果没有非常详细的地址,则只记录国家、省、市和区,
|
|
|
|
|
|
* 需要把全国的行政区划,写入数据库中进行对应。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要有这张表:
|
|
|
|
|
|
* · 通讯录导进来的地址是一整串自由文本(「山东省青岛市市南区香港中路 76 号」),
|
|
|
|
|
|
* 不拆开就只能整串存 —— 于是"按省找人""看看哪些人在青岛"永远做不了;
|
|
|
|
|
|
* · 手工填地址时,省/市/区要有**级联下拉**,否则"市"那一栏会出现
|
|
|
|
|
|
* 「青岛市」和「山东青岛市」两种写法,等于没有结构化;
|
|
|
|
|
|
* · 拆出来的每一级都要能**对得上标准名**,才能与导入的数据互相匹配
|
|
|
|
|
|
* (不然导入的和手填的是两套写法,仍然是两套)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 数据来源:`assets/data/regions.json`(3429 条:31 省 / 342 市 / 3056 区县),
|
|
|
|
|
|
* 安装与结构升级时灌进 `regions` 表 —— **文件是种子,库是权威**。
|
|
|
|
|
|
* ⚠️ 只灌一次(按 COUNT(*) 判空),重复灌不会重复也不会覆盖用户改动。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 这里**只存中国大陆三级**。国家单独一个自由文本字段(addr_country):
|
|
|
|
|
|
* 国外的行政区划要维护到区县一级是另一件事,先不臆造。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Region
|
|
|
|
|
|
{
|
|
|
|
|
|
/** @var array<string,array{name:string,level:int,parent:string}>|null */
|
|
|
|
|
|
private static ?array $all = null;
|
|
|
|
|
|
|
|
|
|
|
|
private static function db(): \PDO
|
|
|
|
|
|
{
|
|
|
|
|
|
return Database::get();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 种子数据文件(相对站点根) */
|
|
|
|
|
|
public static function seedFile(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return dirname(__DIR__) . '/assets/data/regions.json';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把种子数据灌进 regions 表。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return int 本次真的插入的条数(已灌过则返回 0)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function seed(?\PDO $db = null): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = $db ?: self::db();
|
|
|
|
|
|
$n = (int)$db->query('SELECT COUNT(*) FROM `regions`')->fetchColumn();
|
|
|
|
|
|
if ($n > 0) {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
$file = self::seedFile();
|
|
|
|
|
|
$raw = @file_get_contents($file);
|
|
|
|
|
|
if ($raw === false) {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
$rows = json_decode($raw, true);
|
|
|
|
|
|
if (!is_array($rows)) {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⚠️ 一张表一次性灌 3429 条 —— 用事务 + 预编译,别一条一条 autocommit
|
|
|
|
|
|
// (三千次 fsync 会把安装拖到几十秒,看着像卡死)。
|
|
|
|
|
|
$db->beginTransaction();
|
|
|
|
|
|
// ⚠️ 用 INSERT IGNORE 而不是 INSERT:东莞、中山这类"市直管镇"的城市,
|
|
|
|
|
|
// 数据集里同一个 9 位代码会出现在两棵树下(区一级与镇一级),
|
|
|
|
|
|
// 用普通 INSERT 会在第 2170 行炸掉、把后面一千多条全丢掉
|
|
|
|
|
|
// (实测:库里只剩 19 个省,界面上"海南省之后全没了")。
|
|
|
|
|
|
// IGNORE 之后"先来的那条留下",总量与唯一性都对。
|
|
|
|
|
|
$st = $db->prepare(
|
|
|
|
|
|
'INSERT IGNORE INTO `regions` (`code`, `name`, `level`, `parent_code`) VALUES (?, ?, ?, ?)'
|
|
|
|
|
|
);
|
|
|
|
|
|
$done = 0;
|
|
|
|
|
|
foreach ($rows as $r) {
|
|
|
|
|
|
if (!is_array($r) || count($r) < 3) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$code = (string)$r[0];
|
|
|
|
|
|
$name = (string)$r[1];
|
|
|
|
|
|
$parent = (string)$r[2];
|
|
|
|
|
|
$level = $parent === '' ? 1 : (strlen($parent) <= 2 ? 2 : 3);
|
|
|
|
|
|
$st->execute([$code, $name, $level, $parent === '' ? null : $parent]);
|
|
|
|
|
|
$done++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$db->commit();
|
|
|
|
|
|
self::$all = null;
|
|
|
|
|
|
return $done;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 全表(一次请求内只读一次库) */
|
|
|
|
|
|
public static function all(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (self::$all !== null) {
|
|
|
|
|
|
return self::$all;
|
|
|
|
|
|
}
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
foreach (self::db()->query('SELECT code, name, level, parent_code FROM `regions`')->fetchAll() as $r) {
|
|
|
|
|
|
$out[(string)$r['code']] = [
|
|
|
|
|
|
'name' => (string)$r['name'],
|
|
|
|
|
|
'level' => (int)$r['level'],
|
|
|
|
|
|
'parent' => (string)($r['parent_code'] ?? ''),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (\Throwable $ex) {
|
|
|
|
|
|
$out = []; // 表还没建好(极早期):调用方拿到空列表,不至于整页 500
|
|
|
|
|
|
}
|
|
|
|
|
|
self::$all = $out;
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 省级列表:[['code'=>..,'name'=>..], ...] */
|
|
|
|
|
|
public static function provinces(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (self::all() as $code => $r) {
|
|
|
|
|
|
if ($r['level'] === 1) {
|
|
|
|
|
|
$out[] = ['code' => (string)$code, 'name' => $r['name']];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 某级的下级列表(省→市、市→区县) */
|
|
|
|
|
|
public static function childrenOf(string $code): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($code === '') {
|
|
|
|
|
|
return self::provinces();
|
|
|
|
|
|
}
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (self::all() as $c => $r) {
|
|
|
|
|
|
if ($r['parent'] === $code) {
|
|
|
|
|
|
$out[] = ['code' => $c, 'name' => $r['name']];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 代码 → 标准名 */
|
|
|
|
|
|
public static function nameOf(?string $code): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$code = (string)$code;
|
|
|
|
|
|
if ($code === '') {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$all = self::all();
|
|
|
|
|
|
return $all[$code]['name'] ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 代码 → 上级代码(往上找两级,用来回填「省」) */
|
|
|
|
|
|
public static function parentOf(?string $code): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$code = (string)$code;
|
|
|
|
|
|
$all = self::all();
|
|
|
|
|
|
return (string)($all[$code]['parent'] ?? '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把一整串自由文本地址拆成结构化的五段。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 判据是**从前往后逐级前缀匹配**:先认省、再在它下面认市、再在市下面认区县,
|
|
|
|
|
|
* 剩下的整串当详细地址。之所以只能这么干:中文地址没有分隔符,
|
|
|
|
|
|
* 「山东省青岛市市南区香港中路76号」全靠"哪个标准名出现在开头"来切。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 匹配要对**省/市/自治区的后缀**做容错:「山东省」和「山东」都要认,
|
|
|
|
|
|
* 「青岛市」和「青岛」都要认 —— 用户手填、通讯录导出、快递地址三种写法都不一样。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{country:?string,province:?string,city:?string,district:?string,detail:?string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function parse(string $text): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = ['country' => null, 'province' => null, 'city' => null,
|
|
|
|
|
|
'district' => null, 'detail' => null];
|
|
|
|
|
|
$text = trim(preg_replace('/\s+/u', ' ', (string)$text) ?? '');
|
|
|
|
|
|
if ($text === '') {
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ① 国家:只有**写在开头**的显式国家名才算(否则「中国」可能只是路名的一部分)
|
|
|
|
|
|
foreach (['中华人民共和国', '中国'] as $c) {
|
|
|
|
|
|
if (mb_strpos($text, $c) === 0) {
|
|
|
|
|
|
$out['country'] = '中国';
|
2026-10-07 11:35:31 +08:00
|
|
|
|
// ⚠️ 同下面 `parse()` 末尾那条注释:`ltrim(…, ' ,,')` 的 charlist
|
|
|
|
|
|
// **按字节**匹配,「,」= EF BC 8C → 字节 EF/BC/8C 也进了可剥集合,
|
|
|
|
|
|
// 会把以这些字节开头的汉字**剥掉首字节** → 非法 UTF-8。
|
|
|
|
|
|
// 改成字符级(`/u`)去空白与中文逗号。
|
|
|
|
|
|
$text = (string)preg_replace(
|
|
|
|
|
|
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($c)));
|
2026-10-04 08:56:40 +08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$rest = $text;
|
|
|
|
|
|
|
|
|
|
|
|
// ② 省
|
|
|
|
|
|
foreach (self::provinces() as $p) {
|
|
|
|
|
|
$hit = self::matchPrefix($rest, $p['name']);
|
|
|
|
|
|
if ($hit !== null) {
|
|
|
|
|
|
$out['province'] = $p['name'];
|
|
|
|
|
|
$rest = $hit;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ③ 市:优先在已认出的省下面找;省没认出来就在全国范围找
|
|
|
|
|
|
$pool = $out['province'] !== null
|
|
|
|
|
|
? self::childrenOf(self::codeOfName($out['province'], 1))
|
|
|
|
|
|
: self::allCities();
|
|
|
|
|
|
foreach ($pool as $c) {
|
|
|
|
|
|
$hit = self::matchPrefix($rest, $c['name']);
|
|
|
|
|
|
if ($hit !== null) {
|
|
|
|
|
|
$out['city'] = $c['name'];
|
|
|
|
|
|
$rest = $hit;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ④ 区县:在已认出的市下面找
|
|
|
|
|
|
if ($out['city'] !== null) {
|
|
|
|
|
|
foreach (self::childrenOf(self::codeOfName($out['city'], 2)) as $d) {
|
|
|
|
|
|
$hit = self::matchPrefix($rest, $d['name']);
|
|
|
|
|
|
if ($hit !== null) {
|
|
|
|
|
|
$out['district'] = $d['name'];
|
|
|
|
|
|
$rest = $hit;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:35:31 +08:00
|
|
|
|
/* 🔴🔴 这里**绝不能**写成 `trim($rest, " ,,。;;\t\n\r\0\x0B")`
|
|
|
|
|
|
* (v24 第 25 批 2026-10-07 在线上实测抓到的真 bug,且是**数据损坏级**的)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 根因:PHP 的 `trim()` 第二参数(charlist)是**按字节**匹配的,不是按字符。
|
|
|
|
|
|
* 上面那串里带三个**多字节**中文标点:
|
|
|
|
|
|
* 「,」= E3 80 8C、「。」= E3 80 82、「;」= EF BC 9B
|
|
|
|
|
|
* 于是字节集合里混进了 **80 / 82 / 8C / 9B / BC …** ——
|
|
|
|
|
|
* 任何一个**尾字节**恰好等于这些值的汉字,都会被\"剥掉最后一字节\":
|
|
|
|
|
|
* 「层」= E5 B1 82 → 被剥成 E5 B1(**非法 UTF-8**)
|
|
|
|
|
|
* 「,」类标点本身也会被剥掉前两字节,只剩 E3。
|
|
|
|
|
|
* 症状链条(线上实测):`backfillAddress` 拿这个坏 detail 去 UPDATE
|
|
|
|
|
|
* → `SQLSTATE[HY000] 1366 Incorrect string value: '\xE5\xB1' for column 'address'`
|
|
|
|
|
|
* → 升级步骤被 tryStep 捕获跳过 → **这条升级从上线起就没成功过**,
|
|
|
|
|
|
* 而且 request 每来一次就报一次错(日志一直刷,没人注意到)。
|
|
|
|
|
|
* ⚠️ 更阴的地方:它**只在特定数据上发作**(地址里带「层」「,」(半角在
|
|
|
|
|
|
* 中间不影响)等尾字节命中集合的字),本地夹具没有这种地址 → 本地全绿。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 正确做法:先把中文标点(多字节)**按字符**去掉,剩下的 ASCII 空白再交给 trim。
|
|
|
|
|
|
* 顺序不能反 —— 先 trim 会把中文标点剥成半个字节,再去字符级就没救了。
|
|
|
|
|
|
*/
|
|
|
|
|
|
$rest = (string)preg_replace('/^[\s,,。;;、]+|[\s,,。;;、]+$/u', '', $rest);
|
2026-10-04 08:56:40 +08:00
|
|
|
|
if ($rest !== '') {
|
|
|
|
|
|
$out['detail'] = $rest;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 全部地级市(省没认出来时的兜底池子) */
|
|
|
|
|
|
private static function allCities(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach (self::all() as $code => $r) {
|
|
|
|
|
|
if ($r['level'] === 2) {
|
|
|
|
|
|
$out[] = ['code' => (string)$code, 'name' => $r['name']];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 长名优先:否则「市辖区」会先于「市南区」被匹配掉
|
|
|
|
|
|
usort($out, static fn($a, $b) => mb_strlen($b['name']) <=> mb_strlen($a['name']));
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 标准名 → 代码(限某一级,避免「市辖区」那种重名撞车) */
|
|
|
|
|
|
public static function codeOfName(?string $name, int $level): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$name = trim((string)$name);
|
|
|
|
|
|
if ($name === '') {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach (self::all() as $code => $r) {
|
|
|
|
|
|
if ($r['level'] === $level && $r['name'] === $name) {
|
|
|
|
|
|
return (string)$code;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 看 $name 是不是 $text 的开头(容掉省/市/自治区这类后缀)。
|
|
|
|
|
|
* 命中返回**吃掉之后剩下的串**,没命中返回 null。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function matchPrefix(string $text, string $name): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$name = trim($name);
|
|
|
|
|
|
if ($name === '' || $text === '') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 「山东省」→ 也能认「山东」;「青岛市」→ 也能认「青岛」。
|
|
|
|
|
|
// ⚠️ 反过来不行(不能拿短名去吃长名),所以两种写法都试一遍。
|
|
|
|
|
|
$short = preg_replace('/(省|市|自治区|特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治州|地区|盟)$/u', '', $name);
|
|
|
|
|
|
$cands = [$name];
|
|
|
|
|
|
if ($short !== null && $short !== '' && $short !== $name) {
|
|
|
|
|
|
$cands[] = $short;
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($cands as $cand) {
|
|
|
|
|
|
if (mb_strlen($cand) < 2) {
|
|
|
|
|
|
continue; // 太短会误吃(如「东区」)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mb_strpos($text, $cand) === 0) {
|
2026-10-07 11:35:31 +08:00
|
|
|
|
return (string)preg_replace(
|
|
|
|
|
|
'/^[\s,,]+/u', '', mb_substr($text, mb_strlen($cand)));
|
2026-10-04 08:56:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|