0 && $len > $max; } /** 站点设置的「单张照片上限」(字节) */ public static function maxPhotoBytes(): int { return (int)Settings::get('photo_max_mb', '12') * 1024 * 1024; } /** * 处理 $_FILES 里的某个多选字段(形状来自 name="xxx[]")。 * * @param int $countStart 已经计入上限的文件数(files 与 cropped 共用同一个上限) * @return array{uploaded:int, errors:string[], count:int} */ public static function handleFiles( array $filesField, string $targetType, int $targetId, int $uploaderId, int $countStart = 0 ): array { $uploaded = 0; $errors = []; $count = $countStart; if (empty($filesField) || !is_array($filesField['name'] ?? null)) { return ['uploaded' => 0, 'errors' => [], 'count' => $count]; } $photoMax = self::maxPhotoBytes(); foreach (self::restructure($filesField) as $file) { if ($count >= self::MAX_FILES) { $errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略'; break; } $count++; if ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')'; continue; } /* 先识别类型再限大小:图片走管理员设的 photo_max_mb(12MB), 视频 / PDF / Word 等非图片走更宽的 MAX_FILE_BYTES(40MB)。 detectFile 只读文件头 32 字节,先跑也不费。 */ $info = Mime::detectFile($file['tmp_name'], $file['name']); $mime = $info['mime']; if ($mime === null || !isset(Mime::ALLOWED[$mime])) { $errors[] = $file['name'] . ':不支持的文件类型'; continue; } if ($info['is_image']) { $limit = $photoMax > 0 ? $photoMax : self::MAX_BYTES; if ($file['size'] > $limit) { $errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB'; continue; } } elseif ($file['size'] > self::MAX_FILE_BYTES) { $errors[] = $file['name'] . ':文件超过 ' . (int)(self::MAX_FILE_BYTES / 1048576) . 'MB'; continue; } $raw = @file_get_contents($file['tmp_name']); if ($raw === false) { $errors[] = $file['name'] . ':读取临时文件失败'; continue; } $err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime, $file['name'], $info); if ($err === null) { $uploaded++; } else { $errors[] = $file['name'] . ':' . $err; } } return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count]; } /** * 处理前端裁剪/压缩后的图片(base64 data URL)。 * * @return array{uploaded:int, errors:string[], count:int} */ public static function handleCropped( string $json, string $targetType, int $targetId, int $uploaderId, int $countStart = 0 ): array { $uploaded = 0; $errors = []; $count = $countStart; if (trim($json) === '') { return ['uploaded' => 0, 'errors' => [], 'count' => $count]; } $items = json_decode($json, true); if (!is_array($items)) { return ['uploaded' => 0, 'errors' => ['裁剪数据解析失败'], 'count' => $count]; } foreach ($items as $item) { if ($count >= self::MAX_FILES) { $errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略'; break; } $count++; $name = (string)($item['name'] ?? 'photo.png'); $data = (string)($item['data'] ?? ''); if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) { $errors[] = $name . ':裁剪结果格式不正确'; continue; } $raw = base64_decode($m[2], true); if ($raw === false || $raw === '') { $errors[] = $name . ':图片解码失败'; continue; } if (strlen($raw) > self::MAX_BYTES) { $errors[] = $name . ':超过 20MB'; continue; } $info = Mime::detectBuffer($raw); $mime = $info['mime']; if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) { $errors[] = $name . ':裁剪结果不是受支持的图片'; continue; } $err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime, self::imageName($name, $mime), $info); if ($err === null) { $uploaded++; } else { $errors[] = $name . ':' . $err; } } return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count]; } /** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */ public static function storeOne( string $targetType, int $targetId, int $uploaderId, string $raw, string $mime, string $fileName, array $info ): ?string { $ext = Mime::extFor($mime); if ($ext === null) { return '无法确定扩展名'; } try { $key = Storage::keyFor($ext, 'attachments'); $stored = Storage::put($key, $raw, $mime); } catch (\Throwable $e) { return $e->getMessage(); } try { Attachments::create( $targetType, $targetId, $uploaderId, $stored, $fileName, $mime, (bool)$info['is_image'], $info['width'], $info['height'] ); } catch (\Throwable $e) { // 入库失败就把刚写的对象删掉,别留孤儿文件 Storage::delete($stored['storage'], $stored['key']); return '入库失败:' . $e->getMessage(); } return null; } /** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */ public static function imageName(string $name, string $mime): string { $base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo'; return $base . '.' . (Mime::extFor($mime) ?? 'jpg'); } /** 把 $_FILES 的列式数组转成一列「一个文件一项」 */ public static function restructure(array $files): array { $result = []; $count = count($files['name']); for ($i = 0; $i < $count; $i++) { if (empty($files['name'][$i])) { continue; } $result[] = [ 'name' => $files['name'][$i], 'type' => $files['type'][$i], 'tmp_name' => $files['tmp_name'][$i], 'error' => $files['error'][$i], 'size' => $files['size'][$i], ]; } return $result; } }