Files
TripLog/src/Controllers/UserController.php
T
laoyang f9a624ef4b 交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL
- 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次)

照片
- 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64)
- MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射,
  刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1
- 单张体积上限、可传张数在设置页可调

双存储后端
- 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关;
  OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂
- OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录),
  也支持直接填 AccessKey
- OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload)

媒体代理
- 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节
- 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有
- 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏

数据库
- 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑)
- 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes /
  width / height / is_image 与 idx_storage
- MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema

界面
- 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合)
- 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡,
  外加测试连接与角色探测
- 样式表整体重写

验证
- 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过
- 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、
  26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
2026-09-22 23:09:37 +08:00

405 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
use App\Mime;
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:线上 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);
}
}