Files
FamilyBook/app/Auth.php
T
laoyang ed38a40c08 名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5):

- 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id,
  sort_order, source)。默认只看「我添加的 + 别人分享给我的」;
  source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。
- 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库),
  无 JS 时卡片上的「调整分组」表单照旧可用。
- 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0),
  用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。
- 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里
  已有这个人,可直接复用(people/pick),不必重复录入。
- 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php),
  两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留
  资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。
- 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。
- 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小,
  落库的归一化矩形保持不变。
- 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。
- 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。

迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进
tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。
线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id
就位,persons.group_id / sort_order 已删。

验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、
移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中;
部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
2026-09-30 13:24:26 +08:00

178 lines
5.3 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
/**
* 登录态与权限。
*
* 一个重要设计:**人(Person)≠ 账号(Account)**。
* 六岁的孩子应该出现在家庭名册、照片、活动参与人里,但不必有密码。
* 所以 person 可以先于 account 存在,将来再「开通账号」。
*/
declare(strict_types=1);
namespace App;
final class Auth
{
public static function check(): bool
{
return !empty($_SESSION['account_id']);
}
public static function id(): ?int
{
return isset($_SESSION['account_id']) ? (int)$_SESSION['account_id'] : null;
}
public static function personId(): ?int
{
return isset($_SESSION['person_id']) ? (int)$_SESSION['person_id'] : null;
}
public static function familyId(): ?int
{
return isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : null;
}
public static function name(): string
{
return $_SESSION['name'] ?? '';
}
public static function isAdmin(): bool
{
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
{
if (!self::check()) {
redirect('auth/login');
}
}
/** 需要「能写」的权限(访客会被挡下) */
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();
if (!self::isAdmin()) {
http_response_code(403);
\App\View::render('error', [
'title' => '没有权限',
'message' => '这一页只有家庭管理员可以操作。',
], 'home');
exit;
}
}
/**
* 手机号 + 密码登录。
* 返回 true 表示成功并已写入会话。
*/
public static function login(string $phone, string $password): bool
{
// 简单节流:5 次失败后 60 秒内不再尝试。
// 防爆破的关键不在判定多严,而在于「尝试次数有成本」。
$fail = $_SESSION['login_fail'] ?? ['n' => 0, 't' => 0];
if ($fail['n'] >= 5 && (time() - $fail['t']) < 60) {
usleep(500000);
return false;
}
$hash = Crypto::hash($phone);
$stmt = Database::get()->prepare(
'SELECT a.id AS account_id, a.password_hash, a.role, a.status,
p.id AS person_id, p.family_id, p.name, p.avatar
FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.phone_hash = ? LIMIT 1'
);
$stmt->execute([$hash]);
$row = $stmt->fetch();
if (!$row || $row['status'] !== 'active'
|| !password_verify($password, (string)$row['password_hash'])) {
$_SESSION['login_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
usleep(300000);
return false;
}
unset($_SESSION['login_fail']);
session_regenerate_id(true);
$_SESSION['account_id'] = (int)$row['account_id'];
$_SESSION['person_id'] = (int)$row['person_id'];
$_SESSION['family_id'] = (int)$row['family_id'];
$_SESSION['name'] = $row['name'];
$_SESSION['role'] = $row['role'];
$_SESSION['avatar'] = $row['avatar'];
Database::get()->prepare('UPDATE accounts SET last_login_at = NOW() WHERE id = ?')
->execute([$row['account_id']]);
return true;
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** 头像改动后刷新会话里的缓存 */
public static function refreshAvatar(string $avatar): void
{
$_SESSION['avatar'] = $avatar;
}
}