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) { // 升级失败不能把整站挡住;下次请求会再试 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'); } 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) : ''; } 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, ], '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(); $rows = Person::all($familyId, $q); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 名册里也标出「我该怎么称呼他」 foreach ($rows as &$row) { $row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people); } unset($row); View::render('people/index', [ 'title' => '家庭名册', 'people' => $rows, 'q' => $q, 'meId' => $me, ], 'people'); break; case 'people/new': Auth::requireWrite(); clear_old(); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => [], ], 'people'); break; case 'people/create': Auth::requireWrite(); csrf_check(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'nickname' => trim((string)($_POST['nickname'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'birthday' => trim((string)($_POST['birthday'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'avatar' => '', 'kind' => 'member', // ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉, // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, ]; $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], ], 'people'); exit; } if ($up['ok']) { $d['avatar'] = $up['file']; } try { $id = Person::create((int)Auth::familyId(), $d); } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => '添加成员', 'person' => null, 'errors' => ['phone' => '这个手机号已经在名册里了'], 'phoneValue' => $d['phone'], 'idCardValue'=> $d['id_card'], ], 'people'); exit; } clear_old(); flash('ok', '已把「' . $d['name'] . '」加进名册。'); redirect('people/show', ['id' => $id]); break; case 'people/edit': Auth::requireWrite(); $p = Person::find((int)($_GET['id'] ?? 0)); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } clear_old(); View::render('people/form', [ 'title' => '编辑成员', 'person' => $p, 'errors' => [], 'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']), 'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']), ], 'people'); break; case 'people/update': Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); redirect('people'); } $d = [ 'name' => trim((string)($_POST['name'] ?? '')), 'nickname' => trim((string)($_POST['nickname'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), 'birthday' => trim((string)($_POST['birthday'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'avatar' => (string)$p['avatar'], 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, ]; $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } if ($errors) { set_old($_POST); View::render('people/form', [ 'title' => '编辑成员', 'person' => $p, 'errors' => $errors, 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], ], 'people'); exit; } if ($up['ok']) { Upload::remove((int)Auth::familyId(), (string)$p['avatar']); $d['avatar'] = $up['file']; } try { Person::update($id, $d); } catch (\PDOException $ex) { set_old($_POST); View::render('people/form', [ 'title' => '编辑成员', 'person' => $p, 'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'], 'phoneValue' => $d['phone'], 'idCardValue' => $d['id_card'], ], 'people'); exit; } if ((int)$p['id'] === (int)Auth::personId()) { Auth::refreshAvatar((string)$d['avatar']); } clear_old(); 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); 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), 'kinReady' => Kinship::graph($familyId) !== [], ], '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'); 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) { if ($role === 'member' && $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); Relations::remove($familyId, $id); flash('ok', '这条关系已解除。'); redirect('people/show', ['id' => $who]); break; // ==================== 访客邀请 ==================== case 'accounts/invite': Auth::requireAdmin(); clear_old(); View::render('people/invite', [ 'title' => '邀请访客', 'people' => Person::all((int)Auth::familyId()), '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, 'nickname' => '', 'gender' => '', 'birthday' => '', 'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note, 'sort_order' => 2000, 'kind' => 'contact', ]); Account::create($pid, $pass, 'guest'); 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), '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(); 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()), '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()), ], '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(); Family::update( (int)Auth::familyId(), trim((string)($_POST['name'] ?? '家书')), trim((string)($_POST['motto'] ?? '')) ); flash('ok', '已保存。'); redirect('settings'); break; default: http_response_code(404); View::render('error', [ 'title' => '页面不存在', 'message' => '找不到这一页。', ], 'home'); break; }