Files
TripLog/src/Controllers/ActivityController.php
T
laoyang c8515632fb 节点与活动支持导出 ICS 日历(批 27)
- 新增 src/Ics.php:RFC 5545 生成器(CRLF、75 octet 折行且不切断
  UTF-8 字符、TEXT 转义、时间输出 UTC Z、UID 固定为 triplog-node-{id}
  使重复导入按 UID 更新而不堆重复事件)。
- 新增路由 nodes/ics、activities/ics:节点级导出单个事件
  (SUMMARY=活动名·节点标题、DTSTART/DTEND、LOCATION=出发地、
  DESCRIPTION=车次/航班号+备注);活动级导出整个行程(跳过已取消
  节点与已生成执行记录的计划节点,口径与详情页展示一致)。
- 入口:活动详情页「导出日历」、节点详情页「导出日历」、
  节点卡片「日历」链接。
- 修复隐性事故:index.php 漏 require src/Ics.php(无 autoloader 的
  项目里新增类必须同步入口),部署哨兵新增「src/ 每个类文件都被
  index.php require」逐文件对账。
- 验证:php -l 61 文件全过;渲染检查 386 项全过;Ics 单测 42 项
  全过(转义/折行/UTC/拼装);服务器 e2e 391 项全过(含 11c 节
  17 项 ICS 断言)。
2026-09-29 07:58:31 +08:00

