Files
FamilyBook/index.php
T
laoyang 809443d429 v7:家谱树与名册三处口径统一(树上的人自动互相可见)
用户反馈:我把一个人设成配偶、又设了我们的孩子,配偶登录后首页能看到
孩子、家谱也能看到,名册里却看不到——没分享的人却在家谱上看得到,
口径打架。

口径由用户拍板:**以每个人为中心上下三代那棵家谱树(= 首页「我的家人」
同一棵)里的人,自动进他名册(source=kin,默认归公共「家人」组);
树外的人三处都不可见。**

- PersonRoster::syncKinForFamily():整树同步(补缺、撤多余;已存在的
  self/shared/seeded 行绝不碰,用户改过的分组/顺序保留)。幂等。
- 同步挂在事件上:建边 / 解边 / 开通账号 / 结构升级回填。
- Relations::addParent/addSpouse 建边后同步;remove/detach 解边后同步。
- 名册不许单独移走 kin 来源的人(服务端拦截 + 人话提示 + 卡片隐藏
  移走按钮改提示语):他在首页/家谱照样看得见,单藏名册只会回到口径
  打架;想不再看到他,解除关系即自动移出。
- 名册卡片给 kin 来源标「家谱」徽标(来历可辨)。
- Schema v7:widenRosterSourceEnum(⚠️ CREATE TABLE IF NOT EXISTS 不会
  更新已存在表的枚举,v6 老库不扩宽则 strict 模式下插 kin 整条被拒、
  且被容错吞掉——e2e 的 v6 旧库当场抓出)+ 按已有关系回填 kin 条目。

验证:e2e 309/0、升级专测 82/0、部署静态空跑 78/0、桌面几何 0 失败、
交互 97/0、移动几何 180/0;线上探针 ver=7、enum 含 kin、无升级告警。
2026-09-30 16:12:28 +08:00

