补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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:
+182
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user