Files
FamilyBook/app/Upload.php
T
laoyang 1669ab48d0 五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
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(把每个修复改回旧写法,对应断言都会变红)。
2026-09-30 08:13:07 +08:00

124 lines
4.7 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
/**
* 头像上传:裁成正方形、缩到 512×512、转成 JPEG,然后交给 Storage 落盘。
*
* 落盘位置由「设置」决定(本地 或 阿里云 OSS),本类不关心。
*
* 安全要点:
* - 用 finfo 判断真实 MIME,不信浏览器给的扩展名
* - 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
* - 本地落盘时进 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(int $familyId, array $file): array
{
// 「表单里压根没有这个字段」= 用户没打算换头像,不是错误。
// ⚠️ 早先这里一律报「上传参数不对」,于是任何**不带 enctype 多部分**的
// 调用(或没有该字段的表单)都会被判成校验失败,整条新增/编辑被挡下,
// 表现却是「表单提交后又回到表单、什么都没说」—— 很难查。
if (!array_key_exists('error', $file)) {
return ['ok' => false, 'error' => ''];
}
if (is_array($file['error'])) {
return ['ok' => false, 'error' => '上传参数不对'];
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_NO_FILE:
return ['ok' => false, 'error' => ''];
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
default:
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
if ($file['size'] > self::MAX_BYTES) {
return ['ok' => false, 'error' => '图片太大了(上限 5MB)'];
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif', 'image/webp' => 'imagecreatefromwebp'];
if (!isset($allow[$mime])) {
return ['ok' => false, 'error' => '只支持 JPG / PNG / GIF / WebP 图片'];
}
$src = $allow[$mime]($file['tmp_name']);
if (!$src) {
return ['ok' => false, 'error' => '图片读不出来'];
}
$w = imagesx($src);
$h = imagesy($src);
$side = min($w, $h);
$sx = (int)(($w - $side) / 2);
$sy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor(self::SIZE, self::SIZE);
// PNG/GIF 的透明区转成纸白,不然头像会是黑底
$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);
imagedestroy($src);
// 先在临时目录生成,再整体交给 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(int $familyId, ?string $rel): string
{
$rel = trim((string)$rel);
if ($rel === '') {
return asset('/assets/avatar-default.svg');
}
return Storage::url($familyId, $rel);
}
public static function remove(int $familyId, ?string $rel): void
{
if ($rel) {
Storage::delete($familyId, (string)$rel);
}
}
/** 让 Storage 知道自己该用哪个驱动(测试/迁移用) */
public static function driverId(int $familyId): string
{
return Storage::effectiveDriver($familyId);
}
}