404 lines
14 KiB
PHP
404 lines
14 KiB
PHP
<?php
|
|||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Controllers;
|
||
|
|
|
||
|
|
use App\Auth;
|
||
|
|
use App\Database;
|
||
|
|
|
||
|
|
class UserController
|
||
|
|
{
|
||
|
|
public function index(): void
|
||
|
|
{
|
||
|
|
$this->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 = finfo_open(FILEINFO_MIME_TYPE);
|
||
|
|
$mime = finfo_buffer($finfo, $raw);
|
||
|
|
finfo_close($finfo);
|
||
|
|
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)) . '.' . $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);
|
||
|
|
}
|
||
|
|
}
|