diff --git a/app/Account.php b/app/Account.php index d988c27..171c522 100644 --- a/app/Account.php +++ b/app/Account.php @@ -9,6 +9,8 @@ namespace App; final class Account { + public const ROLES = ['admin', 'member', 'guest']; + public static function findByPerson(int $personId): ?array { $st = Database::get()->prepare('SELECT * FROM accounts WHERE person_id = ?'); @@ -19,6 +21,7 @@ final class Account public static function create(int $personId, string $password, string $role = 'member'): int { + $role = in_array($role, self::ROLES, true) ? $role : 'member'; $st = Database::get()->prepare( 'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)' ); @@ -34,6 +37,7 @@ final class Account public static function setRole(int $accountId, string $role): void { + $role = in_array($role, self::ROLES, true) ? $role : 'member'; Database::get()->prepare('UPDATE accounts SET role = ? WHERE id = ?') ->execute([$role, $accountId]); } @@ -44,6 +48,24 @@ final class Account ->execute([$status, $accountId]); } + /** 各角色各有多少人(设置页显示) */ + public static function roleCounts(): array + { + $st = Database::get()->query( + "SELECT role, COUNT(*) AS n FROM accounts WHERE status = 'active' GROUP BY role" + ); + $out = ['admin' => 0, 'member' => 0, 'guest' => 0]; + foreach ($st->fetchAll() as $r) { + $out[(string)$r['role']] = (int)$r['n']; + } + return $out; + } + + public static function roleWord(string $role): string + { + return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][$role] ?? '成员'; + } + /** 管理员不能被停用/降权 —— 否则全家人都会被锁在系统外面 */ public static function adminCount(): int { diff --git a/app/Auth.php b/app/Auth.php index 4124b6e..1681b04 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -43,6 +43,39 @@ final class Auth return ($_SESSION['role'] ?? '') === 'admin'; } + /** 访客:只能看被授权的信息,不能创建/编辑任何东西 */ + public static function isGuest(): bool + { + return ($_SESSION['role'] ?? '') === 'guest'; + } + + /** 能正常使用系统的人(管理员或家庭成员)—— 判断「能否写」用这个 */ + public static function canWrite(): bool + { + $r = $_SESSION['role'] ?? ''; + return $r === 'admin' || $r === 'member'; + } + + public static function role(): string + { + return (string)($_SESSION['role'] ?? ''); + } + + public static function roleWord(): string + { + return ['admin' => '管理员', 'member' => '家庭成员', 'guest' => '访客'][self::role()] ?? '成员'; + } + + /** + * 首页标题 —— 「以我为中心」最直接的一处落点。 + * 登录后看到的不是「家书」,而是「老杨的家书」。 + */ + public static function homeTitle(): string + { + $name = self::name(); + return $name === '' ? '家书' : $name . '的家书'; + } + /** 未登录跳登录页 */ public static function requireLogin(): void { @@ -51,6 +84,20 @@ final class Auth } } + /** 需要「能写」的权限(访客会被挡下) */ + public static function requireWrite(): void + { + self::requireLogin(); + if (!self::canWrite()) { + http_response_code(403); + \App\View::render('error', [ + 'title' => '访客只读', + 'message' => '你的身份是访客,只能查看,不能新增或修改内容。', + ], 'home'); + exit; + } + } + public static function requireAdmin(): void { self::requireLogin(); diff --git a/app/Kinship.php b/app/Kinship.php new file mode 100644 index 0000000..3c95fda --- /dev/null +++ b/app/Kinship.php @@ -0,0 +1,486 @@ + [parentId => 'father'|'mother'] + $children = []; // parentId => [childId, ...] + $spouses = []; // id => [spouseId, ...] + + $st = Database::get()->prepare( + 'SELECT from_id, to_id, kind, from_role FROM relations WHERE family_id = ?' + ); + $st->execute([$familyId]); + foreach ($st->fetchAll() as $r) { + $f = (int)$r['from_id']; + $t = (int)$r['to_id']; + if ($r['kind'] === 'parent') { + $role = $r['from_role'] === 'mother' ? 'mother' : 'father'; + $parents[$t][$f] = $role; + $children[$f][] = $t; + } else { + $spouses[$f][] = $t; + $spouses[$t][] = $f; + } + } + foreach ($children as $k => $v) { + $children[$k] = array_values(array_unique($v)); + } + + return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses]; + } + + /** 某人的父母(father / mother 两个键,可能缺) */ + public static function parents(array $g, int $id): array + { + $out = ['father' => null, 'mother' => null]; + foreach ($g['parents'][$id] ?? [] as $pid => $role) { + $out[$role] = (int)$pid; + } + return $out; + } + + /** 父母的 id 列表(不含 null),判断「跟我有关系」时用 */ + public static function parentIds(array $g, int $id): array + { + $out = []; + foreach (self::parents($g, $id) as $pid) { + if ($pid !== null) { + $out[] = $pid; + } + } + return $out; + } + + public static function spouseIds(array $g, int $id): array + { + return $g['spouses'][$id] ?? []; + } + + /** 兄弟姐妹:与「我」共享至少一位父母的人(不含自己) */ + public static function siblingIds(array $g, int $id): array + { + $sib = []; + foreach ($g['parents'][$id] ?? [] as $pid => $_) { + foreach ($g['children'][$pid] ?? [] as $c) { + if ((int)$c !== $id) { + $sib[(int)$c] = true; + } + } + } + return array_keys($sib); + } + + /** 子女 */ + public static function childIds(array $g, int $id): array + { + return array_map('intval', $g['children'][$id] ?? []); + } + + /** + * 祖父母那一级:往上走一步再取父母。 + * 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。 + */ + public static function grandparents(array $g, int $id): array + { + $out = ['paternal' => ['father' => null, 'mother' => null], + 'maternal' => ['father' => null, 'mother' => null]]; + $p = self::parents($g, $id); + if ($p['father']) { + $out['paternal'] = self::parents($g, $p['father']); + } + if ($p['mother']) { + $out['maternal'] = self::parents($g, $p['mother']); + } + return $out; + } + + /** + * 我该怎么称呼对方 —— 整个系统的「以我为中心」就落在这个函数上。 + * + * 推导顺序:自己 → 配偶 → 父母/子女 → 兄弟姐妹 → 父母的兄弟姐妹 → + * 配偶的父母 → 祖父母/孙辈 → 姻亲 → 兜底「亲属」 + */ + public static function label(array $g, int $me, int $other, array $people): string + { + if ($me === $other) { + return '我'; + } + + $gender = strtolower((string)($people[$other]['gender'] ?? 'x')); + $isMale = $gender === 'm'; + $isKnown = $gender === 'm' || $gender === 'f'; + + // ---- 配偶 ---- + if (in_array($other, self::spouseIds($g, $me), true)) { + return self::spouseLabel($g, $me, $isMale); + } + + // ---- 直系:父母 / 子女 ---- + $myParents = self::parents($g, $me); + foreach (['father', 'mother'] as $role) { + if ($myParents[$role] === $other) { + return $role === 'father' ? '父亲' : '母亲'; + } + } + if (in_array($other, self::childIds($g, $me), true)) { + return self::rankLabel($g, $people, $me, $other, $isMale, $isKnown); + } + + // ---- 兄弟姐妹(含排行)---- + foreach (self::siblingIds($g, $me) as $sib) { + if ($sib === $other) { + // compareRank(我, 他) < 0 表示他的排行键更小 —— 也就是他比我年长。 + // 方向很容易写反:这里比的是「他 vs 我」,不是「我 vs 他」。 + $heIsOlder = self::compareRank($people, $other, $me) < 0; + if ($isKnown) { + if ($isMale) { + return $heIsOlder ? '哥哥' : '弟弟'; + } + return $heIsOlder ? '姐姐' : '妹妹'; + } + return $heIsOlder ? '哥哥' : '弟弟'; + } + } + + // ---- 父母的兄弟姐妹:伯/叔/姑/舅/姨 ---- + $uncle = self::uncleLabel($g, $people, $me, $other); + if ($uncle !== null) { + return $uncle; + } + + // ---- 配偶的父母:公公婆婆 / 岳父岳母 ---- + foreach (self::spouseIds($g, $me) as $sp) { + $spParents = self::parents($g, $sp); + foreach (['father', 'mother'] as $role) { + if ($spParents[$role] === $other) { + $iAmMale = strtolower((string)($people[$me]['gender'] ?? 'x')) === 'm'; + if ($role === 'father') { + return $iAmMale ? '岳父' : '公公'; + } + return $iAmMale ? '岳母' : '婆婆'; + } + } + } + + // ---- 祖父母 / 外祖父母 ---- + $gp = self::grandparents($g, $me); + foreach (['paternal' => '爷爷', 'maternal' => '外公'] as $side => $maleWord) { + $femaleWord = $side === 'paternal' ? '奶奶' : '外婆'; + if (($gp[$side]['father'] ?? null) === $other) { + return $maleWord; + } + if (($gp[$side]['mother'] ?? null) === $other) { + return $femaleWord; + } + } + + // ---- 孙辈 / 曾孙辈 ---- + // 只往下走两级:孙辈(子女的子女)与曾孙辈(子女的孙辈)。 + // 再往下就不必细分了,统一「后代」。 + foreach (self::childIds($g, $me) as $child) { + foreach (self::childIds($g, $child) as $gc) { + if ($gc === $other) { + if (!$isKnown) { + return '孙辈'; + } + return $isMale ? '孙子' : '孙女'; + } + if (in_array($other, self::childIds($g, $gc), true)) { + if (!$isKnown) { + return '后代'; + } + return $isMale ? '曾孙' : '曾孙女'; + } + } + } + + // ---- 姻亲:配偶的兄弟姐妹 / 兄弟姐妹的配偶 ---- + foreach (self::spouseIds($g, $me) as $sp) { + if (in_array($other, self::siblingIds($g, $sp), true)) { + return $isKnown ? ($isMale ? '大舅子/小舅子' : '大姨子/小姨子') : '姻亲'; + } + } + foreach (self::siblingIds($g, $me) as $sib) { + if (in_array($other, self::spouseIds($g, $sib), true)) { + return $isKnown ? ($isMale ? '姐夫/妹夫' : '嫂子/弟媳') : '姻亲'; + } + } + + // ---- 表亲堂亲:父母的兄弟姐妹的子女 ---- + foreach (self::parentSiblings($g, $me) as $ps) { + if (in_array($other, self::childIds($g, $ps), true)) { + return '表亲'; + } + } + + return '亲属'; + } + + /** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */ + private static function spouseLabel(array $g, int $me, bool $otherIsMale): string + { + if ($otherIsMale) { + return '丈夫'; + } + return '妻子'; + } + + /** + * 子女的排行称呼:长子/次子/三子…,长女/次女/三女… + * 优先用 persons.birth_rank;没填就按生日或 id 推。 + * + * ⚠️ $isMale 必须由调用方按 gender 传入 —— 早期这里写死成「子」, + * 结果女儿一律显示成「次子」,是很容易漏的一类 bug。 + * 性别未知时退化成中性的「孩子」,不要瞎猜成儿子。 + */ + private static function rankLabel(array $g, array $people, int $parent, int $child, bool $isMale, bool $known): string + { + $base = $known ? ($isMale ? '子' : '女') : '孩子'; + $sibs = self::childIds($g, $parent); + if (count($sibs) <= 1) { + return $known ? ($isMale ? '儿子' : '女儿') : '孩子'; + } + $rank = self::rankOf($people, $sibs, $child); + $prefix = ['', '长', '次', '三', '四', '五', '六', '七', '八', '九'][$rank] ?? ($rank . ''); + return $prefix === '' ? $base : $prefix . $base; + } + + /** 我在兄弟姐妹里排第几(1 起) */ + private static function rankOf(array $people, array $sibs, int $id): int + { + $sorted = $sibs; + usort($sorted, function ($a, $b) use ($people) { + $ra = self::rankValue($people, $a); + $rb = self::rankValue($people, $b); + return $ra <=> $rb; + }); + $pos = array_search($id, $sorted, true); + return $pos === false ? 1 : $pos + 1; + } + + /** 排序用的键:显式 birth_rank 优先,否则用生日,再否则用 id */ + private static function rankValue(array $people, int $id): string + { + $p = $people[$id] ?? []; + if (!empty($p['birth_rank'])) { + return sprintf('%06d', (int)$p['birth_rank']); + } + if (!empty($p['birthday'])) { + return '5' . (string)$p['birthday']; + } + return '9' . sprintf('%09d', $id); + } + + /** 谁是哥哥谁是弟弟:比较排行键 */ + private static function compareRank(array $people, int $a, int $b): int + { + return strcmp(self::rankValue($people, $a), self::rankValue($people, $b)); + } + + /** 父母的兄弟姐妹(伯父/叔叔/姑姑/舅舅/姨妈) */ + private static function uncleLabel(array $g, array $people, int $me, int $other): ?string + { + $sibs = []; + $myParents = self::parents($g, $me); + $paternal = $myParents['father'] !== null; + foreach (['father' => true, 'mother' => false] as $role => $isPat) { + $pid = $myParents[$role]; + if ($pid === null) { + continue; + } + foreach (self::siblingIds($g, $pid) as $sib) { + $sibs[$sib] = ['side' => $isPat ? 'paternal' : 'maternal', 'parent' => $pid]; + } + } + if (!isset($sibs[$other])) { + return null; + } + $side = $sibs[$other]['side']; + $parent = $sibs[$other]['parent']; + $gender = strtolower((string)($people[$other]['gender'] ?? 'x')); + $isMale = $gender === 'm'; + $older = self::compareRank($people, $other, $parent) < 0; // 比我的父母年长 + + if ($side === 'paternal') { + if ($isMale) { + return $older ? '伯父' : '叔叔'; + } + return $gender === 'f' ? '姑姑' : '姑姑'; + } + if ($isMale) { + return '舅舅'; + } + return $gender === 'f' ? '姨妈' : '姨妈'; + } + + /** 父母的兄弟姐妹(不含配偶那边的) */ + private static function parentSiblings(array $g, int $me): array + { + $out = []; + foreach (self::parents($g, $me) as $pid) { + if ($pid === null) { + continue; + } + foreach (self::siblingIds($g, $pid) as $sib) { + $out[] = $sib; + } + } + return $out; + } + + // ================= 家谱渲染所需的结构化数据 ================= + + /** + * 以 $me 为中心,向上向下各展开若干代,产出可渲染的家谱树。 + * + * 返回结构: + * [ + * 'generations' => [ -2 => [节点...], -1 => [...], 0 => [...], 1 => [...] ], + * 'nodes' => [ id => ['id','name','label','rel'][...] ], + * ] + * 其中 label 是「我该怎么称呼他」,rel 是「他是我的谁」(用于画连接线)。 + */ + public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array + { + $gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1) + $seen = []; + $queue = [[$me, 0]]; + $seen[$me] = true; + + while ($queue) { + [$id, $level] = array_shift($queue); + + // 配偶永远同代 + foreach (self::spouseIds($g, $id) as $sp) { + if (!isset($seen[$sp])) { + $seen[$sp] = true; + $gen[$sp] = $level; + $queue[] = [$sp, $level]; + } + } + // 往上 + if ($level > -$up) { + foreach (self::parents($g, $id) as $pid) { + if ($pid !== null && !isset($seen[$pid])) { + $seen[$pid] = true; + $gen[$pid] = $level - 1; + $queue[] = [$pid, $level - 1]; + } + } + } + // 往下 + if ($level < $down) { + foreach (self::childIds($g, $id) as $cid) { + if (!isset($seen[$cid])) { + $seen[$cid] = true; + $gen[$cid] = $level + 1; + $queue[] = [$cid, $level + 1]; + } + } + } + } + $gen[$me] = 0; + + $generations = []; + $nodes = []; + foreach ($gen as $id => $level) { + if (!isset($people[$id])) { + continue; + } + $label = self::label($g, $me, $id, $people); + $nodes[$id] = [ + 'id' => $id, + 'name' => (string)$people[$id]['name'], + 'label' => $label, + 'rel' => self::relationWord($g, $me, $id), + 'avatar'=> (string)($people[$id]['avatar'] ?? ''), + 'gender'=> (string)($people[$id]['gender'] ?? 'x'), + 'ph' => (int)($people[$id]['is_placeholder'] ?? 0) === 1, + 'person'=> $people[$id], + ]; + $generations[$level][] = $id; + } + ksort($generations); + foreach ($generations as &$ids) { + // 同代内排序:先配偶,再按排行 + usort($ids, function ($a, $b) use ($people) { + return strcmp(self::rankValue($people, $a), self::rankValue($people, $b)); + }); + } + unset($ids); + + return ['generations' => $generations, 'nodes' => $nodes, 'gen' => $gen]; + } + + /** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */ + public static function relationWord(array $g, int $me, int $other): string + { + if ($me === $other) { + return 'self'; + } + if (in_array($other, self::spouseIds($g, $me), true)) { + return 'spouse'; + } + if (self::parents($g, $me)['father'] === $other || self::parents($g, $me)['mother'] === $other) { + return 'parent'; + } + if (in_array($other, self::childIds($g, $me), true)) { + return 'child'; + } + if (in_array($other, self::siblingIds($g, $me), true)) { + return 'sibling'; + } + foreach (self::childIds($g, $me) as $c) { + foreach (self::childIds($g, $c) as $gc) { + if ($gc === $other || in_array($other, self::childIds($g, $gc), true)) { + return 'grandchild'; + } + } + } + foreach (self::grandparents($g, $me) as $side) { + if ($side['father'] === $other || $side['mother'] === $other) { + return 'grandparent'; + } + } + return 'kin'; + } + + /** 把 id 列表变成 名字 => 称谓 的列表,供页面直接渲染 */ + public static function describe(array $g, int $me, array $ids, array $people): array + { + $out = []; + foreach ($ids as $id) { + if (isset($people[$id])) { + $out[] = [ + 'id' => (int)$id, + 'name' => (string)$people[$id]['name'], + 'label' => self::label($g, $me, (int)$id, $people), + ]; + } + } + return $out; + } +} \ No newline at end of file diff --git a/app/Person.php b/app/Person.php index d369ed6..a5e3355 100644 --- a/app/Person.php +++ b/app/Person.php @@ -72,9 +72,10 @@ final class Person { $phone = $d['phone'] ?? ''; $sql = 'INSERT INTO persons - (family_id, kind, name, nickname, gender, birthday, avatar, - phone_hash, phone_encrypted, id_card_encrypted, email, address, note, sort_order) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; + (family_id, kind, name, nickname, gender, birth_rank, birthday, avatar, + phone_hash, phone_encrypted, id_card_encrypted, email, address, note, + is_placeholder, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; $st = Database::get()->prepare($sql); $st->execute([ $familyId, @@ -82,6 +83,7 @@ final class Person $d['name'], $d['nickname'] !== '' ? $d['nickname'] : null, $d['gender'] !== '' ? $d['gender'] : null, + !empty($d['birth_rank']) ? (int)$d['birth_rank'] : null, $d['birthday'] !== '' ? $d['birthday'] : null, $d['avatar'] !== '' ? $d['avatar'] : null, $phone !== '' ? Crypto::hash($phone) : null, @@ -90,6 +92,7 @@ final class Person $d['email'] !== '' ? $d['email'] : null, $d['address'] !== '' ? $d['address'] : null, $d['note'] !== '' ? $d['note'] : null, + !empty($d['is_placeholder']) ? 1 : 0, (int)($d['sort_order'] ?? 1000), ]); return (int)Database::get()->lastInsertId(); @@ -99,14 +102,15 @@ final class Person { $phone = $d['phone'] ?? ''; $sql = 'UPDATE persons SET - name = ?, nickname = ?, gender = ?, birthday = ?, avatar = ?, + name = ?, nickname = ?, gender = ?, birth_rank = ?, birthday = ?, avatar = ?, phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?, - email = ?, address = ?, note = ?, sort_order = ? + email = ?, address = ?, note = ?, is_placeholder = ?, sort_order = ? WHERE id = ?'; Database::get()->prepare($sql)->execute([ $d['name'], $d['nickname'] !== '' ? $d['nickname'] : null, $d['gender'] !== '' ? $d['gender'] : null, + !empty($d['birth_rank']) ? (int)$d['birth_rank'] : null, $d['birthday'] !== '' ? $d['birthday'] : null, $d['avatar'] !== '' ? $d['avatar'] : null, $phone !== '' ? Crypto::hash($phone) : null, @@ -115,6 +119,7 @@ final class Person $d['email'] !== '' ? $d['email'] : null, $d['address'] !== '' ? $d['address'] : null, $d['note'] !== '' ? $d['note'] : null, + !empty($d['is_placeholder']) ? 1 : 0, (int)($d['sort_order'] ?? 1000), $id, ]); @@ -132,6 +137,30 @@ final class Person Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]); } + /** 全部家庭成员,以 id 为键 —— 家谱推导要按 id 频繁取人,这样最省事 */ + public static function map(int $familyId, string $kind = 'member'): array + { + $st = Database::get()->prepare('SELECT * FROM persons WHERE family_id = ? AND kind = ?'); + $st->execute([$familyId, $kind]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['id']] = $r; + } + return $out; + } + + /** 只给「选人」用的精简列表(下拉框、关系选择器) */ + public static function options(int $familyId, int $exceptId = 0): array + { + $st = Database::get()->prepare( + 'SELECT id, name, nickname, gender, is_placeholder FROM persons + WHERE family_id = ? AND kind = "member" AND id <> ? + ORDER BY sort_order ASC, id ASC' + ); + $st->execute([$familyId, $exceptId]); + return $st->fetchAll(); + } + /** * 最近要过生日的人(首页用)。 * 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。 @@ -184,6 +213,16 @@ final class Person if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) { $err['id_card'] = '身份证号应为 15 或 18 位'; } + $rank = $d['birth_rank'] ?? ''; + if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) { + $err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)'; + } return $err; } + + /** 角色与人物的中文名 */ + public static function genderWord(string $g): string + { + return ['m' => '男', 'f' => '女'][$g] ?? ''; + } } diff --git a/app/Relations.php b/app/Relations.php new file mode 100644 index 0000000..238b3c2 --- /dev/null +++ b/app/Relations.php @@ -0,0 +1,131 @@ + false, 'error' => '不能把自己设成自己的父母。']; + } + if (self::wouldCycle($familyId, $parentId, $childId)) { + return ['ok' => false, 'error' => '这样会让两个人互为祖辈,关系说不通,已拒绝。']; + } + // 同一位父母只能有一个「父亲」和一个「母亲」位 + $db = Database::get(); + $st = $db->prepare( + 'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent" AND from_role = ?' + ); + $st->execute([$familyId, $childId, $role]); + + $st = $db->prepare( + 'INSERT INTO relations (family_id, from_id, to_id, kind, from_role) VALUES (?,?,?,"parent",?)' + ); + $st->execute([$familyId, $parentId, $childId, $role]); + return ['ok' => true, 'error' => '']; + } + + /** 互为配偶(对称,只存一条;若已存在反向的记录则视为重复) */ + public static function addSpouse(int $familyId, int $a, int $b): array + { + if ($a === $b) { + return ['ok' => false, 'error' => '不能把自己设成自己的配偶。']; + } + $db = Database::get(); + $st = $db->prepare( + 'SELECT id FROM relations + WHERE family_id = ? AND kind = "spouse" + AND ((from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?))' + ); + $st->execute([$familyId, $a, $b, $b, $a]); + if ($st->fetchColumn()) { + return ['ok' => true, 'error' => '']; + } + $st = $db->prepare( + 'INSERT INTO relations (family_id, from_id, to_id, kind) VALUES (?,?,?,"spouse")' + ); + $st->execute([$familyId, $a, $b]); + return ['ok' => true, 'error' => '']; + } + + /** 解除某条关系(按 id) */ + public static function remove(int $familyId, int $id): void + { + Database::get()->prepare('DELETE FROM relations WHERE id = ? AND family_id = ?') + ->execute([$id, $familyId]); + } + + /** 解除两人之间的某种关系 */ + public static function detach(int $familyId, int $personId, string $kind, string $role = ''): void + { + $sql = 'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = ? AND from_role = ?'; + Database::get()->prepare($sql)->execute([$familyId, $personId, $kind, $role]); + } + + /** 某人身上挂着的全部关系(详情页编辑用) */ + public static function forPerson(int $familyId, int $personId): array + { + $st = Database::get()->prepare( + 'SELECT r.*, + pf.name AS from_name, pt.name AS to_name + FROM relations r + JOIN persons pf ON pf.id = r.from_id + JOIN persons pt ON pt.id = r.to_id + WHERE r.family_id = ? AND (r.from_id = ? OR r.to_id = ?) + ORDER BY r.kind, r.id' + ); + $st->execute([$familyId, $personId, $personId]); + return $st->fetchAll(); + } + + /** + * 防环:若把 $maybeAncestor 挂到 $node 之上,会不会绕回自己? + * 做法是顺着 parent 边从 $maybeAncestor 往上走,看能否走到 $node。 + */ + private static function wouldCycle(int $familyId, int $maybeAncestor, int $node): bool + { + $st = Database::get()->prepare( + 'SELECT from_id FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent"' + ); + $stack = [$maybeAncestor]; + $seen = []; + while ($stack) { + $cur = array_pop($stack); + if ($cur === $node) { + return true; + } + if (isset($seen[$cur])) { + continue; + } + $seen[$cur] = true; + $st->execute([$familyId, $cur]); + foreach ($st->fetchAll(\PDO::FETCH_COLUMN) as $up) { + $stack[] = (int)$up; + } + } + return false; + } + + /** 全家的关系条数(设置页/统计用) */ + public static function count(int $familyId): int + { + $st = Database::get()->prepare('SELECT COUNT(*) FROM relations WHERE family_id = ?'); + $st->execute([$familyId]); + return (int)$st->fetchColumn(); + } +} \ No newline at end of file diff --git a/app/Schema.php b/app/Schema.php index 463cec4..d618fac 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -1,12 +1,13 @@ exec($sql); + } + self::addColumn($db, 'persons', 'birth_rank', + "ADD COLUMN `birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女' AFTER `gender`"); + self::addColumn($db, 'persons', 'is_placeholder', + "ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`"); + self::widenRoleEnum($db); + } + + /** 列不存在才加 */ + private static function addColumn(\PDO $db, string $table, string $column, string $alter): void + { + $st = $db->prepare( + 'SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?' + ); + $st->execute([$table, $column]); + if ((int)$st->fetchColumn() === 0) { + $db->exec("ALTER TABLE `{$table}` {$alter}"); + } + } + + /** accounts.role 原本只有 admin/member,升级时要把 guest 加进枚举 */ + private static function widenRoleEnum(\PDO $db): void + { + $st = $db->prepare( + "SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'accounts' AND COLUMN_NAME = 'role'" + ); + $st->execute(); + $type = (string)$st->fetchColumn(); + if ($type !== '' && strpos($type, 'guest') === false) { + $db->exec( + "ALTER TABLE `accounts` MODIFY COLUMN `role` + ENUM('admin','member','guest') NOT NULL DEFAULT 'member' + COMMENT '管理员 / 家庭成员 / 访客'" + ); + } + } +} \ No newline at end of file diff --git a/app/Storage/EcsMeta.php b/app/Storage/EcsMeta.php new file mode 100644 index 0000000..794f44e --- /dev/null +++ b/app/Storage/EcsMeta.php @@ -0,0 +1,125 @@ += 1 小时,到期前 5 分钟就刷新 + if (!$force) { + $cached = json_decode((string)Setting::get($familyId, 'cache_sts_aliyun', ''), true); + if (is_array($cached) + && !empty($cached['expires']) && $cached['expires'] > time() + 300 + && ($cached['role'] ?? '') === $roleName) { + return $memo[$memoKey] = $cached + ['ok' => true, 'error' => '']; + } + } + + // IMDSv2:先拿一次性 token(拿不到就降级走 v1,兼容老实例) + $token = ''; + $t = Http::send('PUT', self::BASE . '../api/token', + ['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, self::TIMEOUT); + if ($t['ok'] && trim((string)$t['body']) !== '') { + $token = trim($t['body']); + } + $auth = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : []; + + $credsUrl = self::BASE . 'ram/security-credentials/'; + if ($roleName === '') { + $r = Http::send('GET', $credsUrl, $auth, null, self::TIMEOUT); + if (!$r['ok']) { + return $memo[$memoKey] = self::fail('无法访问 ECS 元数据服务(这台服务器可能不是阿里云 ECS)'); + } + $first = trim((string)strtok((string)$r['body'], "\r\n")); + if ($first === '') { + return $memo[$memoKey] = self::fail('该实例没有绑定 RAM 角色'); + } + $roleName = $first; + } + + $r = Http::send('GET', $credsUrl . rawurlencode($roleName), $auth, null, self::TIMEOUT); + if (!$r['ok']) { + return $memo[$memoKey] = self::fail("取 RAM 角色 {$roleName} 的临时凭据失败"); + } + $json = json_decode((string)$r['body'], true); + if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) { + $code = is_array($json) ? (string)($json['Code'] ?? '') : ''; + return $memo[$memoKey] = self::fail('元数据返回的内容看不懂' . ($code !== '' ? "({$code})" : '')); + } + + $cred = [ + 'ok' => true, + 'id' => (string)$json['AccessKeyId'], + 'secret' => (string)$json['AccessKeySecret'], + 'token' => (string)($json['SecurityToken'] ?? ''), + 'expires' => !empty($json['Expiration']) ? (int)strtotime((string)$json['Expiration']) : time() + 1800, + 'error' => '', + 'role' => $roleName, + ]; + Setting::set($familyId, 'cache_sts_aliyun', json_encode($cred, JSON_UNESCAPED_UNICODE)); + return $memo[$memoKey] = $cred; + } + + /** 本机地域,用于判断内网 endpoint 是否可用(1 小时缓存) */ + public static function region(int $familyId, bool $force = false): string + { + static $memo = null; + if ($memo !== null && !$force) { + return $memo; + } + if (!$force) { + $c = json_decode((string)Setting::get($familyId, 'cache_ecs_region', ''), true); + if (is_array($c) && !empty($c['expires']) && $c['expires'] > time()) { + return $memo = (string)($c['region'] ?? ''); + } + } + $r = Http::send('GET', self::BASE . 'region-id', [], null, self::TIMEOUT); + $region = $r['ok'] ? trim((string)$r['body']) : ''; + Setting::set($familyId, 'cache_ecs_region', + json_encode(['region' => $region, 'expires' => time() + 3600])); + return $memo = $region; + } + + /** 清掉全部元数据缓存(换角色/改配置后调用) */ + public static function clearCache(int $familyId): void + { + foreach (['cache_sts_aliyun', 'cache_ecs_region', 'cache_oss_internal'] as $k) { + Setting::set($familyId, $k, ''); + } + } + + private static function fail(string $msg): array + { + return ['ok' => false, 'id' => '', 'secret' => '', 'token' => '', + 'expires' => 0, 'error' => $msg, 'role' => '']; + } +} \ No newline at end of file diff --git a/app/Storage/Http.php b/app/Storage/Http.php new file mode 100644 index 0000000..3e44d16 --- /dev/null +++ b/app/Storage/Http.php @@ -0,0 +1,175 @@ + $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => $connectTimeout === null ? min(10, max(3, $timeout)) : max(1, $connectTimeout), + CURLOPT_TIMEOUT => $timeout, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) { + $pos = strpos($line, ':'); + if ($pos !== false) { + $name = strtolower(trim(substr($line, 0, $pos))); + $respHeaders[$name] = trim(substr($line, $pos + 1)); + } + return strlen($line); + }, + ]; + if ($method === 'HEAD') { + $opts[CURLOPT_NOBODY] = true; // ← 必须,见上面注释 + } else { + $opts[CURLOPT_CUSTOMREQUEST] = $method; + } + curl_setopt_array($ch, $opts); + + $ca = self::caBundle(); + if ($ca !== '') { + curl_setopt($ch, CURLOPT_CAINFO, $ca); + } + if ($body !== null && $method !== 'HEAD') { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $out = curl_exec($ch); + $err = curl_error($ch); + $errno = curl_errno($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($out === false) { + return [ + 'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', + 'error' => $errno === CURLE_OPERATION_TIMEDOUT + ? "请求超时({$timeout}s)" + : "网络错误({$errno}): {$err}", + ]; + } + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'headers' => $respHeaders, + 'body' => $out, + 'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}", + ]; + } + + /** + * 找系统的 CA 证书。宝塔的 open_basedir 会限制路径访问, + * 所以先判断「在不在允许范围内」再去 is_file,否则会刷 Warning 污染输出。 + */ + public static function caBundle(): string + { + $candidates = [ + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/certs/ca-certificates.crt', + '/etc/ssl/cert.pem', + ini_get('curl.cainfo'), + ini_get('openssl.cafile'), + ]; + $allowed = self::openBasedirPaths(); + foreach ($candidates as $p) { + $p = (string)$p; + if ($p === '') { + continue; + } + if ($allowed !== [] && !self::pathAllowed($p, $allowed)) { + continue; + } + if (@is_file($p)) { + return $p; + } + } + return ''; + } + + /** @return string[] */ + private static function openBasedirPaths(): array + { + $raw = (string)ini_get('open_basedir'); + if ($raw === '') { + return []; + } + $out = []; + foreach (preg_split('/[:;]/', $raw) ?: [] as $p) { + if (trim($p) !== '') { + $out[] = rtrim(trim($p), '/'); + } + } + return $out; + } + + private static function pathAllowed(string $path, array $allowed): bool + { + foreach ($allowed as $base) { + if (strpos($path, $base) === 0 || strpos($base, $path) === 0) { + return true; + } + } + return false; + } + + /** 对象键的 URL 编码:保留 / 分段,其余按 RFC3986 编码 */ + public static function encodeKey(string $key): string + { + $parts = explode('/', $key); + $parts = array_map('rawurlencode', $parts); + return implode('/', $parts); + } + + /** 把可能带协议的域名规整成 host[+port] */ + public static function hostOf(string $domain): string + { + $domain = trim($domain); + if (strpos($domain, '//') !== false) { + $p = parse_url($domain, PHP_URL_HOST); + $domain = $p ?: $domain; + } + return trim($domain, '/'); + } + + /** 错误信息压缩成一行,便于塞进页面提示 */ + public static function shortError(array $res, int $limit = 200): string + { + $msg = (string)($res['error'] ?? ''); + $body = (string)($res['body'] ?? ''); + if ($body !== '' && stripos($body, '') !== false) { + $msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body))); + } + $msg = trim($msg); + return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg; + } +} \ No newline at end of file diff --git a/app/Storage/LocalDriver.php b/app/Storage/LocalDriver.php new file mode 100644 index 0000000..222ebc9 --- /dev/null +++ b/app/Storage/LocalDriver.php @@ -0,0 +1,99 @@ +root = rtrim($root, '/'); + } + + public function id(): string + { + return 'local'; + } + + public function validateConfig(): string + { + return is_dir($this->root) || @mkdir($this->root, 0755, true) + ? '' + : '本地存储目录不可写'; + } + + private function abs(string $key): string + { + // 挡目录穿越:.. 一律拒绝 + $key = ltrim(str_replace('\\', '/', $key), '/'); + if (strpos($key, '..') !== false) { + return $this->root . '/__invalid__'; + } + return $this->root . '/' . $key; + } + + public function putFile(string $srcPath, string $key, ?string $mime = null): array + { + $dst = $this->abs($key); + $dir = dirname($dst); + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + return ['ok' => false, 'error' => '建不了目录:' . $dir]; + } + if (!@copy($srcPath, $dst)) { + return ['ok' => false, 'error' => '复制文件失败']; + } + @chmod($dst, 0644); + return ['ok' => true, 'error' => '']; + } + + public function putContents(string $binary, string $key, ?string $mime = null): array + { + $dst = $this->abs($key); + $dir = dirname($dst); + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + return ['ok' => false, 'error' => '建不了目录:' . $dir]; + } + if (@file_put_contents($dst, $binary) === false) { + return ['ok' => false, 'error' => '写入文件失败']; + } + @chmod($dst, 0644); + return ['ok' => true, 'error' => '']; + } + + public function delete(string $key): array + { + $p = $this->abs($key); + if (is_file($p) && !@unlink($p)) { + return ['ok' => false, 'error' => '删不掉:' . $key]; + } + return ['ok' => true, 'error' => '']; + } + + public function get(string $key, array $extra = []): array + { + $p = $this->abs($key); + if (!is_file($p)) { + return ['ok' => false, 'status' => 404, 'headers' => [], 'body' => '', 'error' => '文件不存在']; + } + $body = (string)@file_get_contents($p); + return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '']; + } + + public function exists(string $key): bool + { + return is_file($this->abs($key)); + } + + /** 本地文件返回站点相对路径(相对站点根,前面带 /) */ + public function url(string $key): string + { + return '/' . ltrim(str_replace('\\', '/', $key), '/'); + } +} \ No newline at end of file diff --git a/app/Storage/OssDriver.php b/app/Storage/OssDriver.php new file mode 100644 index 0000000..2009e2b --- /dev/null +++ b/app/Storage/OssDriver.php @@ -0,0 +1,338 @@ +familyId = $familyId; + $this->cfg = $cfg; + } + + public function id(): string + { + return 'oss'; + } + + public function validateConfig(): string + { + if (trim((string)$this->cfg['bucket']) === '') { + return '没有填 Bucket 名称'; + } + if (trim((string)$this->cfg['endpoint']) === '') { + return '没有填 Endpoint'; + } + $auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' + || (trim((string)($this->cfg['access_key_id'] ?? '')) !== '' + && trim((string)($this->cfg['access_key_secret'] ?? '')) !== ''); + if (!$auth) { + return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」'; + } + return ''; + } + + // ---------------- 凭据 ---------------- + + private function credentials(): array + { + if ($this->cred !== null) { + return $this->cred; + } + if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') { + $c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? '')); + if (empty($c['ok'])) { + return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败']; + } + return $this->cred = $c; + } + $id = trim((string)($this->cfg['access_key_id'] ?? '')); + $secret = trim((string)($this->cfg['access_key_secret'] ?? '')); + if ($id === '' || $secret === '') { + return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret']; + } + return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => '']; + } + + // ---------------- endpoint / 域名 ---------------- + + private function useHttps(): bool + { + return (string)($this->cfg['https'] ?? '1') !== '0'; + } + + private function scheme(): string + { + return $this->useHttps() ? 'https' : 'http'; + } + + private function endpoint(): string + { + return trim((string)$this->cfg['endpoint']); + } + + private function host(): string + { + return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint(); + } + + /** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */ + private function internalEndpoint(): string + { + if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') { + return ''; + } + $ep = $this->endpoint(); + if ($ep === '' || strpos($ep, '-internal.') !== false) { + return ''; + } + if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) { + return $m[1] . '-internal.aliyuncs.com'; + } + return ''; + } + + /** 内网是否可用:先比地域(零成本),必要时才真探一次 */ + private function internalUsable(): bool + { + $internal = $this->internalEndpoint(); + if ($internal === '') { + return false; + } + $cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true); + if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal + && !empty($cached['expires']) && $cached['expires'] > time()) { + return !empty($cached['ok']); + } + return $this->probeInternal($internal); + } + + private function probeInternal(string $internal): bool + { + $ok = false; + $reason = ''; + $bucketRegion = $this->bucketRegion(); + $ecsRegion = EcsMeta::region($this->familyId); + + if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) { + $reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域"; + } else { + $cred = $this->credentials(); + if (!empty($cred['ok'])) { + $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/'; + // HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达 + $res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2); + $ok = $res['status'] > 0; + $reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? '')); + } else { + $reason = '凭据不可用'; + } + } + Setting::set($this->familyId, 'cache_oss_internal', json_encode([ + 'endpoint' => $internal, + 'ok' => $ok ? 1 : 0, + 'reason' => $reason, + 'expires' => time() + ($ok ? 3600 : 1800), + ], JSON_UNESCAPED_UNICODE)); + return $ok; + } + + /** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */ + private function bucketRegion(): string + { + if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) { + return $m[1]; + } + return ''; + } + + // ---------------- 签名 ---------------- + + private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string + { + $canonicalHeaders = ''; + if ((string)($cred['token'] ?? '') !== '') { + $canonicalHeaders = "x-oss-security-token:{$cred['token']}\n"; + } + $canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/'); + $stringToSign = $method . "\n" + . "\n" // Content-MD5(未使用) + . $contentType . "\n" + . $date . "\n" + . $canonicalHeaders + . $canonicalResource; + $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); + return 'OSS ' . $cred['id'] . ':' . $signature; + } + + private function writeHeaders(string $method, string $key, string $contentType, array $cred): array + { + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $h = [ + 'Date: ' . $date, + 'Content-Type: ' . $contentType, + 'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred), + ]; + if ((string)($cred['token'] ?? '') !== '') { + $h[] = 'x-oss-security-token: ' . $cred['token']; + } + return $h; + } + + /** 读请求刻意不发 Content-Type,签名串里传空串 */ + private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array + { + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $h = [ + 'Date: ' . $date, + 'Authorization: ' . $this->authorization($method, $key, '', $date, $cred), + ]; + if ((string)($cred['token'] ?? '') !== '') { + $h[] = 'x-oss-security-token: ' . $cred['token']; + } + foreach ($extra as $x) { + $h[] = $x; + } + return $h; + } + + // ---------------- 对象操作 ---------------- + + private function objectKey(string $key): string + { + $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); + $key = ltrim($key, '/'); + return $prefix === '' ? $key : $prefix . '/' . $key; + } + + private function objectUrl(string $key): string + { + return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key); + } + + public function putFile(string $srcPath, string $key, ?string $mime = null): array + { + if (!is_file($srcPath)) { + return ['ok' => false, 'error' => '本地文件不存在']; + } + $binary = @file_get_contents($srcPath); + if ($binary === false) { + return ['ok' => false, 'error' => '读不出本地文件']; + } + return $this->putContents($binary, $key, $mime); + } + + public function putContents(string $binary, string $key, ?string $mime = null): array + { + if ($err = $this->validateConfig()) { + return ['ok' => false, 'error' => $err]; + } + $cred = $this->credentials(); + if (empty($cred['ok'])) { + return ['ok' => false, 'error' => $cred['error']]; + } + $key = $this->objectKey($key); + $mime = $mime ?: 'application/octet-stream'; + $res = Http::send('PUT', $this->objectUrl($key), + $this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60); + + if (!$res['ok']) { + return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)]; + } + return ['ok' => true, 'error' => '']; + } + + public function delete(string $key): array + { + $cred = $this->credentials(); + if (empty($cred['ok'])) { + return ['ok' => false, 'error' => $cred['error']]; + } + $key = $this->objectKey($key); + $res = Http::send('DELETE', $this->objectUrl($key), + $this->writeHeaders('DELETE', $key, '', $cred), null, 30); + + if (!$res['ok'] && (int)$res['status'] !== 404) { + return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)]; + } + return ['ok' => true, 'error' => '']; + } + + /** 服务端读取(内网优先 + 自动回退外网) */ + public function get(string $key, array $extra = []): array + { + if ($err = $this->validateConfig()) { + return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err]; + } + $cred = $this->credentials(); + if (empty($cred['ok'])) { + return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']]; + } + $key = $this->objectKey($key); + $headers = $this->readHeaders($key, $cred, $extra); + + $hosts = []; + $internal = $this->internalEndpoint(); + if ($internal !== '' && $this->internalUsable()) { + $hosts[] = $internal; + } + $hosts[] = $this->endpoint(); + + $res = null; + foreach ($hosts as $i => $host) { + $isInternal = ($internal !== '' && $host === $internal); + $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host + . '/' . Http::encodeKey($key); + $res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null); + if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) { + continue; // 内网连不上,换外网 + } + break; + } + return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS']; + } + + public function exists(string $key): bool + { + $cred = $this->credentials(); + if (empty($cred['ok'])) { + return false; + } + $key = $this->objectKey($key); + $res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15); + return (int)$res['status'] === 200; + } + + /** 对外地址:优先自定义域名/CDN */ + public function url(string $key): string + { + $key = $this->objectKey($key); + $custom = trim((string)($this->cfg['custom_domain'] ?? '')); + if ($custom !== '') { + $host = Http::hostOf($custom); + return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host) + . '/' . Http::encodeKey($key); + } + return $this->objectUrl($key); + } +} \ No newline at end of file diff --git a/app/Storage/Storage.php b/app/Storage/Storage.php new file mode 100644 index 0000000..1a673fa --- /dev/null +++ b/app/Storage/Storage.php @@ -0,0 +1,194 @@ + 'ak', // ak | ecs_role + 'access_key_id' => '', + 'access_key_secret' => '', + 'bucket' => '', + 'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com(不带 https://) + 'prefix' => '', // 桶内前缀,一个桶放多个站点时用 + 'ram_role_name' => '', // 留空 = 自动取实例绑定的角色 + 'custom_domain' => '', // 可选,CDN / 自定义域名 + 'internal_endpoint' => '1', // 服务端读取优先走内网 + 'https' => '1', + ]; + } + + // ---------------- 配置读写 ---------------- + + public static function driver(int $familyId): string + { + $d = (string)Setting::get($familyId, 'storage_driver', 'local'); + return in_array($d, ['local', 'oss'], true) ? $d : 'local'; + } + + public static function config(int $familyId): array + { + $raw = json_decode((string)Setting::get($familyId, 'storage_config_oss', ''), true); + $saved = is_array($raw) ? $raw : []; + return array_merge(self::ossDefaults(), $saved); + } + + public static function saveConfig(int $familyId, string $driver, array $oss): void + { + $driver = in_array($driver, ['local', 'oss'], true) ? $driver : 'local'; + Setting::set($familyId, 'storage_driver', $driver); + Setting::set($familyId, 'storage_config_oss', + json_encode(array_merge(self::ossDefaults(), $oss), JSON_UNESCAPED_UNICODE)); + EcsMeta::clearCache($familyId); + } + + /** 配置是否填齐 —— 没填齐就强制退回本地,避免「传到云端、地址却按本地算」的错配 */ + public static function configComplete(int $familyId): bool + { + if (self::driver($familyId) !== 'oss') { + return true; + } + $c = self::config($familyId); + $auth = ($c['auth_mode'] === 'ecs_role') + || (trim((string)$c['access_key_id']) !== '' && trim((string)$c['access_key_secret']) !== ''); + return $auth && trim((string)$c['bucket']) !== '' && trim((string)$c['endpoint']) !== ''; + } + + /** 实际生效的驱动(配置不全时是 local) */ + public static function effectiveDriver(int $familyId): string + { + $want = self::driver($familyId); + if ($want !== 'local' && !self::configComplete($familyId)) { + return 'local'; + } + return $want; + } + + private static ?StorageDriver $instance = null; + private static ?int $instanceFor = null; + + public static function disk(int $familyId): StorageDriver + { + $id = self::effectiveDriver($familyId); + if (self::$instance === null || self::$instanceFor !== $familyId || self::$instance->id() !== $id) { + self::$instance = $id === 'oss' + ? new OssDriver($familyId, self::config($familyId)) + : new LocalDriver(FB_UPLOADS); + self::$instanceFor = $familyId; + } + return self::$instance; + } + + // ---------------- 读写 ---------------- + + /** 存一个上传的文件;云端失败自动回退本地 */ + public static function putFile(int $familyId, string $srcPath, string $key, ?string $mime = null): array + { + $disk = self::disk($familyId); + $r = $disk->putFile($srcPath, $key, $mime); + if (!empty($r['ok'])) { + return $r; + } + if ($disk->id() !== 'local') { + $local = new LocalDriver(FB_UPLOADS); + $l = $local->putFile($srcPath, $key, $mime); + if (!empty($l['ok'])) { + return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']]; + } + } + return $r; + } + + public static function putContents(int $familyId, string $binary, string $key, ?string $mime = null): array + { + $disk = self::disk($familyId); + $r = $disk->putContents($binary, $key, $mime); + if (!empty($r['ok'])) { + return $r; + } + if ($disk->id() !== 'local') { + $local = new LocalDriver(FB_UPLOADS); + $l = $local->putContents($binary, $key, $mime); + if (!empty($l['ok'])) { + return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']]; + } + } + return $r; + } + + public static function delete(int $familyId, string $key): void + { + if ($key === '') { + return; + } + // 两个地方都试一遍:历史文件可能只在本地 + self::disk($familyId)->delete($key); + if (self::effectiveDriver($familyId) !== 'local') { + (new LocalDriver(FB_UPLOADS))->delete($key); + } + } + + /** 读取内容(导出、代理等场景) */ + public static function read(int $familyId, string $key): array + { + return self::disk($familyId)->get($key); + } + + public static function exists(int $familyId, string $key): bool + { + if ($key === '') { + return false; + } + if (is_file(FB_UPLOADS . '/' . $key)) { + return true; // 本地优先(迁移期两者并存) + } + return self::disk($familyId)->exists($key); + } + + /** + * **唯一出口**:把相对键变成可访问地址。 + * + * 读取顺序刻意设计成「本地优先」: + * ① 已是绝对地址 → 原样返回 + * ② 本地有文件 → 走本地(迁移期老文件仍可访问,随时可回滚) + * ③ 交给当前驱动 + */ + public static function url(int $familyId, ?string $key): string + { + $key = trim((string)$key); + if ($key === '') { + return asset('/assets/avatar-default.svg'); + } + if (preg_match('#^https?://#i', $key)) { + return $key; + } + $key = ltrim($key, '/'); + $local = FB_UPLOADS . '/' . $key; + if (is_file($local)) { + return asset('/uploads/' . $key); + } + $disk = self::disk($familyId); + if ($disk->id() === 'local') { + return asset('/uploads/' . $key); // 本地也没有 → 让默认头像逻辑兜住 + } + return $disk->url($key); + } +} \ No newline at end of file diff --git a/app/Storage/StorageDriver.php b/app/Storage/StorageDriver.php new file mode 100644 index 0000000..51b7605 --- /dev/null +++ b/app/Storage/StorageDriver.php @@ -0,0 +1,29 @@ + false, 'error' => '']; + } + if (is_array($file['error'])) { return ['ok' => false, 'error' => '上传参数不对']; } switch ($file['error']) { @@ -54,7 +65,6 @@ final class Upload $w = imagesx($src); $h = imagesy($src); - // 居中裁成正方形 $side = min($w, $h); $sx = (int)(($w - $side) / 2); $sy = (int)(($h - $side) / 2); @@ -64,42 +74,51 @@ final class Upload $bg = imagecolorallocate($canvas, 253, 251, 245); imagefilledrectangle($canvas, 0, 0, self::SIZE, self::SIZE, $bg); imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, self::SIZE, self::SIZE, $side, $side); - - $dir = FB_UPLOADS . '/avatars'; - if (!is_dir($dir)) { - mkdir($dir, 0755, true); - } - $name = bin2hex(random_bytes(12)) . '.jpg'; - if (!imagejpeg($canvas, $dir . '/' . $name, 88)) { - imagedestroy($canvas); - imagedestroy($src); - return ['ok' => false, 'error' => '保存失败']; - } - imagedestroy($canvas); imagedestroy($src); - return ['ok' => true, 'file' => 'avatars/' . $name]; + // 先在临时目录生成,再整体交给 Storage —— 这样本地与 OSS 是同一条路径 + $tmpOut = tempnam(sys_get_temp_dir(), 'fbav'); + if ($tmpOut === false || !imagejpeg($canvas, $tmpOut, 88)) { + imagedestroy($canvas); + @unlink((string)$tmpOut); + return ['ok' => false, 'error' => '处理图片失败']; + } + imagedestroy($canvas); + + $key = Storage::AVATAR_DIR . '/' . bin2hex(random_bytes(12)) . '.jpg'; + $res = Storage::putFile($familyId, $tmpOut, $key, 'image/jpeg'); + @unlink($tmpOut); + + if (empty($res['ok'])) { + return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; + } + $out = ['ok' => true, 'file' => $key]; + if (!empty($res['note'])) { + $out['note'] = (string)$res['note']; + } + return $out; } - /** 头像访问地址(无头像时回退到内置剪影,保证永远不出现破图) */ - public static function avatarUrl(?string $rel): string + /** 头像访问地址(无头像时回退内置剪影,保证永远不出现破图) */ + public static function avatarUrl(int $familyId, ?string $rel): string { - if ($rel && is_file(FB_UPLOADS . '/' . $rel)) { - $p = FB_UPLOADS . '/' . $rel; - // 注意:filemtime() 返回 int,本文件开了 strict_types,直接喂给 md5() 会 TypeError - return 'uploads/' . $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8); + $rel = trim((string)$rel); + if ($rel === '') { + return asset('/assets/avatar-default.svg'); } - return asset('/assets/avatar-default.svg'); + return Storage::url($familyId, $rel); } - public static function remove(?string $rel): void + public static function remove(int $familyId, ?string $rel): void { - if (!$rel) { - return; - } - $p = FB_UPLOADS . '/' . $rel; - if (is_file($p)) { - @unlink($p); + if ($rel) { + Storage::delete($familyId, (string)$rel); } } -} + + /** 让 Storage 知道自己该用哪个驱动(测试/迁移用) */ + public static function driverId(int $familyId): string + { + return Storage::effectiveDriver($familyId); + } +} \ No newline at end of file diff --git a/app/View.php b/app/View.php index 4922bba..090c373 100644 --- a/app/View.php +++ b/app/View.php @@ -28,15 +28,23 @@ final class View require dirname(__DIR__) . '/templates/layout.php'; } - /** 独立的整页(登录、安装、错误),不套顶栏 */ + /** + * 独立的整页(登录、安装、错误),不套顶栏 —— 但**仍然要是一份完整 HTML**。 + * + * ⚠️ 曾经这里直接 require $file,输出里没有 doctype / head / CSS, + * 浏览器拿默认样式渲染,登录页看起来就像「完全没样式」。 + * 现在统一套一层 layout-bare.php(无顶栏的最小骨架)。 + */ public static function renderBare(string $tpl, array $data = []): void { $file = dirname(__DIR__) . '/templates/' . $tpl . '.php'; if (!is_file($file)) { throw new \RuntimeException('模板不存在:' . $tpl); } + $tplFile = $file; $pageTitle = $data['title'] ?? '家书'; + $extraHead = $data['extraHead'] ?? ''; extract($data, EXTR_SKIP); - require $file; + require dirname(__DIR__) . '/templates/layout-bare.php'; } } diff --git a/app/bootstrap.php b/app/bootstrap.php index 81685cb..fed6b56 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -11,6 +11,7 @@ spl_autoload_register(function ($class) { if (strpos($class, 'App\\') !== 0) { return; } + // App\Storage\OssDriver → app/Storage/OssDriver.php(子命名空间靠这段映射) $rel = str_replace('\\', '/', substr($class, 4)); $file = __DIR__ . '/' . $rel . '.php'; if (is_file($file)) { @@ -49,6 +50,14 @@ ini_set('log_errors', '1'); // ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ---------------- date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai'); +// ---------------- 安全响应头 ---------------- +// Referrer-Policy: 跨域取图不带 Referer —— 这一条能兜住云存储防盗链 +// 导致的「图片不显示、但地址栏直接打开没问题」那类怪异现象。 +if (!headers_sent()) { + header('Referrer-Policy: same-origin'); + header('X-Content-Type-Options: nosniff'); +} + // ---------------- 会话 ---------------- $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); diff --git a/app/helpers.php b/app/helpers.php index 8ca04dd..60086d1 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -21,6 +21,23 @@ function asset(string $rel): string return $rel . '?v=' . substr(md5(filemtime($p) . filesize($p)), 0, 8); } +/** + * 附件(头像等)地址 —— 全站唯一出口。 + * 走这里是纪律:数据库只存相对键,云端/本地的差异由 Storage 自己吸收。 + */ +function storage_url(?string $key): string +{ + $fid = \App\Auth::familyId(); + return \App\Storage\Storage::url((int)$fid, $key); +} + +/** 头像地址(无头像自动回退内置剪影) */ +function avatar_url(?string $key): string +{ + $fid = (int)\App\Auth::familyId(); + return \App\Upload::avatarUrl($fid, $key); +} + /** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */ function url(string $route = 'home', array $query = []): string { @@ -39,7 +56,17 @@ function is_post(): bool return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; } -function fb_config(string $key = null, $default = null) +/** + * 读配置。 + * + * ⚠️ `?string $key = null` 里的 `?` 不能省:PHP 8.4 起, + * 「有类型 + 默认 null」而没写 `?` 的形式(隐式可空)会报 Deprecated。 + * 这个警告一旦在页面顶部打出来,就等于响应体已经输出 —— + * 后面所有 header() 全部失效(典型症状:安装完/登录后**不再跳转**, + * 返回 200 而不是 302,但功能其实是好的,极难排查)。 + * 线上跑 PHP 7.4/8.1 时不报,所以本地用新 PHP 才抓得到。 + */ +function fb_config(?string $key = null, $default = null) { $c = $GLOBALS['config']; if ($key === null) { diff --git a/assets/css/app.css b/assets/css/app.css index a4b8c02..3f6696a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -246,13 +246,133 @@ textarea.input { min-height: 76px; resize: vertical; } .search { display: flex; gap: 8px; margin-bottom: 16px; } .search .input { max-width: 260px; } +/* ---------------- 称谓标签 ---------------- */ +.rel-label { + display: inline-block; padding: 1px 9px; border-radius: 999px; + background: #F6E3D7; color: var(--brand-ink); + font-size: 11px; font-weight: 500; line-height: 1.8; + margin-right: 2px; +} + +/* ---------------- 家谱(以我为中心) ---------------- */ +.tree { display: flex; flex-direction: column; align-items: center; gap: 0; } +.tree-gen { width: 100%; } +.tree-gen-label { + display: flex; align-items: center; gap: 8px; + margin: 0 0 8px; padding-left: 2px; +} +.tree-gen-label .gname { font-size: 13px; font-weight: 600; color: var(--ink); } +.tree-gen-label .gtag { + font-size: 11px; color: var(--muted); background: var(--line-2); + padding: 1px 8px; border-radius: 999px; +} +.tree-row { + display: flex; flex-wrap: wrap; gap: 10px; + justify-content: center; align-items: stretch; +} +.tnode { + display: flex; flex-direction: column; align-items: center; gap: 5px; + width: 104px; padding: 12px 8px 10px; text-align: center; + background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; box-shadow: var(--shadow); + color: var(--ink); transition: transform .15s ease, box-shadow .15s ease; +} +.tnode:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } +.tnode .tph { width: 52px; height: 52px; border-radius: 50%; overflow: hidden; background: #F1E7D6; } +.tnode .tph img { width: 100%; height: 100%; object-fit: cover; display: block; } +.tnode .tname { font-size: 13px; font-weight: 600; line-height: 1.3; } +.tnode .tlabel { font-size: 11px; color: var(--brand); } +.tnode .tph-mark { font-size: 10px; color: var(--faint); } +.tnode.is-me { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(168, 74, 40, .12), var(--shadow); +} +.tnode.is-me .tlabel { color: var(--brand-ink); font-weight: 600; } +.tnode.is-placeholder { border-style: dashed; } +/* 世代之间的连接线(纯装饰,说明"往下走一代") */ +.tree-link { + width: 2px; height: 22px; background: linear-gradient(var(--line), var(--line-2)); + margin: 6px auto; +} + +/* 详情页里的迷你家谱 */ +.mini-tree { display: flex; flex-direction: column; gap: 8px; } +.mini-gen { display: flex; flex-wrap: wrap; gap: 8px; } +.mini-node { + display: flex; align-items: center; gap: 7px; + padding: 5px 12px 5px 5px; border-radius: 999px; + background: #FAF7F1; border: 1px solid var(--line); + color: var(--ink); font-size: 13px; +} +.mini-node:hover { text-decoration: none; border-color: var(--brand); } +.mini-node img { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; } +.mini-node .mn-label { font-size: 11px; color: var(--brand); } +.mini-node.is-me { border-color: var(--brand); background: #F6E3D7; } + +/* ---------------- 亲属关系编辑 ---------------- */ +.rel-summary { + display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; margin-bottom: 18px; +} +.rel-sum-item { + background: #FAF7F1; border: 1px solid var(--line-2); + border-radius: var(--radius-s); padding: 10px 14px; +} +.rel-sum-k { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; } +.rel-sum-v { font-size: 14px; } +.rel-sum-v a { margin-right: 6px; } + +.rel-add { border-top: 1px solid var(--line-2); padding-top: 16px; } +.rel-add-title { font-size: 14px; font-weight: 600; margin: 0 0 12px; } +.rel-form { + display: grid; grid-template-columns: 1fr 1fr 1.4fr auto; + gap: 12px; align-items: end; +} +.rel-list { margin-top: 16px; font-size: 13px; } +.rel-list summary { cursor: pointer; color: var(--muted); } +.rel-list ul { margin: 10px 0 0; padding-left: 18px; } +.rel-list li { margin-bottom: 6px; } +.rel-list .btn { margin-left: 6px; } + +/* ---------------- 三角色 / 存储设置 ---------------- */ +.role-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +.role-card { background: #FAF7F1; border: 1px solid var(--line-2); border-radius: var(--radius-s); padding: 14px 16px; } +.rc-name { font-weight: 600; margin-bottom: 5px; } +.rc-desc { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.65; } + +.radio-row { display: flex; flex-direction: column; gap: 10px; } +.radio-item { + display: flex; gap: 10px; align-items: flex-start; + background: #FAF7F1; border: 1px solid var(--line-2); + border-radius: var(--radius-s); padding: 12px 14px; cursor: pointer; +} +.radio-item input { margin-top: 3px; } +.check-item { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; cursor: pointer; } +.check-item input { margin-top: 3px; } + +.oss-box { + border: 1px solid var(--line-2); border-radius: var(--radius-s); + padding: 12px 16px; background: #FCFAF6; +} +.oss-box summary { cursor: pointer; font-size: 13px; font-weight: 600; color: var(--muted); } + +.plain-list { margin: 0; padding-left: 18px; font-size: 14px; } +.plain-list li { margin-bottom: 7px; } + +@media (max-width: 720px) { + .rel-form { grid-template-columns: 1fr; } + .tnode { width: 88px; padding: 9px 6px 8px; } + .tnode .tph { width: 44px; height: 44px; } + .tree-row { gap: 8px; } +} + @media (max-width: 720px) { /* 390px 宽度放不下整套顶栏:只留 图标 + 导航 + 头像 + 退出 */ - .topbar-inner { gap: 8px; padding: 0 12px; height: 54px; } + .topbar-inner { gap: 6px; padding: 0 10px; height: 54px; } .brand img { width: 26px; height: 26px; } .nav { gap: 0; } - .nav a { padding: 6px 9px; font-size: 14px; } - .topbar-right { gap: 8px; } + .nav a { padding: 6px 8px; font-size: 13px; } + .topbar-right { gap: 6px; } .who-name, .who-pwd { display: none; } /* 名字与「改密码」收起,设置页里都有 */ .who img { width: 26px; height: 26px; } .container, .container-narrow { padding: 20px 16px 48px; } diff --git a/index.php b/index.php index 28c542e..020f7ae 100644 --- a/index.php +++ b/index.php @@ -15,7 +15,10 @@ use App\Auth; use App\Crypto; use App\Database; use App\Family; +use App\Kinship; use App\Person; +use App\Relations; +use App\Schema; use App\Setting; use App\Upload; use App\View; @@ -25,6 +28,40 @@ if (!Database::hasTables()) { exit; } +/** + * 一次性结构升级。 + * + * ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和 + * 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号, + * 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。 + */ +const FB_SCHEMA_VERSION = '2'; +if (Database::hasTables()) { + $familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0; + // 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署) + if ($familyIdForSchema === 0) { + try { + $familyIdForSchema = (int)(Database::get()->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'; @@ -101,29 +138,88 @@ switch ($r) { 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), - 'people' => Person::all($familyId), - 'birthdays' => Person::upcomingBirthdays($familyId), + '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' => Person::all((int)Auth::familyId(), $q), + 'people' => $rows, 'q' => $q, + 'meId' => $me, ], 'people'); break; case 'people/new': - Auth::requireAdmin(); + Auth::requireWrite(); clear_old(); View::render('people/form', [ 'title' => '添加成员', @@ -133,7 +229,7 @@ switch ($r) { break; case 'people/create': - Auth::requireAdmin(); + Auth::requireWrite(); csrf_check(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), @@ -148,10 +244,14 @@ switch ($r) { '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($_FILES['avatar'] ?? []); + $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } @@ -188,7 +288,7 @@ switch ($r) { break; case 'people/edit': - Auth::requireAdmin(); + Auth::requireWrite(); $p = Person::find((int)($_GET['id'] ?? 0)); if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { flash('err', '找不到这个人。'); @@ -205,7 +305,7 @@ switch ($r) { break; case 'people/update': - Auth::requireAdmin(); + Auth::requireWrite(); csrf_check(); $id = (int)($_POST['id'] ?? 0); $p = Person::find($id); @@ -225,9 +325,11 @@ switch ($r) { '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($_FILES['avatar'] ?? []); + $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); if (!$up['ok'] && ($up['error'] ?? '') !== '') { $errors['avatar'] = $up['error']; } @@ -243,7 +345,7 @@ switch ($r) { exit; } if ($up['ok']) { - Upload::remove((string)$p['avatar']); + Upload::remove((int)Auth::familyId(), (string)$p['avatar']); $d['avatar'] = $up['file']; } try { @@ -268,14 +370,33 @@ switch ($r) { break; case 'people/show': - $p = Person::findWithAccount((int)($_GET['id'] ?? 0)); - if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) { + $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, + '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; @@ -295,7 +416,7 @@ switch ($r) { flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。'); redirect('people/show', ['id' => $id]); } - Upload::remove((string)$p['avatar']); + Upload::remove((int)Auth::familyId(), (string)$p['avatar']); Person::delete($id); flash('ok', '已从名册移除「' . $p['name'] . '」。'); } @@ -326,7 +447,8 @@ switch ($r) { flash('err', implode(';', $errs)); redirect('people/show', ['id' => $personId]); } - Account::create($personId, $pass, ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member'); + $r0 = (string)($_POST['role'] ?? ''); + Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member'); flash('ok', '账号已开通,用手机号就能登录。'); redirect('people/show', ['id' => $personId]); break; @@ -335,7 +457,7 @@ switch ($r) { Auth::requireAdmin(); csrf_check(); $personId = (int)($_POST['person_id'] ?? 0); - $role = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'member'; + $role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member'; $p = Person::find($personId); $acc = Account::findByPerson($personId); if ($p && $acc) { @@ -370,6 +492,109 @@ switch ($r) { 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(); @@ -380,14 +605,77 @@ switch ($r) { // 拿不到版本不影响页面 } $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, + '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(); diff --git a/install.php b/install.php index 9d93f68..9e1b3e2 100644 --- a/install.php +++ b/install.php @@ -50,9 +50,9 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { if (!$errors) { try { - foreach (Schema::statements() as $sql) { - Database::get()->exec($sql); - } + // ensure() 而不是逐条 exec(statements()):它还会补齐后加的列与枚举值, + // 所以老库重新跑一次就能自动升级,不必手工改表。 + Schema::ensure(); $familyId = Family::create($familyName, $motto); $personId = Person::create($familyId, [ 'name' => $name, diff --git a/templates/home.php b/templates/home.php index f16282b..2cd39de 100644 --- a/templates/home.php +++ b/templates/home.php @@ -1,19 +1,37 @@ 0; +// 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着 +$list = $hasRel ? $kin : []; +if (!$list) { + foreach (\App\Person::all((int)\App\Auth::familyId()) as $p) { + $list[(int)$p['id']] = $p + ['label' => '']; + } +} ?> -
-

-

+

+

+ + · 这里的称呼都是按你的视角算的 + + + +

-
位家庭成员
-
已开通登录
-
近期生日
+
与我有关
+
名册总数
+
亲属连线
@@ -23,9 +41,12 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
- +
+ + +
就是今天,生日快乐 @@ -42,36 +63,60 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
- + +
+ 先把家人连起来 + 目前还没有任何亲属关系,所以下面显示的是整个名册。
+ 到自己的资料页填上「谁是我的父母、配偶、子女」,之后这一页就只显示跟你有关的人, + 每个人的称呼也会按你的视角来算。 + + + +
+ + +
名册还是空的 先把家里人加进来,之后他们就能出现在每一条记录里。 +
+ \ No newline at end of file diff --git a/templates/install.php b/templates/install.php index 048f9c0..12321cd 100644 --- a/templates/install.php +++ b/templates/install.php @@ -1,6 +1,6 @@
- +

安装「家书」

先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。

diff --git a/templates/kin/tree.php b/templates/kin/tree.php new file mode 100644 index 0000000..5388f36 --- /dev/null +++ b/templates/kin/tree.php @@ -0,0 +1,84 @@ + '曾祖辈', -2 => '祖辈', -1 => '父母辈']; + return $up[$level] ?? '上 ' . abs($level) . ' 代'; + } + $down = [1 => '子女辈', 2 => '孙辈', 3 => '曾孙辈']; + return $down[$level] ?? '下 ' . $level . ' 代'; +}; +?> + +
+

