Files
FamilyBook/app/Resource.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

213 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 资源(v12,用户 2026-10-01 第 8 条):挂在**某个人名下**的附属信息。
*
* 形态:标题 + 一段文字 + 多个附件(证件扫描件、一寸照片、合同…)。
* 访问:名下的人自己当然能看能改;另外可以**共享给多人**,各设只读 / 读写。
*
* 两条关键设计:
* ① **共享表里不含 owner**(`resource_shares` 只记"额外被共享的人")——
* 不然同一个人在两张表里各有一条,权限要取并集,改一处忘一处就会
* "本人反而看不了自己的东西"。
* ② **默认折叠**:一屏列出几十条正文会很长,列表只露标题行,点开才看正文与附件。
* 折叠用原生 <details>,没有 JS 也能开合(app.js 不依赖它)。
*
* ⚠️ 与「名册」的分工:名册记录的是**这个人是谁**(生日、电话、关系);
* 资源记录的是**这个人名下有什么东西**(证书、账号、照片)。不要混。
*/
declare(strict_types=1);
namespace App;
final class Resource
{
/** 资源的宿主类型(附件表用) */
public const ATTACH = 'resource';
/**
* 我能看到的资源 = 我名下的 + 别人共享给我的。
*
* 返回的行里带 `perm`:'owner' / 'write' / 'read'(模板据此决定能不能改)。
*
* @param int $personId 看的人(决定"我能看什么")
* @param int $ownerId 0=不限归属;非 0=只看这个人(且必须是我能看到的)
* @param string $keyword 标题 + 正文里搜
*/
public static function all(int $familyId, int $personId, int $ownerId = 0, string $keyword = ''): array
{
$sql = 'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color,
n.nickname AS owner_nick,
CASE WHEN r.owner_id = ? THEN "owner"
ELSE COALESCE((SELECT s.perm FROM resource_shares s
WHERE s.resource_id = r.id AND s.person_id = ?), "read")
END AS perm
FROM resources r
JOIN persons p ON p.id = r.owner_id
LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_id = ?
LEFT JOIN resource_shares sh ON sh.resource_id = r.id AND sh.person_id = ?
WHERE r.family_id = ? AND (r.owner_id = ? OR sh.person_id IS NOT NULL)';
$bind = [$personId, $personId, $personId, $personId, $familyId, $personId];
if ($ownerId > 0) {
$sql .= ' AND r.owner_id = ?';
$bind[] = $ownerId;
}
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (r.title LIKE ? OR r.content LIKE ?)';
$like = '%' . $kw . '%';
array_push($bind, $like, $like);
}
$sql .= ' ORDER BY r.updated_at DESC, r.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
return $st->fetchAll();
}
/** 一条资源(带归属人与我对它的权限;看不到返回 null) */
public static function find(int $familyId, int $id, int $personId): ?array
{
$st = Database::get()->prepare(
'SELECT r.*, p.name AS owner_name, p.avatar, p.avatar_color,
n.nickname AS owner_nick,
CASE WHEN r.owner_id = ? THEN "owner"
ELSE COALESCE((SELECT s.perm FROM resource_shares s
WHERE s.resource_id = r.id AND s.person_id = ?), "read")
END AS perm
FROM resources r
JOIN persons p ON p.id = r.owner_id
LEFT JOIN nicknames n ON n.target_id = r.owner_id AND n.viewer_id = ?
WHERE r.id = ? AND r.family_id = ?'
);
$st->execute([$personId, $personId, $personId, $id, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
// ⚠️ 权限在**这一层**判死:调用方拿到记录就一定是能看的。
// 曾经想让模板自己判,结果是每条列表都要重复一遍 CASE WHEN。
$mine = (int)$r['owner_id'] === $personId
|| self::isShared((int)$r['id'], $personId);
if (!$mine) {
return null;
}
return $r;
}
public static function canWrite(array $r, int $personId): bool
{
if ((int)($r['owner_id'] ?? 0) === $personId) {
return true;
}
return ($r['perm'] ?? '') === 'write';
}
public static function isShared(int $resourceId, int $personId): bool
{
$st = Database::get()->prepare(
'SELECT 1 FROM resource_shares WHERE resource_id = ? AND person_id = ?'
);
$st->execute([$resourceId, $personId]);
return $st->fetchColumn() !== false;
}
/** @return int 新资源 id */
public static function create(int $familyId, int $ownerId, ?int $createdBy, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO resources (family_id, owner_id, title, content, created_by) VALUES (?,?,?,?,?)'
)->execute([
$familyId, $ownerId,
self::title($d['title'] ?? ''),
self::textOrNull($d['content'] ?? ''),
$createdBy ?: null,
]);
$id = (int)$db->lastInsertId();
// v24(用户 2026-10-02 第 17 条):**我建的、挂在别人名下的资源,我自己也要看得见**。
// 以前只有 owner 能看(共享表里不含 owner),于是"我帮孩子录了一张证件"之后,
// 这条资源从我的列表里**消失了** —— 我刚建的东西自己却找不到,看着像数据丢了。
// 现在建的同时给自己补一条共享(可读写):既能看能改,也不会在"共享给谁"
// 的列表里冒出 owner 自己(owner 那栏由 perm='owner' 单独表示)。
if ($createdBy > 0 && $createdBy !== $ownerId) {
self::setShare($id, $ownerId, $createdBy, 'write');
}
return $id;
}
public static function update(int $familyId, int $id, array $d): void
{
Database::get()->prepare(
'UPDATE resources SET title = ?, content = ? WHERE id = ? AND family_id = ?'
)->execute([
self::title($d['title'] ?? ''),
self::textOrNull($d['content'] ?? ''),
$id, $familyId,
]);
}
public static function destroy(int $familyId, int $id): void
{
// 附件表没有外键(target_type 表达不了),删宿主时自己清存储对象
\App\Attachment::removeAll(self::ATTACH, $id);
Database::get()->prepare('DELETE FROM resources WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- 共享 ----------------
public static function shares(int $resourceId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
FROM resource_shares s JOIN persons p ON p.id = s.person_id
WHERE s.resource_id = ? ORDER BY p.id'
);
$st->execute([$resourceId]);
return $st->fetchAll();
}
/**
* 设共享。
*
* ⚠️ owner 自己**不进**共享表 —— 他本来就有全部权限,
* 记进去只是多一条要维护的行(还会让"共享给谁"的列表里冒出自己)。
*/
public static function setShare(int $resourceId, int $ownerId, int $personId, string $perm): void
{
if ($personId <= 0 || $personId === $ownerId) {
self::dropShare($resourceId, $personId);
return;
}
Database::get()->prepare(
'INSERT INTO resource_shares (resource_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$resourceId, $personId, $perm === 'write' ? 'write' : 'read']);
}
public static function dropShare(int $resourceId, int $personId): void
{
Database::get()->prepare('DELETE FROM resource_shares WHERE resource_id = ? AND person_id = ?')
->execute([$resourceId, $personId]);
}
public static function permWord(string $perm): string
{
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
}
// ---------------- 小工具 ----------------
private static function title(string $v): string
{
return mb_substr(trim($v), 0, 120, 'UTF-8');
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
}