Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 797e7f751c | |||
| d1c44a5878 | |||
| a7e23badf2 | |||
| c76e10d314 | |||
| 9a29eb5af6 | |||
| 8d49d883c0 |
@@ -122,6 +122,12 @@
|
|||||||
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
|
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
|
||||||
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
||||||
.modebtn.hide { display: none; }
|
.modebtn.hide { display: none; }
|
||||||
|
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**(#tabbar .tab / #sidenav .nav / .modebtn),
|
||||||
|
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
|
||||||
|
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
|
||||||
|
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display(#forgot-box 就有)。
|
||||||
|
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
|
||||||
|
.hide { display: none !important; }
|
||||||
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
|
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
|
||||||
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
|
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
|
||||||
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
|
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
|
||||||
@@ -219,12 +225,42 @@ if (!window.ARCRUN_API_BASE) {
|
|||||||
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
||||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
|
||||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
|
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
|
||||||
|
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
|
||||||
|
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
|
||||||
|
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
|
||||||
|
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
|
||||||
|
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
|
||||||
|
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
|
||||||
|
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
|
||||||
|
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
|
||||||
|
</div>
|
||||||
|
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
|
||||||
|
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
|
||||||
|
<button class="btn" id="forgot-submit">寄出連結</button>
|
||||||
|
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
|
||||||
|
</div>
|
||||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
|
||||||
|
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
|
||||||
|
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
|
||||||
|
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
|
||||||
|
<div class="authwrap view" id="v-reset">
|
||||||
|
<div class="authbox">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
|
||||||
|
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
|
||||||
|
<div id="reset-slot"></div>
|
||||||
|
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
|
||||||
|
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||||||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||||||
<div class="authwrap view" id="v-firstsetup">
|
<div class="authwrap view" id="v-firstsetup">
|
||||||
@@ -375,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
|
|||||||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<!-- D62(leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
|
||||||
<div style="font-size:17px;font-weight:600">更改密碼</div>
|
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
|
||||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
|
||||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
|
||||||
|
<div class="panel" id="st-pw-panel">
|
||||||
|
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
|
||||||
|
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||||
|
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||||||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||||||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||||||
@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
}
|
}
|
||||||
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||||||
function route() {
|
function route() {
|
||||||
|
// 🔴 D62:`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
|
||||||
|
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**,
|
||||||
|
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
|
||||||
|
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
|
||||||
|
var rt = resetTokenFromUrl();
|
||||||
|
if (rt) { showReset(rt); return; }
|
||||||
var r = currentRoute();
|
var r = currentRoute();
|
||||||
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
|
||||||
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
|
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
|
||||||
@@ -817,6 +863,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
} catch (e) { /* 登入殼還沒渲染就算了 */ }
|
||||||
}
|
}
|
||||||
function boot() {
|
function boot() {
|
||||||
|
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
|
||||||
|
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
|
||||||
|
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
|
||||||
|
if (rt) { showReset(rt); return; }
|
||||||
if (!S.token) { showAuth(); return; }
|
if (!S.token) { showAuth(); return; }
|
||||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||||
@@ -2041,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||||||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||||||
}
|
}
|
||||||
$('st-pw-save').addEventListener('click', function () {
|
// ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
|
||||||
|
//
|
||||||
|
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
|
||||||
|
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
|
||||||
|
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||||
|
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
|
||||||
|
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
|
||||||
|
// (arcrun-rag#40「同一個事實兩份」)。
|
||||||
|
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
|
||||||
|
|
||||||
|
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
|
||||||
|
|
||||||
|
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
|
||||||
|
function mountPasswordForm(host, needCurrent) {
|
||||||
|
var form = $('pw-form');
|
||||||
|
if (form && form.parentNode !== host) host.appendChild(form);
|
||||||
|
$('st-pw-old').classList.toggle('hide', !needCurrent);
|
||||||
|
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
|
||||||
|
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||||
|
$('st-pw-status').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
|
||||||
|
function submitPasswordChange() {
|
||||||
|
var needCurrent = !S_RESET_TOKEN;
|
||||||
var oldPw = $('st-pw-old').value;
|
var oldPw = $('st-pw-old').value;
|
||||||
var newPw = $('st-pw-new').value;
|
var newPw = $('st-pw-new').value;
|
||||||
var newPw2 = $('st-pw-new2').value;
|
var newPw2 = $('st-pw-new2').value;
|
||||||
var st = $('st-pw-status');
|
var st = $('st-pw-status');
|
||||||
st.className = 'err';
|
st.className = 'err';
|
||||||
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
|
if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
|
||||||
|
if (!newPw) { st.textContent = '請填新密碼'; return; }
|
||||||
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
||||||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||||||
|
var body = { 'new': newPw };
|
||||||
|
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
|
||||||
$('st-pw-save').disabled = true;
|
$('st-pw-save').disabled = true;
|
||||||
st.textContent = '';
|
st.textContent = '處理中…';
|
||||||
fetch(API_BASE + '/portal/me/password', {
|
fetch(API_BASE + '/portal/password/change', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
|
||||||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
body: JSON.stringify(body)
|
||||||
})
|
})
|
||||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||||
.then(function (x) {
|
.then(function (x) {
|
||||||
$('st-pw-save').disabled = false;
|
$('st-pw-save').disabled = false;
|
||||||
if (guard401(x.status)) return;
|
// 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
|
||||||
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
|
if (needCurrent && guard401(x.status)) return;
|
||||||
|
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
|
||||||
st.className = 'ok';
|
st.className = 'ok';
|
||||||
st.textContent = '密碼已更新';
|
|
||||||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||||||
toast('密碼已更新');
|
if (S_RESET_TOKEN) {
|
||||||
|
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
|
||||||
|
var who = (x.d && x.d.email) || '';
|
||||||
|
S_RESET_TOKEN = '';
|
||||||
|
setTimeout(function () {
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
location.hash = '';
|
||||||
|
$('v-reset').classList.remove('on');
|
||||||
|
mountPasswordForm($('st-pw-panel'), true);
|
||||||
|
showAuth();
|
||||||
|
if (who) $('login-email').value = who;
|
||||||
|
$('login-status').className = 'ok';
|
||||||
|
$('login-status').textContent = '新密碼已生效,請用它登入。';
|
||||||
|
}, 1200);
|
||||||
|
} else {
|
||||||
|
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
|
||||||
|
toast('密碼已更新');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
||||||
|
}
|
||||||
|
$('st-pw-save').addEventListener('click', submitPasswordChange);
|
||||||
|
|
||||||
|
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)──
|
||||||
|
$('login-forgot').addEventListener('click', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
$('forgot-box').classList.remove('hide');
|
||||||
|
$('forgot-email').value = $('login-email').value.trim();
|
||||||
|
$('forgot-email').focus();
|
||||||
|
});
|
||||||
|
$('forgot-submit').addEventListener('click', function () {
|
||||||
|
var email = $('forgot-email').value.trim();
|
||||||
|
var st = $('forgot-status');
|
||||||
|
st.className = 'err';
|
||||||
|
if (!email) { st.textContent = '請填 Email'; return; }
|
||||||
|
$('forgot-submit').disabled = true;
|
||||||
|
st.textContent = '寄送中…';
|
||||||
|
fetch(API_BASE + '/portal/password/forgot', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email })
|
||||||
|
})
|
||||||
|
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||||
|
.then(function (x) {
|
||||||
|
$('forgot-submit').disabled = false;
|
||||||
|
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
|
||||||
|
st.className = 'ok';
|
||||||
|
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
|
||||||
|
})
|
||||||
|
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */
|
||||||
|
function resetTokenFromUrl() {
|
||||||
|
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
|
||||||
|
if (m) return decodeURIComponent(m[1]);
|
||||||
|
m = String(location.search || '').match(/[?&]token=([^&]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
function showReset(token) {
|
||||||
|
$('v-login').classList.remove('on');
|
||||||
|
$('v-firstsetup').classList.remove('on');
|
||||||
|
$('shell').classList.remove('on');
|
||||||
|
$('tabbar').classList.remove('on');
|
||||||
|
$('v-reset').classList.add('on');
|
||||||
|
$('reset-error').textContent = '';
|
||||||
|
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
|
||||||
|
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
|
||||||
|
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
|
||||||
|
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||||
|
.then(function (x) {
|
||||||
|
if (!x.ok || !x.d || x.d.valid !== true) {
|
||||||
|
$('reset-who').textContent = '';
|
||||||
|
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
S_RESET_TOKEN = token;
|
||||||
|
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
|
||||||
|
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
|
||||||
|
})
|
||||||
|
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
|
||||||
|
}
|
||||||
|
$('reset-back').addEventListener('click', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
S_RESET_TOKEN = '';
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
location.hash = '';
|
||||||
|
$('v-reset').classList.remove('on');
|
||||||
|
mountPasswordForm($('st-pw-panel'), true);
|
||||||
|
showAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。
|
||||||
|
*
|
||||||
|
* 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
|
||||||
|
* 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
|
||||||
|
* (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
|
||||||
|
*/
|
||||||
|
export async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ healthRouter.get('/health', (c) => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||||
auth_store: authStoreStatus(c.env),
|
auth_store: authStoreStatus(c.env),
|
||||||
|
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||||
|
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||||
|
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
|
||||||
|
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
|
||||||
|
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
|
||||||
|
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type { Context } from 'hono';
|
|||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbBase } from './kbdb-proxy';
|
import { kbdbBase } from './kbdb-proxy';
|
||||||
import { validateConsoleSession } from './console-auth';
|
import { validateConsoleSession } from './console-auth';
|
||||||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
||||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||||
@@ -715,29 +715,310 @@ portalRouter.get('/portal/session', (c) =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /portal/me/password — body {current, new}。驗舊密改新密。
|
// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
|
||||||
portalRouter.post('/portal/me/password', (c) =>
|
//
|
||||||
run(c, async () => {
|
// leo 2026-08-10 拍板(頂層 decisions-summary D62):
|
||||||
const auth = await requirePortalUser(c);
|
// 「這兩個機制其實是一個機制,可以簡化。」
|
||||||
if (!auth.ok) return auth.res;
|
// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
|
||||||
const body = await c.req.json().catch(() => null);
|
// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
|
||||||
const current = String(body?.current ?? '');
|
// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
|
||||||
const next = String(body?.new ?? '');
|
//
|
||||||
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
|
// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
|
||||||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
|
||||||
|
// 🔴 連結的安全性(承 D50 的理由,不可退讓):
|
||||||
|
// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken)
|
||||||
|
// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
|
||||||
|
// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV,
|
||||||
|
// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
|
||||||
|
// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
|
||||||
|
// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
|
||||||
|
// 🔴 **入口在 portal,不是 console**(leo 2026-08-10:「是對 portal 不是對 console,
|
||||||
|
// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
|
||||||
|
// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
|
||||||
|
|
||||||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
|
||||||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
const PWRESET_PREFIX = 'portal_pwreset:';
|
||||||
|
/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
|
||||||
|
const PWRESET_TTL_SECONDS = 30 * 60;
|
||||||
|
/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
|
||||||
|
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
|
||||||
|
const PWRESET_THROTTLE_SECONDS = 120;
|
||||||
|
/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
|
||||||
|
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
|
||||||
|
const RELAY_TICKET_TTL_SECONDS = 120;
|
||||||
|
|
||||||
const newHash = await hashPassword(next);
|
interface ResetTokenPayload {
|
||||||
await patchRecordValues(c.env, auth.user.recordId, {
|
record_id: string;
|
||||||
password_hash: newHash,
|
email: string;
|
||||||
updated_at: new Date().toISOString(),
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* portal 前端(使用者會看到的那個網址)的 origin。
|
||||||
|
* 與 index.ts 的 CORS 白名單**同一套推導**:UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。
|
||||||
|
* (2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。)
|
||||||
|
*/
|
||||||
|
function portalUiOrigin(env: Bindings): string | null {
|
||||||
|
const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (declared.length > 0) return declared[0];
|
||||||
|
const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
|
||||||
|
return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
|
||||||
|
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
|
||||||
|
const token = randomHex(32);
|
||||||
|
const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
|
||||||
|
await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
|
||||||
|
expirationTtl: PWRESET_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
|
||||||
|
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||||
|
if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
|
||||||
|
const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as ResetTokenPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。
|
||||||
|
* 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。
|
||||||
|
*/
|
||||||
|
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
|
||||||
|
const payload = await peekResetToken(env, token);
|
||||||
|
if (!payload) return null;
|
||||||
|
await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。
|
||||||
|
* 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
|
||||||
|
*/
|
||||||
|
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
await patchRecordValues(env, recordId, {
|
||||||
|
password_hash: newHash,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 請中央 landing 代寄「修改密碼」連結。
|
||||||
|
*
|
||||||
|
* 🔴 **為什麼要代寄**(leo 2026-08-10 給的職責切法):
|
||||||
|
* 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover,
|
||||||
|
* 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」
|
||||||
|
* ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。
|
||||||
|
* 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
|
||||||
|
* 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize,**沒有 send_email**。
|
||||||
|
* ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。
|
||||||
|
*
|
||||||
|
* 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、
|
||||||
|
* 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev`
|
||||||
|
* 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼,
|
||||||
|
* 燒的是整個網域的信譽、波及所有用戶、**不可逆**。
|
||||||
|
*
|
||||||
|
* 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會
|
||||||
|
* **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。
|
||||||
|
* 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認,
|
||||||
|
* 不是相信呼叫方的宣稱**。
|
||||||
|
*
|
||||||
|
* `apiOrigin` 取自**這次請求真正抵達的位址**(`new URL(c.req.url).origin`),不新增任何
|
||||||
|
* 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。
|
||||||
|
*
|
||||||
|
* **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。
|
||||||
|
*/
|
||||||
|
async function relayResetLink(
|
||||||
|
env: Bindings,
|
||||||
|
apiOrigin: string,
|
||||||
|
email: string,
|
||||||
|
ticket: string,
|
||||||
|
): Promise<'sent' | 'not_configured' | 'failed'> {
|
||||||
|
const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
|
||||||
|
if (!base) return 'not_configured';
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/send-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
|
||||||
});
|
});
|
||||||
return c.json({ success: true });
|
return res.ok ? 'sent' : 'failed';
|
||||||
|
} catch {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /portal/password/relay-verify — **郵差回頭確認用**(body `{ticket}`)。
|
||||||
|
*
|
||||||
|
* 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。
|
||||||
|
* 它會回頭打**連結裡的那個主機**問這張票在不在——
|
||||||
|
* - 真的是這台發的 → 這裡答得出來 → 寄
|
||||||
|
* - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
|
||||||
|
* 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個,
|
||||||
|
* 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。
|
||||||
|
* 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。
|
||||||
|
*/
|
||||||
|
portalRouter.post('/portal/password/relay-verify', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const ticket = String(body?.ticket ?? '').trim();
|
||||||
|
if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
|
||||||
|
const key = `${RELAY_TICKET_PREFIX}${ticket}`;
|
||||||
|
const raw = await c.env.SESSIONS_KV.get(key);
|
||||||
|
if (!raw) return c.json({ ok: false }, 404);
|
||||||
|
await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
|
||||||
|
let parsed: { email: string; link: string };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as { email: string; link: string };
|
||||||
|
} catch {
|
||||||
|
return c.json({ ok: false }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /portal/password/reset-link?token=… — 信裡連結的落點。
|
||||||
|
// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
|
||||||
|
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
|
||||||
|
// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
|
||||||
|
portalRouter.get('/portal/password/reset-link', (c) => {
|
||||||
|
const token = c.req.query('token') ?? '';
|
||||||
|
const ui = portalUiOrigin(c.env);
|
||||||
|
if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
|
||||||
|
return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
|
||||||
|
//
|
||||||
|
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
|
||||||
|
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
|
||||||
|
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
|
||||||
|
//
|
||||||
|
// 🔴 arcrun-rag#38/#69/#25(2026-08-11 leo 親口點出兩個問題,改字前先讀):
|
||||||
|
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
|
||||||
|
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
|
||||||
|
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
|
||||||
|
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
|
||||||
|
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
|
||||||
|
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
|
||||||
|
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
|
||||||
|
portalRouter.post('/portal/password/forgot', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const email = String(body?.email ?? '').trim().toLowerCase();
|
||||||
|
if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||||||
|
|
||||||
|
if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
|
||||||
|
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
|
||||||
|
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
|
||||||
|
code: 'mail_relay_not_configured',
|
||||||
|
},
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generic = {
|
||||||
|
success: true,
|
||||||
|
message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
|
||||||
|
const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
|
||||||
|
if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
|
||||||
|
await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
|
||||||
|
|
||||||
|
const recordId = await findUserRecordId(c.env, email).catch(() => null);
|
||||||
|
if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
|
||||||
|
|
||||||
|
const token = await issueResetToken(c.env, recordId, email);
|
||||||
|
// 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
|
||||||
|
const apiOrigin = new URL(c.req.url).origin;
|
||||||
|
const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
|
||||||
|
const ticket = randomHex(16);
|
||||||
|
await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
|
||||||
|
expirationTtl: RELAY_TICKET_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
|
||||||
|
return c.json(generic);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
|
||||||
|
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
|
||||||
|
portalRouter.get('/portal/password/reset', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
|
||||||
|
if (!payload) {
|
||||||
|
return c.json(
|
||||||
|
{ valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ valid: true, email: payload.email });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。
|
||||||
|
*
|
||||||
|
* body:`{ new, current? , reset_token? }`
|
||||||
|
* - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據
|
||||||
|
* - 沒帶 → 必須是登入狀態 + 提供正確的 `current`
|
||||||
|
* 兩條路在這一行之後**完全相同**(writeNewPassword)——這就是「差別只有一格」的實體。
|
||||||
|
*/
|
||||||
|
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const next = String(body?.new ?? '');
|
||||||
|
const resetToken = String(body?.reset_token ?? '').trim();
|
||||||
|
if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
|
||||||
|
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||||
|
|
||||||
|
// ── 忘記密碼那一格:憑連結,不問現有密碼 ──
|
||||||
|
if (resetToken) {
|
||||||
|
const payload = await consumeResetToken(c.env, resetToken);
|
||||||
|
if (!payload) {
|
||||||
|
return c.json(
|
||||||
|
{ error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeNewPassword(c.env, payload.record_id, next);
|
||||||
|
return c.json({ success: true, email: payload.email, via: 'reset_link' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 修改密碼那一格:要登入、要現有密碼 ──
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const current = String(body?.current ?? '');
|
||||||
|
if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
|
||||||
|
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||||||
|
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||||||
|
|
||||||
|
await writeNewPassword(c.env, auth.user.recordId, next);
|
||||||
|
return c.json({ success: true, via: 'current_password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
|
||||||
|
|
||||||
|
// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
|
||||||
|
// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
|
||||||
|
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
|
||||||
|
|
||||||
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
|
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
|
||||||
|
|
||||||
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
|
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ export type Bindings = {
|
|||||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||||
PORTAL_SESSION_TTL?: string;
|
PORTAL_SESSION_TTL?: string;
|
||||||
|
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
|
||||||
|
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
|
||||||
|
UI_ORIGINS?: string;
|
||||||
|
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
|
||||||
|
// 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器
|
||||||
|
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize,
|
||||||
|
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
|
||||||
|
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
|
||||||
|
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
|
||||||
|
PORTAL_MAIL_RELAY_BASE?: string;
|
||||||
|
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
|
||||||
|
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
|
||||||
|
PORTAL_MAIL_RELAY_KEY?: string;
|
||||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||||
|
|||||||
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
|||||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════
|
||||||
|
//
|
||||||
|
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||||
|
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||||
|
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||||
|
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||||
|
|
||||||
|
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||||
|
const D62_EMAIL = 'd62-reset@example.com';
|
||||||
|
|
||||||
|
it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
|
||||||
|
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
|
||||||
|
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
|
||||||
|
const { sha256Hex } = await import('../src/lib/portal-auth');
|
||||||
|
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||||
|
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
`portal_pwreset:${await sha256Hex(token)}`,
|
||||||
|
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 票有效時,先「看一眼」不會消耗它
|
||||||
|
const peek = await json('GET', `/portal/password/reset?token=${token}`);
|
||||||
|
expect(peek.status).toBe(200);
|
||||||
|
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
|
||||||
|
|
||||||
|
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
|
||||||
|
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
|
||||||
|
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
|
||||||
|
// 關鍵斷言:同一條連結**不能再用第二次**
|
||||||
|
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
|
||||||
|
expect(again.status).toBe(400);
|
||||||
|
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
|
||||||
|
// 而且票在 KV 裡真的沒了
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
|
||||||
|
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
|
||||||
|
const res = await json('GET', `/portal/password/reset?token=${t}`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json() as { valid: boolean }).valid).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
|
||||||
|
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||||
|
const TOKEN_A = 'sess-token-66-propagating';
|
||||||
|
const TOKEN_B = 'sess-token-66-really-gone';
|
||||||
|
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
|
||||||
|
|
||||||
|
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
|
||||||
|
await seedPortalSession(TOKEN_A, MISSING);
|
||||||
|
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
|
||||||
|
await env.SESSIONS_KV.put(
|
||||||
|
'auth_store_recent',
|
||||||
|
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
|
||||||
|
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
|
||||||
|
await seedPortalSession(TOKEN_B, MISSING);
|
||||||
|
await env.SESSIONS_KV.delete('auth_store_recent');
|
||||||
|
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
|
||||||
|
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
|
||||||
|
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
|
||||||
|
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
|
||||||
|
//
|
||||||
|
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
|
||||||
|
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||||
|
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||||
|
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||||
|
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
|
||||||
|
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
|
||||||
|
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
|
||||||
|
//
|
||||||
|
// 為什麼這是**結構性**故障、不是準度問題:
|
||||||
|
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
|
||||||
|
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
|
||||||
|
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
|
||||||
|
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
|
||||||
|
//
|
||||||
|
// 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
|
||||||
|
// 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
|
||||||
|
//
|
||||||
|
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
|
||||||
|
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
|
||||||
|
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
|
||||||
|
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
|
||||||
|
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
|
||||||
|
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
|
||||||
|
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
|
||||||
|
// `local arcrun` 那 5 筆不會被稀釋掉。
|
||||||
|
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
|
||||||
|
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
|
||||||
|
//
|
||||||
|
// 回歸保證(不是靠測試碰運氣,是靠構造):
|
||||||
|
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
|
||||||
|
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
|
||||||
|
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
|
||||||
|
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
|
||||||
|
//
|
||||||
|
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
|
||||||
|
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
|
||||||
|
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
|
||||||
|
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
|
||||||
|
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
|
||||||
|
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
|
||||||
|
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
|
||||||
|
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
|
||||||
|
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
|
||||||
|
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
|
||||||
|
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
|
||||||
|
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
|
||||||
|
const KEYWORD_RELATIVE_CUT = 0.6;
|
||||||
|
|
||||||
|
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
|
||||||
|
//
|
||||||
|
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
|
||||||
|
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
|
||||||
|
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
|
||||||
|
// 同時是虛詞與實詞的組成部分)。
|
||||||
|
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
|
||||||
|
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
|
||||||
|
//
|
||||||
|
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
|
||||||
|
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
|
||||||
|
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
|
||||||
|
const CJK_STOP_CHARS = new Set(
|
||||||
|
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
|
||||||
|
const ASCII_STOP_WORDS = new Set([
|
||||||
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
|
||||||
|
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
|
||||||
|
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
|
||||||
|
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCjkChar = (ch: string): boolean => /[-ヿ㐀-䶿一-鿿豈-]/.test(ch);
|
||||||
|
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
|
||||||
|
|
||||||
|
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
|
||||||
|
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
|
||||||
|
const runs: { text: string; cjk: boolean }[] = [];
|
||||||
|
let cur = ''; let curCjk = false;
|
||||||
|
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
|
||||||
|
for (const ch of q) {
|
||||||
|
const cjk = isCjkChar(ch);
|
||||||
|
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
|
||||||
|
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
|
||||||
|
cur += ch; curCjk = cjk;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
|
||||||
|
function contentBigrams(run: string): string[] {
|
||||||
|
const chars = [...run];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i + 1 < chars.length; i++) {
|
||||||
|
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
|
||||||
|
out.push(chars[i] + chars[i + 1]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchTerm { term: string; weight: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。
|
||||||
|
* 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。
|
||||||
|
* 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。
|
||||||
|
*/
|
||||||
|
export function tokenizeQuery(q: string): SearchTerm[] {
|
||||||
|
const found = new Map<string, number>();
|
||||||
|
const add = (t: string, w: number) => {
|
||||||
|
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
|
||||||
|
if (!piece) continue;
|
||||||
|
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runs = splitRuns(q);
|
||||||
|
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
|
||||||
|
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
|
||||||
|
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
|
||||||
|
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
|
||||||
|
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
|
||||||
|
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
|
||||||
|
const isQuestion = runs.length > 1;
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!run.cjk) {
|
||||||
|
const w = run.text.toLowerCase();
|
||||||
|
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const chars = [...run.text];
|
||||||
|
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
|
||||||
|
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
|
||||||
|
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
|
||||||
|
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
|
||||||
|
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found.entries()]
|
||||||
|
.map(([term, weight]) => ({ term, weight }))
|
||||||
|
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
|
||||||
|
.slice(0, MAX_SEARCH_TERMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchScorePlan {
|
||||||
|
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
|
||||||
|
scoreExpr: string;
|
||||||
|
scoreParams: string[];
|
||||||
|
terms: SearchTerm[];
|
||||||
|
/** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
|
||||||
|
legacyShape: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。
|
||||||
|
*
|
||||||
|
* 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**:
|
||||||
|
* 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。
|
||||||
|
*/
|
||||||
|
export function buildSearchScore(q: string): SearchScorePlan {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
const terms = tokenizeQuery(trimmed);
|
||||||
|
|
||||||
|
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
|
||||||
|
if (terms.length === 0) {
|
||||||
|
const m = buildContentLike(trimmed);
|
||||||
|
return {
|
||||||
|
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
||||||
|
scoreParams: m.params,
|
||||||
|
terms: [],
|
||||||
|
legacyShape: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const params: string[] = [];
|
||||||
|
for (const { term, weight } of terms) {
|
||||||
|
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||||
|
params.push(`%${term}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||||
|
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||||
|
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||||
|
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||||
|
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
||||||
|
params.push(`%${trimmed}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
|
||||||
|
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
|
||||||
|
if (rows.length <= 1) return rows;
|
||||||
|
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
|
||||||
|
return rows.filter((r) => r.match_score >= cut);
|
||||||
|
}
|
||||||
|
|
||||||
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
|
||||||
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
|
||||||
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
|
||||||
@@ -309,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
|||||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||||
|
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||||
|
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||||
|
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||||
export async function searchEntries(
|
export async function searchEntries(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
q: string,
|
q: string,
|
||||||
@@ -318,18 +524,29 @@ export async function searchEntries(
|
|||||||
library?: string[],
|
library?: string[],
|
||||||
source?: string,
|
source?: string,
|
||||||
includeDeprecated = false,
|
includeDeprecated = false,
|
||||||
): Promise<Entry[]> {
|
): Promise<(Entry & { match_score: number })[]> {
|
||||||
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
|
const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
|
||||||
const conds = [...m.conds];
|
const conds: string[] = [];
|
||||||
const params: unknown[] = [...m.params];
|
const params: unknown[] = [...plan.scoreParams];
|
||||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||||
|
// 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名,
|
||||||
|
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
|
||||||
|
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
|
||||||
|
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
.prepare(
|
||||||
|
`SELECT * FROM (
|
||||||
|
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
|
||||||
|
) WHERE match_score > 0
|
||||||
|
ORDER BY match_score DESC, updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
.bind(...params, Math.min(limit, 200))
|
.bind(...params, Math.min(limit, 200))
|
||||||
.all<Entry>();
|
.all<Entry & { match_score: number }>();
|
||||||
return res.results ?? [];
|
// 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
|
||||||
|
return applyRelativeCut(res.results ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4)
|
||||||
|
//
|
||||||
|
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
|
||||||
|
// kbdb_search("Gemini 逃生口") → 0 筆
|
||||||
|
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
|
||||||
|
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
|
||||||
|
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
|
||||||
|
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
|
||||||
|
//
|
||||||
|
// 本檔守四件事:
|
||||||
|
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
|
||||||
|
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
|
||||||
|
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
|
||||||
|
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
tokenizeQuery,
|
||||||
|
buildSearchScore,
|
||||||
|
applyRelativeCut,
|
||||||
|
searchEntries,
|
||||||
|
} from '../src/actions/entry-crud';
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||||
|
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
|
||||||
|
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
|
||||||
|
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
|
||||||
|
|
||||||
|
describe('① 拆得開:問句要被拆成詞', () => {
|
||||||
|
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
|
||||||
|
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
|
||||||
|
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
|
||||||
|
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
|
||||||
|
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
|
||||||
|
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
|
||||||
|
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
|
||||||
|
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('標點(含全形)當分隔,不會混進詞裡', () => {
|
||||||
|
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
|
||||||
|
for (const t of termsOf('額度、向量化;為什麼?')) {
|
||||||
|
expect(t).not.toMatch(/[、;?,。]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
|
||||||
|
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
|
||||||
|
for (const q of ['是什麼', '的', '。。。', 'a']) {
|
||||||
|
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
|
||||||
|
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
|
||||||
|
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
|
||||||
|
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
|
||||||
|
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
|
||||||
|
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
|
||||||
|
expect(termsOf('這個功能是什麼')).toContain('功能');
|
||||||
|
for (const [q, word] of [
|
||||||
|
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
|
||||||
|
['原因是什麼', '原因'], ['更新了什麼', '更新'],
|
||||||
|
] as const) {
|
||||||
|
expect(termsOf(q)).toContain(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
|
||||||
|
expect(termsOf('今天額度為什麼用完')).toContain('額度');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
|
||||||
|
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
|
||||||
|
const p = buildSearchScore('arcrun');
|
||||||
|
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
|
||||||
|
expect(p.legacyShape).toBe(true);
|
||||||
|
expect(p.scoreParams).toEqual(['%arcrun%']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
|
||||||
|
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
|
||||||
|
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
|
||||||
|
const p = buildSearchScore('語意檢索');
|
||||||
|
expect(p.legacyShape).toBe(true);
|
||||||
|
expect(p.scoreParams).toEqual(['%語意檢索%']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, '語意檢索', 'demo');
|
||||||
|
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
|
||||||
|
expect(captured[0].params[0]).toBe('%語意檢索%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => {
|
||||||
|
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
|
||||||
|
expect(applyRelativeCut(rows)).toHaveLength(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
|
||||||
|
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
|
||||||
|
const q = 'Gemini 在這套系統裡的角色是什麼?';
|
||||||
|
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
|
||||||
|
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
|
||||||
|
const p = buildSearchScore('local arcrun');
|
||||||
|
expect(p.legacyShape).toBe(false);
|
||||||
|
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
|
||||||
|
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
|
||||||
|
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
|
||||||
|
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: 'a', match_score: 10 }, // 兩個詞都中
|
||||||
|
{ id: 'b', match_score: 6 }, // 只中重的那個
|
||||||
|
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
|
||||||
|
];
|
||||||
|
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
|
||||||
|
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
|
||||||
|
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
|
||||||
|
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
|
||||||
|
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
|
||||||
|
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
|
||||||
|
expect(t.length).toBeLessThanOrEqual(6);
|
||||||
|
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
|
||||||
|
const w = t.map((x) => x.weight);
|
||||||
|
expect([...w].sort((a, b) => b - a)).toEqual(w);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
|
||||||
|
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
|
||||||
|
const qs = [
|
||||||
|
'a'.repeat(300),
|
||||||
|
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
|
||||||
|
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
|
||||||
|
'。'.repeat(60),
|
||||||
|
];
|
||||||
|
for (const q of qs) {
|
||||||
|
const p = buildSearchScore(q);
|
||||||
|
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||||
|
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEntries 送出的 SQL', () => {
|
||||||
|
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, 'Gemini 逃生口', 'demo');
|
||||||
|
const sql = captured[0].sql;
|
||||||
|
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
|
||||||
|
expect(sql).toContain('match_score > 0');
|
||||||
|
expect(sql).toContain('ORDER BY match_score DESC');
|
||||||
|
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => {
|
||||||
|
const { db, captured } = fakeDb();
|
||||||
|
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
|
||||||
|
const inner = captured[0].sql.split('WHERE match_score')[0];
|
||||||
|
expect(inner).toContain('owner_id = ?');
|
||||||
|
expect(inner).toContain("json_extract(metadata_json, '$.source')");
|
||||||
|
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeDb() {
|
||||||
|
const captured: { sql: string; params: unknown[] }[] = [];
|
||||||
|
const db = {
|
||||||
|
prepare(sql: string) {
|
||||||
|
return {
|
||||||
|
bind(...params: unknown[]) {
|
||||||
|
captured.push({ sql, params });
|
||||||
|
return { all: async () => ({ results: [] }) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as D1Database;
|
||||||
|
return { db, captured };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user