我的家谱

+ 以 为中心 · 上下各三代 +
+ + +
+ 还没连上任何亲属关系 + 到「」的资料页,把父母、配偶、子女填进去, + 这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。 +
+ + +
+ +
+

这张图是怎么来的 只存两代,其余推导

+

+ 系统里只记录**相邻两代之间**的关系:谁是谁的父母、谁和谁是配偶。 + 至于「奶奶是谁」,是顺着「我的父亲 → 我父亲的母亲」两级边推出来的。 + 这样每条关系只需维护一次,同一个人在每个人眼里还会自动变成不同的称呼。 +

+

+ 兄弟姐妹也不单独存档 —— 只要你们有同一对父母,系统就会把你们认成一家人, + 并按排行显示成哥哥/姐姐/弟弟/妹妹、长子/次女。 +

+
+ + + + \ No newline at end of file diff --git a/templates/layout-bare.php b/templates/layout-bare.php new file mode 100644 index 0000000..ff4371e --- /dev/null +++ b/templates/layout-bare.php @@ -0,0 +1,31 @@ + 与 CSS 链接 —— 浏览器只能拿默认样式渲染, + * 于是登录页看起来「完全没样式」。裸页也必须是一份完整的 HTML 文档。 + * + * 变量:$pageTitle / $tplFile / $extraHead(均由 View::renderBare 提供) + */ +?> + + + + + +<?= e($pageTitle) ?> · 家书 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/layout.php b/templates/layout.php index e61f5a6..3b9c9c9 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -1,12 +1,18 @@ ['首页', 'home'], + 'kin' => ['家谱', 'kin'], 'people' => ['名册', 'people'], ]; if (\App\Auth::isAdmin()) { $navItems['settings'] = ['设置', 'settings']; } -$me = \App\Auth::check(); +// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的, +// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。 +// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后 +// 链接就变成 people/show&id=0,还顺带在页首打出一条 Warning。 +// 改名前请先确认所有模板都没有依赖这个布尔值。 +$isLoggedIn = \App\Auth::check(); ?> @@ -22,7 +28,7 @@ $me = \App\Auth::check(); - +
@@ -36,8 +42,9 @@ $me = \App\Auth::check();
- + + 访客 改密码 退出 diff --git a/templates/login.php b/templates/login.php index 3d098fc..25c7e32 100644 --- a/templates/login.php +++ b/templates/login.php @@ -1,6 +1,6 @@
- +

