164 lines
6.4 KiB
PHP
164 lines
6.4 KiB
PHP
<?php
|
||
/**
|
||
* 用户头像上传接口
|
||
*
|
||
* POST /avatar_upload
|
||
* 需登录,支持 JPG/PNG/WebP/GIF,最大 2MB。
|
||
* 上传成功后返回新头像 URL,前端可即时更新显示。
|
||
*
|
||
* 兼容性说明:
|
||
* - MIME 校验优先用 getimagesize()(几乎所有环境都可用),
|
||
* 再回退到 finfo / mime_content_type,避免某些主机未启用 fileinfo 扩展导致「永远上传失败」。
|
||
* - 目录创建与移动文件均做了失败兜底提示,便于定位环境权限问题。
|
||
*/
|
||
require __DIR__ . '/includes/init.php';
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
// 必须登录
|
||
if (empty($_SESSION['user_id'])) {
|
||
http_response_code(401);
|
||
echo json_encode(['ok' => 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;
|