feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -1,84 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import type {
|
||||
DocumentSearchFilters,
|
||||
DocumentSearchRequest,
|
||||
DocumentSearchResponse,
|
||||
DocumentSearchResult,
|
||||
DocumentSearchTimeRange,
|
||||
} from "@/types/search";
|
||||
import { buildSnippet } from "@/lib/search/snippet";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import { executeDocumentSearchQuery } from "@/lib/search/search-query-adapter";
|
||||
import type { DocumentSearchRequest } from "@/types/search";
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
includeOcr: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
};
|
||||
|
||||
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
|
||||
any: null,
|
||||
"7d": 1000 * 60 * 60 * 24 * 7,
|
||||
"30d": 1000 * 60 * 60 * 24 * 30,
|
||||
};
|
||||
|
||||
const TIME_FIELD_COLUMN = {
|
||||
updated: "updated_at",
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
type RangeIso = { fromIso: string | null; toIso: string | null };
|
||||
|
||||
function parseDateToIso(value: string | undefined): string | null {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
|
||||
const fromIso = parseDateToIso(filters.customRange?.from);
|
||||
const toIso = parseDateToIso(filters.customRange?.to);
|
||||
return { fromIso, toIso };
|
||||
}
|
||||
|
||||
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
|
||||
|
||||
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
|
||||
const out: string[] = [];
|
||||
const pushText = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value : null;
|
||||
if (!s) return;
|
||||
const trimmed = s.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return;
|
||||
out.push(trimmed);
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as MindmapNode;
|
||||
pushText(n.data?.text);
|
||||
if (Array.isArray(n.children)) {
|
||||
for (const c of n.children) {
|
||||
walk(c);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(data);
|
||||
const joined = out.join("\n").trim();
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -86,337 +13,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
||||
const boundaryIso =
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
const { fromIso, toIso } = resolveCustomRangeIso(filters);
|
||||
|
||||
const eligibleDocs = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
|
||||
if (boundaryIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
if (fromIso || toIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (fromIso && ts < fromIso) return false;
|
||||
if (toIso && ts > toIso) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
|
||||
|
||||
type MatchInfo = {
|
||||
score: number;
|
||||
matchField: "title" | "content";
|
||||
snippet: string;
|
||||
hasOcr: boolean;
|
||||
};
|
||||
|
||||
const matches = new Map<string, MatchInfo>();
|
||||
|
||||
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
|
||||
const prev = matches.get(docId);
|
||||
if (!prev) {
|
||||
const score = typeof patch.score === "number" ? patch.score : 0;
|
||||
const matchField = patch.matchField ?? "content";
|
||||
const snippet = patch.snippet ?? "暂无正文内容";
|
||||
const hasOcr = Boolean(patch.hasOcr);
|
||||
matches.set(docId, { score, matchField, snippet, hasOcr });
|
||||
return;
|
||||
}
|
||||
|
||||
const next: MatchInfo = {
|
||||
score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score,
|
||||
matchField: patch.matchField ?? prev.matchField,
|
||||
snippet: patch.snippet ?? prev.snippet,
|
||||
hasOcr: prev.hasOcr || Boolean(patch.hasOcr),
|
||||
};
|
||||
|
||||
// 若现有是标题匹配,不用较低分覆盖文案。
|
||||
if (prev.matchField === "title" && next.matchField !== "title") {
|
||||
next.matchField = "title";
|
||||
next.snippet = prev.snippet;
|
||||
}
|
||||
|
||||
// 若新分数更高,则用新片段(除标题外)
|
||||
if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") {
|
||||
next.snippet = patch.snippet ?? next.snippet;
|
||||
next.matchField = patch.matchField ?? next.matchField;
|
||||
}
|
||||
|
||||
matches.set(docId, next);
|
||||
};
|
||||
|
||||
for (const row of eligibleDocs) {
|
||||
const title = (row.title ?? "无标题").trim() || "无标题";
|
||||
const titleLower = title.toLowerCase();
|
||||
const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower);
|
||||
if (hitTitle) {
|
||||
upsertMatch(row.id, {
|
||||
score: 3,
|
||||
matchField: "title",
|
||||
snippet: buildSnippet(title, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.titleOnly) continue;
|
||||
|
||||
const rawText = String(row.raw_text ?? "").trim();
|
||||
if (rawText) {
|
||||
const hitContent = rawText.toLowerCase().includes(normalizedLower);
|
||||
if (hitContent) {
|
||||
upsertMatch(row.id, {
|
||||
score: 2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(rawText, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.titleOnly) {
|
||||
// 思维导图(默认纳入“正文”搜索)
|
||||
const mindmaps = await client
|
||||
.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false })
|
||||
.catch(() => []);
|
||||
for (const m of mindmaps) {
|
||||
const docId = String(m.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const text = extractTextFromMindmapData(m.data ?? null);
|
||||
if (!text) continue;
|
||||
if (!text.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.6,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`思维导图:${text}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// Luckysheet 在线表格(标题 + 行内容)
|
||||
const [tables, tableRows] = await Promise.all([
|
||||
client
|
||||
.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeArchived: false,
|
||||
limit: 3000,
|
||||
})
|
||||
.catch(() => []),
|
||||
client
|
||||
.query(api.tables.listRowsByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 8000,
|
||||
})
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
const tableTitleById = new Map<string, string>();
|
||||
for (const t of tables) {
|
||||
if (!eligibleDocIds.has(t.document_id)) continue;
|
||||
tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格");
|
||||
|
||||
const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower);
|
||||
if (hitTableTitle && docMap.has(t.document_id)) {
|
||||
upsertMatch(t.document_id, {
|
||||
score: 1.5,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${t.title}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of tableRows) {
|
||||
const docId = String(r.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
const rowHash = String(r.row_hash ?? "").trim();
|
||||
if (!rowHash) continue;
|
||||
if (!rowHash.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格";
|
||||
upsertMatch(docId, {
|
||||
score: 1.4,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。
|
||||
const assets = await client
|
||||
.query(api.mediaAssets.listSearchDataByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeDeleted: false,
|
||||
limit: 5000,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = [];
|
||||
|
||||
for (const a of assets) {
|
||||
const docId = String(a.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const fileName = String(a.file_name ?? "").trim();
|
||||
if (fileName && fileName.toLowerCase().includes(normalizedLower)) {
|
||||
upsertMatch(docId, {
|
||||
score: 1.2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (!filters.includeOcr) continue;
|
||||
|
||||
// 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。
|
||||
const ocrTextValue = String(a.ocr_text ?? "").trim();
|
||||
if (!ocrTextValue) {
|
||||
const assetType = String((a as any).asset_type ?? "").trim() || null;
|
||||
const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null;
|
||||
const ocrStatus = String((a as any).ocr_status ?? "").trim() || null;
|
||||
const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null;
|
||||
|
||||
const supported =
|
||||
assetType === "file" &&
|
||||
(mimeType === "application/pdf" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
(nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false));
|
||||
|
||||
const busy = ocrStatus === "queued" || ocrStatus === "running";
|
||||
if (supported && !busy) {
|
||||
toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.7,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery),
|
||||
hasOcr: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (toEnqueueExtract.length > 0) {
|
||||
// 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。
|
||||
await Promise.all(
|
||||
toEnqueueExtract
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results: DocumentSearchResult[] = [];
|
||||
for (const [docId, match] of matches.entries()) {
|
||||
const row = docMap.get(docId);
|
||||
if (!row) continue;
|
||||
results.push({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: match.snippet,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: match.matchField,
|
||||
hasOcr: match.hasOcr,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: match.score,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const ta = a.updatedAt ?? a.createdAt ?? "";
|
||||
const tb = b.updatedAt ?? b.createdAt ?? "";
|
||||
const byTime = tb.localeCompare(ta);
|
||||
if (byTime) return byTime;
|
||||
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
|
||||
const { response, meta } = await executeDocumentSearchQuery({
|
||||
request,
|
||||
payload,
|
||||
});
|
||||
|
||||
const limitedResults = results.slice(0, limit);
|
||||
|
||||
const response: DocumentSearchResponse = { results: limitedResults, recent };
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"x-request-id": meta.requestId,
|
||||
"x-trace-id": meta.traceId,
|
||||
"x-query-name": meta.queryName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
|
||||
console.error("[search/documents] failed:", err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user