Files
FamilyBook/app/Upload.php
T
laoyang 1c460a0e83 图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单
① 上传图片一律可裁剪,比例由图位决定
   新增 app/ImageSlot.php 统一配置(头像 1:1/512×512;题图 16:9 只留注释,
   将来启用只加一行)。前端只挑区域、写回归一化矩形 "x,y,w,h",真正裁剪在服务端
   Upload::fitRegion():夹回图内 → 在框内居中收缩到目标比例(不外扩)→ 钉住 sx+sw ≤ w。
   比例解析单独写 parseRatioAttr():parseFloat('16:9') 得 16,会把题图裁成细长条且不报错。

② 名册按关系分组,顺序每人可自定
   分类全家共用(person_groups,另一个人可以自己加分类);「自己的」只体现在顺序上
   (user_settings.group_order,每人一份)。persons.group_id 故意不加外键(老库只能
   ADD COLUMN),完整性由 PersonGroup 保证:删分类把人置回未分类,人不会从名册消失。

③ 设置分两级:系统设置 / 个人设置
   个人设置拆成 templates/settings/me.php + _profile.php + _nicknames.php,
   取代 templates/people/me.php(旧路由与旧表单仍兼容)。

④ 右上角「姓名/头像 + 下拉菜单」
   原来是两个裸链接,窄屏会被 CSS 藏掉导致改密码/退出不可达;改成 <details> 零 JS 菜单
   (app.js 只补「点外面 / Esc」)。菜单项:个人设置 / 系统设置(管理员)/ 修改密码 / 退出登录。

Schema 版本 3 → 4;安装与老库升级共用 PersonGroup::backfillMissing() 一条回填路径。

验证:php -l 全量;e2e 202/0(新增「关系分组」与「图片裁剪」两节);老库升级 49/0;
桌面几何 108/0;移动端几何 192/0(360/375/390/414);交互 76/0;
反向验证 31/31(逐条把修复改回旧写法,指定的目标断言必须变红);
部署后本地↔线上 72 个文件 sha256 逐字节一致。
2026-09-30 11:06:22 +08:00

