五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式

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:
2026-09-30 08:13:07 +08:00
parent 8eb19267bc
commit 1669ab48d0
30 changed files with 3034 additions and 161 deletions
+175
View File
@@ -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;
}
}