Files
TripLog/src/Controllers/ActivityController.php
T

425 lines
15 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Comments;
use App\Database;
use App\Expenses;
class ActivityController
{
public function index(): void
{
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('SELECT * FROM activities WHERE family_id = ? ORDER BY created_at DESC');
$stmt->execute([$familyId]);
$activities = $stmt->fetchAll();
// 每个活动的节点数:一次 GROUP BY 查完,避免逐个 COUNT 的 N+1
if (!empty($activities)) {
$ids = array_column($activities, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT activity_id, COUNT(*) AS cnt
FROM trip_nodes WHERE activity_id IN ($ph) GROUP BY activity_id");
$s->execute($ids);
$counts = [];
foreach ($s->fetchAll() as $row) {
$counts[(int)$row['activity_id']] = (int)$row['cnt'];
}
foreach ($activities as &$a) {
$a['node_count'] = $counts[(int)$a['id']] ?? 0;
}
unset($a);
}
view('activities/index', [
'activities' => $activities,
'pageTitle' => '活动列表',
]);
}
public function create(): void
{
view('activities/form', [
'activity' => null,
'pageTitle' => '新建活动',
]);
}
public function store(): void
{
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$userId = Auth::id();
$title = trim($_POST['title'] ?? '');
if ($title === '') {
flash('error', '标题不能为空');
redirect('activities/create');
}
if (mb_strlen($title) > 200) {
flash('error', '标题不能超过 200 个字符');
redirect('activities/create');
}
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
$stmt = $db->prepare('INSERT INTO activities (family_id, title, description, cover_image, created_by) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([
$familyId,
$title,
trim($_POST['description'] ?? ''),
$cover,
$userId,
]);
$id = (int)$db->lastInsertId();
// 创建者自动成为 owner
$stmt = $db->prepare('INSERT INTO activity_members (activity_id, user_id, permission) VALUES (?, ?, "owner")');
$stmt->execute([$id, $userId]);
flash('error', '活动创建成功');
redirect('activities/show', ['id' => $id]);
}
public function edit(): void
{
$activity = $this->fetchOwnActivity((int)($_GET['id'] ?? 0));
view('activities/form', [
'activity' => $activity,
'pageTitle' => '编辑活动',
]);
}
public function update(): void
{
csrf_check();
$db = Database::get();
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
$id = (int)$activity['id'];
$title = trim($_POST['title'] ?? '');
if ($title === '') {
flash('error', '标题不能为空');
redirect('activities/edit', ['id' => $id]);
}
if (mb_strlen($title) > 200) {
flash('error', '标题不能超过 200 个字符');
redirect('activities/edit', ['id' => $id]);
}
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
$oldCover = (string)($activity['cover_image'] ?? '');
$stmt = $db->prepare('UPDATE activities SET title = ?, description = ?, cover_image = ? WHERE id = ?');
$stmt->execute([
$title,
trim($_POST['description'] ?? ''),
$cover,
$id,
]);
// 封面换过或移除后,删掉不再引用的旧文件
if ($oldCover !== '' && $oldCover !== $cover) {
$this->deleteCoverFile($oldCover);
}
flash('error', '保存成功');
redirect('activities/show', ['id' => $id]);
}
public function destroy(): void
{
csrf_check();
$db = Database::get();
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
$id = (int)$activity['id'];
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清
$files = [];
$stmt = $db->prepare('SELECT id, file_url FROM attachments
WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
foreach ($stmt->fetchAll() as $row) {
$files[] = $row['file_url'];
}
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
$stmt->execute([$id]);
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
if (!empty($nodeIds)) {
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = $db->prepare("SELECT file_url FROM attachments
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
foreach ($stmt->fetchAll() as $row) {
$files[] = $row['file_url'];
}
$stmt = $db->prepare("DELETE FROM attachments
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
}
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
// 评论同样是软关联
if (!empty($nodeIds)) {
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = $db->prepare("DELETE FROM comments WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
}
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
// 删活动:外键会把 trip_nodes / activity_members / node_members / expenses 一起带走
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
// 数据库删干净了再删物理文件,避免删早了但事务回滚留下破损记录
// 注意:附件文件在 /uploads/attachments/ 下,不能拿「只认 covers」的
// deleteCoverFile() 去删 —— 那样判定不通过,文件会变成永远删不掉的孤儿
foreach ($files as $url) {
$this->deleteUploadFile((string)$url);
}
if (!empty($activity['cover_image'])) {
$this->deleteCoverFile((string)$activity['cover_image']);
}
flash('error', '活动已删除');
redirect('activities/index');
}
public function show(): void
{
$db = Database::get();
$id = (int)($_GET['id'] ?? 0);
$activity = $this->fetchOwnActivity($id, false);
if (!$activity) {
http_response_code(404);
exit('活动不存在');
}
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
WHERE tn.activity_id = ? AND tn.node_category = "plan"
ORDER BY tn.start_time ASC');
$stmt->execute([$id]);
$planNodes = $stmt->fetchAll();
$this->attachCounts($db, $planNodes);
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
WHERE tn.activity_id = ? AND tn.node_category = "reality"
ORDER BY tn.start_time ASC');
$stmt->execute([$id]);
$realityNodes = $stmt->fetchAll();
$this->attachCounts($db, $realityNodes);
// 计划节点 → 已生成执行记录的计划节点 id(用于在计划卡片上标记「已成行」)
$stmt = $db->prepare('SELECT original_plan_node_id FROM trip_nodes
WHERE activity_id = ? AND original_plan_node_id IS NOT NULL');
$stmt->execute([$id]);
$executedPlanIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
view('activities/show', [
'activity' => $activity,
'planNodes' => $planNodes,
'realityNodes' => $realityNodes,
'executedPlanIds' => $executedPlanIds,
'expenseSummary' => Expenses::summaryForActivity($id),
'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id),
'pageTitle' => $activity['title'],
]);
}
/**
* 上传活动封面(接收前端裁剪后的 base64 图片)
*/
public function uploadCover(): 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;
}
// 封面体积上限 8MB(比头像宽松)
if (strlen($raw) > 8 * 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => '图片不能超过 8MB'], 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/covers';
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->deleteCoverFile((string)($_POST['old'] ?? ''));
echo json_encode([
'url' => '/uploads/covers/' . $filename,
'size' => strlen($raw),
], JSON_UNESCAPED_UNICODE);
}
/**
* 取当前家庭下的活动;$allowMissing=true 时不存在返回 null 而不中断
*/
private function fetchOwnActivity(int $id, bool $allowMissing = false): ?array
{
if ($id <= 0) {
if ($allowMissing) return null;
http_response_code(404);
exit('活动不存在');
}
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$id]);
$activity = $stmt->fetch();
if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) {
if ($allowMissing) return null;
http_response_code(404);
exit('活动不存在');
}
return $activity;
}
/** 只接受本站 covers 目录下的相对地址,其余一律视为未设置 */
private function normalizeCoverUrl(string $url): ?string
{
$url = trim($url);
if ($url === '') return null;
if (strpos($url, '/uploads/covers/') !== 0) return null;
if (strpos($url, '..') !== false) return null;
return $url;
}
/** 只删除本站上传目录下的文件(封面 / 附件),其余一律不碰 */
private function deleteUploadFile(string $url): void
{
$url = trim($url);
if ($url === '' || strpos($url, '..') !== false) return;
if (strpos($url, '/uploads/covers/') !== 0 && strpos($url, '/uploads/attachments/') !== 0) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
private function deleteCoverFile(string $url): void
{
$url = trim($url);
if ($url === '' || strpos($url, '/uploads/covers/') !== 0 || strpos($url, '..') !== false) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
/**
* 给一批节点补上「附件数 / 参与人数 / 评论数 / 花销笔数」
* 每个指标各一条 GROUP BY 查询,避免每张卡片单独查(N+1)
*/
private function attachCounts(\PDO $db, array &$nodes): void
{
if (empty($nodes)) return;
$ids = array_column($nodes, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$counts = [
'attachment_count' => $this->groupCount($db,
"SELECT target_id, COUNT(*) AS cnt FROM attachments
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
GROUP BY target_id", $ids),
'comment_count' => $this->groupCount($db,
"SELECT target_id, COUNT(*) AS cnt FROM comments
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
GROUP BY target_id", $ids),
'member_count' => $this->groupCount($db,
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM node_members
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
'expense_count' => $this->groupCount($db,
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM expenses
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
];
foreach ($nodes as &$n) {
foreach ($counts as $key => $map) {
$n[$key] = $map[(int)$n['id']] ?? 0;
}
}
unset($n);
}
/** @return array<int, int> id => 计数 */
private function groupCount(\PDO $db, string $sql, array $params): array
{
$stmt = $db->prepare($sql);
$stmt->execute(array_values($params));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['target_id']] = (int)$row['cnt'];
}
return $out;
}
}