2026-09-22 22:03:08 +08:00
|
|
|
<?php
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Controllers;
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
use App\Attachments;
|
2026-09-22 22:03:08 +08:00
|
|
|
use App\Auth;
|
|
|
|
|
use App\Comments;
|
|
|
|
|
use App\Database;
|
|
|
|
|
use App\Expenses;
|
2026-09-23 15:18:25 +08:00
|
|
|
use App\Members;
|
2026-09-22 23:09:37 +08:00
|
|
|
use App\Mime;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-09-24 17:03:55 +08:00
|
|
|
$stats = [];
|
2026-09-22 22:03:08 +08:00
|
|
|
if (!empty($activities)) {
|
2026-09-24 17:03:55 +08:00
|
|
|
$stats = $this->collectStats(array_map('intval', array_column($activities, 'id')));
|
2026-09-22 22:03:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
view('activities/index', [
|
|
|
|
|
'activities' => $activities,
|
2026-09-24 17:03:55 +08:00
|
|
|
'stats' => $stats,
|
2026-09-22 22:03:08 +08:00
|
|
|
'pageTitle' => '活动列表',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 17:03:55 +08:00
|
|
|
/**
|
|
|
|
|
* 首页要的聚合数据,**全部一次查完**(几条 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
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'];
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清。
|
|
|
|
|
// 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 ——
|
|
|
|
|
// 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。
|
2026-09-22 22:03:08 +08:00
|
|
|
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
|
|
|
|
|
$stmt->execute([$id]);
|
|
|
|
|
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
2026-09-22 23:09:37 +08:00
|
|
|
|
|
|
|
|
Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id);
|
|
|
|
|
foreach ($nodeIds as $nodeId) {
|
|
|
|
|
Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId);
|
2026-09-22 22:03:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 评论同样是软关联
|
|
|
|
|
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()]);
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
// 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了
|
2026-09-22 22:03:08 +08:00
|
|
|
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));
|
|
|
|
|
|
2026-09-23 15:18:25 +08:00
|
|
|
// 每个节点的参与人(头像 / 姓名),渲染在节点卡片底部 —— 一条 IN 查询,不 N+1
|
|
|
|
|
$nodeMembers = Members::forNodes(array_merge(
|
|
|
|
|
array_column($planNodes, 'id'),
|
|
|
|
|
array_column($realityNodes, 'id')
|
|
|
|
|
));
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
view('activities/show', [
|
|
|
|
|
'activity' => $activity,
|
|
|
|
|
'planNodes' => $planNodes,
|
|
|
|
|
'realityNodes' => $realityNodes,
|
|
|
|
|
'executedPlanIds' => $executedPlanIds,
|
2026-09-23 15:18:25 +08:00
|
|
|
'nodeMembers' => $nodeMembers,
|
2026-09-24 11:12:27 +08:00
|
|
|
// 路线地图:每个执行节点第一张图片附件 id(圆形标记用),一条 GROUP BY 防止 N+1
|
|
|
|
|
'nodeFirstImage' => Attachments::firstImageIdsForNodes(array_column($realityNodes, 'id')),
|
2026-09-22 22:03:08 +08:00
|
|
|
'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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
// 不用 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)) {
|
2026-09-22 22:03:08 +08:00
|
|
|
http_response_code(400);
|
|
|
|
|
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
2026-09-22 23:09:37 +08:00
|
|
|
$ext = Mime::extFor($info['mime']) ?? $ext;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
|
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
/** 只删除本站 uploads/covers/ 下的封面文件 */
|
2026-09-22 22:03:08 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|