五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」: 新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。 2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」; 同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。 3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键, 云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。 4. 三种角色:admin / member / guest,访客只读、写操作一律 403; accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。 5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 —— 兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹), 认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。 顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、 birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、 layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。 验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 · 反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
/**
|
||||
* 亲属关系:**只存相邻两代之间的边,其余全部推导**。
|
||||
*
|
||||
* 设计(这是整个家谱功能的地基,改动前请先读懂):
|
||||
* - 库里只有一张 relations 表,两种边:
|
||||
* parent : from 是 to 的父或母(from_role 记 father/mother)
|
||||
* spouse : from 与 to 是配偶(对称,只存一条)
|
||||
* - 不做「爷爷」「岳母」这种一次性称呼的存储。要知道奶奶是谁,
|
||||
* 就是「我的父亲」再「我父亲的母亲」——两级边串起来。这样:
|
||||
* ① 关系只有两个方向要维护,不会出现「我记了他是我爸,他那边没记我」
|
||||
* ② 同一条边在不同人眼里自动呈现不同称呼
|
||||
* (我的「父亲」= 我孩子的「爷爷」= 我爱人的「公公」)
|
||||
* ③ 虚拟人物(is_placeholder)可以参与关系网,等弄清真实身份再改名即可
|
||||
* - 兄弟姐妹**不存边**:他们共享同一对父母,由「父母的子女」反查得出。
|
||||
* 排行由 persons.birth_rank 决定,用来显示长子/次女/三弟。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Kinship
|
||||
{
|
||||
/** 一次把全家的边读进内存 —— 几十人的家庭,比反复查库快得多,也让推导逻辑干净 */
|
||||
public static function graph(int $familyId): array
|
||||
{
|
||||
$parents = []; // childId => [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;
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* 亲属关系表的读写。
|
||||
*
|
||||
* 只维护两种边(详见 Kinship 的头注释):
|
||||
* parent : from 是 to 的父/母
|
||||
* spouse : from 与 to 是配偶(对称,只存一条)
|
||||
*
|
||||
* ⚠️ 血缘关系必须**防环**:如果把 A 设为 B 的父亲、又顺手把 B 设成 A 的父亲,
|
||||
* 家谱展开会变成死循环。addParent() 在写库前先检查。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Relations
|
||||
{
|
||||
/** 给某人指定一位父/母 */
|
||||
public static function addParent(int $familyId, int $childId, int $parentId, string $role): array
|
||||
{
|
||||
$role = $role === 'mother' ? 'mother' : 'father';
|
||||
if ($childId === $parentId) {
|
||||
return ['ok' => 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();
|
||||
}
|
||||
}
|
||||
+77
-4
@@ -1,12 +1,13 @@
|
||||
<?php
|
||||
/**
|
||||
* 建表语句(安装向导与 tests 共用同一份)。
|
||||
* 建表语句与结构升级(安装向导与运行时共用同一份)。
|
||||
*
|
||||
* 四条约定:
|
||||
* 五条约定:
|
||||
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
|
||||
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
|
||||
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
|
||||
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
|
||||
* 5. 结构升级走 ensure(),不靠人手动改库 —— 已上线的库也能自己长出新列
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -36,6 +37,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '小名',
|
||||
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
|
||||
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
|
||||
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
|
||||
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,相对于 uploads/avatars',
|
||||
`phone_hash` CHAR(64) DEFAULT NULL COMMENT '手机号 sha256,登录查找用;NULL 不参与唯一约束',
|
||||
@@ -44,6 +46,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
||||
`email` VARCHAR(120) DEFAULT NULL,
|
||||
`address` VARCHAR(200) DEFAULT NULL,
|
||||
`note` TEXT DEFAULT NULL COMMENT '备注',
|
||||
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
||||
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '名册排序,小的在前',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -58,7 +61,7 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`person_id` INT UNSIGNED NOT NULL COMMENT '一个人最多一个账号',
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` ENUM('admin','member') NOT NULL DEFAULT 'member' COMMENT '管理员 / 普通成员',
|
||||
`role` ENUM('admin','member','guest') NOT NULL DEFAULT 'member' COMMENT '管理员 / 家庭成员 / 访客',
|
||||
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
|
||||
`last_login_at` DATETIME DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -66,6 +69,25 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
UNIQUE KEY `uk_account_person` (`person_id`),
|
||||
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `relations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`from_id` INT UNSIGNED NOT NULL COMMENT '关系的一端',
|
||||
`to_id` INT UNSIGNED NOT NULL COMMENT '关系的另一端',
|
||||
`kind` ENUM('parent','spouse') NOT NULL COMMENT 'parent: from 是 to 的父/母;spouse: 配偶(对称)',
|
||||
`from_role` ENUM('father','mother') DEFAULT NULL COMMENT 'kind=parent 时必填:from 相对 to 是父亲还是母亲',
|
||||
`note` VARCHAR(200) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_rel` (`from_id`,`to_id`,`kind`,`from_role`),
|
||||
KEY `idx_rel_from` (`from_id`,`kind`),
|
||||
KEY `idx_rel_to` (`to_id`,`kind`),
|
||||
CONSTRAINT `fk_rel_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_rel_from` FOREIGN KEY (`from_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_rel_to` FOREIGN KEY (`to_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='亲属关系:只存相邻两代之间的边,其余靠推导'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
@@ -78,4 +100,55 @@ CREATE TABLE IF NOT EXISTS `settings` (
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
||||
*
|
||||
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
|
||||
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
|
||||
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
|
||||
*/
|
||||
public static function ensure(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
foreach (self::statements() as $sql) {
|
||||
$db->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 '管理员 / 家庭成员 / 访客'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 ECS 实例元数据:RAM 角色临时凭据 + 地域。
|
||||
* (从 MovieLog_Server 移植)
|
||||
*
|
||||
* 为什么值得支持这种方式:这样**服务器上不用落任何长期密钥**。
|
||||
* 实例绑一个 RAM 角色,程序现用现取 STS 临时凭据;即使站点被拖库,
|
||||
* 拿到的也只是几十分钟就过期的临时 token。
|
||||
*
|
||||
* ⚠️ 一台 ECS 只能绑一个 RAM 角色;该角色必须被授权访问目标 Bucket,
|
||||
* 否则报 AccessDenied(NoPermissionType=ImplicitDeny)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
use App\Setting;
|
||||
|
||||
final class EcsMeta
|
||||
{
|
||||
private const BASE = 'http://100.100.100.200/latest/meta-data/';
|
||||
private const TIMEOUT = 3;
|
||||
|
||||
/**
|
||||
* 取 RAM 角色的 STS 临时凭据。
|
||||
* $roleName 留空则自动取实例绑定的第一个角色。
|
||||
*/
|
||||
public static function ramCredentials(int $familyId, string $roleName = '', bool $force = false): array
|
||||
{
|
||||
static $memo = [];
|
||||
$memoKey = 'aliyun:' . $roleName;
|
||||
if (!$force && isset($memo[$memoKey])) {
|
||||
return $memo[$memoKey];
|
||||
}
|
||||
|
||||
// 跨请求缓存:STS 一般 >= 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' => ''];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
/**
|
||||
* 对象存储·共用件:curl 客户端 + 若干编码工具。
|
||||
* (从 MovieLog_Server 移植,去掉了与电影站相关的部分)
|
||||
*
|
||||
* ⚠️ 不依赖官方 SDK / Composer —— 全站手写签名 + curl,与 PHP 7.4+ 完全兼容。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
final class Http
|
||||
{
|
||||
/**
|
||||
* 发一个 HTTP 请求。
|
||||
*
|
||||
* ⚠️ 千万不要用 CURLOPT_CUSTOMREQUEST='HEAD' 去发 HEAD:
|
||||
* 这样 curl 仍会等 Content-Length 声明的 body,而服务端对 HEAD 不回体,
|
||||
* 于是一路等到超时、curl_exec 返回 false。(MovieLog 实测 8011ms / errno=28)
|
||||
* HEAD 要用 CURLOPT_NOBODY=true。
|
||||
*/
|
||||
public static function send(
|
||||
string $method,
|
||||
string $url,
|
||||
array $headers = [],
|
||||
$body = null,
|
||||
int $timeout = 30,
|
||||
?int $connectTimeout = null
|
||||
): array {
|
||||
$respHeaders = [];
|
||||
$ch = curl_init();
|
||||
$method = strtoupper($method);
|
||||
$opts = [
|
||||
CURLOPT_URL => $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, '<Code>') !== false) {
|
||||
$msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body)));
|
||||
}
|
||||
$msg = trim($msg);
|
||||
return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* 本地磁盘驱动 —— 也是云端失败时的**回退目标**。
|
||||
* 绝不因为云配置出问题就让用户丢文件。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
final class LocalDriver implements StorageDriver
|
||||
{
|
||||
private string $root;
|
||||
|
||||
public function __construct(string $root)
|
||||
{
|
||||
$this->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), '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
|
||||
* (从 MovieLog_Server 移植)
|
||||
*
|
||||
* ⚠️ 三件事必须记住:
|
||||
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
|
||||
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
|
||||
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
|
||||
* 所以先比地域(零成本),再只给 2 秒连接超时。
|
||||
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
|
||||
* 否则报 "must be addressed using the specified endpoint"。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
use App\Setting;
|
||||
|
||||
final class OssDriver implements StorageDriver
|
||||
{
|
||||
private array $cfg;
|
||||
private int $familyId;
|
||||
private ?array $cred = null;
|
||||
|
||||
public function __construct(int $familyId, array $cfg)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* 存储门面:全站**唯一**的附件读写出口。
|
||||
*
|
||||
* 三条纪律(照 MovieLog 的教训定,别绕过):
|
||||
* ① 数据库只存「相对键」(如 avatars/ab12.jpg),绝不存完整 URL ——
|
||||
* 换域名、换桶、换前缀都不用改历史数据。
|
||||
* ② 页面绝不手拼路径,一律走 url()。
|
||||
* ③ 云端失败**自动回退本地**,绝不因云配置问题丢用户的文件。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
use App\Setting;
|
||||
|
||||
final class Storage
|
||||
{
|
||||
public const AVATAR_DIR = 'avatars';
|
||||
|
||||
/** OSS 默认配置模板 */
|
||||
public static function ossDefaults(): array
|
||||
{
|
||||
return [
|
||||
'auth_mode' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* 存储驱动接口。三种实现:local / oss / null(未配置)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
interface StorageDriver
|
||||
{
|
||||
public function id(): string;
|
||||
|
||||
/** 配置是否可用;返回空串表示可用 */
|
||||
public function validateConfig(): string;
|
||||
|
||||
public function putFile(string $srcPath, string $key, ?string $mime = null): array;
|
||||
|
||||
public function putContents(string $binary, string $key, ?string $mime = null): array;
|
||||
|
||||
public function delete(string $key): array;
|
||||
|
||||
public function get(string $key, array $extra = []): array;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/** 对外可访问地址 */
|
||||
public function url(string $key): string;
|
||||
}
|
||||
+52
-33
@@ -1,26 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG 落盘。
|
||||
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG,然后交给 Storage 落盘。
|
||||
*
|
||||
* 落盘位置由「设置」决定(本地 或 阿里云 OSS),本类不关心。
|
||||
*
|
||||
* 安全要点:
|
||||
* - 用 finfo 判断真实 MIME,不信浏览器给的扩展名
|
||||
* - 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
|
||||
* - 只写进 uploads/avatars/,该目录有 .htaccess 禁止执行脚本
|
||||
* - 本地落盘时进 uploads/,该目录有 .htaccess 禁止执行脚本
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Storage\Storage;
|
||||
|
||||
final class Upload
|
||||
{
|
||||
private const MAX_BYTES = 5 * 1024 * 1024;
|
||||
private const SIZE = 512;
|
||||
|
||||
/** @return array{ok:bool,file?:string,error?:string} */
|
||||
public static function avatar(array $file): array
|
||||
public static function avatar(int $familyId, array $file): array
|
||||
{
|
||||
if (!isset($file['error']) || is_array($file['error'])) {
|
||||
// 「表单里压根没有这个字段」= 用户没打算换头像,不是错误。
|
||||
// ⚠️ 早先这里一律报「上传参数不对」,于是任何**不带 enctype 多部分**的
|
||||
// 调用(或没有该字段的表单)都会被判成校验失败,整条新增/编辑被挡下,
|
||||
// 表现却是「表单提交后又回到表单、什么都没说」—— 很难查。
|
||||
if (!array_key_exists('error', $file)) {
|
||||
return ['ok' => 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);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
+28
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user