588 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Attachments;
use App\Auth;
use App\Comments;
use App\Database;
use App\Expenses;
use App\Ics;
use App\Members;
use App\Mime;
class ActivityController
{
/**
* 活动级导出:整个活动 → 一个 .ics(多个 VEVENT),导入后就是这次旅行的完整日历。
*
* 口径与详情页展示一致(批 27):
* * 已取消的节点不导出(日历是「要去/去过」的安排,取消的没有意义);
* * 计划节点若已生成执行记录,只导执行版(同一个行程导两遍会重复提醒);
* * 事件 UID 与节点级导出同源(triplog-node-{id}),两个入口重复导入不堆事件。
*/
public function ics(): void
{
$db = Database::get();
$id = (int)($_GET['id'] ?? 0);
$activity = $this->fetchOwnActivity($id);
if (!$activity) {
http_response_code(404);
exit('活动不存在');
}
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, tm.mode_type AS travel_mode_type
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_id
WHERE tn.activity_id = ?
ORDER BY tn.start_time ASC, tn.id ASC');
$stmt->execute([$id]);
$nodes = $stmt->fetchAll();
// 已生成执行记录的计划节点 id 集合(这些计划版跳过,导执行版)
$executed = [];
foreach ($nodes as $n) {
if (!empty($n['original_plan_node_id'])) {
$executed[(int)$n['original_plan_node_id']] = true;
}
}
$blocks = [];
foreach ($nodes as $n) {
if (($n['status'] ?? '') === 'cancelled') continue;
if (isset($executed[(int)$n['id']])) continue;
$n['activity_title'] = (string)$activity['title'];
$blocks[] = Ics::event(NodeController::buildEventFields($n));
}
Ics::emit('TripLog-' . (string)$activity['title'],
Ics::calendar($blocks, (string)$activity['title']));
}
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();
$stats = [];
if (!empty($activities)) {
$stats = $this->collectStats(array_map('intval', array_column($activities, 'id')));
}
view('activities/index', [
'activities' => $activities,
'stats' => $stats,
'pageTitle' => '活动列表',
]);
}
/**
* 首页要的聚合数据,**全部一次查完**(几条 IN 查询,不做逐活动的 N+1):
* - 主要位置(地图图标落哪儿):所有坐标点的重心,执行节点优先、计划节点兜底
* - 去过的几个地方:起终点里的地名去重计数
* - 时间跨度:最早开始 ~ 最晚结束,跨了几个自然日
* - 主要参与人:按参与节点数降序(参与得越多越靠前)
*
* @param array<int, int> $ids
* @return array<int, array>
*/
private function collectStats(array $ids): array
{
if (empty($ids)) return [];
$db = Database::get();
$ph = implode(',', array_fill(0, count($ids), '?'));
$out = [];
foreach ($ids as $id) {
$out[$id] = [
'node_count' => 0,
'place_count' => 0,
'place_main' => '',
'lng' => null,
'lat' => null,
'start' => null,
'end' => null,
'days' => 0,
'span_text' => '',
'members' => [],
'member_count' => 0,
];
}
/* ① 节点:位置 / 时间 / 地点名。取消的节点不计入(它没真的发生)。 */
$s = $db->prepare("SELECT activity_id, node_category, status, start_time, end_time,
start_location, end_location
FROM trip_nodes WHERE activity_id IN ($ph) ORDER BY start_time");
$s->execute($ids);
$coords = []; // activity => ['reality' => [[lng,lat,name],…], 'plan' => …]
$names = []; // activity => [地名 => 1]
foreach ($s->fetchAll() as $n) {
$aid = (int)$n['activity_id'];
if (!isset($out[$aid])) continue;
$st = &$out[$aid];
$st['node_count']++;
if ($n['status'] === 'cancelled') continue;
foreach (['start_location', 'end_location'] as $key) {
$loc = json_decode((string)$n[$key], true);
if (!is_array($loc)) continue;
$name = trim((string)($loc['name'] ?? ''));
if ($name !== '') $names[$aid][$name] = 1;
$lng = isset($loc['lng']) ? (float)$loc['lng'] : 0.0;
$lat = isset($loc['lat']) ? (float)$loc['lat'] : 0.0;
if ($lng == 0.0 && $lat == 0.0) continue; // 只有地名、没坐标
$bucket = ($n['node_category'] === 'reality') ? 'reality' : 'plan';
$coords[$aid][$bucket][] = [$lng, $lat, $name];
}
if (!empty($n['start_time'])
&& ($st['start'] === null || $n['start_time'] < $st['start'])) {
$st['start'] = $n['start_time'];
}
if (!empty($n['end_time'])
&& ($st['end'] === null || $n['end_time'] > $st['end'])) {
$st['end'] = $n['end_time'];
}
}
unset($st);
/* ② 重心 = 主要位置;离重心最近那个点的地名当它的名字。
执行节点优先:计划改了、实际没去的地方不该把图标拽过去。 */
foreach ($coords as $aid => $set) {
$pts = !empty($set['reality']) ? $set['reality'] : ($set['plan'] ?? []);
if (!$pts) continue;
$sl = 0.0; $sb = 0.0;
foreach ($pts as $p) { $sl += $p[0]; $sb += $p[1]; }
$cl = $sl / count($pts);
$cb = $sb / count($pts);
$out[$aid]['lng'] = $cl;
$out[$aid]['lat'] = $cb;
$best = ''; $bestD = null;
foreach ($pts as $p) {
if ($p[2] === '') continue;
$d = ($p[0] - $cl) * ($p[0] - $cl) + ($p[1] - $cb) * ($p[1] - $cb);
if ($bestD === null || $d < $bestD) { $bestD = $d; $best = $p[2]; }
}
$out[$aid]['place_main'] = $best;
}
/* ③ 去过的地方数:地名去重(同一个城市写十次也只算一个) */
foreach ($names as $aid => $set) {
$out[$aid]['place_count'] = count($set);
}
/* ④ 时间跨度:按自然日算(同一天出发并返回算 1 天) */
foreach ($out as $aid => $st) {
if ($st['start'] === null) continue;
$from = strtotime($st['start']);
$to = $st['end'] !== null ? strtotime($st['end']) : $from;
if (!$from) continue;
$fromD = strtotime(date('Y-m-d', $from));
$toD = strtotime(date('Y-m-d', $to));
$days = (int)floor(($toD - $fromD) / 86400) + 1;
$out[$aid]['days'] = max(1, $days);
$out[$aid]['span_text'] = ($toD === $fromD)
? date('Y-m-d', $fromD)
: date('Y-m-d', $fromD) . ' ~ ' . date('m-d', $toD);
}
/* ⑤ 参与人:参与节点越多越靠前 —— 「主要参与人」就是排在前面的那几位 */
$s = $db->prepare("SELECT tn.activity_id, nm.user_id, u.name, u.avatar, COUNT(*) AS hits
FROM node_members nm
JOIN trip_nodes tn ON tn.id = nm.trip_node_id
LEFT JOIN users u ON u.id = nm.user_id
WHERE tn.activity_id IN ($ph)
GROUP BY tn.activity_id, nm.user_id, u.name, u.avatar
ORDER BY tn.activity_id, hits DESC, nm.user_id ASC");
$s->execute($ids);
foreach ($s->fetchAll() as $m) {
$aid = (int)$m['activity_id'];
if (!isset($out[$aid])) continue;
$out[$aid]['members'][] = [
'id' => (int)$m['user_id'],
'name' => (string)($m['name'] ?? ''),
'avatar' => media_avatar($m['avatar'] ?? null),
];
}
foreach ($out as $aid => $st) {
$out[$aid]['member_count'] = count($st['members']);
}
return $out;
}
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),没有外键级联,必须手工清。
// 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 ——
// 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
$stmt->execute([$id]);
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id);
foreach ($nodeIds as $nodeId) {
Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId);
}
// 评论同样是软关联
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()]);
// 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了
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,
tm.mode_type AS travel_mode_type
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_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,
tm.mode_type AS travel_mode_type
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_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));
// 每个节点的参与人(头像 / 姓名),渲染在节点卡片底部 —— 一条 IN 查询,不 N+1
$nodeMembers = Members::forNodes(array_merge(
array_column($planNodes, 'id'),
array_column($realityNodes, 'id')
));
// 批 21:详情页顶部改用列表同款「封面行」(小封面 + 参与人/统计),
// 复用与列表同一份聚合口径(collectStats),不另写一套查询
$actStats = $this->collectStats([$id]);
view('activities/show', [
'activity' => $activity,
'planNodes' => $planNodes,
'realityNodes' => $realityNodes,
'executedPlanIds' => $executedPlanIds,
'nodeMembers' => $nodeMembers,
'actStats' => $actStats,
// 路线地图:每个执行节点第一张图片附件 id(圆形标记用),一条 GROUP BY 防止 N+1
'nodeFirstImage' => Attachments::firstImageIdsForNodes(array_column($realityNodes, 'id')),
// 批 19:路线地图标记图标按出行方式映射(回退节点类型图标)
'modeIcons' => TravelModeController::MODE_ICONS,
'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:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底)
$info = Mime::detectBuffer($raw);
if ($info['mime'] === null || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true)) {
http_response_code(400);
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
exit;
}
$ext = Mime::extFor($info['mime']) ?? $ext;
$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;
}
/** 只删除本站 uploads/covers/ 下的封面文件 */
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;
}
}