Files
FamilyBook/app/Movie.php
T
laoyang 5c81f1991f v35 第 35 批:日期控件改用原生(Semi 时间面板不可用)+ 观影附件上传真修好 + 票根/合影归并成「观影照片」
用户原话(三段):
  ①「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,框里面仍然是空白的,
     也没有确定按钮,这个需要重新调整一下。」
  ②「现在上传附件,也就是在观影模块里面上传附件,怎么还是有问题?上传的附件现在
     都没有保存,有时候我上传两个附件,它只保存一张。」
  ③「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,放在一块。…
     只有海报是单独的,因为它是从豆瓣里面下载回来的。其他的都放在一个模块就行,
     然后所有照片都上传到一起,直接写"观影照片"就可以了。」

① 日期控件:**Semi 的时间面板根本不可用**(这条最费时间)
  实测(独立最小复现页 + 真机):Semi 的 dateTimeRange 面板**只给日期网格**,
  时间藏在 `.semi-datepicker-switch-time` 那个小时钟按钮后面;**点了它切到时间面板,
  面板仍然是空的**(timeLists=0、无选项、无 footer、无"确定")→ 根本无法"只选时间"。
  单点 dateTime 同样。⇒ 与用户描述完全一致。
  修:弃用 Semi 的时间选择,改用**原生** date + time(step=1)
  (24 小时制、精确到秒、点了就选、不选日期也有值、**不需要**确认按钮)。
  原来的 时/分/秒 三个下拉**仍留在 DOM**(POST 契约 *_date/_hour/_minute/_second
  一字不改),只是加 .dt-merged 视觉收起。
  app.js 的 upgradeRange()/upgrade() 都不再挂 Semi;CSS 加 .is-native-range 系列
  把 Semi 时代的"藏原生 / 两端让位"规则覆盖回来。

② 观影附件上传丢文件:**两条路径都断了**
  · 暂存路径(拖放):上传框走"拖进来即刻上传到暂存区",令牌进 stage_token[];
    而 movies/create|update 与 movies/photo_add **只读 $_FILES['photo']**
    ⇒ stage_token[] **从来没人认领** ⇒ 拖进去的一个都没存。
  · Movie::update() **完全不写 photos**(那句"photos 由单独的删除路由维护"的注释下
    根本没写),而路由明明算好了 $d['photos'] 传进来 ⇒ 编辑保存丢照片。
  修:路由新增 stage_token[] 认领循环(与 $_FILES 路径并存);photo_add 加兜底;
  Movie::update 补上 savePhotos。

③ 归并:表单去掉独立"票根"上传框,"现场合影"并入**一个**「观影照片」块;
  详情页「影像记录」→「观影照片」、去掉票根/照片分类标签。
  数据层本来就是平的(event_photos 只有 key/position);
  ⚠️ 旧数据 movie_details.ticket_key **仍要显示**(历史票根不能丢)+ 服务端仍接收
     $_FILES['ticket'](向后兼容,直接 POST 仍可存)。

验证(全部真机跑,不是"看代码觉得对"):
  · _fb_b34_dtrange.py 33/0(四个表单页:组可见 + 两端日期框可见 + 两端时间框
    可见且 step=1 + 三个原生下拉仍在 DOM + 只选时间也能写回 *_hour/_minute/_second);
  · _fb_b35_movie_media.py 14/0(点击上传 2 张→库里 2 行;真 HTTP 打 upload_stage.php
    拿 stoken→随表单提交→落库;编辑补传→3 行;详情页补传→4 行;归并断言);
  · 反向验证:_fb_b34_dtrange_falsify 4/0、_fb_b35_media_falsify 8/0;
  · e2e 1503/0(两条断言随 v35 语义更新)、几何交互 92/0、各批真机/反向全绿、
    部署脚本静态断言空跑通过;线上 app.js / form.php 的 SHA256 与本地一致。

顺带修掉的工具链脆弱(本轮暴露):
  · push.sh 开头加"确保本地测试库在跑"的守卫(长时间跑流水线时 mysqld 会被环境
    回收 → e2e/真机探针全崩,看着像代码坏了);
  · 探针别用固定字符窗口找函数体(注释一长就假红)、断言别写死字段名前缀
    (改期表单是 rev_ends_at_*)、set_input_files 的 id 要照 _stage_drop 的净化规则
    (photo[] → sd-photo)、提交按钮要精确定位(编辑页有多个 submit)。
