false, 'msg' => '请先登录']); exit; } $user = currentUser(); if (!$user) { http_response_code(401); echo json_encode(['ok' => false, 'msg' => '登录已过期']); exit; } // 检查文件上传 if (!isset($_FILES['avatar']) || !is_array($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) { $errMap = [ UPLOAD_ERR_INI_SIZE => '文件超过服务器限制(php.ini 中 upload_max_filesize)', UPLOAD_ERR_FORM_SIZE => '文件超过表单限制', UPLOAD_ERR_PARTIAL => '文件上传不完整', UPLOAD_ERR_NO_FILE => '未选择文件', UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录不存在', UPLOAD_ERR_CANT_WRITE => '服务器写入失败', UPLOAD_ERR_EXTENSION => '文件类型被禁止', ]; $code = $_FILES['avatar']['error'] ?? UPLOAD_ERR_NO_FILE; echo json_encode(['ok' => false, 'msg' => $errMap[$code] ?? '上传错误,请重试']); exit; } $file = $_FILES['avatar']; // ─── 类型与尺寸校验(多重兜底)────────────────────────────────────── $allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; // 1) getimagesize:同时拿到尺寸与 MIME,几乎在所有环境可用 $imgInfo = @getimagesize($file['tmp_name']); $mime = ($imgInfo && !empty($imgInfo['mime'])) ? $imgInfo['mime'] : ''; // 2) 若 getimagesize 没拿到 MIME,再尝试 finfo / mime_content_type if (!in_array($mime, $allowed, true)) { if (function_exists('finfo_open')) { $fi = @finfo_open(FILEINFO_MIME_TYPE); if ($fi) { $m = @finfo_file($fi, $file['tmp_name']); finfo_close($fi); if ($m) { $mime = $m; } } } if (!in_array($mime, $allowed, true) && function_exists('mime_content_type')) { $mime = @mime_content_type($file['tmp_name']); } } if (!in_array($mime, $allowed, true)) { echo json_encode(['ok' => false, 'msg' => '仅支持 JPG / PNG / WebP / GIF 图片']); exit; } // 尺寸检查(至少 64x64) if (!$imgInfo || $imgInfo[0] < 64 || $imgInfo[1] < 64) { echo json_encode(['ok' => false, 'msg' => '图片尺寸不能小于 64×64 像素']); exit; } // 大小检查(2MB) if ($file['size'] > 2 * 1024 * 1024) { echo json_encode(['ok' => false, 'msg' => '图片大小不能超过 2MB']); exit; } // ─── 确保上传目录存在 ───────────────────────────────────────────── $uploadDir = __DIR__ . '/storage/uploads/avatars'; if (!is_dir($uploadDir)) { if (!@mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) { echo json_encode(['ok' => false, 'msg' => '服务器无法创建上传目录(请检查 storage/uploads 目录权限)']); exit; } } if (!is_writable($uploadDir)) { echo json_encode(['ok' => false, 'msg' => '上传目录不可写(请给 storage/uploads/avatars 目录写入权限)']); exit; } // ─── 生成唯一文件名 ─────────────────────────────────────────────── $extMap = [IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_GIF => 'gif', IMAGETYPE_WEBP => 'webp']; $ext = $extMap[$imgInfo[2]] ?? 'png'; $fileName = $user['id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext; $filePath = $uploadDir . '/' . $fileName; // ─── 移动上传文件(move 失败则回退 copy)───────────────────────── $moved = false; if (@move_uploaded_file($file['tmp_name'], $filePath)) { $moved = true; } elseif (@copy($file['tmp_name'], $filePath)) { $moved = true; } if (!$moved || !is_file($filePath)) { echo json_encode(['ok' => false, 'msg' => '文件保存失败(请检查 storage/uploads/avatars 目录权限)']); exit; } // ─── 确保 users 表存在 avatar 列(未跑升级脚本时兜底)───────────── try { $chk = db()->query("SHOW COLUMNS FROM " . tn('users') . " LIKE 'avatar'"); if ($chk && $chk->rowCount() === 0) { db()->exec("ALTER TABLE " . tn('users') . " ADD COLUMN avatar VARCHAR(255) NULL DEFAULT NULL AFTER invited_by"); } } catch (Throwable $e) { // 忽略:以实际 UPDATE 结果为准 } // ─── 删除旧头像 ─────────────────────────────────────────────────── $oldAvatar = $user['avatar'] ?? ''; if ($oldAvatar && strpos($oldAvatar, 'storage/uploads/avatars') !== false) { $oldPath = __DIR__ . '/' . $oldAvatar; if (file_exists($oldPath) && is_file($oldPath)) { @unlink($oldPath); } } // ─── 更新数据库 ─────────────────────────────────────────────────── $dbAvatar = 'storage/uploads/avatars/' . $fileName; try { db()->prepare('UPDATE ' . tn('users') . ' SET avatar=? WHERE id=?') ->execute([$dbAvatar, $user['id']]); } catch (Throwable $e) { // 列确实不存在且兜底 ALTER 也失败时才走到这里 echo json_encode(['ok' => false, 'msg' => '数据库更新失败:' . $e->getMessage()]); exit; } // 清除会话缓存,使其它页面重新读取最新头像 unset($_SESSION['user_id'], $_SESSION['user']); Logger::info('用户上传头像', ['user_id' => $user['id'], 'file' => $fileName]); echo json_encode([ 'ok' => true, 'msg' => '头像更新成功', // 返回相对站点根的路径,前端与导航栏展示保持一致 'data' => ['url' => $dbAvatar] ]); exit;