269 lines
11 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
/**
* 图片上传:按**位置的显示形状**裁切(头像 1:1、题图 16:9…)→ 缩到固定尺寸 → 转 JPEG
* → 交给 Storage 落盘。落盘位置由「设置」决定(本地 或 阿里云 OSS),本类不关心。
*
* 裁剪为什么是**服务端**在裁(前端只是给用户挑区域):
* · 服务端是唯一能保证「不管前端发来什么,落库的图一定是 1:1」的地方。
* 如果只靠前端 canvas 裁好再传,绕过前端(或 JS 没跑到)就会存进一张不是 1:1 的图,
* 页面上要么变形、要么被 object-fit 二次裁掉一半脸。
* · 前端只传一个**归一化的矩形**(相对原图的比例,如 "0.2,0.1,0.5,0.5"),
* 与原图分辨率无关 —— 用户换台设备、原图被压缩过,这个矩形照样对得上。
* · 没有这个矩形(用户没裁 / 没 JS)→ 退回**居中裁切**,与老行为一致,不报错。
*
* 安全要点:
* · 用 finfo 判断真实 MIME,不信浏览器给的扩展名
* · 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
* · 内存先设上限:超大图直接判错,别把 PHP 进程撑爆
*/
declare(strict_types=1);
namespace App;
use App\Storage\Storage;
final class Upload
{
/**
* 图片类上传的统一入口。
*
* @param string $slotKey ImageSlot::SLOTS 里的键(决定比例与输出尺寸)
* @param string $cropRaw 前端挑的裁剪区域,归一化 "x,y,w,h"(相对原图 0..1);留空=居中裁
* @return array{ok:bool,file?:string,error?:string,note?:string,slot?:string,crop?:array}
*/
public static function image(int $familyId, array $file, string $slotKey, string $cropRaw = ''): array
{
$slot = ImageSlot::get($slotKey);
if ($slot === null) {
return ['ok' => false, 'error' => '上传位置配置不正确(' . $slotKey . ')'];
}
// 「表单里压根没有这个字段」= 用户没打算换图,不是错误。
// ⚠️ 早先这里一律报「上传参数不对」,于是任何**不带 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' => '图片太大了(上限 ' . self::maxMbWord() . ')'];
default:
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
if ((int)$file['size'] > ImageSlot::MAX_BYTES) {
return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')'];
}
$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 图片'];
}
// 先用 getimagesize 拿到尺寸 —— 像素数太大就直接拒,避免 imagecreatefrom* 把内存吃光
$info = @getimagesize($file['tmp_name']);
if (!$info || $info[0] < 1 || $info[1] < 1) {
return ['ok' => false, 'error' => '图片读不出来'];
}
if ($info[0] * $info[1] > 40_000_000) {
return ['ok' => false, 'error' => '图片像素太多(超过 4000 万),请先压缩再传'];
}
$src = @$allow[$mime]($file['tmp_name']);
if (!$src) {
return ['ok' => false, 'error' => '图片读不出来'];
}
$w = imagesx($src);
$h = imagesy($src);
// 先按目标比例、在「用户挑的区域」里定位 —— 区域非法时退回整图居中
$region = self::readCrop($cropRaw, $w, $h);
[$sx, $sy, $sw, $sh] = self::fitRegion($region ?? [0.0, 0.0, 1.0, 1.0], $w, $h, ImageSlot::ratio($slot));
// 「框小到没有意义」要明确拒绝(放大之后就是一坨马赛克)。
// ⚠️ 只在**用户真的给了裁剪框**时判:源图本身很小(比如 8×8 的老头像、
// 或者是张一像素级的测试图)时,居中裁出来的区域自然也小 ——
// 那种情况必须照常接受,否则一张本来能用的图会被"太小"挡在外面。
if ($region !== null && ($sw < 16 || $sh < 16)) {
imagedestroy($src);
return ['ok' => false, 'error' => '裁剪区域太小了,请把框放大一点'];
}
$outW = (int)$slot['w'];
$outH = (int)$slot['h'];
$canvas = imagecreatetruecolor($outW, $outH);
// PNG/GIF 的透明区转成纸白,不然头像会是黑底
$bg = imagecolorallocate($canvas, 253, 251, 245);
imagefilledrectangle($canvas, 0, 0, $outW, $outH, $bg);
imagecopyresampled($canvas, $src, 0, 0, $sx, $sy, $outW, $outH, $sw, $sh);
imagedestroy($src);
// 先在临时目录生成,再整体交给 Storage —— 这样本地与 OSS 是同一条路径
$tmpOut = tempnam(sys_get_temp_dir(), 'fbimg');
if ($tmpOut === false || !imagejpeg($canvas, $tmpOut, 88)) {
imagedestroy($canvas);
@unlink((string)$tmpOut);
return ['ok' => false, 'error' => '处理图片失败'];
}
imagedestroy($canvas);
$key = (string)$slot['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, 'slot' => $slotKey,
'crop' => ['x' => $sx, 'y' => $sy, 'w' => $sw, 'h' => $sh]];
if (!empty($res['note'])) {
$out['note'] = (string)$res['note'];
}
return $out;
}
/**
* 头像(= avatar 位置)。保留这个入口,调用方不必关心槽位名。
* @return array{ok:bool,file?:string,error?:string,note?:string}
*/
public static function avatar(int $familyId, array $file, string $cropRaw = ''): array
{
return self::image($familyId, $file, 'avatar', $cropRaw);
}
/**
* 解析归一化裁剪矩形 "x,y,w,h"(各 0..1,相对原图)。
* 任何一项非法(不是数字、越界、宽高为 0)→ 返回 null,调用方退回居中裁切。
*
* ⚠️ 只做**解析与粗筛**,不做"像素级贴合":真正的定位在 fitRegion() 里,
* 它会再把区域夹回图内、并调成目标比例。这里如果直接把非法值夹成合法值,
* 就会出现"用户拖了个空框却莫名得到一张图"的怪事 —— 宁可退回居中。
*/
private static function readCrop(string $raw, int $w, int $h): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = explode(',', $raw);
if (count($parts) !== 4) {
return null;
}
$vals = [];
foreach ($parts as $p) {
$p = trim($p);
if (!is_numeric($p)) {
return null;
}
$vals[] = (float)$p;
}
[$x, $y, $cw, $ch] = $vals;
if ($cw <= 0 || $ch <= 0) {
return null;
}
// 允许 -0.001 这种浮点毛刺,但明显越界的直接判非法
if ($x < -0.001 || $y < -0.001 || $x > 1.001 || $y > 1.001
|| $cw > 1.001 || $ch > 1.001) {
return null;
}
return [max(0.0, $x), max(0.0, $y), min(1.0, $cw), min(1.0, $ch)];
}
/**
* 把归一化区域变成**像素级、且比例等于 $ratio** 的裁切框。
*
* 三步,缺一不可:
* ① 夹回图内(区域右/下边可能超出);
* ② 调成目标比例(前端要是被绕过、或报了稍有不同的比例,这里纠正);
* 做法是**在给定区域内居中收缩**,绝不外扩 —— 用户挑的地方一定还在框里;
* ③ 取整并对齐边界,保证 sx+sw ≤ w。
*
* @param array{0:float,1:float,2:float,3:float} $region
* @return array{0:int,1:int,2:int,3:int}
*/
private static function fitRegion(array $region, int $w, int $h, float $ratio): array
{
[$nx, $ny, $nw, $nh] = $region;
// ① 夹回图内
$nx = min(max(0.0, $nx), 1.0);
$ny = min(max(0.0, $ny), 1.0);
$nw = min($nw, 1.0 - $nx);
$nh = min($nh, 1.0 - $ny);
if ($nw <= 0 || $nh <= 0) {
$nx = $ny = 0.0;
$nw = $nh = 1.0;
}
$sx = $nx * $w;
$sy = $ny * $h;
$sw = $nw * $w;
$sh = $nh * $h;
// ② 在区域内居中收缩到目标比例
if ($ratio > 0 && $sh > 0) {
$cur = $sw / $sh;
if ($cur > $ratio + 1e-6) { // 太宽 → 收窄
$newW = $sh * $ratio;
$sx += ($sw - $newW) / 2;
$sw = $newW;
} elseif ($cur < $ratio - 1e-6) { // 太高 → 收矮
$newH = $sw / $ratio;
$sy += ($sh - $newH) / 2;
$sh = $newH;
}
}
// ③ 取整 + 保证不越界
$ix = (int)round($sx);
$iy = (int)round($sy);
$iw = (int)round($sw);
$ih = (int)round($sh);
$ix = max(0, min($ix, $w - 1));
$iy = max(0, min($iy, $h - 1));
$iw = max(1, min($iw, $w - $ix));
$ih = max(1, min($ih, $h - $iy));
return [$ix, $iy, $iw, $ih];
}
private static function maxMbWord(): string
{
return (int)round(ImageSlot::MAX_BYTES / 1024 / 1024) . 'MB';
}
/** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */
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);
}
}