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); } }