Files
FamilyBook/app/ContactImporter.php
T
laoyang ed122c034b v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 2026-10-15 七条。

① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
   **真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
     'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
     ⇒ 手机上每条行程的结束时间都等于开始。
   **附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
     铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
     而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
   修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
     (末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
     每节点恰好 1 条 VEVENT。
   ⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
     本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
     自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。

② 笔记详情页标题是"看笔记"而不是笔记名
   阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
   (编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。

③ 加段落/加图片要在**输入框下面**
   note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
   点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
   ⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
      挂 wrap 会变成"输入框右边的第三列",不是"下面"。
   ⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。

④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
   模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
   新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
   保存 POST notes/block → 只换这一块。
   服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
   ⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
   ⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
   权限与整条编辑同一把尺(find() + canWrite())。

⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
   新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
   身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
   people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
   (#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
   方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
   ⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
   导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
   名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
   PersonRoster::ensure(..., $meId, ...)。
   顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。

⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
   templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
   templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
   的 templates/_person_groups.php。
   ⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
   ⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
      用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
   ⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。

⑦ 个人设置:模块设置整合成一张表
   原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
   现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
   新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
   home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
   ⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
      (非管理员那列是只读色块;提交时服务端静默忽略 c[])。
   窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。

验证(新增 5 套件 + 更新若干旧断言):
 · _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
 · _b52_falsify.py(**12 注入 / 31 断言全抓**)
 · _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
 · e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
   旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致

⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
 · 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
 · **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
   全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
 · 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
 · 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
 · 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
2026-10-15 23:04:25 +08:00

354 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 把解析出来的联系人**落到名册里**(v24 用户 2026-10-03 第 1 条)。
*
* 与 App\ContactImport 的分工:
* · ContactImport —— 只管"文件 → 记录数组"(纯解析,不碰库);
* · 本类 —— 只管"记录数组 → 库里"(判重、补缺、建号、挂名册)。
* 分开的好处:解析器可以单独测(给一段 vCard 看输出),不需要库。
*
* 用户要求的两条规则(原话):
* 「导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
* 如果这些信息在系统中已经存在,则只导入其他信息。」
* 落成三条:
* ① **判重** 按 手机号(哈希,精确)→ 姓名(大小写无关,精确)依次找;
* ② 命中已有的人 → **只补空字段**,绝不覆盖(批量覆盖没法回退,见 ContactImport 的说明);
* ③ 没命中 → 新建一条,并加进导入者的名册。
*
* ⚠️ 判重范围是**整个家庭**(不只是我的名册):同一个家里"洪勇"只能有一条记录 ——
* 否则导入一次会造出一个重复的人,之后生日、关系、观影记录全都分成两半。
*/
declare(strict_types=1);
namespace App;
final class ContactImporter
{
/**
* @return array{added:int,updated:int,skipped:int,people:array<int,array{name:string,action:string,added:array}>}
*/
public static function run(int $familyId, int $meId, array $records, int $groupId = 0): array
{
$added = 0;
$updated = 0;
$skipped = 0;
$log = [];
// 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判)
// 索引键:phone_hash(精确)+ 姓名(小写)
$byPhone = [];
$byName = [];
foreach (Person::all($familyId, '', 'member', $meId) as $p) {
if (!empty($p['phone_hash'])) {
$byPhone[(string)$p['phone_hash']] = $p;
}
$n = mb_strtolower(trim((string)$p['name']), 'UTF-8');
if ($n !== '' && !isset($byName[$n])) {
$byName[$n] = $p;
}
}
foreach ($records as $r) {
$name = trim((string)($r['name'] ?? ''));
$phone = trim((string)($r['phone'] ?? ''));
if ($name === '' && $phone === '') {
$skipped++;
continue;
}
// ---- ① 判重:手机号优先(最精确),其次姓名 ----
$hit = null;
if ($phone !== '') {
$h = Crypto::hash($phone);
if (isset($byPhone[$h])) {
$hit = $byPhone[$h];
}
}
if ($hit === null && $name !== '') {
$k = mb_strtolower($name, 'UTF-8');
if (isset($byName[$k])) {
$hit = $byName[$k];
}
}
if ($hit !== null) {
// ---- ② 命中:只补空字段 ----
$filled = self::fillMissing($hit, $r, $meId);
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):「而用户导入数据时,**也是一样的**,
如果名册中有这个人,就不用再新增了,而是根据名字、手机号等信息
**自动匹配后添加到这个用户的名册中**。」
⇒ 命中已有的人时,**必须**把他也挂进**导入者的名册** ——
否则会出现最费解的一种结果:导入文件里有这个人、系统里也有这个人,
但导入完**我的名册里还是看不到他**(他只在别人名册里),
用户只会觉得"导入漏了人"。
⚠️ 这一段原先只在"没命中 → 新建"那条路上挂名册(见下面 ③ 的 ensure),
命中那条路**没有** —— 正是本批要补的缺口。
⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */
PersonRoster::ensure($familyId, $meId, (int)$hit['id'],
$groupId > 0 ? $groupId : null, 'self');
if ($filled) {
$updated++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled];
} else {
$skipped++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'same', 'added' => []];
}
continue;
}
// ---- ③ 没命中:新建 ----
$d = array_merge([
'name' => $name !== '' ? $name : $phone,
'phone' => $phone,
'email' => (string)($r['email'] ?? ''),
'org' => (string)($r['org'] ?? ''),
// 职务 + 职称合成一列(与 PersonJob::joinTitle 同一口径:
// 两个都有时用 " · " 分开,只有一个就只用那个)
'title' => PersonJob::joinTitle(
(string)($r['title'] ?? ''),
(string)($r['jobrank'] ?? '')
) ?? '',
'note' => (string)($r['note'] ?? ''),
'kind' => 'member',
'avatar' => '',
'birth_rank' => '',
'is_placeholder' => 0,
// 生日:解析器只给"能确定"的日期,给了就当阳历
'birthday_source' => 'solar',
'birthday_solar' => (string)($r['birthday'] ?? ''),
'birthday_lunar' => '',
'birthday_lunar_leap' => 0,
'birthday_id_card' => '',
], self::addrFields($r));
try {
$pid = Person::create($familyId, $d);
} catch (\Throwable $ex) {
$skipped++;
continue;
}
// 补充联系方式(除主手机号之外的那些)
$extra = [];
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
static fn($v) => trim((string)$v) !== '' && (string)$v !== $phone));
foreach ($extraPhones as $i => $v) {
$extra['phone'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
}
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
static fn($v) => trim((string)$v) !== '' && (string)$v !== (string)($r['email'] ?? '')));
foreach ($extraEmails as $i => $v) {
$extra['email'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
}
if (($r['url'] ?? '') !== '') {
$extra['url'][] = ['label' => '', 'value' => (string)$r['url']];
}
if ($extra) {
Person::replaceContacts($pid, $extra);
}
// 工作经历(第 4 条那张表):导入来的单位/职务就是一段经历。
// ⚠️ 必须**同时**建这一段,否则详情页的"工作经历"永远是空的,
// 而 persons.org 里却有值 —— 两处对不上,用户会以为数据坏了。
if (trim((string)($r['org'] ?? '')) !== '' || trim((string)($r['title'] ?? '')) !== ''
|| trim((string)($r['jobrank'] ?? '')) !== '') {
PersonJob::replace($pid, [[
'org' => (string)($r['org'] ?? ''), 'title' => (string)($r['title'] ?? ''),
'rank_name' => (string)($r['jobrank'] ?? ''),
'start_ym' => '', 'end_ym' => '', 'note' => '',
]]);
}
// 进导入者的名册(谁导入的,就先出现在谁的名册里)
PersonRoster::ensure($familyId, $meId, $pid, $groupId > 0 ? $groupId : null, 'self');
// 建完把索引补上,避免同一个文件里出现两条同名时建出两个人
$byName[mb_strtolower($d['name'], 'UTF-8')] = ['id' => $pid, 'name' => $d['name']];
if ($phone !== '') {
$byPhone[Crypto::hash($phone)] = ['id' => $pid, 'name' => $d['name']];
}
$added++;
$log[] = ['name' => (string)$d['name'], 'action' => 'added', 'added' => ['新建']];
}
return ['added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'people' => $log];
}
/**
* 把记录里**他没有的值**补上去;已有非空值一律不动。
*
* ⚠️ 写库只分两处:
* ① `persons` 上的标量列(邮箱/住址/单位/职务/生日)→ 一条 UPDATE;
* ② 多值(电话/邮箱/网址)→ 走 person_contacts 的整组替换(要把现有的并进去)。
* 第一版把"SQL 片段"与"中文标签"交替塞进同一个数组,读起来极易出错;
* 这里改成两个独立容器,各司其职。
*
* @return string[] 这次补上了哪些(中文标签,用来告诉用户"改了什么")
*/
/**
* 把一条导入记录里的地址整理成 persons 的五个字段。
*
* 分两种来源:
* · vCard 的 ADR **本来就是分段的**(街道/城市/省/国家各占一段)→ 直接用,
* 再用 Region::parse() 从街道那一段里认出「区 / 县」;
* · CSV 那种一整串自由文本 → 整串交给 Region::parse() 逐级前缀匹配拆开。
*
* ⚠️ 认不出来的部分**一律留在 address(详细地址)里**,绝不丢:
* 结构化是为了"以后能按省找人",不是为了让地址变短。
*
* @return array{address:string,addr_country:string,addr_province:string,addr_city:string,addr_district:string}
*/
private static function addrFields(array $r): array
{
$country = trim((string)($r['addr_country'] ?? ''));
$province = trim((string)($r['addr_province'] ?? ''));
$city = trim((string)($r['addr_city'] ?? ''));
$district = trim((string)($r['addr_district'] ?? ''));
$detail = trim((string)($r['address'] ?? ''));
if ($province === '' && $city === '' && $detail !== '') {
// 一整串自由文本 → 逐级前缀匹配
$p = Region::parse($detail);
$country = $country !== '' ? $country : (string)($p['country'] ?? '');
$province = (string)($p['province'] ?? '');
$city = (string)($p['city'] ?? '');
$district = (string)($p['district'] ?? '');
$detail = (string)($p['detail'] ?? '');
} elseif ($detail !== '' && $district === '') {
// 已经有省/市(vCard 给的),从剩下的街道里再认一次区县
$p = Region::parse($detail);
if (($p['district'] ?? null) !== null) {
$district = (string)$p['district'];
$detail = (string)($p['detail'] ?? '');
}
}
return [
'address' => $detail,
'addr_country' => $country,
'addr_province' => $province,
'addr_city' => $city,
'addr_district' => $district,
];
}
private static function fillMissing(array $person, array $r, int $meId): array
{
$pid = (int)$person['id'];
$cols = []; // "`email` = ?" => 值
$labels = []; // 给人看的"补了什么"
// ---- ① persons 上的标量列:他空着才补 ----
$addr = self::addrFields($r);
$scalar = [
'email' => ['邮箱', (string)($r['email'] ?? ''), 200],
// ⚠️ 地址要走 addrFields() 拆过的那份:直接拿 $r['address'] 会把
// vCard 里"省+市+街道"拼成的一整串原样塞进详细地址,等于没结构化。
'address' => ['住址', $addr['address'], 200],
'org' => ['工作单位', (string)($r['org'] ?? ''), 120],
'note' => ['备注', (string)($r['note'] ?? ''), 2000],
];
foreach ($scalar as $col => $def) {
$label = $def[0];
$new = trim($def[1]);
$max = (int)$def[2];
if (trim((string)($person[$col] ?? '')) === '' && $new !== '') {
$cols["`$col` = ?"] = mb_substr($new, 0, $max, 'UTF-8');
$labels[] = $label;
}
}
// 职务与职称在库里是同一列 title(他那一列空着才补)
$newTitle = trim((string)($r['title'] ?? '') . ' ' . (string)($r['jobrank'] ?? ''));
if (trim((string)($person['title'] ?? '')) === '' && $newTitle !== '') {
$cols['`title` = ?'] = mb_substr($newTitle, 0, 120, 'UTF-8');
$labels[] = '职务/职称';
}
// 地址的四级结构:同样**只补空着的**(他已经在表单里选过省市区就不动)
foreach (['addr_country' => '国家', 'addr_province' => '省',
'addr_city' => '市', 'addr_district' => '区/县'] as $col => $label) {
if (trim((string)($person[$col] ?? '')) === '' && trim($addr[$col]) !== '') {
$cols["`$col` = ?"] = mb_substr(trim($addr[$col]), 0, 60, 'UTF-8');
$labels[] = $label;
}
}
// 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。
$hasBd = trim((string)Birthday::solarOf($person)) !== ''
|| trim((string)($person['birthday_lunar'] ?? '')) !== '';
if (!$hasBd && trim((string)($r['birthday'] ?? '')) !== '') {
$cols['`birthday` = ?'] = (string)$r['birthday'] . ' 00:00:00';
$labels[] = '生日';
}
if ($cols) {
$sql = [];
$args = [];
foreach ($cols as $frag => $val) {
$sql[] = $frag;
$args[] = $val;
}
$args[] = $pid;
Database::get()->prepare('UPDATE persons SET ' . implode(', ', $sql) . ' WHERE id = ?')
->execute($args);
}
// ---- ② 多值(电话/邮箱/网址):只在"这一类他一个都没有"时补 ----
// ⚠️ 判据是"这一类为空",不是逐条去重:person_contacts 里的手机号是**密文**,
// 没法拿明文比;而"他这一类本来就空着"是能确定的(也符合"只补空"的要求)。
$cur = Person::contactsOf($pid);
$add = [];
$mainPhone = trim((string)($r['phone'] ?? ''));
$mainMail = trim((string)($r['email'] ?? ''));
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainPhone));
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainMail));
if (!$cur['phone'] && $extraPhones) {
foreach ($extraPhones as $v) {
$add['phone'][] = ['label' => '', 'value' => (string)$v];
}
$labels[] = '电话';
}
if (!$cur['email'] && $extraEmails) {
foreach ($extraEmails as $v) {
$add['email'][] = ['label' => '', 'value' => (string)$v];
}
$labels[] = '邮箱';
}
if (!$cur['url'] && trim((string)($r['url'] ?? '')) !== '') {
$add['url'][] = ['label' => '', 'value' => (string)$r['url']];
$labels[] = '网址';
}
if ($add) {
// replaceContacts 是**整组替换** → 必须把现有的并进去,否则会把已有联系方式清掉
$merged = $cur;
foreach ($add as $k => $rows) {
foreach ($rows as $row) {
$merged[$k][] = $row;
}
}
Person::replaceContacts($pid, $merged);
}
// ---- ③ "工作经历"里空着、而这次**刚补上**了单位/职务 → 补一段经历 ----
// ⚠️⚠️ 两个硬条件,缺一不可:
// ① 这次确实补了(`$cols` 里刚写过 org 或 title)—— 他已经有的值不能碰;
// ② 经历表里本来就没有 —— 有经历时以经历表为准,不重复插。
// 为什么条件①这么关键:`PersonJob::replace()` 末尾会调
// `syncCurrent()` 把"最新那段"回写到 persons.org/title。若在这里对
// "已有单位的人"也插一段导入来的单位,syncCurrent 就会把
// **他原来的单位覆盖掉** —— 正好违反"只补空、不覆盖"。
// (这个 bug 是被 _fb_import_test.py 的 ⑩ 抓出来的。)
$justFilledOrg = isset($cols['`org` = ?']);
$justFilledTitle = isset($cols['`title` = ?']);
if (($justFilledOrg || $justFilledTitle) && !PersonJob::list($pid)) {
PersonJob::replace($pid, [[
// 用**刚存进去的值**(不是导入文件里的原值)——
// 这样 syncCurrent 回写时与库里一致,不会造成二次变动。
'org' => (string)($cols['`org` = ?'] ?? ''),
'title' => (string)($r['title'] ?? ''),
'rank_name' => (string)($r['jobrank'] ?? ''),
'start_ym' => '', 'end_ym' => '', 'note' => '',
]]);
}
return array_values(array_unique($labels));
}
}