补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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,513 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
use App\Expenses;
|
||||
use App\Members;
|
||||
|
||||
class NodeController
|
||||
{
|
||||
/** 计划节点只可能有 planned / completed / cancelled */
|
||||
private const PLAN_STATUSES = ['planned', 'completed', 'cancelled'];
|
||||
/** 执行节点本身就是"已完成",额外允许 cancelled */
|
||||
private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned'];
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$activityId = (int)($_GET['activity_id'] ?? 0);
|
||||
$category = $_GET['category'] ?? 'plan';
|
||||
$category = in_array($category, ['plan', 'reality'], true) ? $category : 'plan';
|
||||
|
||||
$activity = $this->fetchOwnActivity($activityId);
|
||||
|
||||
view('nodes/form', $this->formData($db, $activity, $category, null));
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$node = $this->fetchOwnNode((int)($_GET['id'] ?? 0));
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$node['activity_id']]);
|
||||
$activity = $stmt->fetch();
|
||||
|
||||
view('nodes/form', $this->formData($db, $activity, $node['node_category'], $node));
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$activity = $this->fetchOwnActivity((int)($_POST['activity_id'] ?? 0));
|
||||
$category = $_POST['node_category'] ?? 'plan';
|
||||
if (!in_array($category, ['plan', 'reality'], true)) {
|
||||
exit('无效的节点分类');
|
||||
}
|
||||
|
||||
$input = $this->collectInput($db, $activity, $category);
|
||||
if ($input['error'] !== null) {
|
||||
flash('error', $input['error']);
|
||||
redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]);
|
||||
}
|
||||
|
||||
$data = $input['data'];
|
||||
$sql = 'INSERT INTO trip_nodes
|
||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
|
||||
VALUES
|
||||
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
|
||||
:start_time, :end_time, :duration_minutes,
|
||||
:start_location, :end_location,
|
||||
:title, :description, :travel_mode_id, :vehicle_id,
|
||||
:line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '节点已保存');
|
||||
redirect('activities/show', ['id' => (int)$activity['id']]);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
$category = $node['node_category'];
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
|
||||
$stmt->execute([$node['activity_id']]);
|
||||
$activity = $stmt->fetch();
|
||||
|
||||
$input = $this->collectInput($db, $activity, $category, $node);
|
||||
if ($input['error'] !== null) {
|
||||
flash('error', $input['error']);
|
||||
redirect('nodes/edit', ['id' => (int)$node['id']]);
|
||||
}
|
||||
|
||||
$data = $input['data'];
|
||||
unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']);
|
||||
$data['id'] = (int)$node['id'];
|
||||
|
||||
$sql = 'UPDATE trip_nodes SET
|
||||
node_type_id = :node_type_id,
|
||||
start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes,
|
||||
start_location = :start_location, end_location = :end_location,
|
||||
title = :title, description = :description,
|
||||
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
|
||||
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
|
||||
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
|
||||
WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
flash('error', '保存成功');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上的状态快捷切换(计划节点:标记完成 / 撤销完成)
|
||||
*/
|
||||
public function status(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
$status = (string)($_POST['status'] ?? '');
|
||||
|
||||
$allowed = $node['node_category'] === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
flash('error', '无效的状态');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
$reason = trim($_POST['cancel_reason'] ?? '');
|
||||
$stmt = $db->prepare('UPDATE trip_nodes SET status = ?, cancel_reason = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$status,
|
||||
$status === 'cancelled' ? ($reason !== '' ? $reason : null) : null,
|
||||
(int)$node['id'],
|
||||
]);
|
||||
|
||||
$labels = ['planned' => '已恢复为待执行', 'completed' => '已标记为完成', 'cancelled' => '已取消', 'ongoing' => '已标记为进行中'];
|
||||
flash('error', $labels[$status] ?? '状态已更新');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用计划节点生成一条执行记录(复制字段,保留 original_plan_node_id 指向计划节点)
|
||||
*/
|
||||
public function fromPlan(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$plan = $this->fetchOwnNode((int)($_POST['plan_node_id'] ?? 0));
|
||||
if ($plan['node_category'] !== 'plan') {
|
||||
flash('error', '只有计划节点可以生成为执行记录');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
// 已经生成过就不重复生成
|
||||
$chk = $db->prepare('SELECT id FROM trip_nodes WHERE original_plan_node_id = ? LIMIT 1');
|
||||
$chk->execute([(int)$plan['id']]);
|
||||
$exists = $chk->fetchColumn();
|
||||
if ($exists) {
|
||||
flash('error', '该计划节点已经有执行记录了');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO trip_nodes
|
||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status)
|
||||
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
||||
$stmt->execute([
|
||||
(int)$plan['activity_id'],
|
||||
(int)$plan['node_type_id'],
|
||||
(int)$plan['id'],
|
||||
$plan['start_time'],
|
||||
$plan['end_time'],
|
||||
$plan['duration_minutes'],
|
||||
$plan['start_location'],
|
||||
$plan['end_location'],
|
||||
$plan['title'],
|
||||
$plan['description'],
|
||||
$plan['travel_mode_id'],
|
||||
$plan['vehicle_id'],
|
||||
$plan['line_name'],
|
||||
$plan['carrier'],
|
||||
$plan['trip_number'],
|
||||
$plan['seat_info'],
|
||||
$plan['gate'],
|
||||
]);
|
||||
|
||||
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
|
||||
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
csrf_check();
|
||||
$db = Database::get();
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
|
||||
// 软关联的附件先查出来,删完数据再删文件
|
||||
$stmt = $db->prepare('SELECT file_url FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
// 若有执行记录指向本计划节点,外键是 ON DELETE SET NULL,会自动置空
|
||||
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
foreach ($files as $url) {
|
||||
$this->deleteAttachmentFile((string)$url);
|
||||
}
|
||||
|
||||
flash('error', '节点已删除');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点详情页:把挂在这个节点上的参与人、花销、讨论集中到一页
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||||
|
||||
// 节点类型(本家庭)
|
||||
$stmt = $db->prepare('SELECT name, icon FROM node_types WHERE id = ?');
|
||||
$stmt->execute([(int)$node['node_type_id']]);
|
||||
$type = $stmt->fetch();
|
||||
if (!$type) $type = ['name' => '未分类', 'icon' => '·'];
|
||||
|
||||
// 出行方式(连同车辆信息拼成一行展示文本)
|
||||
$travelModeLabel = '';
|
||||
if (!empty($node['travel_mode_id'])) {
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.mode_type, v.plate_number, v.model_name
|
||||
FROM travel_modes tm
|
||||
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
|
||||
WHERE tm.id = ?');
|
||||
$stmt->execute([(int)$node['travel_mode_id']]);
|
||||
$tm = $stmt->fetch();
|
||||
if ($tm) {
|
||||
$label = TravelModeController::MODE_LABELS[$tm['mode_type']] ?? $tm['mode_type'];
|
||||
if (!empty($tm['plate_number'])) {
|
||||
$label .= ' · ' . $tm['plate_number'];
|
||||
} elseif (!empty($tm['model_name'])) {
|
||||
$label .= ' · ' . $tm['model_name'];
|
||||
}
|
||||
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
|
||||
if (!empty($node['line_name'])) {
|
||||
$label .= ' · ' . $node['line_name'];
|
||||
}
|
||||
$travelModeLabel = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachmentCount = (int)$stmt->fetchColumn();
|
||||
|
||||
$familyId = (int)$node['family_id'];
|
||||
|
||||
view('nodes/show', [
|
||||
'node' => array_merge($node, [
|
||||
'node_type_name' => (string)$type['name'],
|
||||
'node_type_icon' => (string)$type['icon'],
|
||||
'travel_mode_label' => $travelModeLabel,
|
||||
]),
|
||||
'attachmentCount' => $attachmentCount,
|
||||
'members' => Members::forNode($nodeId),
|
||||
'familyUsers' => Members::familyUsers($familyId),
|
||||
'roleTags' => Members::roleTagMap($familyId),
|
||||
'emotions' => Members::EMOTIONS,
|
||||
'expenses' => Expenses::forNode($nodeId),
|
||||
'expenseSummary' => Expenses::summaryForNode($nodeId),
|
||||
'categories' => Expenses::CATEGORIES,
|
||||
'currencies' => Expenses::currencyLabels(),
|
||||
'expenseSymbols' => Expenses::SYMBOLS,
|
||||
'currentUserId' => (int)Auth::id(),
|
||||
'commentTree' => Comments::tree(Comments::TARGET_NODE, $nodeId),
|
||||
'pageTitle' => $node['title'] !== null && $node['title'] !== ''
|
||||
? (string)$node['title']
|
||||
: '节点详情',
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 内部辅助
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 组装 nodes/form 模板所需数据 */
|
||||
private function formData(\PDO $db, array $activity, string $category, ?array $node): array
|
||||
{
|
||||
$stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC');
|
||||
$stmt->execute([$activity['family_id']]);
|
||||
$nodeTypes = $stmt->fetchAll();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.*, v.plate_number, v.model_name
|
||||
FROM travel_modes tm
|
||||
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
|
||||
WHERE tm.family_id = ?
|
||||
ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC
|
||||
');
|
||||
$stmt->execute([$activity['family_id']]);
|
||||
$travelModes = $stmt->fetchAll();
|
||||
|
||||
// 执行节点可以关联计划节点(编辑已有执行节点时不再改关联关系)
|
||||
$planNodes = [];
|
||||
if ($category === 'reality' && $node === null) {
|
||||
$s = $db->prepare('SELECT id, title, start_time FROM trip_nodes WHERE activity_id = ? AND node_category = "plan" ORDER BY start_time');
|
||||
$s->execute([$activity['id']]);
|
||||
$planNodes = $s->fetchAll();
|
||||
}
|
||||
|
||||
$isEdit = $node !== null;
|
||||
|
||||
return [
|
||||
'modeLabels' => TravelModeController::MODE_LABELS,
|
||||
'activity' => $activity,
|
||||
'category' => $category,
|
||||
'nodeTypes' => $nodeTypes,
|
||||
'travelModes' => $travelModes,
|
||||
'planNodes' => $planNodes,
|
||||
'node' => $node,
|
||||
'isEdit' => $isEdit,
|
||||
'pageTitle' => ($isEdit ? '编辑' : '新建') . ($category === 'plan' ? '计划' : '执行') . '节点',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并整理表单输入
|
||||
* @return array{error: ?string, data: array}
|
||||
*/
|
||||
private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array
|
||||
{
|
||||
$startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? ''));
|
||||
$endTime = $this->normalizeDateTime((string)($_POST['end_time'] ?? ''));
|
||||
if ($startTime === null || $endTime === null) {
|
||||
return ['error' => '开始和结束时间必填', 'data' => []];
|
||||
}
|
||||
if (strtotime($endTime) < strtotime($startTime)) {
|
||||
return ['error' => '结束时间不能早于开始时间', 'data' => []];
|
||||
}
|
||||
|
||||
$nodeTypeId = (int)($_POST['node_type_id'] ?? 0);
|
||||
$chk = $db->prepare('SELECT id FROM node_types WHERE id = ? AND family_id = ?');
|
||||
$chk->execute([$nodeTypeId, $activity['family_id']]);
|
||||
if (!$chk->fetch()) {
|
||||
return ['error' => '节点类型不存在', 'data' => []];
|
||||
}
|
||||
|
||||
$travelModeId = !empty($_POST['travel_mode_id']) ? (int)$_POST['travel_mode_id'] : null;
|
||||
$vehicleId = null;
|
||||
if ($travelModeId) {
|
||||
$s = $db->prepare('SELECT vehicle_id FROM travel_modes WHERE id = ? AND family_id = ?');
|
||||
$s->execute([$travelModeId, $activity['family_id']]);
|
||||
$row = $s->fetch();
|
||||
if (!$row) {
|
||||
return ['error' => '出行方式不存在', 'data' => []];
|
||||
}
|
||||
$vehicleId = $row['vehicle_id'] !== null ? (int)$row['vehicle_id'] : null;
|
||||
}
|
||||
|
||||
// 状态:计划节点默认待执行,执行节点默认已完成
|
||||
$defaultStatus = $category === 'plan' ? 'planned' : 'completed';
|
||||
$status = (string)($_POST['status'] ?? $defaultStatus);
|
||||
$allowed = $category === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
$status = $node['status'] ?? $defaultStatus;
|
||||
}
|
||||
|
||||
$originalPlanId = null;
|
||||
if ($category === 'reality' && $node === null && !empty($_POST['original_plan_node_id'])) {
|
||||
$originalPlanId = (int)$_POST['original_plan_node_id'];
|
||||
// 关联的计划节点必须属于同一个活动,否则视为未关联
|
||||
$s = $db->prepare('SELECT id FROM trip_nodes WHERE id = ? AND activity_id = ? AND node_category = "plan"');
|
||||
$s->execute([$originalPlanId, $activity['id']]);
|
||||
if (!$s->fetch()) {
|
||||
$originalPlanId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'activity_id' => (int)$activity['id'],
|
||||
'node_type_id' => $nodeTypeId,
|
||||
'node_category' => $category,
|
||||
'original_plan_node_id' => $originalPlanId,
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'duration_minutes' => $this->calcDuration($startTime, $endTime),
|
||||
'start_location' => $this->buildLocation('start'),
|
||||
'end_location' => $this->buildLocation('end'),
|
||||
'title' => trim($_POST['title'] ?? ''),
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'travel_mode_id' => $travelModeId,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'line_name' => $this->strOrNull('line_name'),
|
||||
'carrier' => $this->strOrNull('carrier'),
|
||||
'trip_number' => $this->strOrNull('trip_number'),
|
||||
'seat_info' => $this->strOrNull('seat_info'),
|
||||
'gate' => $this->strOrNull('gate'),
|
||||
'status' => $status,
|
||||
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
|
||||
];
|
||||
|
||||
return ['error' => null, 'data' => $data];
|
||||
}
|
||||
|
||||
/** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */
|
||||
private function normalizeDateTime(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return null;
|
||||
$value = str_replace('T', ' ', $value);
|
||||
$ts = strtotime($value);
|
||||
if ($ts === false) return null;
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
private function fetchOwnActivity(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
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()) {
|
||||
http_response_code(404);
|
||||
exit('活动不存在');
|
||||
}
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function fetchOwnNode(int $id): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
$stmt = Database::get()->prepare('
|
||||
SELECT tn.*, a.family_id
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$node = $stmt->fetch();
|
||||
if (!$node || (int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function deleteAttachmentFile(string $url): void
|
||||
{
|
||||
if (strpos($url, '/uploads/attachments/') !== 0 || strpos($url, '..') !== false) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
private function calcDuration(string $start, string $end): int
|
||||
{
|
||||
$s = strtotime($start);
|
||||
$e = strtotime($end);
|
||||
return max(0, (int)round(($e - $s) / 60));
|
||||
}
|
||||
|
||||
private function buildLocation(string $which): ?string
|
||||
{
|
||||
$name = trim($_POST["{$which}_location_name"] ?? '');
|
||||
$address = trim($_POST["{$which}_location_address"] ?? '');
|
||||
if ($name === '' && $address === '') return null;
|
||||
|
||||
$lat = $_POST["{$which}_location_lat"] ?? null;
|
||||
$lng = $_POST["{$which}_location_lng"] ?? null;
|
||||
return json_encode([
|
||||
'name' => $name,
|
||||
'address' => $address,
|
||||
'lat' => ($lat === '' || $lat === null) ? null : $lat,
|
||||
'lng' => ($lng === '' || $lng === null) ? null : $lng,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function strOrNull(string $key): ?string
|
||||
{
|
||||
$v = trim($_POST[$key] ?? '');
|
||||
return $v === '' ? null : $v;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user