Files
FamilyBook/app/Upload.php
T
laoyang f7609a3ed9 v6:名册联系信息对齐 vCard + 头像与裁剪四项修正
- ① 头像上传失败(超限/非图片)不再挡保存,改为 flash 提示「已加进名册。头像没有保存:…」
  ——这是「添加不了」的真根因(错误被塞进 errors.avatar 把整条新增打回)
- ② 表单顺序:生日整块下移(身份证能读出生日时无需常改)
- ③ 裁剪缩放语义反转:框在屏幕上的位置与大小不动、动它底下的图;
  落库归一化矩形随倍率缩小(放大=同一块区域看得更清楚)
- ④ 取消应用层 5MB 兆数上限;按像素动态上调 memory_limit(px*6+64M,封顶 768M);
  头像输出恒为 512x512
- ⑤ 成员信息对齐 vCard:person_contacts 表(phone/email/address/url 可多行,
  value 加密落库)+ persons.org(工作单位)/title(职务职称);均可留空

Schema v5→v6:person_contacts 建表 + persons.org/title 加列
2026-09-30 15:02:27 +08:00

301 lines
13 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,不信浏览器给的扩展名
* · 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
* · 不设「兆数」上限(用户原话:上传照片不要有大小限制;真正会进来的照片
* 是手机原图)—— 改成**按像素数动态上调 memory_limit**:
* GD 解码一张真彩图约要 5 字节/像素,默认 128M 只够 ~2500 万像素,
* 一张 4800 万的原图会直接 OOM Fatal(不可捕获、整页白屏)。
* 这里按 w*h*6B 预算内存,不够就 ini_set 上调(封顶 768M),
* 超过 1.5 亿像素(约 1.2 万 × 1.2 万)才明确拒绝 —— 那已经不是照片是扫描件了。
* · 落库前**一律缩放到槽位尺寸**(头像 512×512):原图 30MB 还是 3MB,
* 存下来都是一张小 JPEG —— 所以"上传大图"不会把磁盘和流量撑爆。
*/
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:
// 应用层不再设「兆数」上限;这条只会在超过服务器 ini 上限时发生。
return ['ok' => false, 'error' => '图片太大了(服务器单次上传上限 '
. self::iniUploadWord() . '),请压缩后再试'];
default:
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
$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' => '图片读不出来'];
}
$px = $info[0] * $info[1];
if ($px > 150_000_000) {
return ['ok' => false, 'error' => '图片像素实在太大(超过 1.5 亿),请先压缩再传'];
}
// 按需上调 memory_limit:GD 真彩解码约 5B/px,再加 64M 给缩放画布与脚本本身。
// (memory_limit 是 PHP_INI_ALL,运行时可以调;这是"大图也能传"的关键一步。)
$need = $px * 6 + 64 * 1024 * 1024;
$have = self::iniBytes('memory_limit');
if ($need > $have) {
@ini_set('memory_limit', (string)min($need, 768 * 1024 * 1024));
}
$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];
}
/** 把 ini 里的 upload_max_filesize 读成人话(如 "50M")——报错信息里用 */
private static function iniUploadWord(): string
{
$b = self::iniBytes('upload_max_filesize');
return $b > 0 ? (int)round($b / 1024 / 1024) . 'MB' : '50MB';
}
/** 解析 ini 的简写字节数("128M" / "1G" / "65536K");读不到回退 0 */
private static function iniBytes(string $key): int
{
$v = trim((string)@ini_get($key));
if ($v === '' || !is_numeric($v[0])) {
return 0;
}
$n = (float)$v;
$unit = strtolower(substr($v, -1));
return (int)match (true) {
$unit === 'g' => $n * 1024 ** 3,
$unit === 'm' => $n * 1024 ** 2,
$unit === 'k' => $n * 1024,
default => $n,
};
}
/** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */
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);
}
}