用户:现在不是已经没有票根的概念了吗?全部都换成观影照片了。别管是票根 还是观影照片,都在一起。现在只分两类:一类是海报,一类是观影照片。 海报是来自豆瓣的,这个不用管它,主要就是观影照片,所有的都在观影照片里边。 事实核查:概念取消了,数据没取消。网页端 v35 只在**模板层**把它们画进 同一个网格,数据层仍是两条路:票根在 movie_details.ticket_key(单张槽位), 不在 event_photos 里 ⇒ 没有 id ⇒ 删不掉,端上得写"票根另算"特判。 线上数据:有票根 220 条,其中 209 条只有票根、没有任何照片; event_photos 总数才 14 —— 也就是说「观影照片」里装的几乎全是票根。 ① 数据迁移(已执行,220/220 成功,失败 0): 逐条把 ticket_key 插进 event_photos(position 取比现有最小值再小 10, ⇒ 排第一,因为票根是"这场电影的凭证",用户认知里排最前),然后置 NULL。 event_photos 14 → 234,旧票根残留 0。 ⚠️ 幂等:靠"该 key 已在该 event 的 event_photos 里"判断,重复跑不插重复行; ⚠️ 不删存储对象:只搬引用,图片原地不动,失败也不丢图。 ② 同一段逻辑写进 Schema VERSION 36→37,让新库/重装库走同一套, 不靠"记得手工跑脚本"。对线上是空操作(已验证 234→234)。 ③ 服务端停止读写票根字段(字段保留作历史审计,代码不再碰): - Movie::find 不再 SELECT ticket_key; - Movie::saveDetails UPSERT 去掉 ticket_key; - Movie::destroy 不再单独查/删票根对象(已并入 photos 循环); - index.php:上传槽位 [poster,ticket] → [poster], stage_ticket 不再认领,media_del 白名单只剩 poster; - form.php:删票根特殊格子 + mv-del-ticket 表单 + $mHasTicket; - show.php:删票根格子 + 灯箱 ticket 项 + $hasTicket。 验证:find(1,1)/find(1,224) 已不返回 ticket_key,photos 3/4 条票根在首位; 直接 include 模板无 Fatal,form.php 里 mv-del-ticket / stage_ticket 各 0 次; 6 个文件 md5 逐个核对一致、php -l 全过、已上线且升级到 37。
488 lines
21 KiB
PHP
488 lines
21 KiB
PHP
<?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
|
||
{
|
||
/* 🔴 2026-11-06:**不再 SELECT `ticket_key`**。
|
||
用户要求"只分两类:海报、观影照片",旧票根已经一次性迁进
|
||
`event_photos`(见迁移脚本与 Schema VERSION 37 的说明),
|
||
字段本身也置 NULL 了。继续查它出来只会让上层又长出第三个概念。 */
|
||
$st = Database::get()->prepare(
|
||
'SELECT e.*, d.info, d.poster_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
|
||
{
|
||
/* 🔴 2026-11-06:带上 `kind` / `thumb_key` / `mime`(观影照片支持视频)。
|
||
⚠️ 用 `COALESCE` 兜底 `kind`:极老的库(迁移没跑过)可能没这列,
|
||
但这种情形会直接报 1054 —— 迁移一定会先跑,这里不加 COALESCE。
|
||
真正需要兜底的是**值为 NULL** 的历史行(迁移加列时默认 'image',
|
||
但手写过 INSERT 的行可能是 NULL)。 */
|
||
$st = Database::get()->prepare(
|
||
"SELECT id, `key`,
|
||
COALESCE(`kind`, 'image') AS kind,
|
||
`thumb_key`, `mime`
|
||
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, attendees(int[]), photos(string[])
|
||
* ⚠️ 2026-11-06:`ticket_key` 已取消(票根并入 photos)。
|
||
*/
|
||
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'] ?? ''),
|
||
$cid > 0 ? $cid : null,
|
||
$meta,
|
||
];
|
||
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
|
||
$has->execute([$eventId]);
|
||
/* 🔴 2026-11-06:`ticket_key` 从这条 UPSERT 里**移除**了。
|
||
票根概念已取消(迁进 `event_photos`),再写它等于把它复活。
|
||
⚠️ 列还留在表里(不清字段,留作历史审计),但代码不再碰它;
|
||
迁移已把存量置 NULL,所以它现在是全 NULL 的死列。 */
|
||
if ((int)$has->fetchColumn() > 0) {
|
||
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
|
||
->execute([...$row, $eventId]);
|
||
} else {
|
||
$db->prepare('INSERT INTO movie_details (info, poster_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]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* **只设海报**(不动其它字段)。
|
||
*
|
||
* 🔴 v64(用户 2026-10-19):端上「记一场」是"先建记录、再传海报"两段式
|
||
* (见 `ApiHandler::movies()` 里 poster 分支的说明)。那种写法不能用
|
||
* `Movie::update()` —— 它要求把 title/starts_at 等**全部字段**一起传,
|
||
* 第二段手里只有一张图,硬拼一份"看起来没变"的完整草稿,
|
||
* 任何一处漏传都会**静默覆盖**用户刚填的内容。
|
||
* ⇒ 单字段更新走单字段的 SQL,风险最小。
|
||
*
|
||
* ⚠️ 卫星行可能还不存在(新建的记录若 info 等全空,`saveDetails` 也会插一行,
|
||
* 所以正常情况下有;但历史数据/手工插的 events 可能没有)⇒ 用 UPSERT 语义,
|
||
* 不存在就补一行,免得海报"设置了但存不进去"。
|
||
*/
|
||
public static function setPoster(int $eventId, string $key): void
|
||
{
|
||
$db = Database::get();
|
||
$st = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
|
||
$st->execute([$eventId]);
|
||
if ((int)$st->fetchColumn() > 0) {
|
||
$db->prepare('UPDATE movie_details SET poster_key = ? WHERE event_id = ?')
|
||
->execute([self::keyOrNull($key), $eventId]);
|
||
return;
|
||
}
|
||
$db->prepare('INSERT INTO movie_details (poster_key, event_id) VALUES (?, ?)')
|
||
->execute([self::keyOrNull($key), $eventId]);
|
||
}
|
||
|
||
/**
|
||
* **追加**现场照片(保留库里已有的)。
|
||
*
|
||
* 🔴 v64:与 `savePhotos()`(整组替换)**必须分开**。
|
||
* 端上是"建完记录一张张补传"的,每次只带这一次新增的键 ——
|
||
* 若走替换实现,用户传第 2 张时第 1 张就被删了。
|
||
* ⚠️ 网页表单那条路仍然用 `savePhotos()`(它一次性带全量),别改那边。
|
||
*
|
||
* @param string[] $keys 这次新增的相对键(空串会被跳过)
|
||
*/
|
||
public static function addPhotos(int $eventId, array $keys): void
|
||
{
|
||
/* ⚠️ 兼容两种入参(2026-11-06 起):
|
||
· `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
|
||
· `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
|
||
—— 新调用方(带类型信息)
|
||
统一归一成"每项一个数组",下面只有一条 INSERT 路径,
|
||
不会出现"有的走老分支有的走新分支"那种分叉。 */
|
||
$rows = [];
|
||
foreach ($keys as $item) {
|
||
if (is_array($item)) {
|
||
$k = trim((string)($item['key'] ?? ''));
|
||
if ($k === '') {
|
||
continue;
|
||
}
|
||
$rows[] = [
|
||
'key' => $k,
|
||
'kind' => ((string)($item['kind'] ?? 'image')) === 'video' ? 'video' : 'image',
|
||
'thumb_key' => (string)($item['thumb_key'] ?? ''),
|
||
'mime' => (string)($item['mime'] ?? ''),
|
||
];
|
||
} else {
|
||
$k = trim((string)$item);
|
||
if ($k === '') {
|
||
continue;
|
||
}
|
||
$rows[] = ['key' => $k, 'kind' => 'image', 'thumb_key' => '', 'mime' => ''];
|
||
}
|
||
}
|
||
if ($rows === []) {
|
||
return;
|
||
}
|
||
$db = Database::get();
|
||
// position 接着已有的往后排(否则新照片会插到最前面,与"追加"的直觉相反)
|
||
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
|
||
$st->execute([$eventId]);
|
||
$pos = (int)$st->fetchColumn();
|
||
$ins = $db->prepare(
|
||
'INSERT INTO event_photos (event_id, `key`, kind, thumb_key, mime, position)
|
||
VALUES (?, ?, ?, ?, ?, ?)'
|
||
);
|
||
foreach ($rows as $r) {
|
||
$pos += 10;
|
||
$ins->execute([
|
||
$eventId, $r['key'], $r['kind'],
|
||
$r['thumb_key'] !== '' ? $r['thumb_key'] : null,
|
||
$r['mime'] !== '' ? $r['mime'] : null,
|
||
$pos,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/** 删一张现场照片(连带删存储对象) */
|
||
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
|
||
{
|
||
/* 🔴 2026-11-06:视频的**封面**也要一起删(`thumb_key`),
|
||
否则每删一个视频就在存储里留一张孤儿封面。 */
|
||
$st = Database::get()->prepare(
|
||
'SELECT `key`, `thumb_key` FROM event_photos WHERE id = ? AND event_id = ?'
|
||
);
|
||
$st->execute([$photoId, $eventId]);
|
||
$row = $st->fetch();
|
||
$key = $row ? (string)($row['key'] ?? '') : '';
|
||
$thumb = $row ? (string)($row['thumb_key'] ?? '') : '';
|
||
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
|
||
->execute([$photoId, $eventId]);
|
||
if ($key !== '') {
|
||
Storage\Storage::delete($familyId, $key);
|
||
}
|
||
if ($thumb !== '' && $thumb !== $key) {
|
||
Storage\Storage::delete($familyId, $thumb);
|
||
}
|
||
}
|
||
|
||
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
|
||
public static function destroy(int $familyId, int $id): void
|
||
{
|
||
$keys = [];
|
||
foreach (self::photos($id) as $p) {
|
||
$keys[] = (string)$p['key'];
|
||
/* 视频的封面也要清(2026-11-06) */
|
||
$tk = (string)($p['thumb_key'] ?? '');
|
||
if ($tk !== '') {
|
||
$keys[] = $tk;
|
||
}
|
||
}
|
||
/* 🔴 2026-11-06:票根已并入 `event_photos`,上面的 photos() 循环就把
|
||
它收集进来了 —— 原来这里还要单独查一次 `ticket_key` 并删对象,
|
||
现在**不需要且不该要**(字段已置 NULL,查出来也是空)。
|
||
⚠️ 海报仍单独存在 `movie_details.poster_key`,所以要留这一段。 */
|
||
$st = Database::get()->prepare(
|
||
'SELECT poster_key FROM movie_details WHERE event_id = ?'
|
||
);
|
||
$st->execute([$id]);
|
||
if ($row = $st->fetch()) {
|
||
$k = (string)($row['poster_key'] ?? '');
|
||
if ($k !== '') {
|
||
$keys[] = $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;
|
||
}
|
||
}
|