v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:
1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
→ 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
(`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
/**
|
||||
* 轻量 Markdown 渲染(v28 用户第 12 条)。
|
||||
*
|
||||
* 用户原话:「笔记功能,段落是需要支持富文本的,可以使用 markdown,但是需要使用
|
||||
* 富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。就是说,
|
||||
* 允许所见即所得编辑,也允许 markdown 编辑。」
|
||||
*
|
||||
* 为什么自己写而**不引入第三方库**(wyc 2026-10-07 已确认):
|
||||
* · 本项目全站只有这一处要 Markdown,为一个功能引一个几百 KB~数 MB 的编辑器,
|
||||
* 与"轻量级"直接冲突(Semi 那 4 MB 已经在按需加载了,不该再加一个);
|
||||
* · 我们支持的语法是**有界的**(标题 / 粗斜体 / 行内代码 / 代码块 / 列表 /
|
||||
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
|
||||
* (不会在某次依赖升级后行为漂移)。
|
||||
*
|
||||
* 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`,
|
||||
* 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字,
|
||||
* 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。
|
||||
* 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Markdown
|
||||
{
|
||||
/**
|
||||
* 把一段 Markdown 渲染成安全的 HTML 片段。
|
||||
*
|
||||
* @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生 <p> 包裹
|
||||
*/
|
||||
public static function render(string $src, bool $inline = false): string
|
||||
{
|
||||
// 统一换行,去掉 BOM(从别处粘贴来的文本常带)
|
||||
$src = str_replace(["\r\n", "\r"], "\n", $src);
|
||||
$src = preg_replace('/^\xEF\xBB\xBF/', '', $src) ?? $src;
|
||||
|
||||
$lines = explode("\n", $src);
|
||||
$out = [];
|
||||
$i = 0;
|
||||
$n = count($lines);
|
||||
|
||||
while ($i < $n) {
|
||||
$line = $lines[$i];
|
||||
|
||||
// ---- 代码块 ``` 或 ~~~ ----------------------------------------
|
||||
if (preg_match('/^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/', $line, $m)) {
|
||||
$fence = $m[1][0];
|
||||
$lang = (string)$m[2];
|
||||
$buf = [];
|
||||
$i++;
|
||||
while ($i < $n && !preg_match('/^\s*' . preg_quote(str_repeat($fence, 3), '/') . '+/', $lines[$i])) {
|
||||
$buf[] = $lines[$i];
|
||||
$i++;
|
||||
}
|
||||
$i++; // 跳过收尾的围栏
|
||||
$cls = $lang !== '' ? ' class="md-code lang-' . e($lang) . '"' : ' class="md-code"';
|
||||
$out[] = '<pre' . $cls . '><code>' . e(implode("\n", $buf)) . '</code></pre>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 标题 # ~ ###### ------------------------------------------
|
||||
if (preg_match('/^(#{1,6})\s+(.*)$/', $line, $m)) {
|
||||
$lv = strlen($m[1]);
|
||||
// ⚠️ 从 h4 起(h1/h2 留给页面本身),免得笔记里的标题盖过页面标题
|
||||
$tag = 'h' . min(6, $lv + 2);
|
||||
$out[] = '<' . $tag . ' class="md-h">' . self::inline(trim($m[2])) . '</' . $tag . '>';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 分割线 ----------------------------------------------------
|
||||
if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) {
|
||||
$out[] = '<hr class="md-hr">';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
|
||||
if (preg_match('/^\s*>\s?(.*)$/', $line)) {
|
||||
$buf = [];
|
||||
while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) {
|
||||
$buf[] = $mm[1];
|
||||
$i++;
|
||||
}
|
||||
$out[] = '<blockquote class="md-quote">' . self::render(implode("\n", $buf)) . '</blockquote>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 无序列表(支持**多级**:按前导空格数决定层级)--------------
|
||||
if (preg_match('/^(\s*)[-*+]\s+(.*)$/', $line, $m)) {
|
||||
[$html, $i] = self::list($lines, $i, $n, false);
|
||||
$out[] = $html;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 有序列表 --------------------------------------------------
|
||||
if (preg_match('/^(\s*)\d+[.)]\s+(.*)$/', $line, $m)) {
|
||||
[$html, $i] = self::list($lines, $i, $n, true);
|
||||
$out[] = $html;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 空行 ------------------------------------------------------
|
||||
if (trim($line) === '') {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 普通段落:连续非空行合成一段(软换行)-----------------------
|
||||
$buf = [$line];
|
||||
$i++;
|
||||
while ($i < $n && trim($lines[$i]) !== ''
|
||||
&& !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])) {
|
||||
$buf[] = $lines[$i];
|
||||
$i++;
|
||||
}
|
||||
$para = implode("\n", $buf);
|
||||
// 段内换行 → <br>(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行)
|
||||
$html = self::inline($para);
|
||||
$html = str_replace("\n", "<br>", $html);
|
||||
$out[] = '<p class="md-p">' . $html . '</p>';
|
||||
}
|
||||
|
||||
$html = implode("\n", $out);
|
||||
return $inline ? $html : $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。
|
||||
*
|
||||
* 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 <ul>,
|
||||
* 更浅就逐层关掉。这样"手工敲两个空格"也能自然形成层级,
|
||||
* 不需要用户去记"必须用 4 个空格"。
|
||||
*
|
||||
* @return array{0:string,1:int} [html, 新行号]
|
||||
*/
|
||||
private static function list(array $lines, int $i, int $n, bool $ordered): array
|
||||
{
|
||||
$html = '';
|
||||
$stack = []; // 每层的缩进宽度
|
||||
$tag = $ordered ? 'ol' : 'ul';
|
||||
|
||||
while ($i < $n) {
|
||||
$line = $lines[$i];
|
||||
$pat = $ordered ? '/^(\s*)\d+[.)]\s+(.*)$/' : '/^(\s*)[-*+]\s+(.*)$/';
|
||||
if (!preg_match($pat, $line, $m)) {
|
||||
// 非列表行:若是空行且后面还有更深的列表项,继续;否则结束
|
||||
if (trim($line) === '') {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
$indent = strlen(str_replace("\t", ' ', $m[1]));
|
||||
$content = $m[2];
|
||||
|
||||
if (!$stack) {
|
||||
$html .= '<' . $tag . ' class="md-list">';
|
||||
$stack[] = $indent;
|
||||
} elseif ($indent > end($stack)) {
|
||||
// 更深 → 开新的一层(塞进上一个 <li> 里,成为它的子列表)
|
||||
$html .= '<' . $tag . ' class="md-list">';
|
||||
$stack[] = $indent;
|
||||
} elseif ($indent < end($stack)) {
|
||||
// 更浅 → 逐层关掉
|
||||
while ($stack && $indent < end($stack)) {
|
||||
array_pop($stack);
|
||||
$html .= '</li></' . $tag . '>';
|
||||
}
|
||||
if ($stack) {
|
||||
$html .= '</li>';
|
||||
}
|
||||
} else {
|
||||
$html .= '</li>';
|
||||
}
|
||||
|
||||
$html .= '<li class="md-li">' . self::inline(trim($content));
|
||||
$i++;
|
||||
}
|
||||
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
|
||||
while ($stack) {
|
||||
array_pop($stack);
|
||||
$html .= '</li></' . $tag . '>';
|
||||
}
|
||||
return [$html, $i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。
|
||||
*
|
||||
* ⚠️ 顺序要紧:**先处理行内代码**,再处理粗斜体 ——
|
||||
* 否则 `` `a*b*c` `` 里的星号会被当斜体吃掉(代码里的字符不该被解释)。
|
||||
* ⚠️ 先 escape 再加标记:所有 `<` `>` `&` 在进来时就已经是实体了,
|
||||
* 我们只往里加**自己的**标签,不可能被注入。
|
||||
*/
|
||||
public static function inline(string $s): string
|
||||
{
|
||||
$s = e($s);
|
||||
|
||||
// `code`(先做,且内部不再解释其它语法)
|
||||
$s = preg_replace_callback('/`([^`\n]+)`/', static function ($m): string {
|
||||
return '<code class="md-icode">' . $m[1] . '</code>';
|
||||
}, $s) ?? $s;
|
||||
|
||||
//  图片(笔记里可能有,但我们**不内联渲染外部图片** ——
|
||||
// 那是隐私与外链风险;只显示成一个链接,点了自己开)
|
||||
$s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string {
|
||||
$u = self::safeUrl($m[2]);
|
||||
return $u === '' ? $m[1] : '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
|
||||
. ($m[1] !== '' ? $m[1] : $u) . '</a>';
|
||||
}, $s) ?? $s;
|
||||
|
||||
// [文字](url)
|
||||
// ⚠️ URL 里遇到右括号就截断 —— 所以 `javascript:alert(1)` 这种带括号的
|
||||
// 会被切掉后半截(只剩 `javascript:alert(1`)→ 协议判定仍然不过 → 降级成纯文本。
|
||||
// 剩下的那截尾巴(`)`)会留在正文里,看着像个小瑕疵。
|
||||
// ⇒ 用回调把"不合法链接"的**整段原文**还原回去,而不是只留前半截。
|
||||
$s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]*)\)/', static function ($m): string {
|
||||
$u = self::safeUrl($m[2]);
|
||||
if ($u === '') {
|
||||
return $m[0]; // 不合法 → 原样保留(用户看到自己写的东西,而不是被切一半)
|
||||
}
|
||||
return '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
|
||||
. $m[1] . '</a>';
|
||||
}, $s) ?? $s;
|
||||
|
||||
// **粗体** / __粗体__
|
||||
$s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s) ?? $s;
|
||||
$s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s) ?? $s;
|
||||
// *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤)
|
||||
$s = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $s) ?? $s;
|
||||
// ~~删除线~~
|
||||
$s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $s) ?? $s;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** 只放行 http / https / mailto;其它(含 javascript:)返回空串 */
|
||||
private static function safeUrl(string $u): string
|
||||
{
|
||||
$u = trim($u);
|
||||
if ($u === '') {
|
||||
return '';
|
||||
}
|
||||
// 已 escape 过,还原常见实体再做协议判定
|
||||
$raw = html_entity_decode($u, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
if (!preg_match('#^(https?:|mailto:)#i', $raw)) {
|
||||
return '';
|
||||
}
|
||||
return $u;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本摘要(列表页卡片用):把 Markdown 记号去掉,只留可读的字。
|
||||
* 比"渲染成 HTML 再 strip_tags"更省,也不会把 `**` 这种记号露给用户。
|
||||
*/
|
||||
public static function excerpt(string $src, int $len = 120): string
|
||||
{
|
||||
$s = str_replace(["\r\n", "\r"], "\n", $src);
|
||||
// 去掉代码围栏标记、标题号、引用号、列表号
|
||||
$s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s;
|
||||
$s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s;
|
||||
$s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s;
|
||||
$s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s;
|
||||
// 去掉行内标记
|
||||
$s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s;
|
||||
$s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s;
|
||||
$s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s;
|
||||
$s = preg_replace('/`([^`\n]+)`/', '$1', $s) ?? $s;
|
||||
$s = preg_replace('/!?\[([^\]]*)\]\([^)\s]+\)/', '$1', $s) ?? $s;
|
||||
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
||||
$s = trim($s);
|
||||
if (mb_strlen($s, 'UTF-8') > $len) {
|
||||
$s = mb_substr($s, 0, $len, 'UTF-8') . '…';
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
+44
@@ -983,6 +983,50 @@ final class Nav
|
||||
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
|
||||
];
|
||||
|
||||
/**
|
||||
* **联想接口库**(v28 用户第 10 条)。
|
||||
*
|
||||
* 用户原话:「导航功能,联想接口还是不能用啊,需要你补充几个进去,让用户选择。
|
||||
* 搜索引擎列表,联想接口,是**对所有用户可见**的。」
|
||||
*
|
||||
* ⇒ 提供一份**内置的、大家都能看到**的联想接口清单(不是每人各存一份)。
|
||||
* 用户在「搜索引擎设置」里点一下就能把某个接口用到自己的引擎上。
|
||||
*
|
||||
* ⚠️ 这份清单里的每一条都必须**实测可用**(下方注释标了来源)。
|
||||
* 国内网络下不可达的(谷歌 / DuckDuckGo)**放进去也没用** ——
|
||||
* 用户点了发现永远空下拉,比不提供更差。所以只列走通的。
|
||||
* ⚠️ `type` 决定解析方式(见 nav_suggest.php 的 nav_parse_suggest),
|
||||
* 必须是那边认得的取值,加新类型要**两处一起改**。
|
||||
*/
|
||||
public const SUGGEST_LIBRARY = [
|
||||
// [显示名, 联想地址模板, 解析类型, 说明]
|
||||
['百度', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec',
|
||||
'实测最快最稳(约 0.1 秒)'],
|
||||
['360', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
|
||||
'so360', '速度次之(约 0.2 秒)'],
|
||||
['必应', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson',
|
||||
'可用但偏慢(0.5~1.6 秒)'],
|
||||
['搜狗', 'https://suggestion.sogou.com/suggnew/ajajjson?key={q}&type=web', 'sogou',
|
||||
'备用(域名时好时坏)'],
|
||||
['知乎', 'https://www.zhihu.com/api/v4/search/suggest?q={q}&token=', 'zhihu',
|
||||
'内容向站点,适合当第二来源'],
|
||||
['B站', 'https://s.search.bilibili.com/main/suggest?term={q}', 'bili',
|
||||
'内容向站点'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 某个人现在**能选**的联想接口 = 内置库里的全部(对所有用户可见)。
|
||||
* @return array<int,array{name:string,url:string,type:string,note:string}>
|
||||
*/
|
||||
public static function suggestLibrary(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::SUGGEST_LIBRARY as $row) {
|
||||
$out[] = ['name' => $row[0], 'url' => $row[1], 'type' => $row[2], 'note' => $row[3]];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把**缺失的内置引擎**补给这个人(v24 第 26 批 用户第 4 条)。
|
||||
*
|
||||
|
||||
+106
-5
@@ -40,14 +40,20 @@ namespace App;
|
||||
|
||||
final class Note
|
||||
{
|
||||
/** 附件(图片块)在统一 attachments 表里的宿主类型 */
|
||||
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
||||
public const ATTACH = 'note';
|
||||
|
||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
|
||||
public const BLOCK_KINDS = ['text', 'image', 'list'];
|
||||
|
||||
/** 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类) */
|
||||
public const MAX_BLOCKS = 60;
|
||||
/**
|
||||
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
||||
*
|
||||
* 🔴 v28 第 12 条:从 60 提到 200 —— 现在正文支持 Markdown,
|
||||
* 用户会把它当"一页文档"来写(原来更多是"随手记几句话")。
|
||||
* ⚠️ 上限仍然要有:它是防"误贴一整本书"的护栏,不是体验设计。
|
||||
*/
|
||||
public const MAX_BLOCKS = 200;
|
||||
public const MAX_TEXT = 20000;
|
||||
|
||||
/** 列表页默认显示多少条(超过给"加载更多") */
|
||||
@@ -156,6 +162,10 @@ final class Note
|
||||
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
|
||||
);
|
||||
foreach ($st->fetchAll() as $b) {
|
||||
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
|
||||
$b['html'] = (string)$b['kind'] === 'text'
|
||||
? Markdown::render((string)$b['content'])
|
||||
: '';
|
||||
$blocks[(int)$b['note_id']][] = $b;
|
||||
}
|
||||
|
||||
@@ -171,11 +181,47 @@ final class Note
|
||||
$id = (int)$r['id'];
|
||||
$r['blocks'] = $blocks[$id] ?? [];
|
||||
$r['tags'] = $tags[$id] ?? [];
|
||||
/* v28 第 12 条:列表卡片要用摘要(定尺寸网格里放不下全文)。
|
||||
摘要按 Markdown 去记号后取前 N 字 —— 卡片上不渲染 HTML,
|
||||
这样列表页再长也不会因为 Markdown 变慢。 */
|
||||
$r['excerpt'] = self::excerptOf($r);
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一条笔记的块里生成**纯文本摘要**(列表卡片用)。
|
||||
* 图片块计作 [图片],清单块取前几行,段落走 Markdown 去记号。
|
||||
*/
|
||||
private static function excerptOf(array $note, int $len = 110): string
|
||||
{
|
||||
$buf = [];
|
||||
foreach ((array)($note['blocks'] ?? []) as $b) {
|
||||
$kind = (string)$b['kind'];
|
||||
$c = (string)$b['content'];
|
||||
if ($kind === 'image') {
|
||||
$buf[] = '[图片]';
|
||||
} elseif ($kind === 'list') {
|
||||
$buf[] = Markdown::excerpt($c, 60);
|
||||
} else {
|
||||
$buf[] = Markdown::excerpt($c, 200);
|
||||
}
|
||||
// 凑够长度就不再往下拼(长笔记没必要全量处理一遍)
|
||||
if (mb_strlen(implode(' ', $buf), 'UTF-8') >= $len) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$txt = trim((string)preg_replace('/\s+/u', ' ', implode(' ', $buf)));
|
||||
if ($txt === '') {
|
||||
return '';
|
||||
}
|
||||
if (mb_strlen($txt, 'UTF-8') > $len) {
|
||||
$txt = mb_substr($txt, 0, $len, 'UTF-8') . '…';
|
||||
}
|
||||
return $txt;
|
||||
}
|
||||
|
||||
/** 单条(带权限判定)。看不到返回 null。 */
|
||||
public static function find(int $familyId, int $id, int $viewer): ?array
|
||||
{
|
||||
@@ -191,18 +237,73 @@ final class Note
|
||||
}
|
||||
$r['blocks'] = self::blocks($id);
|
||||
$r['tags'] = self::tags($id);
|
||||
$r['files'] = self::files($id); // v28:底部附件
|
||||
$r['is_mine'] = (int)$r['person_id'] === $viewer ? 1 : 0;
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** 某条笔记的块(按位置) */
|
||||
/**
|
||||
* 一条笔记的**底部附件**(v28 第 12 条:「再加上附件上传功能,
|
||||
* 附件,都放在笔记最下面就行了。」)。
|
||||
*
|
||||
* ⚠️ 与**图片块**(kind=image)不是一回事,别混:
|
||||
* · 图片块 = 插在正文中间、参与图文混排的图(用户第 12 条要的"图片和正文混排");
|
||||
* · 底部附件 = 挂在笔记末尾的任意文件(PDF / 压缩包 / 表格…)。
|
||||
* 两者共用 `attachments` 表(target_type='note'),靠这个顺序体现区别:
|
||||
* 图片块在上传时就由 `claimUploads` 认领(target_id=真实笔记 id),
|
||||
* 底部附件则由 `attach_add` 路由显式挂上来 —— **都是同一张表的行**,
|
||||
* 所以删笔记时外键级联一把全部带走,不会留孤儿。
|
||||
*
|
||||
* 怎么区分两者(同一个 target_id 下):图片块的那些**已经作为 image 块的内容**
|
||||
* 出现在 note_blocks 里;渲染底部附件时把它们排掉即可。
|
||||
*
|
||||
* @return array<int,array> attachments 行(按 position)
|
||||
*/
|
||||
public static function files(int $noteId): array
|
||||
{
|
||||
if ($noteId <= 0) {
|
||||
return [];
|
||||
}
|
||||
// 先取出"用作图片块的那些 key",避免它们在底部附件里重复出现
|
||||
$used = [];
|
||||
foreach (self::blocks($noteId) as $b) {
|
||||
if ((string)$b['kind'] === 'image') {
|
||||
$used[(string)$b['content']] = true;
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
foreach (Attachment::list(self::ATTACH, $noteId) as $a) {
|
||||
if (isset($used[(string)$a['key']])) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $a;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某条笔记的块(按位置)
|
||||
*
|
||||
* ⚠️ 这里给每个 text 块算好 `html`(Markdown 渲染结果)——**在模型层算一次**,
|
||||
* 列表页与详情页共用,不必各自再调一遍 Markdown::render
|
||||
* (两处各算一次,迟早有一处忘了转义或忘了渲染)。
|
||||
*/
|
||||
public static function blocks(int $noteId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT * FROM note_blocks WHERE note_id = ? ORDER BY position, id'
|
||||
);
|
||||
$st->execute([$noteId]);
|
||||
return $st->fetchAll();
|
||||
$rows = $st->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$kind = (string)$r['kind'];
|
||||
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
|
||||
$r['html'] = $kind === 'text'
|
||||
? Markdown::render((string)$r['content'])
|
||||
: '';
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 某条笔记的标签 */
|
||||
|
||||
+29
-6
@@ -153,6 +153,20 @@ final class PersonRoster
|
||||
* @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...]
|
||||
* @return int 实际写入的条数
|
||||
*/
|
||||
/**
|
||||
* 保存名册布局(拖动排序/改栏)。返回真正写了几条。
|
||||
*
|
||||
* 🔴 v28 用户第 5 条:名册**平铺**之后不再有"改栏"这回事(一个人挂多个标签,
|
||||
* 分栏会让他在多栏重复出现)。前端会把整个平铺列表当成"唯一一栏"(`g=0`)报上来。
|
||||
* ⚠️ 这时**绝对不能写 group_id**:
|
||||
* · `g=0` 按下面那条规则会被落成 NULL —— 拖一次就把所有人原有的分组清空了;
|
||||
* · 而 `person_roster.group_id` 正是"旧分组→同名标签"迁移的数据来源
|
||||
* (见 Schema 的迁移步骤),也不能被随手抹掉;
|
||||
* · 它同时还是 vCard CATEGORIES 的读点(导出通讯录要用)。
|
||||
* ⇒ 判据:`g` 缺省(前端没报这一栏的组号)时**只更新 sort_order**,
|
||||
* 完全不碰 group_id。这样"拖动只改顺序"这个新语义是结构上保证的,
|
||||
* 而不是靠调用方记得别传错值。
|
||||
*/
|
||||
public static function applyLayout(int $familyId, int $ownerId, array $columns): int
|
||||
{
|
||||
$mine = self::mine($familyId, $ownerId);
|
||||
@@ -161,7 +175,11 @@ final class PersonRoster
|
||||
}
|
||||
$okGroups = PersonGroup::visibleIds($familyId, $ownerId);
|
||||
$db = Database::get();
|
||||
$up = $db->prepare(
|
||||
$upSort = $db->prepare(
|
||||
'UPDATE person_roster SET sort_order = ?
|
||||
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
|
||||
);
|
||||
$upBoth = $db->prepare(
|
||||
'UPDATE person_roster SET group_id = ?, sort_order = ?
|
||||
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
|
||||
);
|
||||
@@ -171,10 +189,11 @@ final class PersonRoster
|
||||
if (!is_array($col)) {
|
||||
continue;
|
||||
}
|
||||
$gid = (int)($col['g'] ?? 0);
|
||||
// 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错
|
||||
if ($gid !== 0 && !isset($okGroups[$gid])) {
|
||||
$gid = 0;
|
||||
// 只有**显式带了组号**的栏才去动 group_id(见方法注释)
|
||||
$hasG = array_key_exists('g', $col);
|
||||
$gid = (int)($col['g'] ?? 0);
|
||||
if ($hasG && $gid !== 0 && !isset($okGroups[$gid])) {
|
||||
$gid = 0; // 别人家的私自分组 / 已删的分组 → 未分类,绝不静默挂错
|
||||
}
|
||||
foreach ((array)($col['ids'] ?? []) as $pid) {
|
||||
$pid = (int)$pid;
|
||||
@@ -182,7 +201,11 @@ final class PersonRoster
|
||||
continue; // 不在我名册里的人,忽略
|
||||
}
|
||||
$sort += 10;
|
||||
$up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
|
||||
if ($hasG) {
|
||||
$upBoth->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
|
||||
} else {
|
||||
$upSort->execute([$sort, $familyId, $ownerId, $pid]);
|
||||
}
|
||||
$written++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* 名册标签(v28 用户 2026-10-07 第 5 条)。
|
||||
*
|
||||
* 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,一个人,
|
||||
* 他可能并不是只在一个分组,例如,他既是我的小学同学,又是我的初中同学,
|
||||
* 就没法分组,改成标签,似乎更合理。」
|
||||
*
|
||||
* 于是把「一人一组」换成「一人多标签」。三条关键设计:
|
||||
*
|
||||
* ① **每个人一份**(`owner_id`)—— 与 person_roster 同口径。
|
||||
* 我给他打「小学同学」,不该让全家的名册都多出这个标签;
|
||||
* 而且同一个人在我这儿是「球友」、在我妈那儿是「外甥」,本来就该各说各的。
|
||||
*
|
||||
* ② **标签词直接存字符串,不建标签表** —— 与笔记模块同一条轻量取舍。
|
||||
* 建一张标签表就要处理"改名后历史怎么办""两个标签合并",那是重型知识库才值
|
||||
* 的复杂度;这里标签就是"随手打的词",靠 GROUP BY 现算完事。
|
||||
*
|
||||
* ③ **不动 person_roster.group_id** —— 那一列是 vCard CATEGORIES 与老代码的读点,
|
||||
* 删掉会让"没跟上升级"的地方静默少一个字段。旧分组值已在 Schema 里
|
||||
* 一次性迁成同名标签(幂等),之后两边各走各的:新界面只认标签。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class PersonTag
|
||||
{
|
||||
/** 标签长度与数量上限(挡住"把一句话当标签") */
|
||||
public const MAX_LEN = 24;
|
||||
public const MAX_EACH = 12;
|
||||
|
||||
/**
|
||||
* 我的名册里每个人的标签:person_id => [tag, tag, ...]
|
||||
*
|
||||
* 一次取完(列表页要整页渲染),不做 N+1 —— 名册上千人时那是致命的。
|
||||
*/
|
||||
public static function mapFor(int $familyId, int $ownerId): array
|
||||
{
|
||||
if ($familyId <= 0 || $ownerId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT person_id, tag FROM person_tags
|
||||
WHERE family_id = ? AND owner_id = ?
|
||||
ORDER BY person_id, position, id'
|
||||
);
|
||||
$st->execute([$familyId, $ownerId]);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[(int)$r['person_id']][] = (string)$r['tag'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 单个(详情/编辑页用) */
|
||||
public static function of(int $ownerId, int $personId): array
|
||||
{
|
||||
if ($ownerId <= 0 || $personId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT tag FROM person_tags WHERE owner_id = ? AND person_id = ?
|
||||
ORDER BY position, id'
|
||||
);
|
||||
$st->execute([$ownerId, $personId]);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[] = (string)$r['tag'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我用过的全部标签 + 各挂了几个(顶部筛选条用)。
|
||||
*
|
||||
* ⚠️ 只统计**我的名册里还在的人** —— 否则会出现"标签上写着 5 个,
|
||||
* 点进去只有 3 个"(另外 2 个已经被移出名册,标签行还在)。
|
||||
*/
|
||||
public static function cloud(int $familyId, int $ownerId): array
|
||||
{
|
||||
if ($familyId <= 0 || $ownerId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.tag, COUNT(*) AS n
|
||||
FROM person_tags t
|
||||
JOIN person_roster r ON r.owner_id = t.owner_id AND r.person_id = t.person_id
|
||||
WHERE t.family_id = ? AND t.owner_id = ?
|
||||
GROUP BY t.tag
|
||||
ORDER BY n DESC, t.tag ASC'
|
||||
);
|
||||
$st->execute([$familyId, $ownerId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** 建议用的标签词(写标签时的联想;按人气排序) */
|
||||
public static function suggest(int $familyId, int $ownerId, int $limit = 30): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::cloud($familyId, $ownerId) as $r) {
|
||||
$out[] = (string)$r['tag'];
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整组替换某人的标签(名册卡片上改标签就用它)。
|
||||
*
|
||||
* 为什么是"整组替换"而不是逐个增删:界面是一次提交一整套标签的,
|
||||
* 逐个 diff 反而要处理"哪些是新增/哪些是删除",代码多且容易漏。
|
||||
* 标签没有历史价值,全量重写最简单可靠。
|
||||
*
|
||||
* @param array|string $raw 数组,或一段用空格/逗号分隔的标签
|
||||
* @return array<int,string> 归一后真正写进去的标签(调用方可以回显)
|
||||
*/
|
||||
public static function setFor(int $familyId, int $ownerId, int $personId, $raw): array
|
||||
{
|
||||
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$tags = self::clean($raw);
|
||||
$db = Database::get();
|
||||
$db->prepare('DELETE FROM person_tags WHERE owner_id = ? AND person_id = ?')
|
||||
->execute([$ownerId, $personId]);
|
||||
if (!$tags) {
|
||||
return [];
|
||||
}
|
||||
$st = $db->prepare(
|
||||
'INSERT INTO person_tags (family_id, owner_id, person_id, tag, position)
|
||||
VALUES (?,?,?,?,?)'
|
||||
);
|
||||
$pos = 10;
|
||||
foreach ($tags as $t) {
|
||||
$st->execute([$familyId, $ownerId, $personId, $t, $pos]);
|
||||
$pos += 10;
|
||||
}
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/** 删某人时把他的标签一起删(persons 外键也会级联,这里显式一次更稳)。 */
|
||||
public static function forgetPerson(int $personId): void
|
||||
{
|
||||
if ($personId <= 0) {
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare('DELETE FROM person_tags WHERE person_id = ?')->execute([$personId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。
|
||||
*
|
||||
* 与 Note::cleanTags 同一套规则(去 `#`、去空白、去重、限长限数)——
|
||||
* 两处各写一份迟早不一致(用户在笔记里能打 `#`,在名册里却打不了,最恼人)。
|
||||
*/
|
||||
public static function clean($raw): array
|
||||
{
|
||||
$list = [];
|
||||
if (is_array($raw)) {
|
||||
$list = $raw;
|
||||
} elseif (is_string($raw)) {
|
||||
$list = preg_split('/[\s,,]+/u', $raw) ?: [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($list as $t) {
|
||||
$t = trim((string)$t);
|
||||
$t = ltrim($t, '#');
|
||||
// 空白与 `#` 都不能出现在标签内部(它们是分隔符)
|
||||
$t = (string)preg_replace('/[\s#]+/u', '', $t);
|
||||
if ($t === '') {
|
||||
continue;
|
||||
}
|
||||
$t = mb_substr($t, 0, self::MAX_LEN, 'UTF-8');
|
||||
if (!in_array($t, $out, true)) {
|
||||
$out[] = $t;
|
||||
}
|
||||
if (count($out) >= self::MAX_EACH) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
+80
-1
@@ -101,6 +101,13 @@ final class Schema
|
||||
* + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**:
|
||||
* 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表;
|
||||
* 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。
|
||||
* 28 → **名册「分组」改「标签」(用户 2026-10-07 第 5 条)**:新增 `person_tags`
|
||||
* (每人一份、一人多标签、标签词存字符串)。动机是用户的原话:
|
||||
* 「一个人可能并不是只在一个分组,例如他既是我的小学同学,又是我的初中同学,
|
||||
* 就没法分组,改成标签,似乎更合理。」
|
||||
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
|
||||
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
|
||||
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -108,7 +115,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '27';
|
||||
public const VERSION = '28';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -970,6 +977,37 @@ CREATE TABLE IF NOT EXISTS `note_tags` (
|
||||
CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)'
|
||||
SQL,
|
||||
// ---- 名册标签(v28 用户第 5 条)----
|
||||
// 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,
|
||||
// 一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
|
||||
// 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
|
||||
//
|
||||
// ⇒ 分组(一人一组)→ 标签(一人多标签)。**表结构为什么这样切**:
|
||||
// · `person_tags` 是**每人一份**的(owner_id = 谁的名册),
|
||||
// 与 person_roster 同口径:标签是"我和他的关系",不是这个人的属性
|
||||
// (我给他打"小学同学"不该让全家的名册都变)。
|
||||
// · `tag` 直接存字符串,**不建标签表** —— 与笔记模块同一条轻量取舍:
|
||||
// 标签是"随手打的词",不需要改名/合并/配色这些重型能力。
|
||||
// · ⚠️ `person_roster.group_id` **保留不删**:它是老库升级与 CardDAV
|
||||
// 导出的读点(vCard 的 CATEGORIES 由它派生),删掉会让"没跟上升级"
|
||||
// 的地方静默少一个字段。改成"以新表为准、老列只做兼容回填"。
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `person_tags` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`owner_id` INT UNSIGNED NOT NULL COMMENT '谁的名册(persons.id)—— 标签是"我和他的关系"',
|
||||
`person_id` INT UNSIGNED NOT NULL COMMENT '被打标签的人(persons.id)',
|
||||
`tag` VARCHAR(24) NOT NULL COMMENT '标签词(不含 #;同一人同一标签不重复)',
|
||||
`position` INT NOT NULL DEFAULT 10 COMMENT '同一人多个标签时的顺序',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_pt` (`owner_id`, `person_id`, `tag`),
|
||||
KEY `idx_pt_owner_tag` (`owner_id`, `tag`),
|
||||
KEY `idx_pt_person` (`person_id`),
|
||||
CONSTRAINT `fk_pt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
@@ -1815,6 +1853,47 @@ SQL);
|
||||
);
|
||||
});
|
||||
|
||||
// v28:把「分组」逐一迁成**同名标签**(用户第 5 条:一人一组 → 一人多标签)。
|
||||
/* 🔴🔴 必须用**一次性哨兵**,不能"每次 ensure 都尝试搬一遍"。
|
||||
*
|
||||
* 我第一版写的是「该人还没有任何标签时才搬」(NOT EXISTS 判 owner+person),
|
||||
* 想当然地以为那就能防住"删除被撤销"。**实测当场推翻了这个假设**:
|
||||
* · 用户在界面上把某人的标签**清空** → 那人身上一条标签都没有
|
||||
* → `NOT EXISTS` 成立 → 下一次 ensure **把他旧的分组名又灌回来**。
|
||||
* 症状:删掉的标签自己回来(与"移出的人又回来""纪念日关联人被老列覆盖"
|
||||
* 是同一类坑,本项目已经撞过三次)。
|
||||
*
|
||||
* ⇒ 正确做法是"这件事**只做一次**":用一个设置键当哨兵,
|
||||
* 做完了就记下,之后无论库里变成什么样都不再碰。
|
||||
* 这也符合迁移的本质 —— 它是**一次性的数据转换**,不是持续同步。
|
||||
* ⚠️ 哨兵键名带年份/版本(`person_tags_migrated_v28`),
|
||||
* 将来真有第二次迁移要另起一个键,不要复用。
|
||||
*/
|
||||
self::tryStep('名册分组搬到标签(一人一组 → 一人多标签)', static function () use ($db) {
|
||||
$flag = 'person_tags_migrated_v28';
|
||||
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
|
||||
if ((int)$has > 0) {
|
||||
return; // 已经搬过了,永不再搬(见上面的说明)
|
||||
}
|
||||
if (self::hasTable($db, 'person_tags') && self::hasTable($db, 'person_groups')) {
|
||||
$db->exec(
|
||||
"INSERT IGNORE INTO `person_tags` (family_id, owner_id, person_id, tag, position)
|
||||
SELECT r.family_id, r.owner_id, r.person_id, g.name, 10
|
||||
FROM `person_roster` r
|
||||
JOIN `person_groups` g ON g.id = r.group_id
|
||||
WHERE r.group_id IS NOT NULL
|
||||
AND g.name IS NOT NULL AND g.name <> ''"
|
||||
);
|
||||
}
|
||||
// 记哨兵(settings 是**家庭级**的,用最小 family_id 当归属,与
|
||||
// schema_upgrade_warning 同一做法)
|
||||
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
|
||||
if ($fid > 0) {
|
||||
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
|
||||
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
|
||||
}
|
||||
});
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
|
||||
+58
-4
@@ -65,6 +65,32 @@ final class WeRead
|
||||
*/
|
||||
public const SYNC_TTL = 21600;
|
||||
|
||||
/**
|
||||
* 一次同步的**总时长预算**(秒)。
|
||||
*
|
||||
* 🔴🔴 v28 用户第 9 条:「读书,同步微信读书后,感觉有时候点击进来很慢,
|
||||
* 需要加载很长时间,后来就好了,过一段时间又慢。」
|
||||
*
|
||||
* 根因(实测推演,症状完全吻合):
|
||||
* 自动同步挂在**读书列表页**上(见 index.php 的 books 路由),
|
||||
* 而 `syncNow()` 最多扫 60 本书,**每一本**都要打 1~3 次网关
|
||||
* (bookInfo / progress / notes,缺一座就补一次封面还要再下图片)。
|
||||
* 单次 `call()` 的超时是 **20 秒**(见 call() 的 CURLOPT_TIMEOUT)——
|
||||
* 于是"6 小时到期的那一次"最坏能挂几分钟:用户点进来就是白屏等待,
|
||||
* 过后(时间戳已写、间隔未到)再进来只读一个时间戳,立刻就快了;
|
||||
* 再过 6 小时又是那一下 —— 这正是用户描述的"时快时慢、过一阵又慢"。
|
||||
*
|
||||
* ⇒ 修法:给整轮同步一个**总预算**。到点就**停下来、正常返回**,
|
||||
* 已处理的部分照常落库,剩下的等下一轮接着做(游标从头再来一遍也无妨:
|
||||
* `matchLocalBook` 与 `store` 都是幂等的,"已存在的会跳过")。
|
||||
* ⚠️ 为什么设 8 秒:这是"用户能忍的一次页面等待"的量级。
|
||||
* 真实耗时取决于网关,宁少勿多 —— 慢一点同步完,比页面打不开好得多。
|
||||
*/
|
||||
public const SYNC_BUDGET = 8;
|
||||
|
||||
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
|
||||
public const SYNC_MAX_BOOKS = 25;
|
||||
|
||||
// ================================================================
|
||||
// Key 的存取(加密)
|
||||
// ================================================================
|
||||
@@ -140,8 +166,14 @@ final class WeRead
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
// 🔴 v28 用户第 9 条:单次调用超时从 20 秒收到 **8 秒**。
|
||||
// 同步一轮要打好几次网关,20 秒 × N 次可以直接把页面拖垮;
|
||||
// 而网关正常时是零点几秒就回的(见 nav_suggest.php 里那批实测:
|
||||
// 同类接口 0.1~1.6 秒)。8 秒足以覆盖"网关偶发慢",又不会让
|
||||
// 一次页面请求长时间挂着。慢的代价只是"这一轮少同步几本",
|
||||
// 下一轮会接着做(幂等)。
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $key,
|
||||
@@ -685,6 +717,10 @@ final class WeRead
|
||||
* 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。
|
||||
*
|
||||
* @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用)
|
||||
* 🔴 v28 用户第 9 条:$force 时给**更大的扫描量**(用户是
|
||||
* 主动点的,愿意等;而且手动同步本来就该一次做完整)。
|
||||
* 页面上的自动同步则用小量 —— 它挂在读书列表页上,
|
||||
* 每多扫一本就多一次"打开页面就等"。
|
||||
* @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string}
|
||||
*/
|
||||
public static function maybeSync(int $familyId, int $personId, bool $force = false): array
|
||||
@@ -698,12 +734,16 @@ final class WeRead
|
||||
if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) {
|
||||
return $empty;
|
||||
}
|
||||
// ⚠️ 先记时间再干活:万一下面 20 秒里超时/抛错,也不会在同一个页面
|
||||
// ⚠️ 先记时间再干活:万一下面某一轮超时/抛错,也不会在同一个页面
|
||||
// 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。
|
||||
// 🔴 v28:现在同步**有总预算**(SYNC_BUDGET),不会再长时间卡住;
|
||||
// 这条"先记时间"依然要留 —— 它是"网关整个挂掉时别每次访问都重试"的兜底。
|
||||
UserSetting::set($personId, self::SYNC_AT_KEY, (string)time());
|
||||
|
||||
try {
|
||||
$res = self::syncNow($familyId, $personId);
|
||||
// 自动同步只扫一小批(页面等待),手动同步扫完整批(用户愿意等)
|
||||
$limit = $force ? 60 : self::SYNC_MAX_BOOKS;
|
||||
$res = self::syncNow($familyId, $personId, $limit);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(),
|
||||
'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')];
|
||||
@@ -727,6 +767,16 @@ final class WeRead
|
||||
*/
|
||||
public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array
|
||||
{
|
||||
/* 🔴 v28 用户第 9 条:**整轮的总时长预算**(见 SYNC_BUDGET 的注释)。
|
||||
* 到点就停、正常返回已处理的部分,剩下的下一轮继续。
|
||||
* ⚠️ 用"开始时刻 + 每轮循环都检查"而不是"包一个超时异常":
|
||||
* PHP 的 curl 超时是按**单次调用**算的,没有"整个函数超时"这种机制;
|
||||
* 要在中途刹车,只能在循环里自己判。
|
||||
* ⚠️ 判据放在**每本书的开头**(不是每本书之间某处):一本书内部还会打
|
||||
* 2~3 次网关,若在书中途判断就会留下半本书(字段补一半)。
|
||||
* 整本为单位地停,重来时那本书会被幂等地重做一遍。 */
|
||||
$deadline = microtime(true) + self::SYNC_BUDGET;
|
||||
|
||||
$nb = self::notebooks($personId);
|
||||
if (!$nb['ok']) {
|
||||
return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0,
|
||||
@@ -738,6 +788,10 @@ final class WeRead
|
||||
if ($scanned >= $maxBooks) {
|
||||
break;
|
||||
}
|
||||
// 总预算到点 → 停在这里(已处理的都已落库;剩下的下一轮再做)
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user