requireAdmin(); $db = Database::get(); $familyId = Auth::familyId(); $stmt = $db->prepare('SELECT id, family_id, name, phone_encrypted, id_card_encrypted, role, avatar, status, created_at FROM users WHERE family_id = ? ORDER BY FIELD(role, "admin", "user", "guest"), id ASC'); $stmt->execute([$familyId]); $users = $stmt->fetchAll(); view('users/index', [ 'users' => $users, 'pageTitle' => '用户管理', ]); } public function create(): void { $this->requireAdmin(); view('users/form', [ 'user' => null, 'isSelf' => false, 'pageTitle' => '新增用户', ]); } public function store(): void { $this->requireAdmin(); csrf_check(); $db = Database::get(); $familyId = Auth::familyId(); $name = trim($_POST['name'] ?? ''); $phone = trim($_POST['phone'] ?? ''); $password = (string)($_POST['password'] ?? ''); $role = $_POST['role'] ?? 'guest'; $idCard = trim($_POST['id_card'] ?? ''); $avatar = trim($_POST['avatar'] ?? ''); $status = $_POST['status'] ?? 'active'; // 校验 if ($name === '') { flash('error', '姓名不能为空'); redirect('users/create'); } if (!in_array($role, ['admin', 'user', 'guest'], true)) { $role = 'guest'; } // 普通用户 / 管理员必须有手机号和密码 if (in_array($role, ['admin', 'user'], true)) { if ($phone === '' || $password === '') { flash('error', '管理员/普通用户必须填写手机号和密码'); redirect('users/create'); } if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { flash('error', '手机号格式不正确'); redirect('users/create'); } // 手机号唯一性检查 $phoneHash = hash('sha256', $phone); $chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? LIMIT 1'); $chk->execute([$phoneHash]); if ($chk->fetch()) { flash('error', '该手机号已被使用'); redirect('users/create'); } } $phoneEncrypted = $phone !== '' ? $this->encrypt($phone) : null; $phoneHash = $phone !== '' ? hash('sha256', $phone) : null; $idCardEncrypted = $idCard !== '' ? $this->encrypt($idCard) : null; $passwordHash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null; $stmt = $db->prepare('INSERT INTO users (family_id, name, phone_encrypted, phone_hash, id_card_encrypted, password_hash, role, avatar, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); $stmt->execute([ $familyId, $name, $phoneEncrypted, $phoneHash, $idCardEncrypted, $passwordHash, $role, $avatar ?: null, $status, ]); flash('error', '用户创建成功'); // 复用 error flash 显示 redirect('users/index'); } public function edit(): void { $id = (int)($_GET['id'] ?? 0); $currentId = Auth::id(); // 只能编辑自己,或管理员编辑任何人 if (!Auth::isAdmin() && $id !== $currentId) { http_response_code(403); exit('无权编辑此用户'); } $db = Database::get(); $stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?'); $stmt->execute([$id, Auth::familyId()]); $user = $stmt->fetch(); if (!$user) { http_response_code(404); exit('用户不存在'); } view('users/form', [ 'user' => $user, 'isSelf' => $id === $currentId, 'pageTitle' => '编辑用户:' . $user['name'], ]); } public function update(): void { csrf_check(); $db = Database::get(); $id = (int)($_POST['id'] ?? 0); $currentId = Auth::id(); $isSelf = $id === $currentId; if (!Auth::isAdmin() && !$isSelf) { http_response_code(403); exit('无权编辑此用户'); } $stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?'); $stmt->execute([$id, Auth::familyId()]); $user = $stmt->fetch(); if (!$user) { http_response_code(404); exit('用户不存在'); } $name = trim($_POST['name'] ?? ''); $phone = trim($_POST['phone'] ?? ''); $password = (string)($_POST['password'] ?? ''); $role = $_POST['role'] ?? $user['role']; $idCard = trim($_POST['id_card'] ?? ''); $avatar = trim($_POST['avatar'] ?? ''); $status = $_POST['status'] ?? $user['status']; if ($name === '') { flash('error', '姓名不能为空'); redirect('users/edit', ['id' => $id]); } // 非管理员不能改自己的角色和状态 if (!Auth::isAdmin()) { $role = $user['role']; $status = $user['status']; } if (!in_array($role, ['admin', 'user', 'guest'], true)) { $role = $user['role']; } if (!in_array($status, ['active', 'inactive'], true)) { $status = $user['status']; } // 手机号只有在"非空且与原值不同"时才算修改 $phoneChanged = false; if ($phone !== '') { $phoneHash = hash('sha256', $phone); if ($phoneHash !== ($user['phone_hash'] ?? '')) { if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { flash('error', '手机号格式不正确'); redirect('users/edit', ['id' => $id]); } $chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? AND id != ? LIMIT 1'); $chk->execute([$phoneHash, $id]); if ($chk->fetch()) { flash('error', '该手机号已被其他用户使用'); redirect('users/edit', ['id' => $id]); } $phoneChanged = true; } } // 关键修正:编辑时的必填校验 // 只有当"角色是 admin/user" 且 "当前用户既没有原手机号,也没有本次填的新手机号" 时才报错 $hasPhone = !empty($user['phone_hash']) || $phoneChanged; if (in_array($role, ['admin', 'user'], true) && !$hasPhone) { flash('error', '管理员/普通用户必须填写手机号'); redirect('users/edit', ['id' => $id]); } // 组装更新字段 $updates = [ 'name = ?', 'role = ?', 'avatar = ?', 'status = ?', ]; $params = [$name, $role, $avatar ?: null, $status]; if ($phoneChanged) { $updates[] = 'phone_encrypted = ?'; $updates[] = 'phone_hash = ?'; $params[] = $this->encrypt($phone); $params[] = hash('sha256', $phone); } if ($idCard !== '') { $updates[] = 'id_card_encrypted = ?'; $params[] = $this->encrypt($idCard); } if ($password !== '') { $updates[] = 'password_hash = ?'; $params[] = password_hash($password, PASSWORD_DEFAULT); } $params[] = $id; $sql = 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?'; $stmt = $db->prepare($sql); $stmt->execute($params); // 如果编辑的是自己,同步 session if ($isSelf) { $_SESSION['user_name'] = $name; $_SESSION['user_role'] = $role; } flash('error', '保存成功'); redirect('users/index'); } public function destroy(): void { $this->requireAdmin(); csrf_check(); $id = (int)($_POST['id'] ?? 0); if ($id === Auth::id()) { flash('error', '不能删除自己'); redirect('users/index'); } $db = Database::get(); $stmt = $db->prepare('SELECT role FROM users WHERE id = ? AND family_id = ?'); $stmt->execute([$id, Auth::familyId()]); $user = $stmt->fetch(); if (!$user) { flash('error', '用户不存在'); redirect('users/index'); } // 如果是最后一个管理员,不允许删除 if ($user['role'] === 'admin') { $cnt = $db->prepare('SELECT COUNT(*) FROM users WHERE family_id = ? AND role = "admin"'); $cnt->execute([Auth::familyId()]); if ((int)$cnt->fetchColumn() <= 1) { flash('error', '至少需要保留一位管理员'); redirect('users/index'); } } // 这些外键没有级联删除,被引用时直接 DELETE 会抛异常导致 500,先挡下来 $refs = []; $checks = [ ['SELECT COUNT(*) FROM activities WHERE created_by = ?', '创建的活动'], ['SELECT COUNT(*) FROM attachments WHERE uploader_id = ?', '上传的附件'], ['SELECT COUNT(*) FROM comments WHERE author_id = ?', '发表的评论'], ['SELECT COUNT(*) FROM expenses WHERE payer_user_id = ?', '记录的花销'], ]; foreach ($checks as [$sql, $label]) { $s = $db->prepare($sql); $s->execute([$id]); $c = (int)$s->fetchColumn(); if ($c > 0) { $refs[] = "{$label} {$c} 条"; } } if (!empty($refs)) { flash('error', '该用户还有' . implode('、', $refs) . ',不能删除;可以改为「停用」。'); redirect('users/index'); } $stmt = $db->prepare('DELETE FROM users WHERE id = ?'); $stmt->execute([$id]); flash('error', '用户已删除'); redirect('users/index'); } private function requireAdmin(): void { if (!Auth::isAdmin()) { http_response_code(403); exit('仅管理员可访问此页面'); } } /** * 简易对称加密(密钥从 env 取,没有则用默认) * 生产环境请改用环境变量存放密钥 */ private function encrypt(string $plain): string { $key = getenv('APP_ENC_KEY') ?: 'family-trip-default-key-change-me'; $iv = random_bytes(16); $cipher = openssl_encrypt($plain, 'AES-256-CBC', hash('sha256', $key, true), OPENSSL_RAW_DATA, $iv); return base64_encode($iv . $cipher); } /** * 上传头像(接收前端裁剪后的 base64 图片) */ public function uploadAvatar(): void { header('Content-Type: application/json; charset=utf-8'); if (!Auth::check()) { http_response_code(401); echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE); exit; } $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) { http_response_code(419); echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE); exit; } $body = file_get_contents('php://input'); $data = json_decode($body, true); $base64 = $data['image'] ?? ''; if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) { http_response_code(400); echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE); exit; } $ext = strtolower($m[1]); if ($ext === 'jpeg') $ext = 'jpg'; $raw = base64_decode($m[2], true); if ($raw === false) { http_response_code(400); echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE); exit; } if (strlen($raw) > 5 * 1024 * 1024) { http_response_code(413); echo json_encode(['error' => '图片不能超过 5MB'], JSON_UNESCAPED_UNICODE); exit; } // 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底) $info = Mime::detectBuffer($raw); $mime = $info['mime'] ?? ''; if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { http_response_code(400); echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE); exit; } $dir = dirname(__DIR__, 2) . '/uploads/avatars'; if (!is_dir($dir)) mkdir($dir, 0755, true); // 扩展名以「实际识别出的类型」为准,别信前端给的 $filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . (Mime::extFor($mime) ?? $ext); $savePath = $dir . '/' . $filename; if (file_put_contents($savePath, $raw) === false) { http_response_code(500); echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE); exit; } $this->deleteLocalAvatar($_POST['old'] ?? ''); echo json_encode([ 'url' => '/uploads/avatars/' . $filename, 'size' => strlen($raw), ], JSON_UNESCAPED_UNICODE); } private function deleteLocalAvatar(string $url): void { if ($url === '' || strpos($url, '/uploads/avatars/') !== 0) return; $file = dirname(__DIR__, 2) . $url; if (is_file($file)) @unlink($file); } }