fix: tighten decision center query paths

This commit is contained in:
Codex
2026-05-21 04:46:19 +00:00
parent 12a440f9d3
commit 8479258fea
8 changed files with 198 additions and 31 deletions
@@ -438,6 +438,14 @@ function diaryQuery(filters: AnyRecord): string {
return params.toString();
}
function diaryEntryLookupPath(entry: any): string {
const key = entry?.id || entry?.date;
const params = new URLSearchParams();
if (entry?.sourceFile) params.set("sourceFile", String(entry.sourceFile));
const query = params.toString();
return `/api/diary/entries/${encodeURIComponent(key)}${query ? `?${query}` : ""}`;
}
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${stableTestId(entry.date || entry.id)}` },
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
@@ -702,7 +710,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
async function selectDiaryEntry(entry: any): Promise<void> {
setDiaryState((prev: any) => ({ ...prev, selected: entry }));
try {
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(entry.date || entry.id)}`));
const response = await requestJson(decisionApi(apiBaseUrl, diaryEntryLookupPath(entry)));
const selected = response.entry || entry;
setDiaryState((prev: any) => ({ ...prev, selected }));
setDiaryForm(diaryFormFromEntry(selected));
@@ -740,10 +748,18 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
}));
}
function editRecord(record: any): void {
async function editRecord(record: any): Promise<void> {
setRecordForm(recordFormFromRecord(record));
setRecordSaveState({ saving: false, error: "", message: `正在编辑 ${record?.id || ""}` });
setActiveView("records");
if (!record?.id || record?.body) return;
try {
const response = await requestJson(decisionApi(apiBaseUrl, `/api/records/${encodeURIComponent(record.id)}`));
const fullRecord = response.record || record;
setRecordForm(recordFormFromRecord(fullRecord));
} catch (err) {
setRecordSaveState({ saving: false, error: errorMessage(err, "记录正文加载失败"), message: "" });
}
}
async function saveRecord(event: any): Promise<void> {
@@ -281,7 +281,8 @@ function summaryFromBody(body: string): string {
.slice(0, 280);
}
function recordFromRow(row: DecisionRecordRow): DecisionRecord {
function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean } = {}): DecisionRecord {
const includeBody = options.includeBody !== false;
const body = row.body || "";
return {
id: row.id,
@@ -290,7 +291,7 @@ function recordFromRow(row: DecisionRecordRow): DecisionRecord {
status: row.status,
title: row.title,
summary: summaryFromBody(body),
body,
body: includeBody ? body : "",
priority: row.level,
linkedGoalId: row.linked_goal_id,
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
@@ -606,6 +607,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
const tag = asString(url.searchParams.get("tag"));
const query = asString(url.searchParams.get("q") ?? url.searchParams.get("query"));
const requirementOnly = options.requirementOnly === true || url.searchParams.get("requirementOnly") === "true";
const includeBody = url.searchParams.get("includeBody") === "true";
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200));
if (type && !recordTypes.has(type as DecisionRecordType)) throw new HttpError(400, "unsupported type filter", { type });
if (requirementOnly && type && !requirementRecordTypes.has(type as RequirementRecordType)) throw new HttpError(400, "unsupported requirement type filter", { type });
@@ -616,7 +618,23 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
if (tag.length > 120) throw new HttpError(400, "tag filter must be at most 120 characters");
if (query.length > 240) throw new HttpError(400, "query filter must be at most 240 characters");
const rows = await withDatabaseRecovery("list_records", () => sql<DecisionRecordRow[]>`
SELECT *
SELECT
id,
type,
level,
status,
title,
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
linked_goal_id,
tags,
evidence_links,
source,
source_session,
issue_id,
task_id,
commit_id,
created_at,
updated_at
FROM decision_center_records
WHERE (${type || null}::text IS NULL OR type = ${type || null})
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
@@ -642,7 +660,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
updated_at DESC
LIMIT ${limit}
`, { retryRead: true });
return rows.map(recordFromRow);
return rows.map((row) => recordFromRow(row, { includeBody }));
}
async function getRequirementRecord(id: string): Promise<DecisionRecord> {
@@ -851,6 +869,11 @@ function validateDiarySourceFile(value: string): string {
return value;
}
function sourceFileFilterFromUrl(url: URL): string {
const sourceFile = asString(url.searchParams.get("sourceFile") ?? url.searchParams.get("sourcePath") ?? url.searchParams.get("source"));
return sourceFile ? validateDiarySourceFile(sourceFile) : "";
}
function diaryTitleFor(date: string, body: string, rawTitle: unknown): string {
const title = asString(rawTitle) || titleFromMarkdown(body, `${date} 工作日志`);
if (!title) throw new HttpError(400, "title is required");
@@ -952,14 +975,26 @@ async function listDiaryEntries(url: URL): Promise<DiaryEntry[]> {
const month = asString(url.searchParams.get("month"));
const from = asString(url.searchParams.get("from"));
const to = asString(url.searchParams.get("to"));
const sourceFile = asString(url.searchParams.get("sourceFile"));
const sourceFile = sourceFileFilterFromUrl(url);
const includeBody = url.searchParams.get("includeBody") === "true";
const limit = Math.max(1, Math.min(1000, Number(url.searchParams.get("limit") || 240) || 240));
if (month) validMonthFilter(month);
if (from) validDateFilter(from, "from");
if (to) validDateFilter(to, "to");
const rows = await withDatabaseRecovery("list_diary_entries", () => sql<DiaryEntryRow[]>`
SELECT *
SELECT
id,
entry_date,
month,
title,
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
source_file,
markdown_path,
tags,
content_hash,
created_at,
updated_at,
imported_at
FROM decision_center_diary_entries
WHERE (${month || null}::text IS NULL OR month = ${month || null})
AND (${from || null}::date IS NULL OR entry_date >= ${from || null}::date)
@@ -987,17 +1022,21 @@ async function listDiaryMonths(): Promise<JsonRecord[]> {
}));
}
async function getDiaryEntry(key: string): Promise<DiaryEntry> {
async function getDiaryEntry(key: string, options: { sourceFile?: string } = {}): Promise<DiaryEntry> {
const date = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : "";
const sourceFile = options.sourceFile ? validateDiarySourceFile(options.sourceFile) : "";
const rows = await withDatabaseRecovery("get_diary_entry", () => sql<DiaryEntryRow[]>`
SELECT *
FROM decision_center_diary_entries
WHERE (${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
OR (${date || null}::date IS NULL AND id = ${key})
WHERE (
(${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
OR (${date || null}::date IS NULL AND id = ${key})
)
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
ORDER BY imported_at DESC
LIMIT 1
`, { retryRead: true });
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key });
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key, ...(sourceFile ? { sourceFile } : {}) });
return diaryEntryFromRow(rows[0]!);
}
@@ -1012,8 +1051,11 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
const existingRows = await withDatabaseRecovery("get_diary_entry_for_upsert", () => sql<DiaryEntryRow[]>`
SELECT *
FROM decision_center_diary_entries
WHERE (${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date)
OR (${keyDate || null}::date IS NULL AND id = ${key})
WHERE (
(${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date)
OR (${keyDate || null}::date IS NULL AND id = ${key})
)
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
ORDER BY imported_at DESC
LIMIT 1
`, { retryRead: true });
@@ -1147,7 +1189,7 @@ async function route(req: Request): Promise<Response> {
if (diaryMatch !== null) {
const key = decodeURIComponent(diaryMatch[1] ?? "");
if (!key) throw new HttpError(400, "diary entry id or date is required");
if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key) });
if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key, { sourceFile: sourceFileFilterFromUrl(url) }) });
if (method === "PUT") {
const result = await upsertDiaryEntryByKey(key, await readJsonBody(req));
return jsonResponse(result, result.action === "created" ? 201 : 200);