补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊

数据库里早就建好了 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:
2026-09-22 22:03:08 +08:00
parent 6c1345e202
commit a79956758b
49 changed files with 7059 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 节点参与人:谁参加了这个节点、当时担任什么角色、心情如何、有没有签到
* 对应 trip_nodes 的从表 node_members(trip_node_id + user_id 唯一)
*/
class Members
{
/**
* 情绪目录:emoji 只在这里出现,库里存的就是 emoji 字符本身(列宽 varchar(10) 只够 1 个字符)
* 全部选用 Emoji 11.0(2018-06)及以前的码位 —— 更新的码位在旧系统 emoji 字体里没有字形,
* 会显示成空白/方框。
*/
public const EMOTIONS = [
'😄' => '开心',
'🥰' => '温暖',
'😍' => '喜欢',
'🤩' => '兴奋',
'🤗' => '满足',
'😎' => '得意',
'🤔' => '沉思',
'😌' => '平静',
'😋' => '好吃',
'😅' => '无奈',
'😴' => '累了',
'😵' => '晕了',
'😢' => '难过',
'😭' => '大哭',
'😱' => '惊吓',
'😠' => '生气',
'🤒' => '不舒服',
];
public static function isValidEmotion(string $emoji): bool
{
return $emoji !== '' && isset(self::EMOTIONS[$emoji]);
}
/** 某个节点的参与人列表(带姓名、头像、角色标签名) */
public static function forNode(int $nodeId): array
{
$stmt = Database::get()->prepare('
SELECT nm.*, u.name, u.avatar, u.role
FROM node_members nm
LEFT JOIN users u ON u.id = nm.user_id
WHERE nm.trip_node_id = ?
ORDER BY nm.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
$tagNames = self::roleTagMap();
foreach ($rows as &$r) {
$ids = json_decode((string)($r['role_tag_ids'] ?? '[]'), true);
$ids = is_array($ids) ? array_map('intval', $ids) : [];
$r['role_tag_ids'] = $ids;
$r['role_tag_names'] = array_values(array_filter(array_map(
static fn($id) => $tagNames[$id] ?? null,
$ids
)));
}
unset($r);
return $rows;
}
/** 一次查完多个节点的参与人数与情绪,供列表/卡片使用 */
public static function summaryForNodes(array $nodeIds): array
{
if (empty($nodeIds)) return [];
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = Database::get()->prepare("SELECT trip_node_id, COUNT(*) AS cnt,
GROUP_CONCAT(emotion ORDER BY id SEPARATOR '') AS emotions
FROM node_members WHERE trip_node_id IN ($ph) GROUP BY trip_node_id");
$stmt->execute(array_values($nodeIds));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['trip_node_id']] = [
'count' => (int)$row['cnt'],
'emotions' => (string)($row['emotions'] ?? ''),
];
}
return $out;
}
/**
* 新增或更新一个参与人(同一节点同一人只留一条)
* @return array{ok: bool, error?: string}
*/
public static function save(int $nodeId, int $userId, array $roleTagIds, string $emotion, ?string $checkIn): array
{
$db = Database::get();
if ($userId <= 0) {
return ['ok' => false, 'error' => '请选择参与人'];
}
// 只能选本家庭的成员(注意:必须同时比对 users.family_id,
// 否则「节点存在 + 用户存在」就放行,别家用户也能被塞进来)
$stmt = $db->prepare('SELECT u.id, a.family_id
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
JOIN users u ON u.id = ? AND u.family_id = a.family_id
WHERE tn.id = ?');
$stmt->execute([$userId, $nodeId]);
$row = $stmt->fetch();
if (!$row) {
return ['ok' => false, 'error' => '该成员不属于这个活动所在家庭'];
}
// 角色标签也要属于同一个家庭
$validTags = array_keys(self::roleTagMap((int)$row['family_id']));
$roleTagIds = array_values(array_intersect(array_map('intval', $roleTagIds), $validTags));
if ($emotion !== '' && !self::isValidEmotion($emotion)) {
return ['ok' => false, 'error' => '情绪表情不在可选范围内'];
}
$checkIn = $checkIn !== null && trim($checkIn) !== '' ? trim($checkIn) : null;
if ($checkIn !== null) {
$ts = strtotime(str_replace('T', ' ', $checkIn));
$checkIn = $ts !== false ? date('Y-m-d H:i:s', $ts) : null;
}
$stmt = $db->prepare('INSERT INTO node_members (trip_node_id, user_id, role_tag_ids, emotion, check_in_time)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE role_tag_ids = VALUES(role_tag_ids),
emotion = VALUES(emotion), check_in_time = VALUES(check_in_time)');
$stmt->execute([
$nodeId,
$userId,
$roleTagIds ? json_encode($roleTagIds, JSON_UNESCAPED_UNICODE) : null,
$emotion !== '' ? $emotion : null,
$checkIn,
]);
return ['ok' => true];
}
public static function remove(int $nodeId, int $userId): void
{
$stmt = Database::get()->prepare('DELETE FROM node_members WHERE trip_node_id = ? AND user_id = ?');
$stmt->execute([$nodeId, $userId]);
}
/** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */
public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int
{
$n = 0;
foreach (array_unique(array_map('intval', $userIds)) as $uid) {
$r = self::save($nodeId, $uid, $roleTagIds, '', null);
if ($r['ok']) $n++;
}
return $n;
}
/** 家庭内的角色标签 [id => name] */
public static function roleTagMap(?int $familyId = null): array
{
$db = Database::get();
if ($familyId === null) {
$stmt = $db->query('SELECT id, name, family_id FROM role_tags');
} else {
$stmt = $db->prepare('SELECT id, name, family_id FROM role_tags WHERE family_id = ?');
$stmt->execute([$familyId]);
}
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['id']] = $row['name'];
}
return $out;
}
/** 家庭内可作为参与人的用户(guest 也算,记录出行时常常是"待激活"的亲戚) */
public static function familyUsers(int $familyId): array
{
$stmt = Database::get()->prepare('SELECT id, name, avatar, role FROM users
WHERE family_id = ? ORDER BY FIELD(role, "admin","user","guest"), id ASC');
$stmt->execute([$familyId]);
return $stmt->fetchAll();
}
}