v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除

- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
This commit is contained in:
2026-09-30 20:22:46 +08:00
parent 809443d429
commit ff353b1eee
13 changed files with 1112 additions and 43 deletions
+223
View File
@@ -17,6 +17,7 @@ use App\Crypto;
use App\Database;
use App\Family;
use App\Kinship;
use App\Movie;
use App\Nickname;
use App\Person;
use App\PersonGroup;
@@ -1329,6 +1330,228 @@ switch ($r) {
redirect('settings');
break;
// ==================== 观影(v8) ====================
//
// MovieLog 整体迁入后的新家。数据落在统一事件原语上:
// events(骨架)+ movie_details(卫星)+ event_attendees + event_photos,
// 见 App\Movie。可见性与名册同一口径:能登录就能看,能写才能改;
// 迁进来的历史记录 source_ref = 'movielog:movie:<id>'(重跑迁移的幂等锚点)。
/** 观影时间线 —— 按年分组倒序。 */
case 'movies':
$familyId = (int)Auth::familyId();
$rows = Movie::all($familyId);
$years = [];
foreach ($rows as $r) {
$y = $r['starts_at'] !== null ? (int)substr((string)$r['starts_at'], 0, 4) : 0;
$years[$y][] = $r;
}
unset($r);
krsort($years);
View::render('movies/index', [
'title' => '观影记录',
'years' => $years,
'total' => count($rows),
], 'movies');
break;
case 'movies/show':
$familyId = (int)Auth::familyId();
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0));
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
View::render('movies/show', [
'title' => (string)$m['title'],
'm' => $m,
], 'movies');
break;
case 'movies/new':
Auth::requireWrite();
View::render('movies/form', [
'title' => '记一场电影',
'm' => null,
'people' => Person::roster((int)Auth::familyId(), (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'movies');
break;
case 'movies/edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$m = Movie::find($familyId, (int)($_GET['id'] ?? 0));
if (!$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
View::render('movies/form', [
'title' => '编辑观影记录',
'm' => $m,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => [],
], 'movies');
break;
/** 收集表单里和观影有关的字段(create / update 共用一份)。 */
case 'movies/create':
case 'movies/update':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$isEdit = $r === 'movies/update';
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$m = $isEdit ? Movie::find($familyId, $id) : null;
if ($isEdit && !$m) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'starts_at' => trim((string)($_POST['starts_at'] ?? '')),
'location' => trim((string)($_POST['location'] ?? '')),
'note' => trim((string)($_POST['note'] ?? '')),
'info' => trim((string)($_POST['info'] ?? '')),
'rating' => trim((string)($_POST['rating'] ?? '')),
'review' => trim((string)($_POST['review'] ?? '')),
'feelings' => array_values((array)($_POST['feelings'] ?? [])),
'cinema_place' => trim((string)($_POST['cinema_place'] ?? '')),
'cinema_name' => trim((string)($_POST['cinema_name'] ?? '')),
'cinema_address' => trim((string)($_POST['cinema_address'] ?? '')),
// 同行的人:只认「确实存在且属于这个家」的 id —— 表单可以改,
// 不能凭它把别人家的成员挂进自己家的观影记录里(会顺着详情页漏人名)。
'attendees' => array_map('intval', (array)($_POST['attendees'] ?? [])),
];
$_familyPeople = Person::map($familyId);
$d['attendees'] = array_values(array_filter($d['attendees'],
static fn($pid) => isset($_familyPeople[$pid])));
$errors = $d['title'] === '' ? ['title' => '片名不能为空'] : [];
// ⚠️ 图片上传失败**不挡保存**(与头像同一思路:一条记录因为一张 5MB 原图
// 存不进去,看起来像功能坏了)。现在是先存记录,图片的事单独提示。
// 海报 / 票根:free 槽位,不需要裁剪框;编辑时换新图就顺带删掉旧对象。
$warns = [];
foreach (['poster', 'ticket'] as $slot) {
$up = Upload::image($familyId, $_FILES[$slot] ?? [], $slot);
if ($up['ok']) {
$oldKey = $isEdit ? (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '') : '';
$d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)$up['file'];
if ($oldKey !== '' && $oldKey !== (string)$up['file']) {
\App\Storage\Storage::delete($familyId, $oldKey);
}
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = ($slot === 'poster' ? '海报' : '票根') . '没有保存:' . $up['error'];
} elseif ($isEdit) {
// 没传新图 → 保留库里的旧值
$d[$slot === 'poster' ? 'poster_key' : 'ticket_key'] = (string)($m[$slot === 'poster' ? 'poster_key' : 'ticket_key'] ?? '');
}
}
// 现场照片(多选,仅新增表单提供;编辑页在详情页里逐张补/删)
if (!$isEdit) {
$photoFiles = [];
if (!empty($_FILES['photo']) && is_array($_FILES['photo']['name'])) {
foreach ($_FILES['photo']['name'] as $i => $_n) {
$photoFiles[] = [
'name' => $_FILES['photo']['name'][$i],
'type' => $_FILES['photo']['type'][$i],
'tmp_name' => $_FILES['photo']['tmp_name'][$i],
'error' => $_FILES['photo']['error'][$i],
'size' => $_FILES['photo']['size'][$i],
];
}
}
$photoKeys = [];
foreach ($photoFiles as $f) {
$up = Upload::image($familyId, $f, 'photo');
if ($up['ok']) {
$photoKeys[] = (string)$up['file'];
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = '有一张照片没有保存:' . $up['error'];
}
}
$d['photos'] = $photoKeys;
}
if ($errors) {
set_old($_POST);
View::render('movies/form', [
'title' => $isEdit ? '编辑观影记录' : '记一场电影',
'm' => $m,
'people' => Person::roster($familyId, $me, 'member', ''),
'errors' => $errors,
], 'movies');
exit;
}
if ($isEdit) {
Movie::update($familyId, $id, $d);
clear_old();
flash('ok', '已保存。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('movies/show', ['id' => $id]);
} else {
$newId = Movie::create($familyId, $me, $d);
clear_old();
flash('ok', '已记下这场电影。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('movies/show', ['id' => $newId]);
}
break;
/** 编辑页补传一张现场照片(详情页里的小表单,一次一张)。 */
case 'movies/photo_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
$up = Upload::image($familyId, $_FILES['photo'] ?? [], 'photo');
if ($up['ok']) {
// savePhotos 是整组替换 —— 现有键取出来再追加一张,位置接着排
$keys = array_map('strval', array_column(Movie::photos($id), 'key'));
$keys[] = (string)$up['file'];
Movie::savePhotos($id, $keys);
flash('ok', '照片已加上。');
} else {
flash('err', '照片没有保存:' . (string)($up['error'] ?: '没选文件'));
}
redirect('movies/show', ['id' => $id]);
break;
case 'movies/photo_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$pid = (int)($_POST['photo_id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
Movie::deletePhoto($familyId, $id, $pid);
flash('ok', '照片已删除。');
redirect('movies/show', ['id' => $id]);
break;
case 'movies/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Movie::find($familyId, $id)) {
flash('err', '找不到这条观影记录。');
redirect('movies');
}
Movie::destroy($familyId, $id);
flash('ok', '这条观影记录已删除。');
redirect('movies');
break;
default:
http_response_code(404);
View::render('error', [