2026-09-22 22:03:08 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Controllers;
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
use App\Access;
|
|
|
|
|
|
use App\Attachments;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
use App\Auth;
|
2026-09-22 23:09:37 +08:00
|
|
|
|
use App\Mime;
|
|
|
|
|
|
use App\Settings;
|
|
|
|
|
|
use App\Storage;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
|
|
class AttachmentController
|
|
|
|
|
|
{
|
2026-09-22 23:09:37 +08:00
|
|
|
|
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
|
|
|
|
|
private const MAX_FILES = 30;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
|
|
public function node(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->requireLogin();
|
|
|
|
|
|
$nodeId = (int)($_GET['id'] ?? 0);
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
|
|
view('nodes/attachments', [
|
2026-09-22 23:09:37 +08:00
|
|
|
|
'node' => $node,
|
|
|
|
|
|
'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId),
|
|
|
|
|
|
'maxMb' => (int)Settings::get('photo_max_mb', '12'),
|
|
|
|
|
|
'driverLabel' => Storage::label(),
|
|
|
|
|
|
'pageTitle' => '节点照片:' . ($node['title'] ?: '无标题'),
|
2026-09-22 22:03:08 +08:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 上传附件。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 一个请求里同时收两类东西:
|
|
|
|
|
|
* files[] —— 普通 multipart 文件(照片原图、PDF、视频……)
|
|
|
|
|
|
* cropped[] —— 前端裁好的图片,JSON 字符串 {"name":..,"data":"data:image/...;base64,.."}
|
|
|
|
|
|
* 分开传是为了省流量:没裁的照片原图直传,裁过的才走 base64。
|
|
|
|
|
|
*/
|
2026-09-22 22:03:08 +08:00
|
|
|
|
public function upload(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->requireLogin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
|
|
|
|
|
|
$nodeId = (int)($_POST['node_id'] ?? 0);
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$node = Access::node($nodeId);
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$maxBytes = 20 * 1024 * 1024;
|
|
|
|
|
|
$photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
|
|
$uploaded = 0;
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$count = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// ── 1) 普通文件 ──
|
|
|
|
|
|
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
|
|
|
|
|
|
foreach ($this->restructureFiles($_FILES['files']) 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
$limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes;
|
|
|
|
|
|
if ($file['size'] > $limit) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':超过大小上限';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$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'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$raw = @file_get_contents($file['tmp_name']);
|
|
|
|
|
|
if ($raw === false) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':读取临时文件失败';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$err = $this->store($nodeId, $raw, $mime, $file['name'], $info);
|
|
|
|
|
|
if ($err === null) {
|
|
|
|
|
|
$uploaded++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$errors[] = $file['name'] . ':' . $err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 2) 前端裁剪后的图片 ──
|
|
|
|
|
|
$cropped = $_POST['cropped'] ?? '';
|
|
|
|
|
|
if (is_string($cropped) && $cropped !== '') {
|
|
|
|
|
|
$items = json_decode($cropped, true);
|
|
|
|
|
|
if (!is_array($items)) {
|
|
|
|
|
|
$errors[] = '裁剪数据解析失败';
|
|
|
|
|
|
$items = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
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) > $maxBytes) {
|
|
|
|
|
|
$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 = $this->store($nodeId, $raw, $mime, $this->asImageName($name, $mime), $info);
|
|
|
|
|
|
if ($err === null) {
|
|
|
|
|
|
$uploaded++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$errors[] = $name . ':' . $err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($count === 0 && $uploaded === 0) {
|
|
|
|
|
|
flash('error', '没有选择任何文件');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
redirect('attachments/node', ['id' => $nodeId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')';
|
2026-09-22 22:03:08 +08:00
|
|
|
|
if (!empty($errors)) {
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5));
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
|
if (Storage::ossMisconfigured()) {
|
|
|
|
|
|
$msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
flash('error', $msg);
|
2026-09-22 23:09:37 +08:00
|
|
|
|
redirect('attachments/node', ['id' => $nodeId], 'photos');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function destroy(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->requireLogin();
|
|
|
|
|
|
csrf_check();
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
|
|
|
|
$att = Attachments::find($id);
|
|
|
|
|
|
if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) {
|
2026-09-22 22:03:08 +08:00
|
|
|
|
flash('error', '附件不存在');
|
|
|
|
|
|
redirect('activities/index');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$node = Access::node((int)$att['target_id']); // 顺带做归属校验
|
|
|
|
|
|
Attachments::delete($id);
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
flash('error', '照片已删除');
|
|
|
|
|
|
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
|
|
|
|
|
private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$ext = Mime::extFor($mime);
|
|
|
|
|
|
if ($ext === null) {
|
|
|
|
|
|
return '无法确定扩展名';
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
try {
|
|
|
|
|
|
$key = Storage::keyFor($ext, 'attachments');
|
|
|
|
|
|
$stored = Storage::put($key, $raw, $mime);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
return $e->getMessage();
|
|
|
|
|
|
}
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
try {
|
|
|
|
|
|
Attachments::create(
|
|
|
|
|
|
Attachments::TARGET_NODE,
|
|
|
|
|
|
$nodeId,
|
|
|
|
|
|
(int)Auth::id(),
|
|
|
|
|
|
$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 这类名字,避免扩展名与真实格式不符 */
|
|
|
|
|
|
private function asImageName(string $name, string $mime): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
|
|
|
|
|
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function restructureFiles(array $files): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$result = [];
|
2026-09-22 23:09:37 +08:00
|
|
|
|
$count = count($files['name']);
|
2026-09-22 22:03:08 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function requireLogin(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!Auth::check()) {
|
|
|
|
|
|
redirect('auth/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
|
}
|