补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 comments、node_members + role_tags、expenses +
expense_splits 三组表,但一直没有代码接入。本次把这三块连上业务,
并新增「节点详情页」作为聚合入口。
新增
- src/Access.php 统一归属校验。此前每个控制器各写各的 family_id
条件,容易漏;现在 activity()/node()/target() 一处收敛,
requireAdmin() 统一管理员判断。
- src/Comments.php 两级平铺:parent_id 只允许指向顶层,写「回复的回复」
时规范化回顶层;删顶层会连同回复一起删。
- src/Members.php 参与人 + 角色标签 + 情绪 + 签到。情绪目录 17 项,
全部选用 Emoji 11.0(2018)及以前的码位,避免旧系统
字体缺字形显示成空白方块。
- src/Expenses.php 花销与分摊。分摊按「分」为单位 intdiv 平均,
余数归最后一人,保证 SUM(share_amount) = total_amount。
- templates/nodes/show.php 节点详情页:基本信息 + 参与人 + 花销 + 讨论 + 附件入口。
- 角色标签管理、评论区、参与人区、花销区各自的模板与控制器。
修复
- 越权:往别人活动的节点上插节点、把别家用户加成参与人,都只校验了
「节点存在」,没校验「用户与节点同家庭」。现统一走 Access。
- 删活动时节点附件文件删不掉:附件在 /uploads/attachments/ 下,却调了
只认 /uploads/covers/ 的删除函数,判定不通过 → 文件永久残留。改为
统一的 deleteUploadFile(),两个目录都认。
- 删有引用记录的用户会 500(activities.created_by / attachments.uploader_id /
comments.author_id / expenses.payer_user_id 这几处没有级联,也没做前置检查)。
其他
- 活动/节点列表补上附件、评论、参与人、花销四个计数(一次 GROUP BY 查完)。
- 首页布局新增一条迁移易踩的规避:新列上的索引必须排在 ALTER TABLE 之后。
- 新增 .gitignore:config.php(含数据库口令)与 uploads/ 下用户上传的
头像封面不入库,只保留 uploads/.htaccess 那条禁止执行脚本的规则。
- 新增 config.example.php 作为新环境的配置模板。
本批不改数据库结构,不需要任何数据迁移。
本地与线上验证
- php -l 全绿(PHP 8.4 本地 / 线上 7.4.30 与 8.1.7 双版本)。
- 模板渲染检查 180 项全通过。
- 线上端到端 153 项全通过:登录、活动增删改查、节点流转、
参与人/角色标签/情绪/签到、评论、花销与分摊、权限、级联清理、
全站页面无 PHP 报错泄漏、跑完基线逐表核对且既有 id 集合逐字未变。
- 顺带写了 trip_sql_column_check.py:拿真实库表结构静态校验全项目 SQL 的
列名引用。本次就是靠它之外的 e2e 抓到 travel_modes 并没有 line_name
(该列属于 trip_nodes),一个列名写错导致节点详情页整页 Fatal。
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user