|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'] = '中国'; $text = ltrim(mb_substr($text, mb_strlen($c)), ' ,,'); 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; } } } $rest = trim($rest, " ,,。;;\t\n\r\0\x0B"); 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) { return ltrim(mb_substr($text, mb_strlen($cand)), ' ,,'); } } return null; } }