2026-10-09 20:23:09 +08:00

365 lines
15 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
/**
* 观影记录(统一事件原语的第一个落地种类)。
*
* 归一模型(见 familybook.md 的核心设计):
* events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
* movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
* attendees = 同行观影的人(persons.id)
* photos = 现场照片(可多张)
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
* cinemas = 影院独立管理(见 Cinema)
*
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
*
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
* 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
*/
declare(strict_types=1);
namespace App;
final class Movie
{
/** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
* $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。
*
* ⚠️ 列表上的「观影人」是**每人一个标签**(头像 + 底色 + 称呼),
* 不再拼成一串名字 —— 所以这里用 attachAttendees() 补出 `attendees`,
* 而不是 SQL 里 GROUP_CONCAT 出一个 attendee_names 字符串。 */
public static function all(int $familyId, string $keyword = '', int $viewerId = 0): array
{
$sql =
'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note,
d.poster_key, c.name AS cinema_name, c.color AS cinema_color,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = "movie"';
$bind = [$familyId];
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (e.title LIKE ? OR c.name LIKE ? OR e.location LIKE ?
OR EXISTS (SELECT 1 FROM event_attendees a JOIN persons p ON p.id = a.person_id
WHERE a.event_id = e.id AND p.name LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like, $like);
}
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
$rows = $st->fetchAll();
MovieReview::attachStats($rows);
self::attachAttendees($rows, $viewerId);
return $rows;
}
/**
* 给一批记录补上 `attendees`(每人一条:id / name / my_nickname / avatar /
* avatar_color)。**一次查询**补完,不逐条查 —— 瀑布流一页几十部电影,
* 逐条查就是几十次往返(N+1)。
*/
public static function attachAttendees(array &$rows, int $viewerId = 0): void
{
if (!$rows) {
return;
}
$ids = [];
foreach ($rows as $r) {
$ids[] = (int)$r['id'];
}
$ids = array_values(array_unique($ids));
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color,
n.nickname AS my_nickname
FROM event_attendees a
JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id IN ($in)
ORDER BY a.position, p.id"
);
$st->execute(array_merge([$viewerId], $ids));
$map = [];
foreach ($st->fetchAll() as $r) {
$map[(int)$r['event_id']][] = $r;
}
foreach ($rows as $k => $r) {
$rows[$k]['attendees'] = $map[(int)$r['id']] ?? [];
}
}
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
{
$st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta,
c.place AS cinema_place, c.name AS cinema_name,
c.address AS cinema_address, c.color AS cinema_color
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"'
);
$st->execute([$familyId, $id]);
$row = $st->fetch();
if (!$row) {
return null;
}
$row['attendees'] = self::attendees($id, $viewerId);
$row['photos'] = self::photos($id);
return $row;
}
/**
* 同行的人。带 `my_nickname` 与 `avatar_color` —— 页面上要按「我叫他什么」
* 显示、并用他自己的底色画出标签(见 helpers 的 person_name / person_bg)。
*/
public static function attendees(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM event_attendees a JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id = ?
ORDER BY a.position, p.id'
);
$st->execute([$viewerId, $eventId]);
return $st->fetchAll();
}
public static function photos(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
/**
* 新建一条观影记录,返回事件 id。
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
* cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[])
*/
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
)->execute([
$familyId, $creatorId, (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null,
]);
$id = (int)$db->lastInsertId();
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
self::savePhotos($id, $d['photos'] ?? []);
return $id;
}
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
$db->prepare(
'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ?
WHERE id = ? AND family_id = ?'
)->execute([
(string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $familyId,
]);
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
/* 🔴 v35:原来这里只留一句"photos 由单独的删除路由维护,编辑保存不整组替换",
于是**根本没写 photos** —— 而 movies/update 路由明明算好了 `$d['photos']`
(库里已有的 + 这次新选上来的)传进来,**被整段忽略**。
症状正是用户报的「上传的附件现在都没有保存」「上传两张只保存一张」。
⇒ updated 时一并写回(路由已经保证 `$d['photos']` = 已有 + 新增,
不是空数组;真的没传时路由会给 `$d['photos']` 缺席,
`?? []` 也不会误清空 —— 见 movies/update 分支的条件)。 */
if (array_key_exists('photos', $d)) {
self::savePhotos($id, $d['photos']);
}
}
/** 卫星表 upsert(编辑时可能还没建过这行) */
private static function saveDetails(int $eventId, array $d): void
{
$db = Database::get();
$cid = (int)($d['cinema_id'] ?? 0);
// 豆瓣元数据:进来是数组(表单提交的 JSON 已由路由解码校验),存成 JSON 文本;
// 空数组/缺省 = 不写(保持 NULL,详情页就不渲染豆瓣块)
$meta = isset($d['douban_meta']) && is_array($d['douban_meta']) && $d['douban_meta'] !== []
? json_encode($d['douban_meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: null;
$row = [
self::textOrNull($d['info'] ?? ''),
self::keyOrNull($d['poster_key'] ?? ''),
self::keyOrNull($d['ticket_key'] ?? ''),
$cid > 0 ? $cid : null,
$meta,
];
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]);
if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
->execute([...$row, $eventId]);
} else {
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)')
->execute([...$row, $eventId]);
}
}
/** 整组替换同行人(与 Person::replaceContacts 同一思路:一行不剩、一行不多) */
public static function saveAttendees(int $eventId, array $personIds): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare(
'INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?, ?, ?)'
);
$pos = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$pos += 10;
$ins->execute([$eventId, $pid, $pos]);
}
}
/** 整组替换现场照片(导入用;表单的「删一张」走 deletePhoto) */
public static function savePhotos(int $eventId, array $keys): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_photos WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
$pos = 0;
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k === '') {
continue;
}
$pos += 10;
$ins->execute([$eventId, $k, $pos]);
}
}
/** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{
$st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?');
$st->execute([$photoId, $eventId]);
$key = (string)($st->fetchColumn() ?: '');
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
->execute([$photoId, $eventId]);
if ($key !== '') {
Storage\Storage::delete($familyId, $key);
}
}
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
public static function destroy(int $familyId, int $id): void
{
$keys = [];
foreach (self::photos($id) as $p) {
$keys[] = (string)$p['key'];
}
$st = Database::get()->prepare(
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
);
$st->execute([$id]);
if ($row = $st->fetch()) {
foreach ([$row['poster_key'], $row['ticket_key']] as $k) {
if ($k) {
$keys[] = (string)$k;
}
}
}
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
->execute([$id, $familyId]);
foreach ($keys as $k) {
Storage\Storage::delete($familyId, $k);
}
}
// ---------------- 展示格式化 ----------------
/**
* 观影时刻的人话展示:
* 只有日期(时间恰好 00:00:00 且无散场)→「2026年09月30日」;
* 有开场时刻 →「2026年09月30日 19:30:00」;有散场 → 追加「 – 21:45:12」。
* 两个都空 →「日期待补」。
*/
public static function formatWatch(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '日期待补';
}
$ts = strtotime($start);
if ($ts === false) {
return '日期待补';
}
$out = date('Y年m月d日', $ts);
$midnight = substr((string)$start, 11) === '00:00:00';
if (!$midnight) {
$out .= ' ' . date('H:i:s', $ts);
}
if ($end !== null && $end !== '') {
$ets = strtotime($end);
if ($ets !== false) {
if (date('Y-m-d', $ets) !== date('Y-m-d', $ts)) {
$out = date('Y年m月d日', $ts) . ($midnight ? '' : ' ' . date('H:i:s', $ts))
. ' – ' . date('Y年m月d日 H:i:s', $ets);
} else {
$out .= ' – ' . date('H:i:s', $ets);
}
}
}
return $out;
}
// ---------------- 小工具 ----------------
/** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */
private static function dtOrNull(string $v): ?string
{
$v = trim($v);
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) {
return null;
}
return $v;
}
private static function locOrNull(string $v): ?string
{
$v = mb_substr(trim($v), 0, 200, 'UTF-8');
return $v !== '' ? $v : null;
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
/** 存储键只留安全字符(相对键,不带路径穿越),空串回 NULL */
private static function keyOrNull(string $v): ?string
{
$v = ltrim(trim($v), '/');
if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) {
return null;
}
return $v;
}
}