query('SELECT MIN(id) FROM families')->fetchColumn() ?: 0); } catch (\Throwable $ex) { $familyIdForSchema = 0; } } if ($familyIdForSchema > 0) { $have = (string)Setting::get($familyIdForSchema, 'schema_version', '1'); if ($have !== FB_SCHEMA_VERSION) { try { Schema::ensure(); Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION); } catch (\Throwable $ex) { // 升级失败不能把整站挡住;下次请求会再试。 // ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级 // 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2), // 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。 // 一行 error_log 就能省掉几小时排查,别再删掉它。 error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):' . $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine()); if (!empty($GLOBALS['config']['debug'])) { throw $ex; } } } } } $r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home'; $r = $r === '' ? 'home' : $r; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $publicRoutes = ['auth/login']; if (!Auth::check() && !in_array($r, $publicRoutes, true)) { redirect('auth/login'); } /** * 从表单里读「三个生日」。 * * 为什么要有 `birthday_source` 这个显式字段: * 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。 * 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个", * 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。 * 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。 */ function read_birthday_input(array $existing = []): array { $source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar'; $solar = (string)(read_dt_input('birthday_solar') ?? ''); $lunar = (string)(read_dt_input('birthday_lunar') ?? ''); if ($source === 'solar' && $solar === '' && $lunar !== '') { $source = 'lunar'; } return [ 'birthday_source' => $source, 'birthday_solar' => $solar, 'birthday_lunar' => $lunar, 'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0, // 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉 'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''), ]; } /** * 从表单解析「补充联系方式」(vCard 对齐,v6 起)。 * * 每类两个平行数组:ct__label[] / ct__value[] —— * 平行数组而不是二维键名(ct[phone][0][value]),是因为 label 与 value * 分列在两个 input 里,PHP 自动按数字补齐下标,zip 起来最直观。 * * @return string[][][] kind => 行列表(label/value,value 保留原样,空行不丢 —— * 交给 Person::replaceContacts 丢弃;校验失败重画表单时还要用它回填) */ function read_contacts_input(): array { $out = []; foreach (['phone', 'email', 'address', 'url'] as $kind) { $labels = $_POST['ct_' . $kind . '_label'] ?? []; $values = $_POST['ct_' . $kind . '_value'] ?? []; if (!is_array($labels) || !is_array($values)) { $out[$kind] = []; continue; } $rows = []; $n = max(count($labels), count($values)); for ($i = 0; $i < $n; $i++) { $rows[] = ['label' => (string)($labels[$i] ?? ''), 'value' => trim((string)($values[$i] ?? ''))]; } $out[$kind] = $rows; } return $out; } /** 补充联系方式的宽松校验(主手机号/主邮箱的校验在 Person::validate 里) */ function validate_contacts(array $contacts): string { foreach ($contacts as $kind => $rows) { foreach ($rows as $i => $row) { $v = trim((string)($row['value'] ?? '')); if ($v === '') { continue; } $no = $i + 1; if ($kind === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) { return '第 ' . $no . ' 个补充邮箱的格式看起来不对:' . $v; } if ($kind === 'phone' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) { return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v; } } } return ''; } /** * 渲染「个人设置」页(settings/me)。 * * 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。 * 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单 * (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。 * 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事, * 两套表单必然填出两种口径。 * * @param array $person 当前登录者自己的名册记录 * @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用) */ function render_me_settings(array $person, array $errors = []): void { $familyId = (int)Auth::familyId(); $me = (int)$person['id']; View::render('settings/me', [ 'title' => '个人设置', 'person' => $person, 'errors' => $errors, // 我怎么称呼别人(视角属性,只有我自己能定) 'nicknames' => Nickname::mineWithNames($familyId, $me), 'options' => Person::options($familyId, $me, $me), 'birthdays' => Birthday::describe($person), // 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order) 'groups' => PersonGroup::orderFor($familyId, $me), 'groupCounts' => PersonGroup::counts($familyId, $me), 'rosterCount' => PersonRoster::countFor($familyId, $me), ], 'settings'); } switch ($r) { // ==================== 登录 / 退出 ==================== case 'auth/login': if (Auth::check()) { redirect('home'); } if ($method === 'POST') { csrf_check(); $phone = trim((string)($_POST['phone'] ?? '')); $pass = (string)($_POST['password'] ?? ''); if (Auth::login($phone, $pass)) { redirect('home'); } View::renderBare('login', [ 'title' => '登录', 'familyName' => fb_config('site_name', '家书'), 'error' => '手机号或密码不对,或者账号还没开通/已被停用。', 'phone' => $phone, ]); exit; } View::renderBare('login', [ 'title' => '登录', 'familyName' => fb_config('site_name', '家书'), ]); break; case 'auth/logout': if (isset($_GET['_csrf'], $_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) { Auth::logout(); } redirect('auth/login'); break; case 'auth/password': Auth::requireLogin(); if ($method === 'POST') { csrf_check(); $old = (string)($_POST['old_password'] ?? ''); $new = (string)($_POST['password'] ?? ''); $new2 = (string)($_POST['password2'] ?? ''); $st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?'); $st->execute([Auth::id()]); $hash = (string)$st->fetchColumn(); $err = null; if (!password_verify($old, $hash)) { $err = '当前密码不对'; } elseif ($new !== $new2) { $err = '两次输入的新密码不一致'; } elseif (Account::validatePassword($new)) { $err = implode(';', Account::validatePassword($new)); } if ($err) { View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home'); exit; } Account::setPassword((int)Auth::id(), $new); flash('ok', '密码已更新,下次登录用新密码。'); redirect('home'); } View::render('auth/password', ['title' => '修改密码'], 'home'); break; // ==================== 首页(以我为中心) ==================== case 'home': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 「我的家人」= 以我为中心上下各三代那张家谱里的人。 // ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**: // 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。 // 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。 $tree = Kinship::tree($graph, $me, $people, 3, 3); $kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]); $kin = []; // 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标 foreach (Person::all($familyId) as $p) { $pid = (int)$p['id']; if (in_array($pid, $kinIds, true)) { $kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)]; } } $birthdays = Person::upcomingBirthdays($familyId); // 生日条上也标出称谓 foreach ($birthdays as &$b) { $b['label'] = isset($people[(int)$b['id']]) ? Kinship::label($graph, $me, (int)$b['id'], $people) : ''; // 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝" $b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name']; } unset($b); View::render('home', [ 'title' => '首页', 'family' => Family::find($familyId), 'me' => $people[$me] ?? null, 'meId' => $me, 'kin' => $kin, 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) 'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')), 'myNicknames'=> Nickname::mapFor($me), ], 'home'); break; // ==================== 家谱(以我为中心) ==================== case 'kin': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $people = Person::map($familyId); $graph = Kinship::graph($familyId); $tree = Kinship::tree($graph, $me, $people, 3, 3); View::render('kin/tree', [ 'title' => '我的家谱', 'tree' => $tree, 'meId' => $me, 'people' => $people, 'graph' => $graph, ], 'kin'); break; // ==================== 名册 ==================== case 'people': $q = trim((string)($_GET['q'] ?? '')); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); // ⚠️ 用 roster() 而不是 all():名册是「每人一份」—— // 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。 $rows = Person::roster($familyId, $me, 'member', $q); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 名册里也标出「我该怎么称呼他」 foreach ($rows as &$row) { $row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people); $row['shown'] = Nickname::display($me, $row) ?: (string)$row['name']; } unset($row); // 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序); // 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。 $grouped = []; foreach ($rows as $row) { $grouped[(int)($row['group_id'] ?? 0)][] = $row; } View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, 'grouped'=> $grouped, 'groups' => PersonGroup::orderFor($familyId, $me), 'q' => $q, 'meId' => $me, // 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口, // 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份) 'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0, ], 'people'); break; /** * 名册卡片上的「调整分组」。 * * 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id), * 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。 * 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长 * (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。 * 而且分组**本来就只能在名册里改**(它是"我和他的关系")。 */ case 'people/group': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改名册里的分组。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $gid = (int)($_POST['group_id'] ?? 0); $p = Person::find($pid); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) { flash('err', '这个人不在你的名册里,或者那个分组你用不了。'); redirect('people'); } flash('ok', $gid > 0 ? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。' : '已把「' . $p['name'] . '」改成未分类。'); redirect('people'); break; /** * 名册拖动排序 / 拖动换栏 —— 整版提交。 * * 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。 * 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**, * 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。 * 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。 */ case 'people/reorder': Auth::requireLogin(); // 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302: // 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。 $isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'); if (!Auth::canWrite()) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8', true, 403); echo json_encode(['ok' => false, 'error' => 'readonly']); exit; } flash('err', '你的身份是访客,不能调整名册顺序。'); redirect('people'); } csrf_check(); $raw = (string)($_POST['layout'] ?? ''); $layout = json_decode($raw, true); if (!is_array($layout)) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'error' => 'invalid layout']); exit; } flash('err', '没收到有效的顺序,请刷新页面再试一次。'); redirect('people'); } $n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout); if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE); exit; } flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。'); redirect('people'); break; /** * 「从家里已有的人里挑」—— 添加成员的第二个入口。 * * 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons, * 于是账号、亲属关系、生日各存一份,后面永远对不上。 * 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。 */ case 'people/pick': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能往名册里加人。'); redirect('people'); } $familyPick = PersonRoster::pickable($familyId, $me); if ($method === 'POST') { csrf_check(); $ids = array_map('intval', (array)($_POST['ids'] ?? [])); $gid = (int)($_POST['group_id'] ?? 0); // 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来 $ok = array_column($familyPick, 'id'); $ok = array_map('intval', $ok); $n = 0; foreach ($ids as $pid) { if (!in_array($pid, $ok, true)) { continue; } if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) { $n++; } } flash($n > 0 ? 'ok' : 'err', $n > 0 ? '已把 ' . $n . ' 个人加进你的名册。' : '一个人都没加上 —— 先勾选要加的人。'); redirect('people'); } View::render('people/pick', [ 'title' => '从家里已有的人里挑', 'people' => $familyPick, 'groups' => PersonGroup::orderFor($familyId, $me), 'meId' => $me, ], 'people'); break; /** * 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户, * 则这个名片,自动出现在这个用户的名册中」)。 * 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。 */ case 'people/share': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能分享名片。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $to = (int)($_POST['to_id'] ?? 0); $back = (int)($_POST['back'] ?? 0); $p = Person::find($pid); $t = Person::find($to); if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } if ($pid === $to) { flash('err', '不用把名片分享给他自己。'); redirect('people/show', ['id' => $back > 0 ? $back : $pid]); } PersonRoster::share($familyId, $to, [$pid], 'shared'); flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。'); redirect('people/show', ['id' => $back > 0 ? $back : $pid]); break; /** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */ case 'people/forget': Auth::requireLogin(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改名册。'); redirect('people'); } csrf_check(); $pid = (int)($_POST['person_id'] ?? 0); $p = Person::find($pid); if ($p && (int)$p['family_id'] === (int)Auth::familyId()) { // v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见, // 单独从名册藏掉只会把三处口径又搅回打架的状态。 // 想不再看到他,解除那条关系(名册条目会跟着自动收回)。 $src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? ''); if ($src === 'kin') { flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。'); } else { PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid); flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。'); } } redirect('people'); break; // ==================== 我自己的资料 / 个人设置 ==================== // 顶栏头像点开 →「个人设置」。访客也能改自己的资料 —— // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 case 'people/me': // 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。 // 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。 redirect('settings/me'); break; case 'settings/me': Auth::requireLogin(); $p = Person::find((int)Auth::personId()); if (!$p) { flash('err', '找不到你自己的名册记录。'); redirect('home'); } clear_old(); render_me_settings($p); break; /** * 旧的「保存我的资料」入口。 * * v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update), * 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置, * 免得一次提交撞上 404 让人以为"保存坏了"。 */ case 'settings/me_save': case 'people/me_save': redirect('settings/me'); break; // ==================== 分组的顺序 ==================== // 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。 // 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。 case 'settings/groups/move': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_POST['id'] ?? 0); $dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down'; $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; if ($scope === 'public') { Auth::requireAdmin(); } $list = $scope === 'public' ? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER) : PersonGroup::orderFor($familyId, $me); $ids = array_map(fn($g) => (int)$g['id'], $list); $at = array_search($id, $ids, true); if ($at === false) { flash('err', '找不到这个分组。'); redirect($scope === 'public' ? 'settings' : 'settings/me'); } // 挪到边界就什么也不做(按钮在两端本来就是 disabled 的, // 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱) $to = $dir === 'up' ? $at - 1 : $at + 1; if ($to >= 0 && $to < count($ids)) { [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; if ($scope === 'public') { PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids); } else { PersonGroup::saveOrder($familyId, $me, $ids); } } redirect($scope === 'public' ? 'settings' : 'settings/me'); break; // ==================== 分组的增删改名 ==================== // 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里, // 只有自己看得见)。访客只读,给个明确的说法再回去, // 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。 case 'settings/groups/add': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能加分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。' : (string)($res['error'] ?? '加不上。')); redirect($scope === 'public' ? 'settings' : 'settings/me'); break; case 'settings/groups/rename': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能改分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0), (string)($_POST['name'] ?? ''), $owner); flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '分组名已改。' : (string)($res['error'] ?? '改不了。')); redirect($scope === 'public' ? 'settings' : 'settings/me'); break; case 'settings/groups/delete': Auth::requireLogin(); csrf_check(); if (!Auth::canWrite()) { flash('err', '你的身份是访客,不能删分组。'); redirect('settings/me'); } $scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me'; $owner = PersonGroup::PUBLIC_OWNER; if ($scope === 'public') { Auth::requireAdmin(); } else { $owner = (int)Auth::personId(); } $res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner); if ($res['ok']) { $n = (int)($res['moved'] ?? 0); flash('ok', $n > 0 ? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。' : '分组已删掉(那一组本来也没人)。'); } else { flash('err', (string)($res['error'] ?? '删不了。')); } redirect($scope === 'public' ? 'settings' : 'settings/me'); break; // ==================== 别人怎么称呼(只有我自己能定) ==================== case 'nickname/save': Auth::requireLogin(); csrf_check(); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $target = (int)($_POST['target_id'] ?? 0); $nick = (string)($_POST['nickname'] ?? ''); $t = Person::find($target); $back = (string)($_POST['back'] ?? 'people/show'); if (!$t || (int)$t['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } Nickname::set($familyId, $me, $target, $nick); flash('ok', trim($nick) === '' ? '已恢复成用姓名显示。' : '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。'); if ($back === 'settings/me' || $back === 'people/me') { // 两个写法都认:老表单发的是 people/me(那一页现在是跳转) redirect('settings/me'); } redirect('people/show', ['id' => $target]); break; case 'people/new': Auth::requireWrite(); clear_old(); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => [], 'canEditAll' => true, 'isSelf' => false, ], 'people'); break; case 'people/create': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => '', 'kind' => 'member', // ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉, // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, ] + read_birthday_input(); $errors = Person::validate($d); $contacts = read_contacts_input(); $ctErr = validate_contacts($contacts); if ($ctErr !== '') { $errors['contacts'] = $ctErr; } // ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」—— // 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。 // 现在是:人先存进去,头像的事单独提示。 $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, 'contacts' => $contacts, ], 'people'); exit; } if ($up['ok']) { $d['avatar'] = $up['file']; } try { $id = Person::create($familyId, $d); } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => ['phone' => '这个手机号已经在名册里了'], 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], 'canEditAll' => true, 'isSelf' => false, 'contacts' => $contacts, ], 'people'); exit; } // 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。 // 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。 PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self'); Person::replaceContacts($id, $contacts); clear_old(); if ($avatarWarn !== '') { flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn); } else { flash('ok', '已把「' . $d['name'] . '」加进名册。'); } redirect('people/show', ['id' => $id]); break; /** * 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。 * * 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的 * 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字), * 而编辑别人必须是能写的人。 */ case 'people/edit': $id = (int)($_GET['id'] ?? 0); $isSelf = $id > 0 && $id === (int)Auth::personId(); if (!$isSelf) { Auth::requireWrite(); } $p = Person::find($id); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } clear_old(); // 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文 $editContacts = Person::contactsOf($id); foreach ($editContacts['phone'] as &$r) { $r['value'] = Crypto::decrypt($r['value']); } unset($r); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => [], 'canEditAll' => Auth::canWrite(), 'isSelf' => $isSelf, 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), 'contacts' => $editContacts, ], 'people'); break; case 'people/update': csrf_check(); $familyId = (int)Auth::familyId(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } $isSelf = $id === (int)Auth::personId(); if (!$isSelf) { Auth::requireWrite(); } // 改别人(= 能写的人编名册):字段是完整的一套。 // 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。 $canEditAll = Auth::canWrite(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']), 'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'org' => trim((string)($_POST['org'] ?? '')), 'title' => trim((string)($_POST['title'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'avatar' => (string)$p['avatar'], 'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''), 'is_placeholder' => $canEditAll ? (!empty($_POST['is_placeholder']) ? 1 : 0) : (int)($p['is_placeholder'] ?? 0), ] + read_birthday_input($p); $errors = Person::validate($d); $contacts = read_contacts_input(); $ctErr = validate_contacts($contacts); if ($ctErr !== '') { $errors['contacts'] = $ctErr; } // 同 people/create:头像失败不挡保存(见 create 分支里的说明) $up = Upload::avatar($familyId, $_FILES['avatar'] ?? [], (string)($_POST['avatar_crop'] ?? '')); $avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : ''; if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => $errors, 'canEditAll' => $canEditAll, 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], 'contacts' => $contacts, ], 'people'); exit; } if ($up['ok']) { Upload::remove($familyId, (string)$p['avatar']); $d['avatar'] = $up['file']; } try { // 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。 // 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把 // 手机号(= 登录凭据)或身份证号改掉。 if ($isSelf && !$canEditAll) { Person::updateSelf($id, $d); } else { Person::update($id, $d); } } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => $isSelf ? '我的资料' : '编辑成员', 'person' => $p, 'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'], 'canEditAll' => $canEditAll, 'isSelf' => $isSelf, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], 'contacts' => $contacts, ], 'people'); exit; } Person::replaceContacts($id, $contacts); if ((int)$p['id'] === (int)Auth::personId()) { Auth::refreshAvatar((string)$d['avatar']); // 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字 $_SESSION['name'] = (string)$d['name']; } clear_old(); if ($avatarWarn !== '') { flash('ok', '已保存。头像没有更换:' . $avatarWarn); } else { flash('ok', '已保存。'); } redirect('people/show', ['id' => $id]); break; case 'people/show': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_GET['id'] ?? 0); $p = Person::findWithAccount($id, $me); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); } $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些) $tree = Kinship::tree($graph, $id, $people, 2, 2); View::render('people/show', [ 'title' => $p['name'], 'person' => $p, 'meId' => $me, 'people' => $people, 'graph' => $graph, 'labelToMe' => Kinship::label($graph, $me, $id, $people), 'isMe' => $id === $me, // 以这个人为中心的两代亲属,供详情页展示 'theirTree' => $tree, 'theirRels' => Relations::forPerson($familyId, $id), 'options' => Person::options($familyId, $id, $me), 'kinReady' => Kinship::graph($familyId) !== [], // 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的 'myNickname' => $id === $me ? '' : Nickname::get($me, $id), 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), 'birthdays' => Birthday::describe($p), 'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), // 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示 'contacts' => Person::contactsOf($id), // 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁 'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true), ], 'people'); break; case 'people/destroy': Auth::requireAdmin(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); if ($p && (int)$p['family_id'] === (int)Auth::familyId()) { if ((int)$p['id'] === (int)Auth::personId()) { flash('err', '不能把自己从名册里移除。'); redirect('people/show', ['id' => $id]); } // 不让最后一个管理员消失:系统里没人能管理家庭就锁死了 $acc = Account::findByPerson($id); if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。'); redirect('people/show', ['id' => $id]); } Upload::remove((int)Auth::familyId(), (string)$p['avatar']); Person::delete($id); flash('ok', '已从名册移除「' . $p['name'] . '」。'); } redirect('people'); break; // ==================== 账号 ==================== case 'accounts/create': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $p = Person::find($personId); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } if (Account::findByPerson($personId)) { flash('err', '这个人的账号已经开通过了。'); redirect('people/show', ['id' => $personId]); } if (Crypto::decrypt((string)$p['phone_encrypted']) === '') { flash('err', '要先填手机号,才能开通登录。'); redirect('people/show', ['id' => $personId]); } $pass = (string)($_POST['password'] ?? ''); $errs = Account::validatePassword($pass); if ($errs) { flash('err', implode(';', $errs)); redirect('people/show', ['id' => $personId]); } $r0 = (string)($_POST['role'] ?? ''); Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member'); // v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的 PersonRoster::syncKinForFamily((int)Auth::familyId()); flash('ok', '账号已开通,用手机号就能登录。'); redirect('people/show', ['id' => $personId]); break; case 'accounts/role': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member'; $p = Person::find($personId); $acc = Account::findByPerson($personId); if ($p && $acc) { // ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`: // 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 —— // 降完家里一个管理员都不剩,谁也进不了系统设置了。 // 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。 if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。'); redirect('people/show', ['id' => $personId]); } Account::setRole((int)$acc['id'], $role); flash('ok', '权限已更新。'); } redirect('people/show', ['id' => $personId]); break; case 'accounts/status': Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); $status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled'; $acc = Account::findByPerson($personId); if ($acc) { if ((int)$acc['person_id'] === (int)Auth::personId()) { flash('err', '不能停用自己。'); redirect('people/show', ['id' => $personId]); } if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) { flash('err', '至少要留一位管理员。'); redirect('people/show', ['id' => $personId]); } Account::setStatus((int)$acc['id'], $status); flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。'); } redirect('people/show', ['id' => $personId]); break; // ==================== 亲属关系 ==================== case 'kin/relate': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人) $to = (int)($_POST['to_id'] ?? 0); // 对方 $kind = (string)($_POST['kind'] ?? ''); $role = (string)($_POST['role'] ?? ''); $ok = Person::find($from) && Person::find($to) && (int)Person::find($from)['family_id'] === $familyId && (int)Person::find($to)['family_id'] === $familyId; if (!$ok) { flash('err', '选的人不对。'); redirect('people'); } // ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」, // 只是把人记成了对方的父母 —— 家谱整个倒过来。 // 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」; // kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。 // Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。 if ($kind === 'parent') { // 对方是我的父母:我是孩子,对方是父母 $r = Relations::addParent($familyId, $from, $to, $role); } elseif ($kind === 'child') { // 对方是我的子女:对方是孩子,我是父母 $r = Relations::addParent($familyId, $to, $from, $role); } else { $r = Relations::addSpouse($familyId, $from, $to); } flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']); redirect('people/show', ['id' => $from]); break; case 'kin/unrelate': Auth::requireWrite(); csrf_check(); $familyId = (int)Auth::familyId(); $id = (int)($_POST['id'] ?? 0); $who = (int)($_POST['person_id'] ?? 0); $kinRemoved = Relations::remove($familyId, $id); // v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破, // 不然用户会以为名册里的人凭空消失了 flash('ok', $kinRemoved ? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。' : '这条关系已解除。'); redirect('people/show', ['id' => $who]); break; // ==================== 访客邀请 ==================== case 'accounts/invite': Auth::requireAdmin(); clear_old(); // ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' —— // 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。 View::render('people/invite', [ 'title' => '邀请访客', 'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()), 'options' => Person::options((int)Auth::familyId()), ], 'people'); break; case 'accounts/invite_create': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $name = trim((string)($_POST['name'] ?? '')); $phone = trim((string)($_POST['phone'] ?? '')); $pass = (string)($_POST['password'] ?? ''); $note = trim((string)($_POST['note'] ?? '')); $errors = []; if ($name === '') { $errors['name'] = '请填写对方的名字'; } if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) { $errors['phone'] = '手机号格式看起来不对'; } $pw = Account::validatePassword($pass); if ($pw) { $errors['password'] = implode(';', $pw); } if (!$errors) { try { // 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱 $pid = Person::create($familyId, [ 'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note, 'slogan' => '', 'kind' => 'contact', ]); Account::create($pid, $pass, 'guest'); // 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由 // Account::create 给他补上他自己) PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self'); clear_old(); flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。'); redirect('accounts/invite'); } catch (\PDOException $ex) { $errors['phone'] = '这个手机号已经有人用了'; } } set_old($_POST); View::render('people/invite', [ 'title' => '邀请访客', 'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()), 'options' => Person::options($familyId), 'errors' => $errors, ], 'people'); break; // ==================== 设置 ==================== case 'settings': Auth::requireAdmin(); $dbVersion = '未知'; try { $dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn(); } catch (\Throwable $ex) { // 拿不到版本不影响页面 } $rawKey = (string)fb_config('app_key', ''); $acc = Account::roleCounts(); // 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET) $diag = $_SESSION['_oss_diag'] ?? null; unset($_SESSION['_oss_diag']); // 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。 // 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。 View::render('settings', [ 'title' => '设置', 'family' => Family::find((int)Auth::familyId()), 'dbVersion' => $dbVersion, 'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0, 'roleCounts' => $acc, 'relCount' => Relations::count((int)Auth::familyId()), 'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER), 'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER), 'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''), 'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()), 'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()), 'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()), 'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()), 'ossDiag' => $diag, ], 'settings'); break; /** * 阿里云 OSS 连通性测试。 * * 与 settings/test_storage 的分工: * · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西"; * · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 —— * 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。 * 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写), * 让"连不上"这件事有具体原因,而不是一句"写入失败"。 */ case 'settings/test_oss': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId); if ($disk === null) { $_SESSION['_oss_diag'] = [ 'ok' => false, 'steps' => [[ 'name' => '配置完整性', 'ok' => false, 'ms' => 0, 'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。', ]], 'at' => time(), ]; redirect('settings'); } $steps = $disk->diagnose(); $ok = true; foreach ($steps as $s) { if (empty($s['ok'])) { $ok = false; break; } } $_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()]; redirect('settings'); break; case 'settings/storage': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $old = \App\Storage\Storage::config($familyId); $in = (array)($_POST['oss'] ?? []); // 密钥留空 = 不改(避免「保存时没重填就把已存的密钥清掉」) foreach (['access_key_secret', 'access_key_id'] as $k) { if (trim((string)($in[$k] ?? '')) === '') { $in[$k] = (string)$old[$k]; } } $oss = [ 'auth_mode' => ($in['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ecs_role' : 'ak', 'access_key_id' => trim((string)($in['access_key_id'] ?? '')), 'access_key_secret' => trim((string)($in['access_key_secret'] ?? '')), 'bucket' => trim((string)($in['bucket'] ?? '')), 'endpoint' => trim((string)($in['endpoint'] ?? '')), 'prefix' => trim((string)($in['prefix'] ?? '')), 'ram_role_name' => trim((string)($in['ram_role_name'] ?? '')), 'custom_domain' => trim((string)($in['custom_domain'] ?? '')), 'internal_endpoint' => !empty($in['internal_endpoint']) ? '1' : '0', 'https' => !empty($in['https']) ? '1' : '0', ]; \App\Storage\Storage::saveConfig($familyId, (string)($_POST['driver'] ?? 'local'), $oss); flash('ok', '附件存储设置已保存。'); redirect('settings'); break; case 'settings/test_storage': Auth::requireAdmin(); csrf_check(); $familyId = (int)Auth::familyId(); $disk = \App\Storage\Storage::disk($familyId); $err = $disk->validateConfig(); if ($err !== '') { flash('err', '配置不完整:' . $err); redirect('settings'); } // 真写一个探针对象再读回来,最后删掉 —— 比只 ping 一下可靠得多 $probeKey = 'avatars/.probe-' . bin2hex(random_bytes(6)) . '.txt'; $payload = 'familybook-probe-' . time(); $put = \App\Storage\Storage::putContents($familyId, $payload, $probeKey, 'text/plain'); if (empty($put['ok'])) { flash('err', '写入失败:' . (string)($put['error'] ?? '')); redirect('settings'); } $get = \App\Storage\Storage::read($familyId, $probeKey); $ok = !empty($get['ok']) && trim((string)$get['body']) === $payload; \App\Storage\Storage::delete($familyId, $probeKey); flash($ok ? 'ok' : 'err', $ok ? '存储可用:写入 → 读回 → 删除 三步都通过了(驱动:' . $disk->id() . ')' : '写入成功但读回来对不上,请检查桶权限或内网/外网 endpoint。'); redirect('settings'); break; case 'settings/update': Auth::requireAdmin(); csrf_check(); $fid = (int)Auth::familyId(); // ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明): // 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。 // 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。 $cur = Family::find($fid); Family::update( $fid, (string)($cur['name'] ?? '家书'), trim((string)($_POST['motto'] ?? '')) ); flash('ok', '已保存。'); redirect('settings'); break; // ==================== 观影(v8) ==================== // // MovieLog 整体迁入后的新家。数据落在统一事件原语上: // events(骨架)+ movie_details(卫星)+ event_attendees + event_photos, // 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改; // 迁进来的历史记录 source_ref = 'movielog:movie:'(重跑迁移的幂等锚点)。 /** 观影瀑布流(v9,MovieLog 同款海报墙;带关键词搜索)。 */ case 'movies': $familyId = (int)Auth::familyId(); $keyword = trim((string)($_GET['keyword'] ?? '')); $rows = Movie::all($familyId, $keyword); View::render('movies/index', [ 'title' => '观影记录', 'rows' => $rows, 'total' => count($rows), 'keyword' => $keyword, ], 'movies'); break; case 'movies/show': $familyId = (int)Auth::familyId(); $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $meId = (int)Auth::personId(); $rvError = ''; View::render('movies/show', [ 'title' => (string)$m['title'], 'm' => $m, 'reviews' => MovieReview::forEvent((int)$m['id']), 'rvStats' => MovieReview::stats((int)$m['id']), 'myReview' => MovieReview::mine((int)$m['id'], $meId), 'meId' => $meId, 'rvError' => $rvError, 'feelCat' => MovieReview::CATALOG, ], 'movies'); break; case 'movies/new': Auth::requireWrite(); View::render('movies/form', [ 'title' => '记一场电影', 'm' => null, 'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''), 'cinemas' => Cinema::all((int)Auth::familyId()), 'errors' => [], ], 'movies'); break; case 'movies/edit': Auth::requireWrite(); $familyId = (int)Auth::familyId(); $m = Movie::find($familyId, (int)($_GET['id'] ?? 0)); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } View::render('movies/form', [ 'title' => '编辑观影记录', 'm' => $m, 'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => [], ], 'movies'); break; /** 收集表单里和观影有关的字段(create / update 共用一份)。 */ case 'movies/create': case 'movies/update': $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $isEdit = $r === 'movies/update'; $id = $isEdit ? (int)($_POST['id'] ?? 0) : 0; $m = $isEdit ? Movie::find($familyId, $id) : null; if ($isEdit && !$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } // 起止时刻精确到秒(_datetime.php 控件 → read_dt_input) $startsAt = read_dt_input('starts_at'); $endsAt = read_dt_input('ends_at'); $cinemaId = (int)($_POST['cinema_id'] ?? 0); if ($cinemaId > 0 && !Cinema::find($familyId, $cinemaId)) { $cinemaId = 0; // 表单可改:只认本家的影院 } $d = [ 'title' => trim((string)($_POST['title'] ?? '')), 'starts_at' => (string)($startsAt ?? ''), 'ends_at' => (string)($endsAt ?? ''), 'location' => trim((string)($_POST['location'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'info' => trim((string)($_POST['info'] ?? '')), 'cinema_id' => $cinemaId, // 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改, // 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。 'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])), ]; // 豆瓣元数据(v10):表单隐藏字段带 JSON,解码后只认白名单键、逐字段限长, // 防手搓请求往库里塞垃圾。整体一个数组交给模型存 douban_meta。 $doubanMeta = []; $metaRaw = trim((string)($_POST['douban_meta'] ?? '')); if ($metaRaw !== '') { $metaDecoded = json_decode($metaRaw, true); if (is_array($metaDecoded)) { foreach (['douban_id', 'douban_url', 'year', 'genre', 'runtime', 'director', 'actors', 'plot', 'rating', 'country', 'alias'] as $mk) { if (isset($metaDecoded[$mk]) && is_scalar($metaDecoded[$mk])) { $mv = trim((string)$metaDecoded[$mk]); if ($mv !== '') { $doubanMeta[$mk] = mb_substr($mv, 0, 2000, 'UTF-8'); } } } } } $d['douban_meta'] = $doubanMeta; $_familyPeople = Person::map($familyId); $d['attendees'] = array_values(array_filter($d['attendees'], static fn($pid) => isset($_familyPeople[$pid]))); $errors = []; if ($d['title'] === '') { $errors['title'] = '片名不能为空'; } elseif ($d['starts_at'] !== '' && $d['ends_at'] !== '' && strcmp($d['ends_at'], $d['starts_at']) < 0) { $errors['ends_at'] = '散场时间不能早于开场时间'; } // ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图 // 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。 // 海报 / 票根:free 槽位,不需要裁剪框;编辑时换新图就顺带删掉旧对象。 $warns = []; foreach (['poster', 'ticket'] as $slot) { $up = Upload::image($familyId, $_FILES[$slot] ?? [], $slot); if ($up['ok']) { $oldKey = $isEdit ? (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '') : ''; $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)$up['file']; if ($oldKey !== '' && $oldKey !== (string)$up['file']) { \App\Storage\Storage::delete($familyId, $oldKey); } } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = ($slot === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error']; } elseif ($isEdit) { // 没传新图 → 保留库里的旧值 $d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? ''); } } // 豆瓣抓取的海报(v10):前端抓取成功时把存储键放进了隐藏 poster_key。 // 用户**自己选的文件优先**($_FILES['poster'] 真有文件就不覆盖); // 键必须是 posters/ 下的本站存储键,编辑时换用新键就顺带删掉旧对象。 $dbPosterKey = trim((string)($_POST['poster_key'] ?? '')); $posterFileGiven = isset($_FILES['poster']['error']) && !is_array($_FILES['poster']['error']) && (int)$_FILES['poster']['error'] === UPLOAD_ERR_OK; if (!$posterFileGiven && $dbPosterKey !== '' && preg_match('#^posters/[A-Za-z0-9._-]+$#', $dbPosterKey)) { if (($d['poster_key'] ?? '') !== $dbPosterKey) { $oldPosterKey = $isEdit ? (string)($m['poster_key'] ?? '') : ''; $d['poster_key'] = $dbPosterKey; if ($oldPosterKey !== '' && $oldPosterKey !== $dbPosterKey) { \App\Storage\Storage::delete($familyId, $oldPosterKey); } } } // 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删) if (!$isEdit) { $photoFiles = []; if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) { foreach ($_FILES['photo']['name'] as $i => $_n) { $photoFiles[] = [ 'name' => $_FILES['photo']['name'][$i], 'type' => $_FILES['photo']['type'][$i], 'tmp_name' => $_FILES['photo']['tmp_name'][$i], 'error' => $_FILES['photo']['error'][$i], 'size' => $_FILES['photo']['size'][$i], ]; } } $photoKeys = []; foreach ($photoFiles as $f) { $up = Upload::image($familyId, $f, 'photo'); if ($up['ok']) { $photoKeys[] = (string)$up['file']; } elseif ((string)($up['error'] ?? '') !== '') { $warns[] = '有一张照片没有保存:' . $up['error']; } } $d['photos'] = $photoKeys; } if ($errors) { set_old($_POST); View::render('movies/form', [ 'title' => $isEdit ? '编辑观影记录' : '记一场电影', 'm' => $m, 'people' => Person::roster($familyId, $me, 'member', ''), 'cinemas' => Cinema::all($familyId), 'errors' => $errors, ], 'movies'); exit; } if ($isEdit) { Movie::update($familyId, $id, $d); clear_old(); flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $id]); } else { $newId = Movie::create($familyId, $me, $d); clear_old(); flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : '')); redirect('movies/show', ['id' => $newId]); } break; /** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */ case 'movies/photo_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo'); if ($up['ok']) { // savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排 $keys = array_map('strval', array_column(Movie::photos($id), 'key')); $keys[] = (string)$up['file']; Movie::savePhotos($id, $keys); flash('ok', '照片已加上。'); } else { flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件')); } redirect('movies/show', ['id' => $id]); break; case 'movies/photo_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $pid = (int)($_POST['photo_id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } Movie::deletePhoto($familyId, $id, $pid); flash('ok', '照片已删除。'); redirect('movies/show', ['id' => $id]); break; case 'movies/destroy': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $id)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } Movie::destroy($familyId, $id); flash('ok', '这条观影记录已删除。'); redirect('movies'); break; // ---------------- 豆瓣自动补充(v10,MovieLog fetch_movie 同源迁移) ---------------- /** 片名联想 / 缩略图代理 / 抓取详情+海报入库。全部回 JSON(thumb 回图片字节)。 */ case 'movies/douban': $familyId = (int)Auth::familyId(); $act = (string)($_GET['action'] ?? ''); if (!Auth::check()) { http_response_code(401); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => '未登录或登录已过期,请重新登录后再试'], JSON_UNESCAPED_UNICODE); break; } if ($act === 'suggest') { header('Content-Type: application/json; charset=utf-8'); $res = Douban::suggest((string)($_GET['q'] ?? '')); echo json_encode($res, JSON_UNESCAPED_UNICODE); break; } if ($act === 'thumb') { // 浏览器不能直连豆瓣图片(防盗链),由服务端代取;只读、不落盘 $img = Douban::thumb((string)($_GET['u'] ?? '')); if ($img === null) { http_response_code(404); break; } header('Content-Type: ' . $img['mime']); header('Content-Length: ' . strlen($img['body'])); header('Cache-Control: public, max-age=86400'); echo $img['body']; break; } if ($act !== '') { http_response_code(400); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => '不支持的 action'], JSON_UNESCAPED_UNICODE); break; } // 默认:抓取详情 + 海报入库(写存储,访客不行)。JSON 错误而非 302, // 前端才能在会话过期/无权限时给出明确提示。 header('Content-Type: application/json; charset=utf-8'); if (!Auth::canWrite()) { http_response_code(403); echo json_encode(['error' => '访客账号没有写权限'], JSON_UNESCAPED_UNICODE); break; } $idDb = trim((string)($_GET['id'] ?? '')); $qDb = trim((string)($_GET['q'] ?? '')); if ($idDb !== '') { $resDb = Douban::fetchById($idDb); } elseif ($qDb !== '') { $resDb = Douban::fetchByTitle($qDb); } else { $resDb = ['ok' => false, 'error' => '请输入电影名称']; } if (!$resDb['ok']) { echo json_encode(['error' => $resDb['error']], JSON_UNESCAPED_UNICODE); break; } // 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口) $posterKey = ''; $posterShow = ''; if (($resDb['poster_url'] ?? '') !== '') { $bytes = Douban::downloadPoster((string)$resDb['poster_url']); if ($bytes !== '') { $tmp = tempnam(sys_get_temp_dir(), 'fbdb'); if ($tmp !== false) { file_put_contents($tmp, $bytes); $up = Upload::image($familyId, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster'); @unlink($tmp); if ($up['ok']) { $posterKey = (string)$up['file']; $posterShow = \App\Storage\Storage::url($familyId, $posterKey); } } } } // 清掉上一次抓取但没被采用的海报(每次「自动获取」都下一张,不清就堆孤儿) $oldPoster = trim((string)($_GET['old_poster'] ?? '')); if ($oldPoster !== '' && $oldPoster !== $posterKey && preg_match('#^posters/[A-Za-z0-9._-]+$#', $oldPoster)) { $stRef = Database::get()->prepare('SELECT COUNT(*) FROM movie_details WHERE poster_key = ?'); $stRef->execute([$oldPoster]); if ((int)$stRef->fetchColumn() === 0) { \App\Storage\Storage::delete($familyId, $oldPoster); } } echo json_encode([ 'ok' => true, 'source' => 'douban', 'title' => (string)($resDb['title'] ?? ''), 'meta' => $resDb['meta'], 'poster_key' => $posterKey, 'poster' => $posterShow, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); break; // ---------------- 影院管理(v9,MovieLog 同款) ---------------- /** 影院管理单页:上面加/编表单,下面列表。GET 带参数时进入编辑态。 */ case 'movies/cinemas': $familyId = (int)Auth::familyId(); $editId = (int)($_GET['edit'] ?? 0); $editing = $editId > 0 ? Cinema::find($familyId, $editId) : null; View::render('movies/cinemas', [ 'title' => '影院管理', 'cinemas' => Cinema::all($familyId), 'editing' => $editing, ], 'movies'); break; case 'movies/cinemas_save': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $editing = $id > 0 ? Cinema::find($familyId, $id) : null; if ($id > 0 && !$editing) { flash('err', '找不到这家影院。'); redirect('movies/cinemas'); } $err = Cinema::save( $familyId, $id, trim((string)($_POST['place'] ?? '')), trim((string)($_POST['name'] ?? '')), trim((string)($_POST['address'] ?? '')) !== '' ? trim((string)($_POST['address'] ?? '')) : null, Cinema::normalizeColor((string)($_POST['color'] ?? '#888888')) ); if ($err !== '') { flash('err', $err); redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []); } flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。'); redirect('movies/cinemas'); break; case 'movies/cinemas_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if (Cinema::find($familyId, $id)) { Cinema::destroy($familyId, $id); flash('ok', '影院已删除,关联观影记录的影院信息已置空。'); } else { flash('err', '找不到这家影院。'); } redirect('movies/cinemas'); break; // ---------------- 统计 / 高级搜索 / 年度报告(v11,从 MovieLog 迁入) ---------------- // 三个页面都挂在观影模块「设置」按钮下(顶栏不再单独放影院/统计入口)。 // 只读,不需要 requireWrite —— 访客也能看统计,与观影列表同一口径。 /** 统计仪表板 */ case 'movies/stats': $familyId = (int)Auth::familyId(); View::render('movies/stats', [ 'title' => '观影统计', 'ov' => MovieStats::overview($familyId), 'byCinema' => MovieStats::byCinema($familyId), 'byPerson' => MovieStats::byPerson($familyId), 'byMonth' => MovieStats::byMonth($familyId, 12), 'byYear' => MovieStats::byYear($familyId), ], 'movies'); break; /** 高级搜索(组合条件;条件全空时不查,页面提示先设置条件) */ case 'movies/search': $familyId = (int)Auth::familyId(); $p = [ 'title' => trim((string)($_GET['title'] ?? '')), 'cinema_id' => trim((string)($_GET['cinema_id'] ?? '')), 'person_id' => trim((string)($_GET['person_id'] ?? '')), 'date_from' => trim((string)($_GET['date_from'] ?? '')), 'date_to' => trim((string)($_GET['date_to'] ?? '')), ]; $hasCond = false; foreach ($p as $v) { if ($v !== '' && $v !== '0') { $hasCond = true; break; } } View::render('movies/search', [ 'title' => '高级搜索', 'cinemas' => Cinema::all($familyId), 'persons' => Person::all($familyId, '', 'member', (int)Auth::personId()), 'p' => $p, 'hasCond' => $hasCond, 'rows' => $hasCond ? MovieStats::search($familyId, $p) : [], ], 'movies'); break; /** 年度报告(以登录者为中心) */ case 'movies/report': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); $years = MovieStats::years($familyId); $year = (int)($_GET['year'] ?? 0); if ($year <= 0 || !in_array($year, $years, true)) { $year = $years ? (int)$years[0] : (int)date('Y'); } $people = Person::map($familyId); View::render('movies/report', [ 'title' => $year . ' 年度观影报告', 'year' => $year, 'years' => $years ?: [$year], 'stats' => MovieStats::annual($familyId, $meId, $year), 'meName' => (string)($people[$meId]['name'] ?? '我'), ], 'movies'); break; // ---------------- 评价与讨论(v9,MovieLog 同款) ---------------- // 评价人是「登录账号背后的人」,与同行观影人无关;访客只读(requireWrite)。 case 'movies/review_save': $familyId = (int)Auth::familyId(); $meId = (int)Auth::personId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); $m = Movie::find($familyId, $eventId); if (!$m) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $my = MovieReview::mine($eventId, $meId); $err = MovieReview::save( $eventId, $familyId, $meId, (int)($_POST['rating'] ?? 0), trim((string)($_POST['content'] ?? '')), array_values((array)($_POST['feelings'] ?? [])), $my !== null ); if ($err !== '') { flash('err', $err); } else { flash('ok', $my !== null ? '评价已更新。' : '评价已发布。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/review_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } if (!MovieReview::deleteMine((int)($_POST['review_id'] ?? 0), (int)Auth::personId())) { flash('err', '只能删除自己的评价。'); } else { flash('ok', '评价已删除。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/comment_add': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); $reviewId = (int)($_POST['review_id'] ?? 0); $parentId = (int)($_POST['parent_id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } $res = MovieReview::addComment( $reviewId, (int)Auth::personId(), trim((string)($_POST['content'] ?? '')), array_values((array)($_POST['feelings'] ?? [])), $parentId ); if (is_string($res)) { flash('err', $res); } else { flash('ok', $parentId > 0 ? '回复已发表。' : '评论已发表。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; case 'movies/comment_del': $familyId = (int)Auth::familyId(); Auth::requireWrite(); csrf_check(); $eventId = (int)($_POST['id'] ?? 0); if (!Movie::find($familyId, $eventId)) { flash('err', '找不到这条观影记录。'); redirect('movies'); } if (!MovieReview::deleteComment((int)($_POST['comment_id'] ?? 0), (int)Auth::personId())) { flash('err', '只能删除自己的评论。'); } else { flash('ok', '评论已删除。'); } redirect('movies/show', ['id' => $eventId], 'reviews'); break; default: http_response_code(404); View::render('error', [ 'title' => '页面不存在', 'message' => '找不到这一页。', ], 'home'); break; }