1340 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · 前端控制器
*
* 路由走 query-string(index.php?r=模块/动作),不依赖 Apache 重写,
* 换服务器、换 Nginx 都不用改配置。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Account;
use App\Auth;
use App\Birthday;
use App\Crypto;
use App\Database;
use App\Family;
use App\Kinship;
use App\Nickname;
use App\Person;
use App\PersonGroup;
use App\PersonRoster;
use App\Relations;
use App\Schema;
use App\Setting;
use App\Upload;
use App\View;
if (!Database::hasTables()) {
header('Location: install.php');
exit;
}
/**
* 一次性结构升级。
*
* ⚠️ 为什么要有这段:上线后的库不认识新加的列(birth_rank / is_placeholder)和
* 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号,
* 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。
*/
const FB_SCHEMA_VERSION = Schema::VERSION;
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) {
// 升级失败不能把整站挡住;下次请求会再试。
// ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级
// 从 2026-09-30 起一次都没跑完(报错被吞、版本号永远停在 2),
// 而首页/名册都 200,看着完全正常 —— 直到「个人设置 500」才暴露。
// 一行 error_log 就能省掉几小时排查,别再删掉它。
error_log('[familybook] Schema::ensure() 失败(已跳过,下次请求重试):'
. $ex->getMessage() . ' @ ' . $ex->getFile() . ':' . $ex->getLine());
if (!empty($GLOBALS['config']['debug'])) {
throw $ex;
}
}
}
}
}
$r = isset($_GET['r']) ? trim((string)$_GET['r'], '/') : 'home';
$r = $r === '' ? 'home' : $r;
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$publicRoutes = ['auth/login'];
if (!Auth::check() && !in_array($r, $publicRoutes, true)) {
redirect('auth/login');
}
/**
* 从表单里读「三个生日」。
*
* 为什么要有 `birthday_source` 这个显式字段:
* 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。
* 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个",
* 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。
* 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。
*/
function read_birthday_input(array $existing = []): array
{
$source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar';
$solar = (string)(read_dt_input('birthday_solar') ?? '');
$lunar = (string)(read_dt_input('birthday_lunar') ?? '');
if ($source === 'solar' && $solar === '' && $lunar !== '') {
$source = 'lunar';
}
return [
'birthday_source' => $source,
'birthday_solar' => $solar,
'birthday_lunar' => $lunar,
'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0,
// 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉
'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''),
];
}
/**
* 从表单解析「补充联系方式」(vCard 对齐,v6 起)。
*
* 每类两个平行数组:ct_<kind>_label[] / ct_<kind>_value[] ——
* 平行数组而不是二维键名(ct[phone][0][value]),是因为 label 与 value
* 分列在两个 input 里,PHP 自动按数字补齐下标,zip 起来最直观。
*
* @return string[][][] kind => 行列表(label/value,value 保留原样,空行不丢 ——
* 交给 Person::replaceContacts 丢弃;校验失败重画表单时还要用它回填)
*/
function read_contacts_input(): array
{
$out = [];
foreach (['phone', 'email', 'address', 'url'] as $kind) {
$labels = $_POST['ct_' . $kind . '_label'] ?? [];
$values = $_POST['ct_' . $kind . '_value'] ?? [];
if (!is_array($labels) || !is_array($values)) {
$out[$kind] = [];
continue;
}
$rows = [];
$n = max(count($labels), count($values));
for ($i = 0; $i < $n; $i++) {
$rows[] = ['label' => (string)($labels[$i] ?? ''), 'value' => trim((string)($values[$i] ?? ''))];
}
$out[$kind] = $rows;
}
return $out;
}
/** 补充联系方式的宽松校验(主手机号/主邮箱的校验在 Person::validate 里) */
function validate_contacts(array $contacts): string
{
foreach ($contacts as $kind => $rows) {
foreach ($rows as $i => $row) {
$v = trim((string)($row['value'] ?? ''));
if ($v === '') {
continue;
}
$no = $i + 1;
if ($kind === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) {
return '第 ' . $no . ' 个补充邮箱的格式看起来不对:' . $v;
}
if ($kind === 'phone' && !preg_match('/^\+?[0-9\- ]{6,25}$/', $v)) {
return '第 ' . $no . ' 个补充电话的格式看起来不对:' . $v;
}
}
}
return '';
}
/**
* 渲染「个人设置」页(settings/me)。
*
* 从 v5 起这一页只剩两件事:**我怎么称呼家人**、**我的分组**。
* 「我的资料」表单不在这里 —— 它和「成员资料」是同一张表单
* (`templates/people/form.php`,两条入口都指过去),页面顶部给一个按钮过去。
* 这是用户明确要求的:「编辑成员」和「修改自己的资料」本来就是一件事,
* 两套表单必然填出两种口径。
*
* @param array $person 当前登录者自己的名册记录
* @param array $errors 字段名 => 错误文案(保留给"校验失败就地重画"用)
*/
function render_me_settings(array $person, array $errors = []): void
{
$familyId = (int)Auth::familyId();
$me = (int)$person['id'];
View::render('settings/me', [
'title' => '个人设置',
'person' => $person,
'errors' => $errors,
// 我怎么称呼别人(视角属性,只有我自己能定)
'nicknames' => Nickname::mineWithNames($familyId, $me),
'options' => Person::options($familyId, $me, $me),
'birthdays' => Birthday::describe($person),
// 我的分组 = 公共的 + 我自己加的,顺序是我的(user_settings.group_order)
'groups' => PersonGroup::orderFor($familyId, $me),
'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => PersonRoster::countFor($familyId, $me),
], 'settings');
}
switch ($r) {
// ==================== 登录 / 退出 ====================
case 'auth/login':
if (Auth::check()) {
redirect('home');
}
if ($method === 'POST') {
csrf_check();
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
if (Auth::login($phone, $pass)) {
redirect('home');
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
'error' => '手机号或密码不对,或者账号还没开通/已被停用。',
'phone' => $phone,
]);
exit;
}
View::renderBare('login', [
'title' => '登录',
'familyName' => fb_config('site_name', '家书'),
]);
break;
case 'auth/logout':
if (isset($_GET['_csrf'], $_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], (string)$_GET['_csrf'])) {
Auth::logout();
}
redirect('auth/login');
break;
case 'auth/password':
Auth::requireLogin();
if ($method === 'POST') {
csrf_check();
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['password'] ?? '');
$new2 = (string)($_POST['password2'] ?? '');
$st = Database::get()->prepare('SELECT password_hash FROM accounts WHERE id = ?');
$st->execute([Auth::id()]);
$hash = (string)$st->fetchColumn();
$err = null;
if (!password_verify($old, $hash)) {
$err = '当前密码不对';
} elseif ($new !== $new2) {
$err = '两次输入的新密码不一致';
} elseif (Account::validatePassword($new)) {
$err = implode(';', Account::validatePassword($new));
}
if ($err) {
View::render('auth/password', ['title' => '修改密码', 'error' => $err], 'home');
exit;
}
Account::setPassword((int)Auth::id(), $new);
flash('ok', '密码已更新,下次登录用新密码。');
redirect('home');
}
View::render('auth/password', ['title' => '修改密码'], 'home');
break;
// ==================== 首页(以我为中心) ====================
case 'home':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 「我的家人」= 以我为中心上下各三代那张家谱里的人。
// ⚠️ 早先只取「直接关系」(父母/配偶/子女/兄弟姐妹),于是**姻亲与祖孙都被漏掉**:
// 爱人登进来看不到「公公婆婆」,我看不到「岳父岳母」,爷爷奶奶也不在首页上。
// 这恰恰是「以个人为中心」最该体现的地方,所以改用家谱的节点集合。
$tree = Kinship::tree($graph, $me, $people, 3, 3);
$kinIds = array_diff(array_map('intval', array_keys($tree['nodes'])), [$me]);
$kin = [];
// 用 Person::all()(带账号信息)而不是 Person::map(),首页才能显示管理员/访客徽标
foreach (Person::all($familyId) as $p) {
$pid = (int)$p['id'];
if (in_array($pid, $kinIds, true)) {
$kin[$pid] = $p + ['label' => Kinship::label($graph, $me, $pid, $people)];
}
}
$birthdays = Person::upcomingBirthdays($familyId);
// 生日条上也标出称谓
foreach ($birthdays as &$b) {
$b['label'] = isset($people[(int)$b['id']])
? Kinship::label($graph, $me, (int)$b['id'], $people) : '';
// 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝"
$b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name'];
}
unset($b);
View::render('home', [
'title' => '首页',
'family' => Family::find($familyId),
'me' => $people[$me] ?? null,
'meId' => $me,
'kin' => $kin,
'allCount' => count($people),
'relCount' => Relations::count($familyId),
'birthdays' => $birthdays,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')),
'myNicknames'=> Nickname::mapFor($me),
], 'home');
break;
// ==================== 家谱(以我为中心) ====================
case 'kin':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
$tree = Kinship::tree($graph, $me, $people, 3, 3);
View::render('kin/tree', [
'title' => '我的家谱',
'tree' => $tree,
'meId' => $me,
'people' => $people,
'graph' => $graph,
], 'kin');
break;
// ==================== 名册 ====================
case 'people':
$q = trim((string)($_GET['q'] ?? ''));
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
// ⚠️ 用 roster() 而不是 all():名册是「每人一份」——
// 只列我加的人 + 别人分享给我的(v5 起,见 App\PersonRoster)。
$rows = Person::roster($familyId, $me, 'member', $q);
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 名册里也标出「我该怎么称呼他」
foreach ($rows as &$row) {
$row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people);
$row['shown'] = Nickname::display($me, $row) ?: (string)$row['name'];
}
unset($row);
// 按关系分组分栏。顺序用**当前用户自己的**(没自定义过就是默认顺序);
// 归到键 0 的那一堆是还没分类的人,模板单独放在最后一栏。
$grouped = [];
foreach ($rows as $row) {
$grouped[(int)($row['group_id'] ?? 0)][] = $row;
}
View::render('people/index', [
'title' => '家庭名册',
'people' => $rows,
'grouped'=> $grouped,
'groups' => PersonGroup::orderFor($familyId, $me),
'q' => $q,
'meId' => $me,
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
], 'people');
break;
/**
* 名册卡片上的「调整分组」。
*
* 从 v5 起改的是**我自己那份名册**里的归类(person_roster.group_id),
* 不再动 persons —— 所以「我把张伟归到同事」不会让妈妈那边也变成同事。
* 单开一个路由而不是让人去「编辑成员」里改,是因为那张表很长
* (三个生日、身份证号、住址…),为了挪一个分组去翻半页是折磨。
* 而且分组**本来就只能在名册里改**(它是"我和他的关系")。
*/
case 'people/group':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册里的分组。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$gid = (int)($_POST['group_id'] ?? 0);
$p = Person::find($pid);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if (!PersonRoster::assignGroup($familyId, $me, $pid, $gid > 0 ? $gid : null)) {
flash('err', '这个人不在你的名册里,或者那个分组你用不了。');
redirect('people');
}
flash('ok', $gid > 0
? '已把「' . $p['name'] . '」归到「' . PersonGroup::name($familyId, $gid) . '」。'
: '已把「' . $p['name'] . '」改成未分类。');
redirect('people');
break;
/**
* 名册拖动排序 / 拖动换栏 —— 整版提交。
*
* 前端把每一栏(含「未分类」)的有序 id 列表发过来,服务端一次写完。
* 为什么不"移动一条算一次":拖拽的结果是一组**相对顺序**,
* 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
* 没有 JS 也能用:卡片上的「调整分组」表单还在(只是没有拖拽那么顺手)。
*/
case 'people/reorder':
Auth::requireLogin();
// 拖动是 AJAX 发的(见 app.js)→ 出错也回 JSON 而不是 302:
// 不然那句 flash 会攒到"下一次刷新"才冒出来,看着像迟到的鬼影。
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest');
if (!Auth::canWrite()) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8', true, 403);
echo json_encode(['ok' => false, 'error' => 'readonly']);
exit;
}
flash('err', '你的身份是访客,不能调整名册顺序。');
redirect('people');
}
csrf_check();
$raw = (string)($_POST['layout'] ?? '');
$layout = json_decode($raw, true);
if (!is_array($layout)) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'invalid layout']);
exit;
}
flash('err', '没收到有效的顺序,请刷新页面再试一次。');
redirect('people');
}
$n = PersonRoster::applyLayout((int)Auth::familyId(), (int)Auth::personId(), $layout);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'n' => $n], JSON_UNESCAPED_UNICODE);
exit;
}
flash('ok', $n > 0 ? '名册顺序已更新。' : '没有需要改动的。');
redirect('people');
break;
/**
* 「从家里已有的人里挑」—— 添加成员的第二个入口。
*
* 存在的理由:同一个人不该被录两遍。录两遍会变成两条 persons,
* 于是账号、亲属关系、生日各存一份,后面永远对不上。
* 所以只要人已经在这个家庭里(别人先录过了),就走这里把他**复用**进我的名册。
*/
case 'people/pick':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能往名册里加人。');
redirect('people');
}
$familyPick = PersonRoster::pickable($familyId, $me);
if ($method === 'POST') {
csrf_check();
$ids = array_map('intval', (array)($_POST['ids'] ?? []));
$gid = (int)($_POST['group_id'] ?? 0);
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok);
$n = 0;
foreach ($ids as $pid) {
if (!in_array($pid, $ok, true)) {
continue;
}
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++;
}
}
flash($n > 0 ? 'ok' : 'err', $n > 0
? '已把 ' . $n . ' 个人加进你的名册。'
: '一个人都没加上 —— 先勾选要加的人。');
redirect('people');
}
View::render('people/pick', [
'title' => '从家里已有的人里挑',
'people' => $familyPick,
'groups' => PersonGroup::orderFor($familyId, $me),
'meId' => $me,
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
* 这只是"让他看得见",不改变任何资料本身;对方可以自己决定归到哪一组。
*/
case 'people/share':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能分享名片。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$to = (int)($_POST['to_id'] ?? 0);
$back = (int)($_POST['back'] ?? 0);
$p = Person::find($pid);
$t = Person::find($to);
if (!$p || !$t || (int)$p['family_id'] !== $familyId || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
if ($pid === $to) {
flash('err', '不用把名片分享给他自己。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
}
PersonRoster::share($familyId, $to, [$pid], 'shared');
flash('ok', '已把「' . $p['name'] . '」的名片放进「' . $t['name'] . '」的名册里。');
redirect('people/show', ['id' => $back > 0 ? $back : $pid]);
break;
/** 把一个人从**我的**名册里移掉(人还在名册与家谱里,只是我不再看到他) */
case 'people/forget':
Auth::requireLogin();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改名册。');
redirect('people');
}
csrf_check();
$pid = (int)($_POST['person_id'] ?? 0);
$p = Person::find($pid);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
// v7:家谱关系带来的人不许「移走」——他在首页/家谱上照样看得见,
// 单独从名册藏掉只会把三处口径又搅回打架的状态。
// 想不再看到他,解除那条关系(名册条目会跟着自动收回)。
$src = (string)(PersonRoster::mine((int)Auth::familyId(), (int)Auth::personId())[$pid]['source'] ?? '');
if ($src === 'kin') {
flash('err', '「' . $p['name'] . '」是因为家谱关系自动进你名册的,不能单独移走;解除关系后他会自动移出。');
} else {
PersonRoster::remove((int)Auth::familyId(), (int)Auth::personId(), $pid);
flash('ok', '已把「' . $p['name'] . '」从你的名册里移走(他还在家里)。');
}
}
redirect('people');
break;
// ==================== 我自己的资料 / 个人设置 ====================
// 顶栏头像点开 →「个人设置」。访客也能改自己的资料 ——
// 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。
case 'people/me':
// 「我的资料」已并进「个人设置」(设置分两个维度:系统设置 / 个人设置)。
// 旧链接、老书签、以及别处写死的 people/me 一律跳过去,不再留第二个入口。
redirect('settings/me');
break;
case 'settings/me':
Auth::requireLogin();
$p = Person::find((int)Auth::personId());
if (!$p) {
flash('err', '找不到你自己的名册记录。');
redirect('home');
}
clear_old();
render_me_settings($p);
break;
/**
* 旧的「保存我的资料」入口。
*
* v5 起资料表单只剩一张(`templates/people/form.php`,提交到 people/update),
* 所以这两个地址不再处理表单 —— 只把还开着旧标签页的人送回个人设置,
* 免得一次提交撞上 404 让人以为"保存坏了"。
*/
case 'settings/me_save':
case 'people/me_save':
redirect('settings/me');
break;
// ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
case 'settings/groups/move':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_POST['id'] ?? 0);
$dir = ($_POST['dir'] ?? '') === 'up' ? 'up' : 'down';
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
if ($scope === 'public') {
Auth::requireAdmin();
}
$list = $scope === 'public'
? PersonGroup::forOwner($familyId, PersonGroup::PUBLIC_OWNER)
: PersonGroup::orderFor($familyId, $me);
$ids = array_map(fn($g) => (int)$g['id'], $list);
$at = array_search($id, $ids, true);
if ($at === false) {
flash('err', '找不到这个分组。');
redirect($scope === 'public' ? 'settings' : 'settings/me');
}
// 挪到边界就什么也不做(按钮在两端本来就是 disabled 的,
// 但地址栏里手敲一次也能到这儿,别让它把顺序搞乱)
$to = $dir === 'up' ? $at - 1 : $at + 1;
if ($to >= 0 && $to < count($ids)) {
[$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]];
if ($scope === 'public') {
PersonGroup::savePositions($familyId, PersonGroup::PUBLIC_OWNER, $ids);
} else {
PersonGroup::saveOrder($familyId, $me, $ids);
}
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 分组的增删改名 ====================
// 两套:公共的(管理员在系统设置里维护,全家共用)与我自己的(个人设置里,
// 只有自己看得见)。访客只读,给个明确的说法再回去,
// 而不是甩一张 403 页面(他明明是点了自己设置里的按钮)。
case 'settings/groups/add':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能加分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::add((int)Auth::familyId(), (string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '已加上分组「' . trim((string)($_POST['name'] ?? '')) . '」。'
: (string)($res['error'] ?? '加不上。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/rename':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::rename((int)Auth::familyId(), (int)($_POST['id'] ?? 0),
(string)($_POST['name'] ?? ''), $owner);
flash($res['ok'] ? 'ok' : 'err', $res['ok']
? '分组名已改。'
: (string)($res['error'] ?? '改不了。'));
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
case 'settings/groups/delete':
Auth::requireLogin();
csrf_check();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能删分组。');
redirect('settings/me');
}
$scope = ($_POST['scope'] ?? 'me') === 'public' ? 'public' : 'me';
$owner = PersonGroup::PUBLIC_OWNER;
if ($scope === 'public') {
Auth::requireAdmin();
} else {
$owner = (int)Auth::personId();
}
$res = PersonGroup::delete((int)Auth::familyId(), (int)($_POST['id'] ?? 0), $owner);
if ($res['ok']) {
$n = (int)($res['moved'] ?? 0);
flash('ok', $n > 0
? '分组已删掉,那 ' . $n . ' 个人现在显示在「未分类」里,去名册重新归类即可。'
: '分组已删掉(那一组本来也没人)。');
} else {
flash('err', (string)($res['error'] ?? '删不了。'));
}
redirect($scope === 'public' ? 'settings' : 'settings/me');
break;
// ==================== 别人怎么称呼(只有我自己能定) ====================
case 'nickname/save':
Auth::requireLogin();
csrf_check();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$target = (int)($_POST['target_id'] ?? 0);
$nick = (string)($_POST['nickname'] ?? '');
$t = Person::find($target);
$back = (string)($_POST['back'] ?? 'people/show');
if (!$t || (int)$t['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
Nickname::set($familyId, $me, $target, $nick);
flash('ok', trim($nick) === ''
? '已恢复成用姓名显示。'
: '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。');
if ($back === 'settings/me' || $back === 'people/me') {
// 两个写法都认:老表单发的是 people/me(那一页现在是跳转)
redirect('settings/me');
}
redirect('people/show', ['id' => $target]);
break;
case 'people/new':
Auth::requireWrite();
clear_old();
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => [],
'canEditAll' => true,
'isSelf' => false,
], 'people');
break;
case 'people/create':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => trim((string)($_POST['phone'] ?? '')),
'id_card' => trim((string)($_POST['id_card'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => '',
'kind' => 'member',
// ⚠️ 这两项必须显式从表单取回:表单里有,但漏了这一步就会被静默丢掉,
// 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。
'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')),
'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0,
] + read_birthday_input();
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
// 现在是:人先存进去,头像的事单独提示。
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => $errors,
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
], 'people');
exit;
}
if ($up['ok']) {
$d['avatar'] = $up['file'];
}
try {
$id = Person::create($familyId, $d);
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => ['phone' => '这个手机号已经在名册里了'],
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
], 'people');
exit;
}
// 我加的人,自动进**我的**名册(名册是每人一份的,v5 起)。
// 分组先留空 —— 用户可以在名册里拖到对应那一栏,也可以在那张卡片的「调整分组」里选。
PersonRoster::ensure($familyId, (int)Auth::personId(), $id, null, 'self');
Person::replaceContacts($id, $contacts);
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已把「' . $d['name'] . '」加进名册。头像没有保存:' . $avatarWarn);
} else {
flash('ok', '已把「' . $d['name'] . '」加进名册。');
}
redirect('people/show', ['id' => $id]);
break;
/**
* 成员资料表单 —— **只有这一张**(需求:「编辑成员」与「改自己的资料」本来就是一件事)。
*
* 两个入口都指到这里:名册卡片/详情页的「编辑资料」、以及个人设置顶部的
* 「编辑我的资料」。区别只有一个:编辑**自己**时不要求能写(访客也能改自己的名字),
* 而编辑别人必须是能写的人。
*/
case 'people/edit':
$id = (int)($_GET['id'] ?? 0);
$isSelf = $id > 0 && $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
clear_old();
// 补充联系方式:kind=phone 存的是密文,编辑表单里要给本人(能写的人)看明文
$editContacts = Person::contactsOf($id);
foreach ($editContacts['phone'] as &$r) {
$r['value'] = Crypto::decrypt($r['value']);
}
unset($r);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => [],
'canEditAll' => Auth::canWrite(),
'isSelf' => $isSelf,
'phoneValue' => Crypto::decrypt((string)$p['phone_encrypted']),
'idCardValue' => Crypto::decrypt((string)$p['id_card_encrypted']),
'contacts' => $editContacts,
], 'people');
break;
case 'people/update':
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$isSelf = $id === (int)Auth::personId();
if (!$isSelf) {
Auth::requireWrite();
}
// 改别人(= 能写的人编名册):字段是完整的一套。
// 改自己(可能是访客):只允许动属于自己的那些 —— 与 Person::updateSelf 一一对应。
$canEditAll = Auth::canWrite();
$d = [
'name' => trim((string)($_POST['name'] ?? '')),
'gender' => (string)($_POST['gender'] ?? ''),
'phone' => $canEditAll ? trim((string)($_POST['phone'] ?? '')) : Crypto::decrypt((string)$p['phone_encrypted']),
'id_card' => $canEditAll ? trim((string)($_POST['id_card'] ?? '')) : Crypto::decrypt((string)$p['id_card_encrypted']),
'email' => trim((string)($_POST['email'] ?? '')),
'address' => trim((string)($_POST['address'] ?? '')),
'org' => trim((string)($_POST['org'] ?? '')),
'title' => trim((string)($_POST['title'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'slogan' => trim((string)($_POST['slogan'] ?? '')),
'avatar' => (string)$p['avatar'],
'birth_rank' => $canEditAll ? trim((string)($_POST['birth_rank'] ?? '')) : ($p['birth_rank'] ?? ''),
'is_placeholder' => $canEditAll
? (!empty($_POST['is_placeholder']) ? 1 : 0)
: (int)($p['is_placeholder'] ?? 0),
] + read_birthday_input($p);
$errors = Person::validate($d);
$contacts = read_contacts_input();
$ctErr = validate_contacts($contacts);
if ($ctErr !== '') {
$errors['contacts'] = $ctErr;
}
// 同 people/create:头像失败不挡保存(见 create 分支里的说明)
$up = Upload::avatar($familyId, $_FILES['avatar'] ?? [],
(string)($_POST['avatar_crop'] ?? ''));
$avatarWarn = (!$up['ok'] && ($up['error'] ?? '') !== '') ? (string)$up['error'] : '';
if ($errors) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => $errors,
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
if ($up['ok']) {
Upload::remove($familyId, (string)$p['avatar']);
$d['avatar'] = $up['file'];
}
try {
// 改自己且没有编名册的权限 → 只能动属于自己的字段(Person::updateSelf)。
// 这一条不是"防贼",是为了让访客也能改自己的名字,同时不会顺手把
// 手机号(= 登录凭据)或身份证号改掉。
if ($isSelf && !$canEditAll) {
Person::updateSelf($id, $d);
} else {
Person::update($id, $d);
}
} catch (\PDOException $ex) {
set_old($_POST);
View::render('people/form', [
'title' => $isSelf ? '我的资料' : '编辑成员',
'person' => $p,
'errors' => ['phone' => '这个手机号已经被名册里的另一个人用了'],
'canEditAll' => $canEditAll,
'isSelf' => $isSelf,
'phoneValue' => $d['phone'],
'idCardValue' => $d['id_card'],
'contacts' => $contacts,
], 'people');
exit;
}
Person::replaceContacts($id, $contacts);
if ((int)$p['id'] === (int)Auth::personId()) {
Auth::refreshAvatar((string)$d['avatar']);
// 姓名也可能改了 —— 不同步会话的话顶栏还是旧名字
$_SESSION['name'] = (string)$d['name'];
}
clear_old();
if ($avatarWarn !== '') {
flash('ok', '已保存。头像没有更换:' . $avatarWarn);
} else {
flash('ok', '已保存。');
}
redirect('people/show', ['id' => $id]);
break;
case 'people/show':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
$id = (int)($_GET['id'] ?? 0);
$p = Person::findWithAccount($id, $me);
if (!$p || (int)$p['family_id'] !== $familyId) {
flash('err', '找不到这个人。');
redirect('people');
}
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
// 这个人在「我」眼里是谁,以及我与他之间的直接关系(可编辑的那些)
$tree = Kinship::tree($graph, $id, $people, 2, 2);
View::render('people/show', [
'title' => $p['name'],
'person' => $p,
'meId' => $me,
'people' => $people,
'graph' => $graph,
'labelToMe' => Kinship::label($graph, $me, $id, $people),
'isMe' => $id === $me,
// 以这个人为中心的两代亲属,供详情页展示
'theirTree' => $tree,
'theirRels' => Relations::forPerson($familyId, $id),
'options' => Person::options($familyId, $id, $me),
'kinReady' => Kinship::graph($familyId) !== [],
// 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的
'myNickname' => $id === $me ? '' : Nickname::get($me, $id),
'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']),
'birthdays' => Birthday::describe($p),
'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400),
// 补充联系方式(vCard 对齐):kind=phone 的 value 是密文,模板里用 maskPhone 展示
'contacts' => Person::contactsOf($id),
// 名册(每人一份):他是不是在我的名册里、可以把他的名片分享给谁
'inMyRoster' => in_array($id, PersonRoster::visibleIds($familyId, $me), true),
], 'people');
break;
case 'people/destroy':
Auth::requireAdmin();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$p = Person::find($id);
if ($p && (int)$p['family_id'] === (int)Auth::familyId()) {
if ((int)$p['id'] === (int)Auth::personId()) {
flash('err', '不能把自己从名册里移除。');
redirect('people/show', ['id' => $id]);
}
// 不让最后一个管理员消失:系统里没人能管理家庭就锁死了
$acc = Account::findByPerson($id);
if ($acc && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '这是唯一的管理员,先把另一个人设为管理员再移除。');
redirect('people/show', ['id' => $id]);
}
Upload::remove((int)Auth::familyId(), (string)$p['avatar']);
Person::delete($id);
flash('ok', '已从名册移除「' . $p['name'] . '」。');
}
redirect('people');
break;
// ==================== 账号 ====================
case 'accounts/create':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$p = Person::find($personId);
if (!$p || (int)$p['family_id'] !== (int)Auth::familyId()) {
flash('err', '找不到这个人。');
redirect('people');
}
if (Account::findByPerson($personId)) {
flash('err', '这个人的账号已经开通过了。');
redirect('people/show', ['id' => $personId]);
}
if (Crypto::decrypt((string)$p['phone_encrypted']) === '') {
flash('err', '要先填手机号,才能开通登录。');
redirect('people/show', ['id' => $personId]);
}
$pass = (string)($_POST['password'] ?? '');
$errs = Account::validatePassword($pass);
if ($errs) {
flash('err', implode(';', $errs));
redirect('people/show', ['id' => $personId]);
}
$r0 = (string)($_POST['role'] ?? '');
Account::create($personId, $pass, in_array($r0, Account::ROLES, true) ? $r0 : 'member');
// v7:多了一个「看家谱的人」——他的名册也要按树播种,一登录三处口径就是对的
PersonRoster::syncKinForFamily((int)Auth::familyId());
flash('ok', '账号已开通,用手机号就能登录。');
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/role':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$role = in_array(($r1 = (string)($_POST['role'] ?? '')), Account::ROLES, true) ? $r1 : 'member';
$p = Person::find($personId);
$acc = Account::findByPerson($personId);
if ($p && $acc) {
// ⚠️ 判的是 `$role !== 'admin'` 而不是 `$role === 'member'`:
// 早先只拦"降成家庭成员",于是把**最后一位管理员**降成访客能一路通过 ——
// 降完家里一个管理员都不剩,谁也进不了系统设置了。
// 允许改自己的权限(包括给自己降级),只在"会一个管理员都不剩"时拦。
if ($role !== 'admin' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员 —— 把最后一位管理员降级,全家就没人能管理系统了。');
redirect('people/show', ['id' => $personId]);
}
Account::setRole((int)$acc['id'], $role);
flash('ok', '权限已更新。');
}
redirect('people/show', ['id' => $personId]);
break;
case 'accounts/status':
Auth::requireAdmin();
csrf_check();
$personId = (int)($_POST['person_id'] ?? 0);
$status = ($_POST['status'] ?? '') === 'active' ? 'active' : 'disabled';
$acc = Account::findByPerson($personId);
if ($acc) {
if ((int)$acc['person_id'] === (int)Auth::personId()) {
flash('err', '不能停用自己。');
redirect('people/show', ['id' => $personId]);
}
if ($status === 'disabled' && $acc['role'] === 'admin' && Account::adminCount() <= 1) {
flash('err', '至少要留一位管理员。');
redirect('people/show', ['id' => $personId]);
}
Account::setStatus((int)$acc['id'], $status);
flash('ok', $status === 'active' ? '账号已启用。' : '账号已停用。');
}
redirect('people/show', ['id' => $personId]);
break;
// ==================== 亲属关系 ====================
case 'kin/relate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$from = (int)($_POST['from_id'] ?? 0); // 我(或当前正在编辑的人)
$to = (int)($_POST['to_id'] ?? 0); // 对方
$kind = (string)($_POST['kind'] ?? '');
$role = (string)($_POST['role'] ?? '');
$ok = Person::find($from) && Person::find($to)
&& (int)Person::find($from)['family_id'] === $familyId
&& (int)Person::find($to)['family_id'] === $familyId;
if (!$ok) {
flash('err', '选的人不对。');
redirect('people');
}
// ⚠️⚠️ 方向极易写反,而且写反了**不会报任何错**:页面照常显示「关系已记录」,
// 只是把人记成了对方的父母 —— 家谱整个倒过来。
// 约定:表单里 from_id 是「当前打开的人」,也就是表单上写的那个「我」;
// kind=parent 表示「对方是我的父母」→ 所以我是 child、对方是 parent。
// Relations::addParent($familyId, $childId, $parentId, $role) 的第二个参数是**孩子**。
if ($kind === 'parent') {
// 对方是我的父母:我是孩子,对方是父母
$r = Relations::addParent($familyId, $from, $to, $role);
} elseif ($kind === 'child') {
// 对方是我的子女:对方是孩子,我是父母
$r = Relations::addParent($familyId, $to, $from, $role);
} else {
$r = Relations::addSpouse($familyId, $from, $to);
}
flash($r['ok'] ? 'ok' : 'err', $r['ok'] ? '关系已记录。' : $r['error']);
redirect('people/show', ['id' => $from]);
break;
case 'kin/unrelate':
Auth::requireWrite();
csrf_check();
$familyId = (int)Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$who = (int)($_POST['person_id'] ?? 0);
$kinRemoved = Relations::remove($familyId, $id);
// v7:关系没了,因这条关系自动进两端名册的条目也收回 —— 把这件事说破,
// 不然用户会以为名册里的人凭空消失了
flash('ok', $kinRemoved
? '这条关系已解除,两人名册里因此自动加入的对方也一并移出了。'
: '这条关系已解除。');
redirect('people/show', ['id' => $who]);
break;
// ==================== 访客邀请 ====================
case 'accounts/invite':
Auth::requireAdmin();
clear_old();
// ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' ——
// 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()),
'options' => Person::options((int)Auth::familyId()),
], 'people');
break;
case 'accounts/invite_create':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$name = trim((string)($_POST['name'] ?? ''));
$phone = trim((string)($_POST['phone'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$note = trim((string)($_POST['note'] ?? ''));
$errors = [];
if ($name === '') {
$errors['name'] = '请填写对方的名字';
}
if (!preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$errors['phone'] = '手机号格式看起来不对';
}
$pw = Account::validatePassword($pass);
if ($pw) {
$errors['password'] = implode(';', $pw);
}
if (!$errors) {
try {
// 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱
$pid = Person::create($familyId, [
'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone,
'id_card' => '', 'email' => '', 'address' => '', 'note' => $note,
'slogan' => '', 'kind' => 'contact',
]);
Account::create($pid, $pass, 'guest');
// 我邀的访客也进**我的**名册(名册是每人一份的;访客自己那份由
// Account::create 给他补上他自己)
PersonRoster::ensure($familyId, (int)Auth::personId(), $pid, null, 'self');
clear_old();
flash('ok', '访客「' . $name . '」已开通,把手机号和密码告诉他就能登录。');
redirect('accounts/invite');
} catch (\PDOException $ex) {
$errors['phone'] = '这个手机号已经有人用了';
}
}
set_old($_POST);
View::render('people/invite', [
'title' => '邀请访客',
'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()),
'options' => Person::options($familyId),
'errors' => $errors,
], 'people');
break;
// ==================== 设置 ====================
case 'settings':
Auth::requireAdmin();
$dbVersion = '未知';
try {
$dbVersion = Database::get()->query('SELECT VERSION()')->fetchColumn();
} catch (\Throwable $ex) {
// 拿不到版本不影响页面
}
$rawKey = (string)fb_config('app_key', '');
$acc = Account::roleCounts();
// 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET)
$diag = $_SESSION['_oss_diag'] ?? null;
unset($_SESSION['_oss_diag']);
// 公共分组(owner_id = 0)—— 管理员在这里维护全家共用的那一套。
// 人数按"全家名册"统计(这里要看的是"这一组里全家有多少人",不是我个人那份)。
View::render('settings', [
'title' => '设置',
'family' => Family::find((int)Auth::familyId()),
'dbVersion' => $dbVersion,
'keyOk' => $rawKey !== '' && strpos($rawKey, '请填写') !== 0,
'roleCounts' => $acc,
'relCount' => Relations::count((int)Auth::familyId()),
'pubGroups' => PersonGroup::forOwner((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'pubCounts' => PersonGroup::counts((int)Auth::familyId(), PersonGroup::PUBLIC_OWNER),
'upgradeWarn'=> (string)Setting::get((int)Auth::familyId(), 'schema_upgrade_warning', ''),
'storageDriver' => \App\Storage\Storage::driver((int)Auth::familyId()),
'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()),
'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()),
'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()),
'ossDiag' => $diag,
], 'settings');
break;
/**
* 阿里云 OSS 连通性测试。
*
* 与 settings/test_storage 的分工:
* · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西";
* · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 ——
* 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。
* 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写),
* 让"连不上"这件事有具体原因,而不是一句"写入失败"。
*/
case 'settings/test_oss':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId);
if ($disk === null) {
$_SESSION['_oss_diag'] = [
'ok' => false,
'steps' => [[
'name' => '配置完整性', 'ok' => false, 'ms' => 0,
'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。',
]],
'at' => time(),
];
redirect('settings');
}
$steps = $disk->diagnose();
$ok = true;
foreach ($steps as $s) {
if (empty($s['ok'])) {
$ok = false;
break;
}
}
$_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()];
redirect('settings');
break;
case 'settings/storage':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$old = \App\Storage\Storage::config($familyId);
$in = (array)($_POST['oss'] ?? []);
// 密钥留空 = 不改(避免「保存时没重填就把已存的密钥清掉」)
foreach (['access_key_secret', 'access_key_id'] as $k) {
if (trim((string)($in[$k] ?? '')) === '') {
$in[$k] = (string)$old[$k];
}
}
$oss = [
'auth_mode' => ($in['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ecs_role' : 'ak',
'access_key_id' => trim((string)($in['access_key_id'] ?? '')),
'access_key_secret' => trim((string)($in['access_key_secret'] ?? '')),
'bucket' => trim((string)($in['bucket'] ?? '')),
'endpoint' => trim((string)($in['endpoint'] ?? '')),
'prefix' => trim((string)($in['prefix'] ?? '')),
'ram_role_name' => trim((string)($in['ram_role_name'] ?? '')),
'custom_domain' => trim((string)($in['custom_domain'] ?? '')),
'internal_endpoint' => !empty($in['internal_endpoint']) ? '1' : '0',
'https' => !empty($in['https']) ? '1' : '0',
];
\App\Storage\Storage::saveConfig($familyId, (string)($_POST['driver'] ?? 'local'), $oss);
flash('ok', '附件存储设置已保存。');
redirect('settings');
break;
case 'settings/test_storage':
Auth::requireAdmin();
csrf_check();
$familyId = (int)Auth::familyId();
$disk = \App\Storage\Storage::disk($familyId);
$err = $disk->validateConfig();
if ($err !== '') {
flash('err', '配置不完整:' . $err);
redirect('settings');
}
// 真写一个探针对象再读回来,最后删掉 —— 比只 ping 一下可靠得多
$probeKey = 'avatars/.probe-' . bin2hex(random_bytes(6)) . '.txt';
$payload = 'familybook-probe-' . time();
$put = \App\Storage\Storage::putContents($familyId, $payload, $probeKey, 'text/plain');
if (empty($put['ok'])) {
flash('err', '写入失败:' . (string)($put['error'] ?? ''));
redirect('settings');
}
$get = \App\Storage\Storage::read($familyId, $probeKey);
$ok = !empty($get['ok']) && trim((string)$get['body']) === $payload;
\App\Storage\Storage::delete($familyId, $probeKey);
flash($ok ? 'ok' : 'err',
$ok ? '存储可用:写入 → 读回 → 删除 三步都通过了(驱动:' . $disk->id() . ')'
: '写入成功但读回来对不上,请检查桶权限或内网/外网 endpoint。');
redirect('settings');
break;
case 'settings/update':
Auth::requireAdmin();
csrf_check();
$fid = (int)Auth::familyId();
// ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明):
// 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。
// 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。
$cur = Family::find($fid);
Family::update(
$fid,
(string)($cur['name'] ?? '家书'),
trim((string)($_POST['motto'] ?? ''))
);
flash('ok', '已保存。');
redirect('settings');
break;
default:
http_response_code(404);
View::render('error', [
'title' => '页面不存在',
'message' => '找不到这一页。',
], 'home');
break;
}