用户 9 条原话要点:
①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
这个卡片,不要了。我的资料这个卡片,不要了。」
③「旅程模块,编辑行程,点击保存没有反应。」
④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
其他情况下默认不出现。出发地和到达地,应该在同一行。」
⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
还是删除这一次及以后的。」
⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
10 分钟提醒等等。」
⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
保持统一,但是不要单纯的换一个不协调的背景色。」
🔴 三个真根因(都是"看着不像 bug"的地方):
1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
`</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
`button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
→ 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
**本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
`display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
**只有真机量尺寸才发现** —— 由新写的真机探针抓到。
本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
`templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
`media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
(还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
(INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
+ 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
清成"交给统一段"。`person_picker.js` 同时改为按需加载。
验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
**真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
323 lines
13 KiB
PHP
323 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* 暂存上传(v24 第 20 批 用户第 4 条)。
|
||
*
|
||
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
|
||
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,
|
||
* 最后统一保存即可。」
|
||
*
|
||
* ────────────────────────────────────────────────────────────────
|
||
* 职责划分(与 `upload_stage.php` 端点配套):
|
||
* · stage() 把拖进来的**一个**文件落进暂存目录,回一个随机令牌(stoken);
|
||
* · claim() 保存时把一批令牌**认领**到某个条目上(移文件 + 建附件行);
|
||
* · sweep() 清掉过期(没被认领)的暂存文件,别让它们永远堆着。
|
||
*
|
||
* 🔴 两条设计取舍(都栽过同类):
|
||
* ① **暂存文件不能只放 tmp**:web 请求结束 tmp 就没了,而用户可能在页面上
|
||
* 停留几十分钟才点保存。所以落到 `uploads/_staging/` 下,并按 TTL 清。
|
||
* ② **认领要"要么全成、要么不动"**:认领到一半失败(比如某张图坏了)时,
|
||
* 已经移过去的文件要能回滚 —— 否则用户会看到"保存了但少了一张"。
|
||
* 这里用"先全部校验、再逐个移"的顺序,把失败面收到最小;
|
||
* 单个失败时只跳过那一个并如实回报告,不静默吞。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
use App\Storage\Storage;
|
||
|
||
final class StagedUpload
|
||
{
|
||
/** 暂存根目录(在 uploads 下,Web 不可直接访问 —— 由 media.php 判登录态) */
|
||
private const DIR = '_staging';
|
||
|
||
/** 令牌长度(字节 → 十六进制 48 字符,猜不出来) */
|
||
private const TOK_BYTES = 24;
|
||
|
||
/** 多久没被认领就当垃圾清掉(秒)。2 小时:够一次正常的"慢慢挑图" */
|
||
public const TTL = 7200;
|
||
|
||
/** 一次最多认领多少个(防手搓请求塞几百个) */
|
||
public const MAX_CLAIM = 60;
|
||
|
||
/** 暂存目录绝对路径 */
|
||
private static function root(): string
|
||
{
|
||
return FB_UPLOADS . '/' . self::DIR;
|
||
}
|
||
|
||
/** 一个令牌对应的目录(令牌只允许十六进制,杜绝 `..` 穿越) */
|
||
private static function tokDir(string $stoken): string
|
||
{
|
||
if (!preg_match('/^[0-9a-f]{16,64}$/', $stoken)) {
|
||
return '';
|
||
}
|
||
return self::root() . '/' . $stoken;
|
||
}
|
||
|
||
/**
|
||
* 落一个文件到暂存区。
|
||
*
|
||
* @return array{ok:bool,stoken?:string,name?:string,is_image?:bool,kind?:string,
|
||
* size?:int,size_word?:string,url?:string,error?:string}
|
||
*/
|
||
public static function stage(int $familyId, int $personId, array $file, string $slot = ''): array
|
||
{
|
||
$tmp = (string)($file['tmp_name'] ?? '');
|
||
if ($tmp === '' || !is_uploaded_file($tmp)) {
|
||
return ['ok' => false, 'error' => '临时文件不见了,请重试。'];
|
||
}
|
||
$origName = self::safeName((string)($file['name'] ?? ''));
|
||
|
||
$fino = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mime = (string)$fino->file($tmp);
|
||
$isImage = strpos($mime, 'image/') === 0;
|
||
|
||
$stoken = bin2hex(random_bytes(self::TOK_BYTES));
|
||
$dir = self::tokDir($stoken);
|
||
if ($dir === '') {
|
||
return ['ok' => false, 'error' => '令牌生成异常。'];
|
||
}
|
||
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||
return ['ok' => false, 'error' => '暂存目录建不出来(检查 uploads 权限)。'];
|
||
}
|
||
|
||
if ($isImage) {
|
||
// 图片:走既定的槽位规则(比例 / 尺寸 / 内存守卫都在 Upload 里,一处说了算)。
|
||
// ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的 —— 传空/未知时**回落到 'photo'**
|
||
// (那个是 ratio=free 的"只缩不裁"槽位,正是"上传照片"该有的行为)。
|
||
$slotKey = ($slot !== '' && ImageSlot::get($slot) !== null) ? $slot : 'photo';
|
||
$up = Upload::image($familyId, $file, $slotKey);
|
||
if (empty($up['ok'])) {
|
||
self::rmDir($dir);
|
||
return ['ok' => false, 'error' => (string)($up['error'] ?: '图片没存上')];
|
||
}
|
||
// Upload 已经把图落到**正式目录**了;但"还没保存"这段时间不该占用正式目录的语义。
|
||
// ⚠️ 这里**不做"先落正式再搬暂存"**:Upload 只支持落到存储层,
|
||
// 而暂存在本地。所以取回那个正式键里的**字节**再写进暂存目录,
|
||
// 最后把正式键删掉 —— 全程只多一次读+写,换来语义干净。
|
||
// ⚠️ 读字节必须用 Storage::read()(**不是** Storage::get —— 那是个不存在的方法,
|
||
// 第一版就写错成 get 了;read 才同时覆盖"本地文件"与"OSS 取回"两条路)。
|
||
$formalkey = (string)$up['file'];
|
||
$got = Storage::read($familyId, $formalkey);
|
||
Storage::delete($familyId, $formalkey);
|
||
$bin = (string)($got['body'] ?? '');
|
||
if ($bin === '') {
|
||
self::rmDir($dir);
|
||
return ['ok' => false, 'error' => '图片转存失败,请重试。'];
|
||
}
|
||
$fname = 'blob.jpg'; // Upload 输出恒为 JPEG(见 Upload 类注释)
|
||
if (@file_put_contents($dir . '/' . $fname, $bin) === false) {
|
||
self::rmDir($dir);
|
||
return ['ok' => false, 'error' => '写暂存文件失败。'];
|
||
}
|
||
} else {
|
||
// 非图片(视频 / PDF / 视频原件…):原样存,不压缩(压缩会把它们弄坏)
|
||
$fname = 'blob' . self::extOf($origName, $mime);
|
||
if (!@copy($tmp, $dir . '/' . $fname)) {
|
||
self::rmDir($dir);
|
||
return ['ok' => false, 'error' => '写暂存文件失败。'];
|
||
}
|
||
}
|
||
|
||
$size = (int)@filesize($dir . '/' . $fname);
|
||
// 元信息写一个小 json:认领时要用它的**原始文件名**与 mime
|
||
// (不能认领时再去嗅探一遍 —— 多一次 IO,且非图片形态各一)
|
||
@file_put_contents($dir . '/meta.json', json_encode([
|
||
'name' => $origName,
|
||
'mime' => $mime,
|
||
'is_image' => $isImage,
|
||
'size' => $size,
|
||
'file' => $fname,
|
||
'by' => $personId,
|
||
'at' => time(),
|
||
], JSON_UNESCAPED_UNICODE));
|
||
|
||
return [
|
||
'ok' => true,
|
||
'stoken' => $stoken,
|
||
'name' => $origName,
|
||
'is_image' => $isImage,
|
||
'kind' => self::kindWord($mime, $origName, $isImage),
|
||
'size' => $size,
|
||
'size_word' => Attachment::sizeWord($size),
|
||
// 预览地址:走 media.php 的 `stage` 形态(见 media.php 里那段的说明)
|
||
'url' => 'media.php?stage=' . $stoken,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 把一批令牌**认领**到某个条目上(保存时调用)。
|
||
*
|
||
* @param string $type 附件宿主类型('trip_node' / 'resource' / 'calendar' / ...)
|
||
* @param int $targetId 宿主 id
|
||
* @return array{ok:int,warns:string[]} ok=成功认领的个数
|
||
*/
|
||
public static function claim(int $familyId, int $personId, array $stokens,
|
||
string $type, int $targetId): array
|
||
{
|
||
$warns = [];
|
||
$done = 0;
|
||
if (!$stokens) {
|
||
return ['ok' => 0, 'warns' => []];
|
||
}
|
||
// ⚠️ 上限:手搓请求塞几百个令牌会把这次保存拖死
|
||
$stokens = array_slice(array_values(array_unique(array_map('strval', $stokens))), 0, self::MAX_CLAIM);
|
||
|
||
foreach ($stokens as $stoken) {
|
||
$dir = self::tokDir($stoken);
|
||
if ($dir === '' || !is_dir($dir)) {
|
||
continue; // 已在别处认领过 / 已被清掉 → 跳过(不是错误)
|
||
}
|
||
$metaRaw = @file_get_contents($dir . '/meta.json');
|
||
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
|
||
if (!is_array($meta)) {
|
||
self::rmDir($dir);
|
||
continue;
|
||
}
|
||
// 只认**自己**暂存的(拿到别人的令牌也认领不了)
|
||
if ((int)($meta['by'] ?? 0) !== $personId) {
|
||
$warns[] = '有一个文件不是你自己上传的,已跳过。';
|
||
continue;
|
||
}
|
||
$src = $dir . '/' . (string)($meta['file'] ?? '');
|
||
if (!is_file($src)) {
|
||
self::rmDir($dir);
|
||
continue;
|
||
}
|
||
$name = (string)($meta['name'] ?? '');
|
||
$mime = (string)($meta['mime'] ?? '');
|
||
$isImage = !empty($meta['is_image']);
|
||
// 复用 Attachment::save 的既定路径(它负责落存储 + 建行 + 位置 + 限额)
|
||
$fake = [
|
||
'name' => $name !== '' ? $name : '文件',
|
||
'type' => $mime,
|
||
'tmp_name' => $src,
|
||
'error' => UPLOAD_ERR_OK,
|
||
'size' => (int)@filesize($src),
|
||
];
|
||
$res = \App\Attachment::save($familyId, $type, $targetId, $fake, $personId);
|
||
if (!empty($res['ok'])) {
|
||
$done++;
|
||
self::rmDir($dir);
|
||
} else {
|
||
$msg = (string)($res['error'] ?? '');
|
||
if ($msg !== '') {
|
||
$warns[] = ($name !== '' ? $name : '一个文件') . ':' . $msg;
|
||
}
|
||
// 存不上就别留暂存(留着下次还会失败,且占地方)
|
||
self::rmDir($dir);
|
||
}
|
||
}
|
||
return ['ok' => $done, 'warns' => $warns];
|
||
}
|
||
|
||
/** 从 media.php 回一个暂存文件的字节(只在刚拖完、还没保存时用) */
|
||
public static function read(int $familyId, int $personId, string $stoken): ?array
|
||
{
|
||
$dir = self::tokDir($stoken);
|
||
if ($dir === '' || !is_dir($dir)) {
|
||
return null;
|
||
}
|
||
$metaRaw = @file_get_contents($dir . '/meta.json');
|
||
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
|
||
if (!is_array($meta) || (int)($meta['by'] ?? 0) !== $personId) {
|
||
return null;
|
||
}
|
||
$src = $dir . '/' . (string)($meta['file'] ?? '');
|
||
if (!is_file($src)) {
|
||
return null;
|
||
}
|
||
return [
|
||
'path' => $src,
|
||
'mime' => (string)($meta['mime'] ?? 'application/octet-stream'),
|
||
'name' => (string)($meta['name'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/** 清掉一个暂存目录(被"自己接管了文件"的调用方用完时调,比如海报/票根那条路) */
|
||
public static function forget(string $stoken): void
|
||
{
|
||
$dir = self::tokDir($stoken);
|
||
if ($dir !== '') {
|
||
self::rmDir($dir);
|
||
}
|
||
}
|
||
|
||
/** 清掉过期没被认领的暂存目录(在 business 路由里顺手调一次,不必挂定时任务) */
|
||
public static function sweep(): void
|
||
{
|
||
$root = self::root();
|
||
if (!is_dir($root)) {
|
||
return;
|
||
}
|
||
$now = time();
|
||
foreach ((array)@scandir($root) as $d) {
|
||
if ($d === '.' || $d === '..') {
|
||
continue;
|
||
}
|
||
$p = $root . '/' . $d;
|
||
if (!is_dir($p)) {
|
||
continue;
|
||
}
|
||
$mt = (int)@filemtime($p);
|
||
if ($mt > 0 && ($now - $mt) > self::TTL) {
|
||
self::rmDir($p);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------- 内部 ----------------
|
||
|
||
private static function safeName(string $n): string
|
||
{
|
||
$n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n);
|
||
$n = trim(preg_replace('/\s+/u', ' ', $n));
|
||
return mb_substr($n, 0, 180, 'UTF-8');
|
||
}
|
||
|
||
/** 扩展名:优先用原文件名的,没有就按 mime 猜一个(都认不出就不带) */
|
||
private static function extOf(string $name, string $mime): string
|
||
{
|
||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||
$ext = preg_replace('/[^a-z0-9]/', '', $ext);
|
||
if ($ext !== '') {
|
||
return '.' . $ext;
|
||
}
|
||
$m = [
|
||
'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'video/webm' => 'webm',
|
||
'application/pdf' => 'pdf', 'audio/mpeg' => 'mp3', 'audio/mp4' => 'm4a',
|
||
];
|
||
return isset($m[$mime]) ? '.' . $m[$mime] : '';
|
||
}
|
||
|
||
private static function kindWord(string $mime, string $name, bool $isImage): string
|
||
{
|
||
if ($isImage) {
|
||
return '图片';
|
||
}
|
||
return Attachment::kindWord(Attachment::kind([
|
||
'is_image' => 0, 'mime' => $mime, 'name' => $name,
|
||
]));
|
||
}
|
||
|
||
private static function rmDir(string $dir): void
|
||
{
|
||
if (!is_dir($dir)) {
|
||
return;
|
||
}
|
||
foreach ((array)@scandir($dir) as $f) {
|
||
if ($f === '.' || $f === '..') {
|
||
continue;
|
||
}
|
||
$p = $dir . '/' . $f;
|
||
if (is_dir($p)) {
|
||
self::rmDir($p);
|
||
} else {
|
||
@unlink($p);
|
||
}
|
||
}
|
||
@rmdir($dir);
|
||
}
|
||
} |