425 lines
15 KiB
PHP
425 lines
15 KiB
PHP
<?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;
|
||
|
|
}
|
||
|
|
}
|