用手机号登录你的家书。

diff --git a/templates/people/form.php b/templates/people/form.php index cdcd85d..40d4ef4 100644 --- a/templates/people/form.php +++ b/templates/people/form.php @@ -1,6 +1,6 @@
@@ -48,6 +48,14 @@ $avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
+
+ + + 用来认兄弟姐妹的长幼:长子、次女、三弟… + +
+
小的排前面。
+
+ +
+
diff --git a/templates/people/index.php b/templates/people/index.php index ae3197e..b9f8b4f 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -26,17 +26,26 @@
-
+
-
+
+ + 待确认 +
- - 管理员 - - 已开通 - - 未开通 + + + 管理员'; + } elseif ($r === 'guest') { + echo '访客'; + } elseif (!empty($p['account_id'])) { + echo '已开通'; + } + ?> ·
diff --git a/templates/people/invite.php b/templates/people/invite.php new file mode 100644 index 0000000..fee7583 --- /dev/null +++ b/templates/people/invite.php @@ -0,0 +1,90 @@ +
+

邀请访客

+ 朋友只读 —— 能看,不能改 +
+ +
+

三种身份 决定能看到多少、能不能改

+
+
+
管理员
+

一个家庭通常只有一位。改家庭名称、附件存哪儿、谁能登录,都在他手里。

+
+
+
家庭成员
+

正常使用的人。可以建自己的通讯录、日历本,维护家里的名册与亲属关系。

+
+
+
访客
+

一起旅行、一起看过电影的朋友。能登录进来看,但改不了任何东西,也不出现在家谱里。

+
+
+
+ +
+

开通一个访客账号

+ + +
+ + +
+ +
+
+ + + +
+
+ + + +
+
+ + + 至少 8 位,含字母和数字。把手机号和密码告诉他即可登录。 + +
+
+ + +
+
+
+ +
+
+
+ + + +
+

已有访客 位

+
+

+ 要停用或删除,点进对方的名字,在「登录账号」里操作。 +

+
+ \ No newline at end of file diff --git a/templates/people/show.php b/templates/people/show.php index a53064a..209d190 100644 --- a/templates/people/show.php +++ b/templates/people/show.php @@ -1,21 +1,48 @@
-

+

- 小名 + + + + 这是你自己 + + · 小名 + 虚拟人物
+ + 编辑资料 + - 编辑
+ onsubmit="return confirm('确定要从名册里移除 吗?相关的账号与亲属关系也会一起删掉。')"> - +
@@ -24,13 +51,15 @@ $admin = \App\Auth::isAdmin();