补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
|
||||
class AttachmentController
|
||||
{
|
||||
private const ALLOWED_MIMES = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/heic' => 'heic',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/zip' => 'zip',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/quicktime' => 'mov',
|
||||
];
|
||||
|
||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
public function node(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
$node = $this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON u.id = a.uploader_id
|
||||
WHERE a.target_type = "trip_node" AND a.target_id = ?
|
||||
ORDER BY a.created_at DESC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachments = $stmt->fetchAll();
|
||||
|
||||
view('nodes/attachments', [
|
||||
'node' => $node,
|
||||
'attachments' => $attachments,
|
||||
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
||||
flash('error', '未选择文件');
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
$files = $this->restructureFiles($_FILES['files']);
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
|
||||
$db = Database::get();
|
||||
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ': 上传错误';
|
||||
continue;
|
||||
}
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
$errors[] = $file['name'] . ': 超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset(self::ALLOWED_MIMES[$mime])) {
|
||||
$errors[] = $file['name'] . ': 不支持的类型';
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = self::ALLOWED_MIMES[$mime];
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
$errors[] = $file['name'] . ': 保存失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name)
|
||||
VALUES ("trip_node", ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$nodeId,
|
||||
Auth::id(),
|
||||
'/uploads/attachments/' . $filename,
|
||||
$file['name'],
|
||||
]);
|
||||
$uploaded++;
|
||||
}
|
||||
|
||||
$msg = "成功上传 {$uploaded} 个文件";
|
||||
if (!empty($errors)) {
|
||||
$msg .= ';失败:' . implode(',', $errors);
|
||||
}
|
||||
flash('error', $msg);
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
|
||||
$stmt->execute([$id]);
|
||||
$att = $stmt->fetch();
|
||||
if (!$att) {
|
||||
flash('error', '附件不存在');
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
$this->fetchNodeWithAccess((int)$att['target_id']);
|
||||
|
||||
$url = $att['file_url'];
|
||||
if (strpos($url, '/uploads/attachments/') === 0) {
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
flash('error', '附件已删除');
|
||||
redirect('attachments/node', ['id' => (int)$att['target_id']]);
|
||||
}
|
||||
|
||||
private function restructureFiles(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) continue;
|
||||
$result[] = [
|
||||
'name' => $files['name'][$i],
|
||||
'type' => $files['type'][$i],
|
||||
'tmp_name' => $files['tmp_name'][$i],
|
||||
'error' => $files['error'][$i],
|
||||
'size' => $files['size'][$i],
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchNodeWithAccess(int $nodeId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT tn.*, a.family_id, a.id AS activity_id, a.title AS activity_title
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?
|
||||
');
|
||||
$stmt->execute([$nodeId]);
|
||||
$node = $stmt->fetch();
|
||||
|
||||
if (!$node) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
if ((int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(403);
|
||||
exit('无权访问此节点');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user