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

数据库里早就建好了 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
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 花销与分摊
*
* 表结构:expenses(挂在 trip_nodes 上,is_budget 区分"预算"和"实际")
* + expense_splits(这笔钱按人分摊,谁承担多少)
*/
class Expenses
{
public const CATEGORIES = ['餐饮', '门票', '交通', '住宿', '购物', '娱乐', '其他'];
/** 展示用的货币符号(库里只存三字母代码) */
public const SYMBOLS = ['CNY' => '¥', 'JPY' => '¥', 'USD' => '$', 'EUR' => '€', 'HKD' => 'HK$'];
public const MAX_AMOUNT = 99999999.99;
public static function currencyLabels(): array
{
return ['CNY' => '人民币 ¥', 'JPY' => '日元 ¥', 'USD' => '美元 $', 'EUR' => '欧元 €', 'HKD' => '港币 HK$'];
}
/** 某个节点下的花销,带付款人和分摊明细 */
public static function forNode(int $nodeId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT e.*, u.name AS payer_name, u.avatar AS payer_avatar
FROM expenses e
LEFT JOIN users u ON u.id = e.payer_user_id
WHERE e.trip_node_id = ?
ORDER BY e.is_budget ASC, e.created_at ASC, e.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
if (empty($rows)) return [];
$ids = array_column($rows, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT es.*, u.name AS user_name
FROM expense_splits es
LEFT JOIN users u ON u.id = es.user_id
WHERE es.expense_id IN ($ph)
ORDER BY es.id ASC");
$s->execute($ids);
$splits = [];
foreach ($s->fetchAll() as $row) {
$splits[(int)$row['expense_id']][] = $row;
}
foreach ($rows as &$row) {
$row['splits'] = $splits[(int)$row['id']] ?? [];
}
unset($row);
return $rows;
}
/** 活动下的花销合计(预算 / 实际),以及各节点的小计 */
public static function summaryForActivity(int $activityId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT tn.id AS node_id, e.is_budget, SUM(e.total_amount) AS amount, COUNT(*) AS cnt
FROM expenses e
JOIN trip_nodes tn ON tn.id = e.trip_node_id
WHERE tn.activity_id = ?
GROUP BY tn.id, e.is_budget');
$stmt->execute([$activityId]);
$byNode = [];
$budgetTotal = 0.0;
$actualTotal = 0.0;
$count = 0;
foreach ($stmt->fetchAll() as $row) {
$nodeId = (int)$row['node_id'];
$isBudget = (int)$row['is_budget'] === 1;
$amount = (float)$row['amount'];
$byNode[$nodeId][$isBudget ? 'budget' : 'actual'] = $amount;
if ($isBudget) {
$budgetTotal += $amount;
} else {
$actualTotal += $amount;
}
$count += (int)$row['cnt'];
}
return [
'budget' => $budgetTotal,
'actual' => $actualTotal,
'count' => $count,
'by_node' => $byNode,
'diff' => $actualTotal - $budgetTotal,
];
}
/**
* 新增一笔花销,并把金额按人平均分摊(尾差归最后一人,保证分摊之和等于总额)
* @return array{ok: bool, error?: string, id?: int}
*/
public static function create(int $nodeId, int $payerId, string $amountRaw, string $currency, string $category, string $description, bool $isBudget, array $splitUserIds): array
{
$db = Database::get();
$amountRaw = str_replace([',', ',', ' '], '', trim($amountRaw));
if ($amountRaw === '' || !is_numeric($amountRaw)) {
return ['ok' => false, 'error' => '金额必须是数字'];
}
$amount = round((float)$amountRaw, 2);
if ($amount <= 0) {
return ['ok' => false, 'error' => '金额必须大于 0'];
}
if ($amount > self::MAX_AMOUNT) {
return ['ok' => false, 'error' => '金额过大'];
}
if (!isset(self::currencyLabels()[$currency])) {
$currency = 'CNY';
}
if ($category === '' || !in_array($category, self::CATEGORIES, true)) {
$category = '其他';
}
$description = trim($description);
if (mb_strlen($description) > 200) {
return ['ok' => false, 'error' => '描述不能超过 200 个字'];
}
// 付款人和分摊人都必须是这个节点所属家庭的成员
$stmt = $db->prepare('SELECT a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
$stmt->execute([$nodeId]);
$familyId = $stmt->fetchColumn();
if ($familyId === false) {
return ['ok' => false, 'error' => '节点不存在'];
}
$familyId = (int)$familyId;
$memberIds = array_column(Members::familyUsers($familyId), 'id');
$memberIds = array_map('intval', $memberIds);
if (!in_array($payerId, $memberIds, true)) {
return ['ok' => false, 'error' => '付款人必须是本家庭成员'];
}
$splitUserIds = array_values(array_unique(array_intersect(array_map('intval', $splitUserIds), $memberIds)));
if (empty($splitUserIds)) {
// 没选分摊人时默认由付款人自己承担
$splitUserIds = [$payerId];
}
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO expenses
(trip_node_id, payer_user_id, currency, total_amount, category, description, is_budget)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$nodeId, $payerId, $currency, number_format($amount, 2, '.', ''),
$category, $description !== '' ? $description : null, $isBudget ? 1 : 0,
]);
$expenseId = (int)$db->lastInsertId();
// 以「分」为单位平均分,把除不尽的尾差加到最后一个人身上
$totalCents = (int)round($amount * 100);
$n = count($splitUserIds);
$base = intdiv($totalCents, $n);
$rest = $totalCents - $base * $n;
$stmt = $db->prepare('INSERT INTO expense_splits (expense_id, user_id, share_amount) VALUES (?, ?, ?)');
foreach ($splitUserIds as $i => $uid) {
$cents = $base + ($i === $n - 1 ? $rest : 0);
$stmt->execute([$expenseId, $uid, number_format($cents / 100, 2, '.', '')]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['ok' => false, 'error' => '保存失败:' . $e->getMessage()];
}
return ['ok' => true, 'id' => $expenseId];
}
public static function delete(int $id): bool
{
$db = Database::get();
$stmt = $db->prepare('SELECT id FROM expenses WHERE id = ?');
$stmt->execute([$id]);
if (!$stmt->fetch()) return false;
// expense_splits 有 ON DELETE CASCADE,这里显式删一遍更直观
$db->prepare('DELETE FROM expense_splits WHERE expense_id = ?')->execute([$id]);
$db->prepare('DELETE FROM expenses WHERE id = ?')->execute([$id]);
return true;
}
/** 某个节点的花销合计 */
public static function summaryForNode(int $nodeId): array
{
$stmt = Database::get()->prepare('SELECT is_budget, SUM(total_amount) AS amount, COUNT(*) AS cnt
FROM expenses WHERE trip_node_id = ? GROUP BY is_budget');
$stmt->execute([$nodeId]);
$budget = 0.0;
$actual = 0.0;
$count = 0;
foreach ($stmt->fetchAll() as $row) {
if ((int)$row['is_budget'] === 1) {
$budget = (float)$row['amount'];
} else {
$actual = (float)$row['amount'];
}
$count += (int)$row['cnt'];
}
return ['budget' => $budget, 'actual' => $actual, 'count' => $count, 'diff' => $actual - $budget];
}
}