v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):
1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
**每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
· 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
· 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
· Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
→ 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。
顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。
验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* 家书 FamilyBook · 导航展示页的**头像**公开出口(v24 第 25 批 用户第 9 条新增)
|
||||
*
|
||||
* 用户原话:「导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
|
||||
*
|
||||
* 但这里有两个约束撞在一起:
|
||||
* ① 头像一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)——
|
||||
* 展示页是免登录的,拿不到;
|
||||
* ② `uploads/` 整个目录 `Require all denied`(线上实测直接取 403)。
|
||||
* ⇒ 于是展示页的头像永远取不到(正是用户报的「头像没显示」)。
|
||||
*
|
||||
* 本端点的暴露面刻意压到**最小**:
|
||||
* · 只认 `?p=<短语>`,短语必须有效(`Nav::ownerByPhrase`);
|
||||
* · **只返回这个主人自己的头像** —— 不接任何 id、不接任何别的键,
|
||||
* 所以拿别人的短语只能看到那个人自己的头像(那本来就公开在他的展示页上);
|
||||
* · 头像是**公开内容**(它天天挂在那个公开页上),不含隐私;
|
||||
* · 没有头像时 404(调用方 `onerror` 换成默认剪影,不在这里回退 ——
|
||||
* 那样"有没有设过头像"在服务端就分不清了)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/app/bootstrap.php';
|
||||
|
||||
use App\Nav;
|
||||
use App\Person;
|
||||
use App\Storage\Storage;
|
||||
|
||||
function nav_av_fail(int $code, string $message): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
|
||||
$phrase = trim((string)($_GET['p'] ?? ''));
|
||||
if ($phrase === '') {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
$personId = Nav::ownerByPhrase($phrase);
|
||||
if ($personId <= 0) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
|
||||
$p = Person::find($personId);
|
||||
if (!$p) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
$key = trim((string)($p['avatar'] ?? ''));
|
||||
if ($key === '') {
|
||||
nav_av_fail(404, '这个人没有头像'); // 前端 onerror 会换成默认剪影
|
||||
}
|
||||
// 外部绝对地址(历史数据)→ 直接跳过去,不必代理
|
||||
if (preg_match('#^https?://#i', $key)) {
|
||||
header('Location: ' . $key, true, 302);
|
||||
exit;
|
||||
}
|
||||
// 键净化:与 media.php 同一条判据(拒绝 `..`、绝对路径、点文件)
|
||||
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||
if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false
|
||||
|| !preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
foreach (explode('/', $key) as $seg) {
|
||||
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
|
||||
nav_av_fail(404, '没有这个头像');
|
||||
}
|
||||
}
|
||||
|
||||
$familyId = (int)($p['family_id'] ?? 0);
|
||||
$res = Storage::read($familyId, $key);
|
||||
$status = (int)($res['status'] ?? 0);
|
||||
$body = (string)($res['body'] ?? '');
|
||||
if (empty($res['ok']) || $status < 200 || $status >= 300) {
|
||||
nav_av_fail($status === 404 || $status === 0 ? 404 : 502,
|
||||
$status === 404 || $status === 0 ? '没有这个头像' : '头像暂时取不到');
|
||||
}
|
||||
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
|
||||
if ($mime === '' || $mime === 'application/octet-stream'
|
||||
|| strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false) {
|
||||
$mime = 'image/jpeg'; // 头像只可能是位图;嗅探失败按图片给
|
||||
}
|
||||
if (strpos($mime, 'svg') !== false) {
|
||||
$mime = 'image/svg+xml';
|
||||
}
|
||||
|
||||
$etag = '"' . md5($key . '|' . strlen($body) . '|' . $mime) . '"';
|
||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||
http_response_code(304);
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . strlen($body));
|
||||
// ⚠️ 只缓存 1 天(不是 immutable):头像可以被本人随时换掉,
|
||||
// 而这里的 URL 不带版本号(键就是那个短语,换头像时短语不变)。
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('ETag: ' . $etag);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
if ($mime === 'image/svg+xml') {
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
Reference in New Issue
Block a user