-
-
- 荔波POI图层
-
- {visiblePois.length.toLocaleString()} / {mapPois.length.toLocaleString()}
-
-
- {poiLoading &&
}
+ {/* 高德式搜索条:logo + 搜索框 + 展开箭头,默认只占一行;点击箭头才展开图层详情。
+ 整条外框只保留最外层卡片的描边,输入框本身去边框,避免"框中框"。 */}
+
+
+

+ {/* 输入框容器保持 static,让下拉面板以外层 searchBoxRef 定位 → 宽度与整条搜索栏一致 */}
+
+
{
+ setPoiKeyword(event.target.value);
+ setSearchCatFilter("");
+ setSearchOpen(true);
+ }}
+ onFocus={() => setSearchOpen(true)}
+ onPressEnter={() => {
+ if (poiSuggestions.length) flyToPoi(poiSuggestions[0]);
+ }}
+ allowClear
+ placeholder="搜索附近 美食、酒店、景区、交通…"
+ style={{ padding: 0 }}
+ />
+ {searchOpen && (
+
+ {searchTyping ? (
+ poiSuggestions.length ? (
+ <>
+ {searchCatFilter && (
+
+ )}
+
+ {poiSuggestions.map((poi) => {
+ const color = poiCategoryColor(poi.category);
+ return (
+
flyToPoi(poi)}
+ className="libo-poi-search-row"
+ style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "9px 8px", borderRadius: 9, cursor: "pointer" }}
+ >
+
+
+
+
+ {highlightMatch(poi.name, poiKeyword)}
+
+
+ {poi.category}
+
+
+ {poi.address && (
+
+ {poi.address}
+
+ )}
+
+
+ );
+ })}
+
+
+ 共 {poiSuggestions.length} 条{poiSuggestions.length >= 20 ? "(仅显示前 20)" : ""}
+ 回车定位第一个
+
+ >
+ ) : (
+
+ 没有匹配的 POI,换个词试试
+
+ )
+ ) : (
+ <>
+ {/* 高德式快捷分类:无标题,一排大图标 + 文字,占满宽度。
+ 固定顺序与占位文字一致:美食 → 酒店 → 景点 → 交通设施。 */}
+
+ {(["美食", "酒店", "景点", "交通设施"] as string[])
+ .filter((category) => poiCategories.some((item) => item.category === category))
+ .map((category) => {
+ const iconUrl = poiCategoryIcon(category);
+ const showImg = iconUrl && !catIconErr[category];
+ return (
+
{
+ // 同时:① 列表按该大类过滤 ② 地图只显示该大类的点(高德点分类即筛地图)
+ setSearchCatFilter(category);
+ setPoiKeyword("");
+ setSearchOpen(true);
+ setSelectedCategories([category]);
+ }}
+ className="libo-poi-cat-tile"
+ style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "12px 4px 10px", borderRadius: 12, cursor: "pointer" }}
+ >
+ {showImg ? (
+

setCatIconErr((m) => ({ ...m, [category]: true }))}
+ style={{ display: "block", objectFit: "contain" }}
+ />
+ ) : (
+
{poiCategoryEmoji(category)}
+ )}
+
{category}
+
+ );
+ })}
+
+
+
+
最近搜索
+ {recentSearches.length > 0 && (
+
清除
+ )}
+
+ {recentSearches.length ? (
+
+ {recentSearches.map((poi) => (
+
flyToPoi(poi)}
+ className="libo-poi-search-row"
+ style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px", borderRadius: 8, cursor: "pointer", fontSize: 13, color: "#1f2329" }}
+ >
+
+ {poi.name}
+
+ ))}
+
+ ) : (
+
+ 还没有搜索记录
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* 高德式搜索按钮:输入框右侧的独立方形按钮,放大镜居中;对应高德 #searchbtn */}
+
{ if (poiSuggestions.length) flyToPoi(poiSuggestions[0]); }}
+ className="libo-searchbox-btn"
+ style={{
+ width: 38,
+ height: 34,
+ flex: "0 0 auto",
+ display: "grid",
+ placeItems: "center",
+ cursor: "pointer",
+ borderRadius: 8,
+ }}
+ >
+
+
+ {/* 竖向分隔线:区隔搜索区与右侧箭头,对应高德 iptbox 与 direntry 的分界 */}
+
+ {/* 展开箭头:对应高德 .direntry 的蓝色导航按钮位置 */}
+
+ setPoiPanelExpanded((v) => !v)}
+ className="libo-searchbox-btn"
+ style={{
+ width: 38,
+ height: 34,
+ flex: "0 0 auto",
+ display: "grid",
+ placeItems: "center",
+ cursor: "pointer",
+ borderRadius: 8,
+ }}
+ >
+ {poiPanelExpanded
+ ?
+ : }
+
+
+
+ {poiPanelExpanded && (
+ <>
+
+
+
+ 荔波POI图层
+
+ {mapRenderPois.length.toLocaleString()} / {regionPois.length.toLocaleString()}
+
+
+ {poiLoading &&
}
+
{poiError ? (
{poiError}
) : (
@@ -3078,6 +3901,26 @@ export function KnowledgeMap({
+ setZonePickerOpen(checked)}
+ style={{
+ marginInlineEnd: 0,
+ border: `1px solid ${zonePickerOpen || selectedTowncode ? ZONE_FACE_COLOR : "#d9d9d9"}`,
+ color: zonePickerOpen || selectedTowncode ? "#fff" : "#475467",
+ background: zonePickerOpen || selectedTowncode
+ ? ZONE_FACE_COLOR
+ : "rgba(255,255,255,.84)",
+ }}
+ >
+ 区域图层
+ {selectedZone
+ ? ` · ${selectedZone.zone_name}`
+ : selectedTown
+ ? ` · ${selectedTown.name}`
+ : ""}
+
+ {zoneLoading && }
setBusLayerVisible(checked)}
@@ -3091,6 +3934,47 @@ export function KnowledgeMap({
公交图层
{busLoading && }
+ {/* 底图图层选择:标准 / 卫星影像 + 实时路况叠加。
+ 用 span 作触发器(CheckableTag 会吞掉点击导致菜单打不开)。 */}
+ node.parentElement || document.body}
+ menu={{
+ selectable: false,
+ items: [
+ { key: "standard", label: `${!satelliteOn ? "✓ " : " "}标准地图` },
+ { key: "satellite", label: `${satelliteOn ? "✓ " : " "}卫星影像` },
+ { type: "divider" as const },
+ { key: "traffic", label: `${trafficOn ? "✓ " : " "}实时路况` },
+ ],
+ onClick: ({ key }) => {
+ if (key === "standard") setSatelliteOn(false);
+ else if (key === "satellite") setSatelliteOn(true);
+ else if (key === "traffic") setTrafficOn((v) => !v);
+ },
+ }}
+ >
+
+ 图层
+
+
+ {zonePickerOpen && (
+
+ {zoneError ? (
+
{zoneError}
+ ) : (
+ <>
+
+ 行政区(法定 · 高德无乡镇级边界矢量,只过滤不画线)
+
+
+
+ 全部
+
+ {zoneTowns.map((town) => {
+ const checked = selectedTowncode === town.towncode;
+ return (
+ selectTown(next ? town.towncode : "")}
+ style={{
+ marginInlineEnd: 0,
+ border: `1px solid ${checked ? ZONE_FACE_COLOR : "#d9d9d9"}`,
+ color: checked ? "#fff" : "#475467",
+ background: checked ? ZONE_FACE_COLOR : "rgba(255,255,255,.84)",
+ }}
+ >
+ {town.name} {town.poi_count}
+
+ );
+ })}
+
+ {townZones.length > 0 && (
+ <>
+
+ {selectedTown?.name} 片区(业务口径 · 无行政效力 · 实测多边形 · 点在多边形内判定)
+
+
+ setSelectedZoneId("")}
+ style={{ marginInlineEnd: 0 }}
+ >
+ 全片区
+
+ {townZones.map((zone) => {
+ const checked = selectedZoneId === zone.zone_id;
+ return (
+
+ setSelectedZoneId(next ? zone.zone_id : "")}
+ style={{
+ marginInlineEnd: 0,
+ border: `1px solid ${checked ? ZONE_FACE_COLOR : `${ZONE_FACE_COLOR}55`}`,
+ color: checked ? "#fff" : "#4c1d95",
+ background: checked ? ZONE_FACE_COLOR : `${ZONE_FACE_COLOR}12`,
+ }}
+ >
+ {zone.zone_name} {zone.poi_count}
+
+
+ );
+ })}
+
+ >
+ )}
+ {selectedTowncode && !townZones.length && (
+
+ {selectedTown?.name} 暂无业务片区划分(片区仅覆盖玉屏街道县城范围)。
+
+ )}
+ >
+ )}
+
+ )}
{busLines.map((lineName) => {
const color = busLineColor(lineName);
@@ -3134,6 +4109,195 @@ export function KnowledgeMap({
{busError && (
{busError}
)}
+ >
+ )}
+
+ )}
+ {isLibo && weather && (
+ /* 高德式天气卡:紧贴搜索卡右侧;点击展开未来多日预报(数据来自高德 AMap.Weather)。 */
+
+
weatherForecast.length && setWeatherOpen((v) => !v)}
+ style={{
+ height: 54, // 与搜索卡等高(padding 9 + 内容 34 + 边框 2 = 54),顶部、底部对齐
+ boxSizing: "border-box",
+ display: "flex",
+ alignItems: "center",
+ gap: 11,
+ padding: "0 12px 0 14px",
+ borderRadius: 6,
+ background: "rgba(255,255,255,.94)",
+ border: `1px solid ${weatherOpen ? "#1677ff" : "rgba(145,202,255,.9)"}`,
+ boxShadow: "0 8px 24px rgba(15,23,42,.14)",
+ backdropFilter: "blur(8px)",
+ whiteSpace: "nowrap",
+ cursor: weatherForecast.length ? "pointer" : "default",
+ }}
+ >
+ {weatherEmoji(weather.condition)}
+
+ 荔波县
+
+ {weather.condition}
+
+
+ {(weather.low || weather.high) && (
+ <>
+
+
+
+ {weather.high ?? "—"}°
+
+
+ {weather.low ?? "—"}°-{weather.high ?? "—"}°
+
+
+ >
+ )}
+
+ {weatherOpen && weatherForecast.length > 0 && (
+
+
+ 荔波县
+ 天气预报
+
+ {/* 「现在」块:用高德实时接口(getLive)的此刻数据,代表当前小时的天气。 */}
+
+
{weatherEmoji(weather.condition)}
+
+ 现在
+
+
+ {weather.temp ? `${weather.temp}°` : `${weather.high ?? "—"}°`}
+
+ {weather.condition}
+
+
+ {(weather.wind || weather.humidity) && (
+
+ {weather.wind && {weather.wind}}
+ {weather.humidity && 湿度 {weather.humidity}%}
+
+ )}
+
+ {/* 逐小时气温(和风天气):横向滚动,仿 iPhone「现在 / 20时 / 21时…」 */}
+ {weatherHours.length > 0 && (
+
+
逐小时预报
+
+ {/* 「现在」格:和风 24h 从下一个整点起,缺当前小时;用实时温度补一格,和 iPhone 一致。 */}
+ {weather.temp && (
+
+ 现在
+ {weatherEmoji(weather.condition)}
+ {weather.temp}°
+
+ )}
+ {weatherHours.map((hour, index) => (
+
+
+ {`${hour.time.slice(11, 13)}时`}
+
+ {weatherEmojiAt(hour.text, hour.time)}
+ {hour.temp}°
+
+ ))}
+
+
+ )}
+
逐日预报
+
+ {weatherForecast.map((day, index) => (
+
+
+ {forecastDayLabel(day, index)}
+
+ {weatherEmoji(day.dayWeather)}
+
+ {day.dayWeather}
+
+ {day.nightTemp}°
+ {/* 温度条:低温(左·冷蓝) → 高温(右·暖橙),整条铺满,不做跨天比例定位以免出现左侧空白。 */}
+
+ {day.dayTemp}°
+
+ ))}
+
+
+ 数据来源:高德天气
+
+
+ )}
)}
@@ -3269,6 +4433,54 @@ export function KnowledgeMap({
accent={poiCategoryColor(poiDetail.category)}
/>
+ {isBusStop && busStopLines.length > 0 && (
+
+
+
+ 途经线路
+ {busStopLines.length} 条
+
+
+ {busStopLines.map(({ lineName, directions }) => {
+ const color = busLineColor(lineName);
+ return (
+
+
+ {lineName}
+ {directions[0]?.fare_yuan ? (
+ 票价 {directions[0].fare_yuan} 元
+ ) : null}
+
+ {directions.length ? (
+
+ {directions.map((route) => (
+
openBusRoute(route)}
+ className="libo-poi-search-row"
+ style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 7px", borderRadius: 7, cursor: "pointer", fontSize: 12.5, color: "#475467" }}
+ >
+
+
+ {route.start_stop || "起点"} → {route.end_stop || "终点"}
+
+
+ {route.stop_count ? `${route.stop_count}站` : ""}
+ {route.first_bus ? ` · ${route.first_bus}-${route.last_bus}` : ""}
+
+
+ ))}
+
+ ) : (
+
该线路暂无方向/班次数据
+ )}
+
+ );
+ })}
+
+
+ )}
+
;
+};
+
+export type OverviewCategoryStatus = "loading" | "ready" | "empty" | "disconnected";
+
+export type OverviewCategoryView = {
+ categories: EntityCategory[];
+ source: "graph" | "legacy-candidates";
+ status: OverviewCategoryStatus;
+};
+
+/**
+ * Build the generic-project distribution from FalkorDB's real node labels.
+ * PostgreSQL candidate_entities represents the review pipeline and can be
+ * empty even when a newly provisioned graph already contains nodes.
+ */
+export function buildGenericGraphCategories(
+ graph: Record,
+ labelNames: Record = {},
+): EntityCategory[] {
+ if (graph.connected !== true || !Array.isArray(graph.label_counts)) return [];
+
+ const totals = new Map();
+ graph.label_counts.forEach((raw) => {
+ if (!raw || typeof raw !== "object") return;
+ const row = raw as Record;
+ const label = String(row.label || "").trim();
+ const count = Number(row.count || 0);
+ if (!label || label === "(no label)" || !Number.isFinite(count) || count <= 0) return;
+ totals.set(label, (totals.get(label) || 0) + count);
+ });
+
+ return Array.from(totals.entries())
+ .map(([label, total]) => ({
+ key: `label:${label}`,
+ label: labelNames[label] || label,
+ total,
+ sub: [],
+ }))
+ .sort((a, b) => b.total - a.total || a.label.localeCompare(b.label, "zh-CN"));
+}
+
+/**
+ * Select the distribution shown by PlazaOverview without confusing an empty
+ * real graph with an empty result that should fall back to review-pipeline
+ * data. Only the two legacy knowledge-map projects retain that fallback.
+ */
+export function resolveOverviewCategoryView({
+ graph,
+ graphCategories,
+ legacyCategories,
+ allowLegacyFallback,
+ loading,
+}: {
+ graph: Record;
+ graphCategories: EntityCategory[];
+ legacyCategories: EntityCategory[];
+ allowLegacyFallback: boolean;
+ loading: boolean;
+}): OverviewCategoryView {
+ if (loading) {
+ return { categories: [], source: "graph", status: "loading" };
+ }
+
+ if (graph.connected === true && graphCategories.length > 0) {
+ return { categories: graphCategories, source: "graph", status: "ready" };
+ }
+
+ if (allowLegacyFallback && legacyCategories.length > 0) {
+ return {
+ categories: legacyCategories,
+ source: "legacy-candidates",
+ status: "ready",
+ };
+ }
+
+ if (graph.connected !== true) {
+ return { categories: [], source: "graph", status: "disconnected" };
+ }
+
+ return { categories: [], source: "graph", status: "empty" };
+}
diff --git a/admin-web/src/projectCatalog.ts b/admin-web/src/projectCatalog.ts
new file mode 100644
index 0000000..2cf0634
--- /dev/null
+++ b/admin-web/src/projectCatalog.ts
@@ -0,0 +1,33 @@
+export type ProjectCatalogItem = {
+ project_id: string;
+ tenant_id?: string;
+ display_name?: string;
+ name?: string;
+ description?: string | null;
+ status?: string;
+ created_at?: string;
+ updated_at?: string;
+};
+
+export const PROJECT_CATALOG_CHANGED_EVENT = "znkg-project-catalog-changed";
+
+export function mergeProjectCatalogItem(
+ projects: T[],
+ updatedProject: ProjectCatalogItem,
+): T[] {
+ const existingIndex = projects.findIndex(
+ (project) => project.project_id === updatedProject.project_id,
+ );
+ if (existingIndex < 0) return [...projects, updatedProject as T];
+
+ return projects.map((project, index) => (
+ index === existingIndex ? { ...project, ...updatedProject } : project
+ )) as T[];
+}
+
+export function notifyProjectCatalogChanged(project: ProjectCatalogItem): void {
+ window.dispatchEvent(new CustomEvent(
+ PROJECT_CATALOG_CHANGED_EVENT,
+ { detail: project },
+ ));
+}
diff --git a/admin-web/src/schemaCapabilities.ts b/admin-web/src/schemaCapabilities.ts
new file mode 100644
index 0000000..783e083
--- /dev/null
+++ b/admin-web/src/schemaCapabilities.ts
@@ -0,0 +1,264 @@
+export type SchemaObject = Record;
+
+export type SchemaTypeEntry = {
+ name: string;
+ meta: SchemaObject;
+};
+
+export type GraphQueryTemplate = {
+ label: string;
+ cypher: string;
+};
+
+/**
+ * The generic browser must not derive its initial canvas from relationships
+ * alone: a newly created graph may validly contain only isolated nodes.
+ * OPTIONAL MATCH keeps normal relationships in the same result while still
+ * emitting one row for nodes without an outgoing relationship.
+ */
+export const GENERIC_GRAPH_DEFAULT_CYPHER =
+ "MATCH (n) OPTIONAL MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 2500";
+
+/** Relationship-only sampling remains available as an explicit shortcut. */
+export const GENERIC_GRAPH_RELATIONS_CYPHER =
+ "MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 2500";
+
+type GraphOverviewLike = {
+ label_counts?: unknown;
+ relationship_type_counts?: unknown;
+};
+
+function asObject(value: unknown): SchemaObject {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as SchemaObject)
+ : {};
+}
+
+function parseObject(value: unknown): SchemaObject {
+ if (typeof value !== "string") return asObject(value);
+ try {
+ return asObject(JSON.parse(value));
+ } catch {
+ return {};
+ }
+}
+
+/** Accept either a schema body or an ontology-schema API row. */
+export function schemaDefinition(value: unknown): SchemaObject {
+ const row = asObject(value);
+ return Object.prototype.hasOwnProperty.call(row, "schema_jsonb")
+ ? parseObject(row.schema_jsonb)
+ : row;
+}
+
+function entryMeta(value: unknown, kind: "entity" | "relation"): SchemaObject {
+ if (!Array.isArray(value)) return asObject(value);
+ if (kind === "relation") {
+ return {
+ from: value[0],
+ to: value[1],
+ definition: value[2],
+ };
+ }
+ return {};
+}
+
+function namedEntries(value: unknown, kind: "entity" | "relation"): SchemaTypeEntry[] {
+ if (Array.isArray(value)) {
+ return value.flatMap((item) => {
+ const meta = asObject(item);
+ const name = String(
+ meta.name || meta.entity_type || meta.relation_type || "",
+ ).trim();
+ return name ? [{ name, meta }] : [];
+ });
+ }
+
+ return Object.entries(asObject(value)).flatMap(([rawName, rawMeta]) => {
+ const name = rawName.trim();
+ return name ? [{ name, meta: entryMeta(rawMeta, kind) }] : [];
+ });
+}
+
+function uniqueEntries(entries: SchemaTypeEntry[]): SchemaTypeEntry[] {
+ const seen = new Set();
+ return entries.filter(({ name }) => {
+ if (seen.has(name)) return false;
+ seen.add(name);
+ return true;
+ });
+}
+
+export function schemaEntityTypes(value: unknown): SchemaTypeEntry[] {
+ return uniqueEntries(namedEntries(schemaDefinition(value).entity_types, "entity"));
+}
+
+export function schemaRelationTypes(value: unknown): SchemaTypeEntry[] {
+ return uniqueEntries(namedEntries(schemaDefinition(value).relation_types, "relation"));
+}
+
+export type LegacyGraphProfile = "travel" | "travel_item" | "libo";
+
+export function schemaHasDeclaredTypes(value: unknown): boolean {
+ return schemaEntityTypes(value).length > 0 || schemaRelationTypes(value).length > 0;
+}
+
+/** Keep specialized legacy shortcuts only when the active Schema has their model shape. */
+export function schemaSupportsLegacyGraphProfile(
+ value: unknown,
+ profile: LegacyGraphProfile,
+): boolean {
+ const entities = new Set(schemaEntityTypes(value).map(({ name }) => name));
+ const relations = new Set(schemaRelationTypes(value).map(({ name }) => name));
+ const hasEntities = (...names: string[]) => names.every((name) => entities.has(name));
+ const hasAnyRelation = (...names: string[]) => names.some((name) => relations.has(name));
+
+ if (profile === "travel_item") {
+ return hasEntities("TourProduct", "RouteStop", "TravelItem")
+ && hasAnyRelation("DAY_HAS_STOP", "PRODUCT_HAS_ORDERED_STOP")
+ && hasAnyRelation("STOP_VISITS_ATTRACTION", "STOP_VISITS_SCENIC_AREA");
+ }
+ if (profile === "travel") {
+ return hasEntities("TourProduct", "RouteStop")
+ && hasAnyRelation("DAY_HAS_STOP", "PRODUCT_HAS_ORDERED_STOP", "ROUTE_STOP_NEXT");
+ }
+
+ const liboEntityCount = ["Hotel", "FoodPlace", "TransitFacility", "ScenicSpot"]
+ .filter((name) => entities.has(name)).length;
+ return liboEntityCount >= 2;
+}
+
+/**
+ * Guidance on the Schema page must describe capabilities that the selected
+ * schema actually declares. Generic projects should not inherit travel-route
+ * rules merely because the admin page is shared.
+ */
+export function buildSchemaGuidance(value: unknown): string[] {
+ const body = schemaDefinition(value);
+ const entities = new Set(schemaEntityTypes(body).map(({ name }) => name));
+ const relations = new Set(schemaRelationTypes(body).map(({ name }) => name));
+ const guidance: string[] = [];
+
+ const hasRouteStop = entities.has("RouteStop");
+ const hasRouteSkeleton = [
+ "ROUTE_STOP_NEXT",
+ "DAY_HAS_STOP",
+ "PRODUCT_HAS_ORDERED_STOP",
+ ].some((name) => relations.has(name));
+ const schemaText = JSON.stringify(body).toLowerCase();
+ const explicitlyFixed = /(固定路线|固定线路|不可改|immutable|固定且有序)/.test(schemaText);
+
+ if (hasRouteStop && hasRouteSkeleton && (explicitlyFixed || relations.has("ROUTE_STOP_NEXT"))) {
+ guidance.push("固定路线不可改");
+ }
+ if (hasRouteStop && [...relations].some((name) => name.startsWith("STOP_VISITS_"))) {
+ guidance.push("RouteStop 串联景区");
+ }
+ if (hasRouteStop && relations.has("STOP_USES_DEFAULT_RESOURCE")) {
+ guidance.push("默认资源挂停靠点");
+ }
+ if (hasRouteStop && [...relations].some((name) => name.includes("NEARBY"))) {
+ guidance.push("可替换资源走 nearby");
+ }
+ if (entities.has("ResourcePriceRule")) {
+ guidance.push("价格统一用 ResourcePriceRule");
+ }
+
+ return guidance;
+}
+
+function overviewEntries(value: unknown, key: "label" | "type"): SchemaTypeEntry[] {
+ if (!Array.isArray(value)) return [];
+ return uniqueEntries(value.flatMap((item) => {
+ const row = asObject(item);
+ const name = String(row[key] || "").trim();
+ const count = Number(row.count);
+ return name && Number.isFinite(count) && count > 0 ? [{ name, meta: {} }] : [];
+ }));
+}
+
+// Schema identifiers created by the project wizard are ASCII identifiers.
+// Unicode letters are also accepted for existing graphs; punctuation is not.
+function isSafeCypherIdentifier(value: string): boolean {
+ return /^[\p{L}_][\p{L}\p{N}_]*$/u.test(value);
+}
+
+function quoteCypherIdentifier(value: string): string {
+ return `\`${value}\``;
+}
+
+function displayTypeLabel(entry: SchemaTypeEntry, suffix: "节点" | "关系"): string {
+ const localized = String(entry.meta.label || entry.meta.display_name || "").trim();
+ return localized && localized !== entry.name
+ ? `${localized}(${entry.name})${suffix}`
+ : `${entry.name} ${suffix}`;
+}
+
+function endpointLabel(meta: SchemaObject, side: "from" | "to"): string {
+ const aliases = side === "from"
+ ? ["from", "source", "start", "start_node"]
+ : ["to", "target", "end", "end_node"];
+ for (const key of aliases) {
+ const value = String(meta[key] || "").trim();
+ if (isSafeCypherIdentifier(value)) return value;
+ }
+ return "";
+}
+
+/** Build safe generic-project shortcuts from the active schema, with graph stats as fallback. */
+export function buildGenericGraphQueryTemplates(
+ schemaLike: unknown,
+ overview: GraphOverviewLike = {},
+): GraphQueryTemplate[] {
+ const schema = schemaDefinition(schemaLike);
+ const declaredEntities = schemaEntityTypes(schema);
+ const declaredRelations = schemaRelationTypes(schema);
+ const entities = declaredEntities.length
+ ? declaredEntities
+ : overviewEntries(overview.label_counts, "label");
+ const relations = declaredRelations.length
+ ? declaredRelations
+ : overviewEntries(overview.relationship_type_counts, "type");
+
+ const templates: GraphQueryTemplate[] = [
+ { label: "全部节点", cypher: "MATCH (n) RETURN n LIMIT 200" },
+ { label: "全部节点关系", cypher: GENERIC_GRAPH_RELATIONS_CYPHER },
+ ];
+
+ entities
+ .filter(({ name }) => isSafeCypherIdentifier(name))
+ .slice(0, 8)
+ .forEach((entry) => {
+ templates.push({
+ label: displayTypeLabel(entry, "节点"),
+ cypher: `MATCH (n:${quoteCypherIdentifier(entry.name)}) RETURN n LIMIT 100`,
+ });
+ });
+
+ relations
+ .filter(({ name }) => isSafeCypherIdentifier(name))
+ .slice(0, 8)
+ .forEach((entry) => {
+ const from = endpointLabel(entry.meta, "from");
+ const to = endpointLabel(entry.meta, "to");
+ const fromClause = from ? `:${quoteCypherIdentifier(from)}` : "";
+ const toClause = to ? `:${quoteCypherIdentifier(to)}` : "";
+ templates.push({
+ label: displayTypeLabel(entry, "关系"),
+ cypher: `MATCH (a${fromClause})-[r:${quoteCypherIdentifier(entry.name)}]->(b${toClause}) RETURN a,r,b LIMIT 100`,
+ });
+ });
+
+ return templates;
+}
+
+export function genericGraphQueryPlaceholder(schemaLike: unknown): string {
+ const entities = schemaEntityTypes(schemaLike)
+ .filter(({ name }) => isSafeCypherIdentifier(name))
+ .slice(0, 2)
+ .map(({ name }) => name);
+ if (!entities.length) {
+ return "输入自然语言或 Cypher,例如:查询全部节点和关系";
+ }
+ return `输入自然语言或 Cypher,例如:查询 ${entities.join("、")} 节点`;
+}
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index b7be527..0baee84 100644
--- a/admin-web/src/styles.css
+++ b/admin-web/src/styles.css
@@ -80,365 +80,1557 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
}
/* ── Relational data center ── */
+.admin-layout .ant-layout-content.app-content-workbench {
+ height: 100vh;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
.data-center-page {
- max-width: 1680px;
- margin: 0 auto;
-}
-
-.data-center-header {
display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 20px;
- margin-bottom: 20px;
+ flex-direction: column;
+ height: 100vh;
+ min-width: 920px;
+ color: #273142;
+ background: #f4f5f7;
}
-.data-center-header .ant-typography {
- margin: 0;
-}
-
-.data-center-header h2.ant-typography {
- font-size: 24px;
-}
-
-.data-center-header p.ant-typography {
- margin-top: 6px;
-}
-
-.data-center-title-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 38px;
- height: 38px;
- border-radius: 10px;
- color: #1677ff;
- background: #eaf3ff;
- font-size: 20px;
-}
-
-.data-center-section,
-.data-center-table-catalog,
-.data-center-record-card,
-.data-center-summary .ant-card {
- border-color: #e6edf7;
- border-radius: 12px;
-}
-
-.data-center-section {
- padding: 20px;
- background: #fbfcff;
- border: 1px solid #e6edf7;
-}
-
-.data-center-section-heading {
+.data-center-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
+ flex: 0 0 48px;
gap: 16px;
- margin-bottom: 16px;
-}
-
-.data-center-section-heading .ant-typography {
- margin: 0;
-}
-
-.data-center-section-heading h4.ant-typography {
- margin-bottom: 4px;
-}
-
-.data-center-database-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(220px, 1fr));
- gap: 12px;
-}
-
-.data-center-database-card {
min-width: 0;
- padding: 15px;
- color: #1f2937;
- text-align: left;
+ padding: 0 14px;
background: #fff;
- border: 1px solid #e4eaf3;
- border-radius: 10px;
- cursor: pointer;
- transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease;
+ border-bottom: 1px solid #d9dfe7;
}
-.data-center-database-card:hover {
- border-color: #8bbcff;
- box-shadow: 0 6px 18px rgba(22, 119, 255, .08);
- transform: translateY(-1px);
-}
-
-.data-center-database-card.active {
- border-color: #1677ff;
- box-shadow: 0 0 0 2px rgba(22, 119, 255, .1);
-}
-
-.data-center-database-card-title {
+.data-center-toolbar-path,
+.data-center-toolbar-status {
display: flex;
align-items: center;
- gap: 10px;
min-width: 0;
}
-.data-center-database-card-title > span:nth-child(2) {
+.data-center-toolbar-path {
+ gap: 8px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.data-center-toolbar-path > span,
+.data-center-toolbar-path > strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.data-center-toolbar-status {
+ flex: 0 0 auto;
+ gap: 14px;
+ color: #7c8797;
+ font-size: 12px;
+}
+
+.data-center-toolbar-status .ant-tag {
+ margin-inline-end: 0;
+}
+
+.data-center-shell {
+ display: grid;
+ grid-template-columns: 278px minmax(0, 1fr);
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+ background: #fff;
+}
+
+.data-center-shell.explorer-collapsed {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.data-center-shell.explorer-collapsed .data-center-record-workspace {
+ grid-column: 1;
+}
+
+.data-center-explorer {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ background: #fafbfc;
+ border-right: 1px solid #cfd6df;
+}
+
+.data-center-explorer.collapsed {
+ display: none;
+}
+
+.data-center-explorer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex: 0 0 38px;
+ padding: 0 12px;
+ background: #f3f5f8;
+ border-bottom: 1px solid #d9dfe7;
+ font-size: 13px;
+}
+
+.data-center-explorer-header-actions {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 2px;
+}
+
+.data-center-explorer-header .ant-btn {
+ height: 28px;
+ padding-inline: 4px;
+ font-size: 12px;
+}
+
+.data-center-explorer-toggle {
+ color: #5f6b7b;
+}
+
+.data-center-explorer-toggle:hover {
+ color: #1677ff !important;
+ background: #e6f4ff !important;
+}
+
+.data-center-workspace-explorer-toggle {
+ align-self: center;
+ flex: 0 0 28px;
+ margin: 0 6px 0 0;
+ color: #5f6b7b;
+ border-radius: 3px;
+}
+
+.data-center-workspace-explorer-toggle:hover {
+ color: #1677ff !important;
+ background: #e6f4ff !important;
+}
+
+.data-center-explorer > .ant-spin-nested-loading {
+ min-height: 0;
+ flex: 1 1 auto;
+}
+
+.data-center-explorer > .ant-spin-nested-loading > .ant-spin-container {
+ height: 100%;
+}
+
+.data-center-tree {
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+ padding: 7px 0 12px;
+}
+
+.data-center-tree-database,
+.data-center-tree-schema,
+.data-center-tree-tables-label {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.data-center-tree-database {
+ gap: 7px;
+ width: 100%;
+ min-height: 42px;
+ padding: 4px 10px 4px 7px;
+ color: inherit;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+.data-center-tree-database:hover {
+ background: #edf2f8;
+}
+
+.data-center-project-node.active > .data-center-tree-database {
+ background: #f0f6ff;
+ box-shadow: inset 2px 0 #1677ff;
+}
+
+.data-center-tree-caret {
+ flex: 0 0 12px;
+ width: 12px;
+ color: #737d8d;
+ font-size: 10px;
+ text-align: center;
+}
+
+.data-center-tree-database-icon {
+ color: #1677ff;
+ font-size: 17px;
+}
+
+.data-center-tree-database-name {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
-.data-center-database-card-title strong,
-.data-center-database-card-title small {
+.data-center-tree-database-name strong,
+.data-center-tree-database-name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.data-center-database-card-title strong {
- font-size: 15px;
-}
-
-.data-center-database-card-title small {
- margin-top: 3px;
- color: #8b96a8;
- font-size: 12px;
-}
-
-.data-center-database-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 36px;
- width: 36px;
- height: 36px;
- color: #1677ff;
- background: #edf5ff;
- border-radius: 9px;
- font-size: 17px;
-}
-
-.data-center-database-selected {
- color: #1677ff;
-}
-
-.data-center-database-stats {
- display: flex;
- gap: 20px;
- margin: 15px 0 12px 46px;
- color: #7a8699;
- font-size: 12px;
-}
-
-.data-center-database-stats b {
- margin-right: 3px;
- color: #263247;
- font-size: 15px;
-}
-
-.data-center-database-meta {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding-top: 10px;
- color: #909aab;
- border-top: 1px dashed #edf0f5;
- font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
-}
-
-.data-center-database-meta > span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.data-center-workspace {
- margin-top: 18px;
-}
-
-.data-center-table-catalog,
-.data-center-record-card {
- height: 100%;
- overflow: hidden;
-}
-
-.data-center-catalog-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- min-height: 60px;
- padding: 0 16px;
- border-bottom: 1px solid #edf0f5;
-}
-
-.data-center-table-groups {
- max-height: 720px;
- padding: 10px;
- overflow-y: auto;
-}
-
-.data-center-table-group + .data-center-table-group {
- margin-top: 14px;
-}
-
-.data-center-table-group-label {
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 5px 9px 7px;
- color: #7a8699;
- font-size: 12px;
- font-weight: 600;
-}
-
-.data-center-table-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- gap: 10px;
- padding: 9px 10px;
- color: #334057;
- text-align: left;
- background: transparent;
- border: 0;
- border-radius: 8px;
- cursor: pointer;
-}
-
-.data-center-table-item:hover {
- background: #f4f8fe;
-}
-
-.data-center-table-item.active {
- color: #0958d9;
- background: #eaf3ff;
-}
-
-.data-center-table-item > span {
- display: flex;
- flex-direction: column;
- min-width: 0;
-}
-
-.data-center-table-item strong,
-.data-center-table-item small {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.data-center-table-item strong {
+.data-center-tree-database-name strong {
+ color: #202938;
font-size: 13px;
- font-weight: 600;
}
-.data-center-table-item small {
- margin-top: 2px;
- color: #9aa3b3;
+.data-center-tree-database-name small {
+ color: #8b95a5;
font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
}
-.data-center-table-item b {
- min-width: 24px;
- padding: 1px 6px;
- color: #7c8799;
- text-align: center;
- background: #f0f2f5;
- border-radius: 10px;
+.data-center-project-table-count {
+ color: #8994a4;
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.data-center-tree-schema {
+ gap: 6px;
+ min-height: 28px;
+ padding: 0 10px 0 24px;
+ color: #4f5c70;
+ font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.data-center-tree-schema > span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.data-center-schema-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 17px;
+ height: 17px;
+ color: #0b6cc4;
+ background: #e6f4ff;
+ border: 1px solid #91caff;
+ border-radius: 3px;
+ font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.data-center-tree-tables-label {
+ gap: 7px;
+ min-height: 30px;
+ padding: 0 12px 0 39px;
+ color: #3e4a5d;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.data-center-tree-tables-label b {
+ margin-left: auto;
+ color: #8b95a5;
font-size: 11px;
+ font-weight: 500;
}
-.data-center-record-header {
+.data-center-table-groups {
+ padding: 0 6px 10px 43px;
+}
+
+.data-center-table-group {
+ margin: 1px 0;
+}
+
+.data-center-table-group > summary {
display: flex;
- align-items: flex-start;
+ align-items: center;
+ gap: 6px;
+ min-height: 27px;
+ padding: 0 7px;
+ color: #687487;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 11px;
+ list-style: none;
+}
+
+.data-center-table-group > summary::-webkit-details-marker {
+ display: none;
+}
+
+.data-center-table-group > summary::before {
+ content: "▾";
+ width: 10px;
+ color: #818a98;
+ font-size: 9px;
+}
+
+.data-center-table-group:not([open]) > summary::before {
+ content: "▸";
+}
+
+.data-center-table-group > summary:hover {
+ background: #edf1f5;
+}
+
+.data-center-table-group > summary b {
+ margin-left: auto;
+ color: #9aa3af;
+ font-weight: 500;
+}
+
+.data-center-table-item {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr) auto;
+ align-items: center;
+ width: 100%;
+ min-height: 29px;
+ gap: 5px;
+ margin: 1px 0;
+ padding: 0 7px 0 18px;
+ color: #303b4c;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.data-center-table-item:hover {
+ background: #eaf0f7;
+}
+
+.data-center-table-item.active {
+ color: #064f9f;
+ background: #dcecff;
+ box-shadow: inset 2px 0 #1677ff;
+}
+
+.data-center-table-item > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.data-center-table-item b {
+ color: #8994a4;
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.data-center-explorer-footer {
+ display: flex;
+ align-items: center;
justify-content: space-between;
- gap: 20px;
- padding: 18px 20px 14px;
+ flex: 0 0 29px;
+ gap: 8px;
+ padding: 0 9px;
+ color: #7d8796;
+ background: #f3f5f8;
+ border-top: 1px solid #d9dfe7;
+ font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
}
-.data-center-record-header .ant-typography {
- margin: 0;
+.data-center-explorer-footer span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.data-center-record-header h4.ant-typography {
- margin-bottom: 4px;
+.data-center-record-workspace {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: #fff;
}
-.data-center-record-context {
- padding: 9px 20px;
- color: #738097;
- background: #fafbfd;
- border-top: 1px solid #f0f2f5;
- border-bottom: 1px solid #edf0f5;
+.data-center-tabbar {
+ display: flex;
+ align-items: flex-end;
+ flex: 0 0 37px;
+ padding: 0 8px;
+ background: #f0f2f5;
+ border-bottom: 1px solid #cbd2dc;
+}
+
+.data-center-active-tab {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ max-width: 440px;
+ height: 31px;
+ padding: 0 12px;
+ color: #223047;
+ background: #fff;
+ border: 1px solid #cbd2dc;
+ border-bottom-color: #fff;
+ border-radius: 4px 4px 0 0;
font-size: 12px;
}
-.data-center-context-divider {
- display: inline-block;
- width: 1px;
- height: 12px;
- background: #d9dfe8;
+.data-center-active-tab span,
+.data-center-active-tab small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.data-center-record-card .ant-table-wrapper {
- padding: 0 1px;
+.data-center-active-tab span {
+ font-weight: 600;
}
-.data-center-record-card .ant-table-thead > tr > th {
- color: #596579;
- background: #fafbfd;
+.data-center-active-tab small {
+ color: #8a95a6;
+ font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.data-center-view-tabs {
+ display: flex;
+ align-items: stretch;
+ flex: 0 0 39px;
+ gap: 4px;
+ padding: 0 12px;
+ background: #fff;
+ border-bottom: 1px solid #dbe0e7;
+}
+
+.data-center-view-tabs button {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 76px;
+ padding: 0 12px;
+ color: #5f6c80;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
font-size: 12px;
}
-.data-center-record-card .ant-empty {
- margin: 48px 0;
+.data-center-view-tabs button::after {
+ position: absolute;
+ right: 10px;
+ bottom: -1px;
+ left: 10px;
+ height: 2px;
+ background: transparent;
+ content: "";
}
-.data-center-summary {
- margin-top: 18px;
+.data-center-view-tabs button:hover {
+ color: #1677ff;
+ background: #f6f9fe;
}
-.data-center-summary .ant-card {
+.data-center-view-tabs button.active {
+ color: #1677ff;
+ font-weight: 600;
+}
+
+.data-center-view-tabs button.active::after {
+ background: #1677ff;
+}
+
+.data-center-record-workspace > .ant-spin-nested-loading {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.data-center-record-workspace > .ant-spin-nested-loading > .ant-spin-container {
height: 100%;
}
-.data-center-record-form {
- max-height: 62vh;
+.data-center-inspection {
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+ padding: 14px;
+ background: #f4f6f9;
+}
+
+.data-center-inspection-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 48px;
+ margin-bottom: 12px;
+ padding: 9px 12px;
+ background: #fff;
+ border: 1px solid #dfe5ed;
+ border-radius: 5px;
+}
+
+.data-center-inspection-header > div {
+ display: flex;
+ align-items: baseline;
+ min-width: 0;
+ gap: 12px;
+}
+
+.data-center-inspection-header strong {
+ flex: 0 0 auto;
+ color: #223047;
+ font-size: 14px;
+}
+
+.data-center-inspection-header span {
+ overflow: hidden;
+ color: #7b8798;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+}
+
+.data-center-inspection-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.data-center-inspection-summary > div {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 5px;
+ padding: 11px 13px;
+ background: #fff;
+ border: 1px solid #dfe5ed;
+ border-radius: 5px;
+}
+
+.data-center-inspection-summary span {
+ color: #7d8898;
+ font-size: 10px;
+}
+
+.data-center-inspection-summary strong {
+ overflow: hidden;
+ color: #26344b;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 15px;
+}
+
+.data-center-inspection-card {
+ overflow: hidden;
+ background: #fff;
+ border: 1px solid #dfe5ed;
+ border-radius: 5px;
+}
+
+.data-center-inspection-card-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 41px;
+ padding: 0 12px;
+ border-bottom: 1px solid #e4e8ee;
+}
+
+.data-center-inspection-card-title strong {
+ color: #2b374b;
+ font-size: 12px;
+}
+
+.data-center-inspection-card-title span,
+.data-center-inspection-card-title code {
+ color: #8390a2;
+ font-size: 10px;
+}
+
+.data-center-inspection-card .ant-table {
+ border-radius: 0 !important;
+ font-size: 11px;
+}
+
+.data-center-inspection-card .ant-table-thead > tr > th {
+ padding: 7px 9px !important;
+ color: #3c485c;
+ background: #eef2f7 !important;
+ font-size: 11px;
+}
+
+.data-center-inspection-card .ant-table-tbody > tr > td {
+ padding: 7px 9px !important;
+}
+
+.data-center-structure-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.data-center-structure-field code {
+ color: #26344b;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.data-center-structure-field span {
+ color: #8a95a5;
+ font-size: 10px;
+}
+
+.data-center-protected-field {
+ color: #a0a8b4;
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.data-center-column-alert {
+ margin: 14px 0 16px;
+}
+
+.data-center-column-switches {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ padding: 12px 14px 0;
+ background: #f7f9fc;
+ border: 1px solid #e3e8ef;
+ border-radius: 6px;
+}
+
+.data-center-column-switches .ant-form-item {
+ margin-bottom: 12px;
+}
+
+.data-center-column-switches .ant-form-item-row {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.data-center-column-switches .ant-form-item-label {
+ padding: 0;
+}
+
+.data-center-column-delete-confirm {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 14px;
+}
+
+.data-center-column-delete-confirm code {
+ color: #cf1322;
+ font-weight: 600;
+}
+
+.data-center-inspection-panels {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.data-center-definition-row {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ border-bottom: 1px solid #edf0f4;
+}
+
+.data-center-definition-row:last-child {
+ border-bottom: 0;
+}
+
+.data-center-definition-row > div {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.data-center-definition-row strong {
+ overflow: hidden;
+ color: #354258;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+}
+
+.data-center-definition-row code {
+ overflow: hidden;
+ color: #788598;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 10px;
+}
+
+.data-center-relation-board {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) 260px minmax(220px, 1fr);
+ align-items: start;
+ gap: 54px;
+ min-height: 430px;
+ padding: 44px 28px;
+ background:
+ linear-gradient(#eef1f5 1px, transparent 1px),
+ linear-gradient(90deg, #eef1f5 1px, transparent 1px),
+ #fff;
+ background-size: 24px 24px;
+ border: 1px solid #dfe5ed;
+ border-radius: 5px;
+}
+
+.data-center-relation-board section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.data-center-relation-board h4 {
+ margin: 0 0 4px;
+ color: #79869a;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.data-center-relation-current,
+.data-center-relation-node {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 5px;
+ padding: 13px 14px;
+ background: #fff;
+ border: 1px solid #cdd6e2;
+ border-radius: 6px;
+ box-shadow: 0 3px 12px rgb(42 58 82 / 7%);
+}
+
+.data-center-relation-current {
+ align-items: center;
+ align-self: center;
+ padding: 22px 18px;
+ color: #1677ff;
+ border: 2px solid #69a8ff;
+}
+
+.data-center-relation-current .anticon {
+ font-size: 26px;
+}
+
+.data-center-relation-current strong {
+ color: #20304a;
+ font-size: 16px;
+}
+
+.data-center-relation-current code,
+.data-center-relation-current span {
+ color: #7a8799;
+ font-size: 10px;
+}
+
+.data-center-relation-node::after {
+ position: absolute;
+ top: 50%;
+ width: 55px;
+ height: 1px;
+ background: #9ab3d3;
+ content: "";
+}
+
+.data-center-relation-board .incoming .data-center-relation-node::after {
+ right: -55px;
+}
+
+.data-center-relation-board .outgoing .data-center-relation-node::after {
+ left: -55px;
+}
+
+.data-center-relation-node > span {
+ color: #1677ff;
+ font-size: 9px;
+}
+
+.data-center-relation-node strong {
+ overflow: hidden;
+ color: #28364c;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+
+.data-center-relation-node code,
+.data-center-relation-node small {
+ overflow: hidden;
+ color: #7a8799;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 9px;
+}
+
+.data-center-relation-empty {
+ padding: 16px;
+ color: #9aa5b3;
+ background: rgb(255 255 255 / 75%);
+ border: 1px dashed #cfd7e2;
+ border-radius: 5px;
+ text-align: center;
+ font-size: 10px;
+}
+
+.data-center-relation-none {
+ display: grid;
+ min-height: 430px;
+ place-items: center;
+ background:
+ linear-gradient(#eef1f5 1px, transparent 1px),
+ linear-gradient(90deg, #eef1f5 1px, transparent 1px),
+ #fff;
+ background-size: 24px 24px;
+ border: 1px solid #dfe5ed;
+ border-radius: 5px;
+}
+
+.data-center-relation-none .ant-empty-description {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ color: #7c899a;
+ font-size: 11px;
+}
+
+.data-center-relation-none .ant-empty-description strong {
+ color: #35435a;
+ font-size: 13px;
+}
+
+.data-center-record-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 51px;
+ gap: 16px;
+ min-width: 0;
+ padding: 0 12px;
+ border-bottom: 1px solid #dbe0e7;
+}
+
+.data-center-record-summary {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 9px;
+}
+
+.data-center-record-summary > strong {
+ flex: 0 0 auto;
+ font-size: 14px;
+}
+
+.data-center-record-summary > span {
+ overflow: hidden;
+ color: #7f8a99;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+}
+
+.data-center-record-summary .ant-tag {
+ flex: 0 0 auto;
+ margin-inline-end: 0;
+}
+
+.data-center-record-toolbar > .ant-space {
+ flex: 0 0 auto;
+}
+
+.data-center-record-toolbar .ant-input-affix-wrapper,
+.data-center-record-toolbar .ant-btn {
+ border-radius: 3px;
+}
+
+.data-center-csv-dropzone {
+ margin-bottom: 16px;
+ background: #fafcff;
+}
+
+.data-center-csv-dropzone .ant-upload {
+ padding: 18px 16px !important;
+}
+
+.data-center-csv-dropzone .ant-upload-drag-icon {
+ margin-bottom: 6px !important;
+}
+
+.data-center-csv-dropzone .ant-upload-drag-icon .anticon {
+ color: #1677ff;
+ font-size: 32px;
+}
+
+.data-center-csv-preview {
+ display: grid;
+ gap: 14px;
+ max-height: 520px;
+ overflow: auto;
+ padding-right: 3px;
+}
+
+.data-center-csv-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.data-center-csv-summary > div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 10px 12px;
+ background: #f6f8fb;
+ border: 1px solid #e2e7ee;
+ border-radius: 5px;
+}
+
+.data-center-csv-summary span {
+ color: #7a8697;
+ font-size: 11px;
+}
+
+.data-center-csv-summary strong {
+ color: #25344c;
+ font-size: 16px;
+}
+
+.data-center-csv-summary small {
+ color: #8a94a3;
+ font-size: 10px;
+}
+
+.data-center-csv-section-title {
+ margin-bottom: -6px;
+ color: #36445b;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.data-center-csv-errors {
+ max-height: 150px;
+ overflow: auto;
+ padding: 9px 12px;
+ color: #43546a;
+ background: #fffbe6;
+ border: 1px solid #ffe58f;
+ border-radius: 5px;
+ font-size: 11px;
+ line-height: 1.8;
+}
+
+.data-center-csv-errors .invalid,
+.data-center-csv-errors .failed {
+ color: #cf1322;
+}
+
+.data-center-csv-errors .duplicate {
+ color: #ad6800;
+}
+
+.data-center-record-context {
+ display: flex;
+ align-items: center;
+ flex: 0 0 29px;
+ gap: 18px;
+ padding: 0 12px;
+ color: #738095;
+ background: #f8f9fb;
+ border-bottom: 1px solid #dbe0e7;
+ font-size: 10px;
+}
+
+.data-center-record-context > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.data-center-grid {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.data-center-grid-table {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.data-center-grid.compact {
+ background: #f3f5f8;
+}
+
+.data-center-grid.compact .data-center-grid-table {
+ flex: 0 0 auto;
+ background: #fff;
+}
+
+.data-center-grid-table .ant-table-wrapper,
+.data-center-grid-table .ant-spin-nested-loading,
+.data-center-grid-table .ant-spin-container {
+ height: 100%;
+}
+
+.data-center-grid.compact .ant-table-wrapper,
+.data-center-grid.compact .ant-spin-nested-loading,
+.data-center-grid.compact .ant-spin-container {
+ height: auto;
+}
+
+.data-center-grid .ant-table {
+ border-radius: 0 !important;
+ font-size: 11px;
+}
+
+.data-center-grid .ant-table-container,
+.data-center-grid .ant-table-cell {
+ border-radius: 0 !important;
+}
+
+.data-center-grid .ant-table-thead > tr > th {
+ height: 35px;
+ padding: 6px 8px !important;
+ color: #354157;
+ background: #eef2f7 !important;
+ border-color: #cdd5df !important;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.data-center-grid .ant-table-tbody > tr > td {
+ height: 34px;
+ padding: 5px 8px !important;
+ color: #273142;
+ border-color: #e2e6ec !important;
+ white-space: nowrap;
+}
+
+.data-center-grid .ant-table-tbody > tr:nth-child(even) > td {
+ background: #f5f8fc;
+}
+
+.data-center-grid .ant-table-tbody > tr:hover > td {
+ background: #e8f2ff !important;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-selected > td {
+ background: #e7f2ff !important;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-pending-change > td {
+ background: #fffbea !important;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-pending-insert > td {
+ background: #e8ffd1 !important;
+ border-color: #c7e6aa !important;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-pending-insert:hover > td {
+ background: #ddfbc2 !important;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-pending-insert .data-center-row-number {
+ color: #3f6600;
+ background: #d8f7ba;
+ box-shadow: inset 3px 0 #73d13d;
+}
+
+.data-center-grid .ant-table-tbody > tr.data-center-row-pending-delete > td {
+ color: #9ba3af;
+ background: #fff2f0 !important;
+ text-decoration: line-through;
+}
+
+.data-center-row-number {
+ display: block;
+ width: calc(100% + 16px);
+ min-height: 34px;
+ margin: -5px -8px;
+ padding: 0 9px 0 4px;
+ color: #263347;
+ text-align: right;
+ background: #f4f6f9;
+ border: 0;
+ border-right: 1px solid #d5dbe3;
+ cursor: pointer;
+ font: inherit;
+}
+
+.data-center-row-number:hover,
+.data-center-row-number.active {
+ color: #0958d9;
+ background: #dcecff;
+ box-shadow: inset 3px 0 #1677ff;
+}
+
+.data-center-grid-cell {
+ display: block;
+ min-height: 22px;
+ margin: -5px -8px;
+ padding: 5px 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.data-center-grid-cell.editable {
+ cursor: cell;
+}
+
+.data-center-grid-cell.editable:hover {
+ outline: 1px solid #91caff;
+ outline-offset: -1px;
+}
+
+.data-center-grid-cell.changed {
+ color: #263347;
+ background: #ffe3ad;
+ box-shadow:
+ inset 0 0 0 1px #f0b35c,
+ inset 3px 0 #e69a2f;
+}
+
+.data-center-grid-cell.draft {
+ color: #264d17;
+}
+
+.data-center-null-value {
+ color: #8ea282;
+ font-style: italic;
+}
+
+.data-center-inline-editor {
+ min-width: 0;
+ margin: -4px -7px;
+ background: #dcecff;
+ box-shadow: inset 0 0 0 2px #1677ff;
+}
+
+.data-center-inline-editor .ant-input,
+.data-center-inline-editor .ant-input-number,
+.data-center-inline-editor .ant-select {
+ width: 100%;
+ background: transparent;
+ border-color: transparent;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.data-center-inline-editor.changed {
+ background: #ffe3ad;
+ box-shadow: inset 0 0 0 2px #1677ff;
+}
+
+.data-center-grid-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 38px;
+ gap: 16px;
+ padding: 0 10px 0 6px;
+ color: #697588;
+ background: #f7f8fa;
+ border-top: 1px solid #cdd5df;
+ font-size: 11px;
+}
+
+.data-center-grid-footer-actions {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 2px;
+}
+
+.data-center-grid-footer-actions .ant-btn {
+ flex: 0 0 auto;
+ height: 26px;
+ padding-inline: 8px;
+ border-radius: 3px;
+}
+
+.data-center-grid-footer-actions .data-center-import-button,
+.data-center-grid-footer-actions .data-center-export-button,
+.data-center-grid-footer-actions .data-center-console-button {
+ color: #43546a;
+}
+
+.data-center-grid-footer-actions .data-center-console-button {
+ margin-left: 2px;
+}
+
+.data-center-grid-footer-actions .data-center-save-button:not(:disabled) {
+ color: #389e0d;
+ background: #f6ffed;
+}
+
+.data-center-grid-footer-actions .data-center-cancel-button:not(:disabled),
+.data-center-grid-footer-actions .data-center-delete-button:not(:disabled) {
+ color: #cf1322;
+}
+
+.data-center-pending-status {
+ margin-left: 8px;
+ color: #8b95a4;
+ white-space: nowrap;
+}
+
+.data-center-pending-status.active {
+ color: #ad6800;
+}
+
+.data-center-grid-count {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.data-center-sql-console {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: #f4f6f9;
+}
+
+.data-center-sql-console-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 46px;
+ gap: 16px;
+ padding: 0 12px;
+ background: #fff;
+ border-bottom: 1px solid #d9dfe7;
+}
+
+.data-center-sql-console-header > div {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ gap: 12px;
+}
+
+.data-center-sql-console-header strong {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 6px;
+ color: #223047;
+ font-size: 14px;
+}
+
+.data-center-sql-console-header span {
+ overflow: hidden;
+ color: #7c8797;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font: 11px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.data-center-sql-editor-toolbar {
+ display: flex;
+ align-items: center;
+ flex: 0 0 39px;
+ gap: 6px;
+ padding: 0 12px;
+ background: #f7f8fa;
+ border-bottom: 1px solid #d9dfe7;
+}
+
+.data-center-sql-editor-toolbar > span {
+ margin-left: 6px;
+ color: #8994a4;
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.data-center-sql-editor.ant-input {
+ flex: 0 0 218px;
+ width: 100%;
+ min-height: 0 !important;
+ padding: 13px 16px;
+ color: #1f2a3d;
+ background: #fbfcfe;
+ border: 0;
+ border-bottom: 1px solid #cfd6df;
+ border-radius: 0;
+ box-shadow: none;
+ resize: none;
+ font: 12px/1.68 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ tab-size: 2;
+}
+
+.data-center-sql-editor.ant-input:focus {
+ background: #fff;
+ box-shadow: inset 3px 0 #1677ff;
+}
+
+.data-center-sql-result-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 35px;
+ gap: 12px;
+ padding: 0 12px;
+ color: #667386;
+ background: #f7f8fa;
+ border-bottom: 1px solid #d9dfe7;
+ font-size: 10px;
+}
+
+.data-center-sql-result-header strong {
+ color: #2d394d;
+ font-size: 12px;
+}
+
+.data-center-sql-result {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow: auto;
+ background: #fff;
+}
+
+.data-center-sql-result > .ant-empty {
+ margin: auto;
+}
+
+.data-center-sql-result > .ant-alert {
+ margin: 14px;
+}
+
+.data-center-sql-result .ant-table-wrapper,
+.data-center-sql-result .ant-spin-nested-loading,
+.data-center-sql-result .ant-spin-container {
+ min-height: 0;
+}
+
+.data-center-sql-result .ant-table {
+ border-radius: 0 !important;
+ font-size: 11px;
+}
+
+.data-center-sql-result .ant-table-thead > tr > th {
+ padding: 7px 9px !important;
+ color: #3c485c;
+ background: #eef2f7 !important;
+}
+
+.data-center-sql-result .ant-table-tbody > tr > td {
+ padding: 7px 9px !important;
+}
+
+.data-center-sql-success {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 7px;
+ color: #5f6d80;
+}
+
+.data-center-sql-success > .anticon {
+ color: #52c41a;
+ font-size: 30px;
+}
+
+.data-center-sql-success strong {
+ color: #27354a;
+ font-size: 14px;
+}
+
+.data-center-sql-truncated {
+ position: sticky;
+ right: 10px;
+ bottom: 10px;
+ left: 10px;
+ z-index: 2;
+}
+
+.data-center-grid .ant-empty {
+ margin: 80px 0;
+}
+
+.data-center-workspace-empty {
+ margin: auto;
+}
+
+.data-center-structure-hint {
+ margin: 0 0 18px;
+ padding: 10px 12px;
+ color: #516070;
+ line-height: 1.65;
+ background: #f4f8ff;
+ border: 1px solid #d8e7ff;
+ border-radius: 6px;
+}
+
+.data-center-create-mode {
+ margin-bottom: 12px;
+}
+
+.data-center-structure-form {
+ max-height: min(68vh, calc(100vh - 340px));
padding-right: 4px;
overflow-y: auto;
}
+.data-center-danger-copy {
+ color: #475569;
+ line-height: 1.65;
+}
+
+.data-center-danger-copy p {
+ margin: 4px 0;
+}
+
+.data-center-sql-editor {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.65;
+ tab-size: 2;
+}
+
+.data-center-sql-safety-note {
+ padding: 10px 12px;
+ color: #566275;
+ font-size: 12px;
+ line-height: 1.65;
+ background: #fafbfc;
+ border: 1px solid #e3e8ef;
+ border-radius: 6px;
+}
+
+.data-center-permission-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin: 2px 0 18px;
+ padding: 10px 12px;
+ color: #566275;
+ background: #fafbfc;
+ border: 1px solid #e3e8ef;
+ border-radius: 6px;
+}
+
+.data-center-field-builder {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ background: #f7f8fa;
+ border: 1px solid #dde3eb;
+ border-radius: 8px;
+}
+
+.data-center-field-builder-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.data-center-field-builder-title > div {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.data-center-field-builder-title span {
+ color: #8a94a3;
+ font-size: 12px;
+}
+
+.data-center-field-definition {
+ display: flex;
+ gap: 10px;
+ padding: 12px;
+ background: #fff;
+ border: 1px solid #e0e5ec;
+ border-radius: 6px;
+}
+
+.data-center-field-index {
+ display: grid;
+ place-items: center;
+ flex: 0 0 24px;
+ width: 24px;
+ height: 24px;
+ margin-top: 30px;
+ color: #5c6f84;
+ font-size: 12px;
+ background: #edf2f7;
+ border-radius: 4px;
+}
+
+.data-center-field-content {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.data-center-field-content .ant-form-item {
+ margin-bottom: 10px;
+}
+
+.data-center-field-content > .ant-space {
+ color: #697586;
+ font-size: 12px;
+}
+
@media (max-width: 1280px) {
- .data-center-database-grid {
- grid-template-columns: repeat(3, minmax(220px, 1fr));
+ .data-center-shell {
+ grid-template-columns: 244px minmax(0, 1fr);
+ }
+
+ .data-center-record-summary > span {
+ display: none;
+ }
+
+ .data-center-record-toolbar {
+ gap: 8px;
+ }
+
+ .data-center-record-toolbar .ant-input-search {
+ width: 190px !important;
}
}
-@media (max-width: 900px) {
- .data-center-database-grid {
- grid-template-columns: repeat(2, minmax(200px, 1fr));
+@media (max-width: 1080px) {
+ .data-center-toolbar-status > span {
+ display: none;
}
- .data-center-table-catalog {
- margin-bottom: 18px;
- }
-}
-
-@media (max-width: 640px) {
- .data-center-header {
- flex-direction: column;
- }
-
- .data-center-database-grid {
- grid-template-columns: 1fr;
- }
-
- .data-center-record-header {
- flex-direction: column;
+ .data-center-record-toolbar .ant-input-search {
+ width: 190px !important;
}
}
@@ -1886,3 +3078,460 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
flex-direction: column;
}
}
+
+/* ── Knowledge graph project lifecycle ── */
+.project-context-inline {
+ margin-bottom: 18px;
+ padding: 12px 14px;
+ background: #f7faff;
+ border: 1px solid #e2ebf8;
+ border-radius: 8px;
+}
+
+.project-page {
+ max-width: 1500px;
+ margin: 0 auto;
+}
+
+.project-page-hero {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 28px;
+ margin-bottom: 18px;
+}
+
+.project-page-hero > div {
+ max-width: 980px;
+}
+
+.project-page-hero h2.ant-typography {
+ margin: 0 0 8px;
+ font-size: 24px;
+}
+
+.project-page-hero p.ant-typography {
+ margin-bottom: 0;
+ color: #667085;
+ line-height: 1.75;
+}
+
+.project-page-hero > .ant-btn {
+ flex: 0 0 auto;
+}
+
+.project-isolation-alert {
+ margin-bottom: 18px;
+}
+
+.project-page .ant-list-grid .ant-col > .ant-list-item {
+ height: 100%;
+}
+
+.project-card-menu-target,
+.project-card {
+ height: 100%;
+}
+
+.project-card-menu-target {
+ border-radius: 8px;
+}
+
+.project-card.ant-card {
+ border-color: #e5eaf1;
+}
+
+.project-card.ant-card:hover {
+ border-color: #91caff;
+}
+
+.project-card .ant-card-body {
+ height: 100%;
+ min-height: 198px;
+}
+
+.project-card-heading {
+ justify-content: space-between;
+ width: 100%;
+}
+
+.project-card-heading > .ant-space-item:first-child {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.project-card-heading > .ant-space-item:last-child {
+ flex: 0 0 auto;
+}
+
+.project-card-heading h4.ant-typography {
+ min-width: 0;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-card-heading .ant-tag {
+ flex: 0 0 auto;
+ margin-inline-end: 0;
+}
+
+.project-card-actions {
+ flex: 0 0 auto;
+}
+
+.project-card-more.ant-btn {
+ color: #667085;
+}
+
+.project-card-more.ant-btn:hover,
+.project-card-more.ant-btn:focus-visible {
+ color: #0958d9;
+ background: #eaf3ff;
+}
+
+.project-create-steps {
+ padding: 4px 18px 22px;
+ border-bottom: 1px solid #edf0f5;
+}
+
+.project-create-body {
+ min-height: 430px;
+ max-height: min(64vh, 660px);
+ padding: 24px 2px 2px;
+ overflow-y: auto;
+}
+
+.project-basic-form {
+ max-width: 780px;
+ margin: 0 auto;
+}
+
+.project-form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0 20px;
+}
+
+.project-json-step {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.project-json-workspace {
+ display: grid;
+ grid-template-columns: 230px minmax(0, 1fr);
+ gap: 16px;
+ min-height: 390px;
+}
+
+.project-json-uploader.ant-upload-wrapper .ant-upload-drag {
+ height: 100%;
+ min-height: 210px;
+ background: #fafcff;
+ border-color: #cddcf2;
+}
+
+.project-json-uploader .ant-upload-drag-container {
+ padding: 20px 14px;
+}
+
+.project-json-uploader .ant-upload-drag-icon {
+ margin-bottom: 10px !important;
+}
+
+.project-json-uploader .ant-upload-drag-icon .anticon {
+ color: #1677ff;
+ font-size: 40px;
+}
+
+.project-json-uploader .ant-upload-text {
+ font-size: 14px !important;
+}
+
+.project-json-uploader .ant-upload-hint {
+ overflow-wrap: anywhere;
+ font-size: 12px !important;
+}
+
+.project-json-editor-shell {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid #d9dfe8;
+ border-radius: 8px;
+ background: #fbfcfe;
+}
+
+.project-json-editor-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 0 0 39px;
+ padding: 0 10px 0 14px;
+ color: #526176;
+ background: #f4f6f9;
+ border-bottom: 1px solid #d9dfe8;
+ font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.project-json-editor.ant-input {
+ flex: 1 1 auto;
+ min-height: 350px;
+ padding: 14px 16px;
+ resize: vertical;
+ color: #202d41;
+ background: #fbfcfe;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ tab-size: 2;
+}
+
+.project-json-editor.ant-input:focus {
+ background: #fff;
+ box-shadow: inset 3px 0 #1677ff;
+}
+
+.project-validation-alert ul {
+ margin: 8px 0 0;
+ padding-left: 20px;
+}
+
+.project-confirm-step {
+ max-width: 760px;
+ margin: 0 auto;
+ text-align: center;
+}
+
+.project-confirm-icon {
+ margin: 2px 0 8px;
+ color: #52c41a;
+ font-size: 46px;
+ line-height: 1;
+}
+
+.project-confirm-step h4.ant-typography {
+ margin: 0 0 6px;
+}
+
+.project-confirm-descriptions {
+ margin: 20px 0 18px;
+ text-align: left;
+}
+
+.project-confirm-step > .ant-alert {
+ text-align: left;
+}
+
+.project-provision-error {
+ margin-top: 12px;
+}
+
+.project-delete-policy {
+ margin-bottom: 18px;
+}
+
+.project-impact-spin,
+.project-impact-stage {
+ width: 100%;
+}
+
+.project-impact-stage {
+ min-height: 180px;
+}
+
+.project-impact-content {
+ display: grid;
+ gap: 18px;
+ min-height: 180px;
+}
+
+.project-impact-content > section {
+ display: grid;
+ gap: 10px;
+}
+
+.project-falkor-error.ant-typography {
+ display: block;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.project-impact-counts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.project-impact-counts > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+ padding: 9px 11px;
+ background: #fafbfc;
+ border: 1px solid #e6e9ef;
+ border-radius: 6px;
+}
+
+.project-impact-counts span {
+ overflow: hidden;
+ color: #667085;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+
+.project-impact-counts strong {
+ color: #cf1322;
+ font-size: 15px;
+}
+
+.project-impact-graphs {
+ display: grid;
+ gap: 8px;
+}
+
+.project-impact-graphs > div {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ background: #fffdf8;
+ border: 1px solid #f2e5c7;
+ border-radius: 6px;
+}
+
+.project-impact-graphs > div > div {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.project-impact-graphs .ant-tag {
+ margin-inline-end: 0;
+}
+
+.project-protected-resources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.project-protected-resources .ant-tag {
+ margin-inline-end: 0;
+}
+
+.project-delete-confirmation {
+ display: grid;
+ gap: 9px;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid #edf0f5;
+}
+
+.project-delete-confirmation > label {
+ width: fit-content;
+}
+
+.project-cleanup-details {
+ display: grid;
+ gap: 10px;
+ max-height: 58vh;
+ margin-top: 14px;
+ overflow-y: auto;
+}
+
+.project-cleanup-details > .ant-typography {
+ margin-bottom: 0;
+}
+
+.project-cleanup-details > div {
+ display: grid;
+ gap: 6px;
+ padding: 10px 12px;
+ background: #fff7e6;
+ border: 1px solid #ffd591;
+ border-radius: 6px;
+}
+
+.project-cleanup-details > div > .ant-typography {
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 800px) {
+ .project-page-hero {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .project-page-hero > .ant-btn {
+ align-self: flex-start;
+ }
+
+ .project-form-grid,
+ .project-json-workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .project-json-workspace {
+ min-height: 0;
+ }
+
+ .project-json-uploader.ant-upload-wrapper .ant-upload-drag {
+ height: auto;
+ }
+
+ .project-impact-counts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .project-impact-graphs > div {
+ flex-direction: column;
+ }
+}
+
+@media (max-width: 520px) {
+ .project-impact-counts {
+ grid-template-columns: 1fr;
+ }
+
+ .project-create-steps {
+ padding-inline: 0;
+ }
+}
+
+/* 荔波 POI 高德式搜索下拉:hover 反馈 */
+.libo-poi-search-row:hover {
+ background: rgba(22, 119, 255, 0.07);
+}
+.libo-poi-cat-tile {
+ transition: background 0.12s;
+}
+.libo-poi-cat-tile:hover {
+ background: rgba(22, 119, 255, 0.06);
+}
+/* 搜索条右侧按钮(放大镜 / 展开箭头):hover 浅底反馈 */
+.libo-searchbox-btn {
+ transition: background 0.12s;
+}
+.libo-searchbox-btn:hover {
+ background: rgba(22, 119, 255, 0.08);
+}
+/* 逐小时天气横条:可左右滑动,但隐藏滚动条 */
+.libo-weather-hours {
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* 旧 Edge */
+}
+.libo-weather-hours::-webkit-scrollbar {
+ display: none; /* WebKit:隐藏滚动条 */
+}
diff --git a/admin-web/tests/overviewCategories.test.ts b/admin-web/tests/overviewCategories.test.ts
new file mode 100644
index 0000000..69b1c02
--- /dev/null
+++ b/admin-web/tests/overviewCategories.test.ts
@@ -0,0 +1,131 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildGenericGraphCategories,
+ resolveOverviewCategoryView,
+ type EntityCategory,
+} from "../src/panels/plaza/overviewCategories.ts";
+
+const legacyCategories: EntityCategory[] = [
+ { key: "candidate:Place", label: "审核地点", total: 9, sub: [] },
+];
+
+test("generic projects use real FalkorDB label counts", () => {
+ const categories = buildGenericGraphCategories(
+ {
+ connected: true,
+ label_counts: [
+ { label: "Place", count: 2 },
+ { label: "Hotel", count: 1 },
+ ],
+ },
+ { Place: "地点", Hotel: "酒店" },
+ );
+
+ assert.deepEqual(categories, [
+ { key: "label:Place", label: "地点", total: 2, sub: [] },
+ { key: "label:Hotel", label: "酒店", total: 1, sub: [] },
+ ]);
+});
+
+test("invalid graph rows are ignored and duplicate labels are aggregated", () => {
+ const categories = buildGenericGraphCategories({
+ connected: true,
+ label_counts: [
+ { label: "Place", count: 1 },
+ { label: "Place", count: 2 },
+ { label: "", count: 5 },
+ { label: "Hotel", count: 0 },
+ { label: "(no label)", count: 3 },
+ ],
+ });
+
+ assert.deepEqual(categories, [
+ { key: "label:Place", label: "Place", total: 3, sub: [] },
+ ]);
+});
+
+test("disconnected graph does not present stale graph categories", () => {
+ assert.deepEqual(
+ buildGenericGraphCategories({
+ connected: false,
+ label_counts: [{ label: "Place", count: 2 }],
+ }),
+ [],
+ );
+});
+
+test("generic empty graphs never fall back to PostgreSQL candidate categories", () => {
+ assert.deepEqual(resolveOverviewCategoryView({
+ graph: { connected: true, label_counts: [] },
+ graphCategories: [],
+ legacyCategories,
+ allowLegacyFallback: false,
+ loading: false,
+ }), {
+ categories: [],
+ source: "graph",
+ status: "empty",
+ });
+});
+
+test("generic disconnected graphs never fall back to PostgreSQL candidate categories", () => {
+ assert.deepEqual(resolveOverviewCategoryView({
+ graph: { connected: false, error: "offline" },
+ graphCategories: [],
+ legacyCategories,
+ allowLegacyFallback: false,
+ loading: false,
+ }), {
+ categories: [],
+ source: "graph",
+ status: "disconnected",
+ });
+});
+
+test("generic loading state never flashes PostgreSQL candidate categories", () => {
+ assert.deepEqual(resolveOverviewCategoryView({
+ graph: { connected: true },
+ graphCategories: [],
+ legacyCategories,
+ allowLegacyFallback: false,
+ loading: true,
+ }), {
+ categories: [],
+ source: "graph",
+ status: "loading",
+ });
+});
+
+test("city and Libo knowledge maps retain the explicit legacy fallback", () => {
+ assert.deepEqual(resolveOverviewCategoryView({
+ graph: { connected: false, error: "offline" },
+ graphCategories: [],
+ legacyCategories,
+ allowLegacyFallback: true,
+ loading: false,
+ }), {
+ categories: legacyCategories,
+ source: "legacy-candidates",
+ status: "ready",
+ });
+});
+
+test("real FalkorDB categories win over the legacy fallback", () => {
+ const graphCategories: EntityCategory[] = [
+ { key: "label:Place", label: "地点", total: 2, sub: [] },
+ ];
+
+ assert.deepEqual(resolveOverviewCategoryView({
+ graph: { connected: true },
+ graphCategories,
+ legacyCategories,
+ allowLegacyFallback: true,
+ loading: false,
+ }), {
+ categories: graphCategories,
+ source: "graph",
+ status: "ready",
+ });
+});
diff --git a/admin-web/tests/projectCatalog.test.ts b/admin-web/tests/projectCatalog.test.ts
new file mode 100644
index 0000000..0c2831a
--- /dev/null
+++ b/admin-web/tests/projectCatalog.test.ts
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { mergeProjectCatalogItem } from "../src/projectCatalog.ts";
+
+test("renaming a project updates its display name without changing stable identifiers", () => {
+ const current = [{
+ project_id: "human_flow_e2e_20260804_224229",
+ tenant_id: "human_flow_e2e_20260804_224229",
+ display_name: "人类全流程测试图谱",
+ status: "active",
+ }];
+
+ const updated = mergeProjectCatalogItem(current, {
+ project_id: "human_flow_e2e_20260804_224229",
+ display_name: "人类全流程测试图谱(已重命名)",
+ });
+
+ assert.deepEqual(updated, [{
+ project_id: "human_flow_e2e_20260804_224229",
+ tenant_id: "human_flow_e2e_20260804_224229",
+ display_name: "人类全流程测试图谱(已重命名)",
+ status: "active",
+ }]);
+ assert.notEqual(updated, current);
+});
+
+test("an unknown project is added to the catalog", () => {
+ const updated = mergeProjectCatalogItem([], {
+ project_id: "new_graph",
+ display_name: "新图谱",
+ });
+
+ assert.deepEqual(updated, [{
+ project_id: "new_graph",
+ display_name: "新图谱",
+ }]);
+});
diff --git a/admin-web/tests/schemaCapabilities.test.ts b/admin-web/tests/schemaCapabilities.test.ts
new file mode 100644
index 0000000..141f80f
--- /dev/null
+++ b/admin-web/tests/schemaCapabilities.test.ts
@@ -0,0 +1,150 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildGenericGraphQueryTemplates,
+ buildSchemaGuidance,
+ GENERIC_GRAPH_DEFAULT_CYPHER,
+ GENERIC_GRAPH_RELATIONS_CYPHER,
+ genericGraphQueryPlaceholder,
+ schemaHasDeclaredTypes,
+ schemaSupportsLegacyGraphProfile,
+} from "../src/schemaCapabilities.ts";
+
+const genericSchema = {
+ namespace: "human_flow_e2e_20260804_224229",
+ version: "1.0.0",
+ entity_types: {
+ Place: { label: "地点", fields: { name: { type: "string" } } },
+ Hotel: { label: "酒店", fields: { name: { type: "string" } } },
+ },
+ relation_types: {
+ LOCATED_IN: { from: "Place", to: "Place", properties: {} },
+ NEAR: { from: "Hotel", to: "Place", properties: {} },
+ },
+};
+
+test("generic schemas do not show fixed-route guidance", () => {
+ assert.deepEqual(buildSchemaGuidance(genericSchema), []);
+ assert.equal(schemaHasDeclaredTypes(genericSchema), true);
+ assert.equal(schemaSupportsLegacyGraphProfile(genericSchema, "travel"), false);
+ assert.equal(schemaSupportsLegacyGraphProfile(genericSchema, "travel_item"), false);
+ assert.equal(schemaSupportsLegacyGraphProfile(genericSchema, "libo"), false);
+});
+
+test("generic default query preserves isolated nodes when the schema has no relations", () => {
+ const zeroRelationSchema = {
+ namespace: "isolated_nodes",
+ entity_types: {
+ Place: { label: "地点", fields: { name: { type: "string" } } },
+ },
+ relation_types: {},
+ };
+
+ assert.equal(schemaHasDeclaredTypes(zeroRelationSchema), true);
+ assert.equal(
+ GENERIC_GRAPH_DEFAULT_CYPHER,
+ "MATCH (n) OPTIONAL MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 2500",
+ );
+ assert.deepEqual(buildGenericGraphQueryTemplates(zeroRelationSchema).slice(0, 2), [
+ { label: "全部节点", cypher: "MATCH (n) RETURN n LIMIT 200" },
+ { label: "全部节点关系", cypher: GENERIC_GRAPH_RELATIONS_CYPHER },
+ ]);
+});
+
+test("Libo shortcuts require the specialized POI model shape", () => {
+ assert.equal(schemaSupportsLegacyGraphProfile({
+ entity_types: {
+ Hotel: {},
+ FoodPlace: {},
+ TransitFacility: {},
+ ScenicSpot: {},
+ },
+ relation_types: {},
+ }, "libo"), true);
+});
+
+test("travel guidance only includes capabilities declared by that schema", () => {
+ const travelSchema = {
+ design_principles: ["主干路线固定且有序。"],
+ entity_types: {
+ TourProduct: {},
+ ProductDay: {},
+ RouteStop: {},
+ ScenicAttraction: {},
+ TravelItem: {},
+ ResourcePriceRule: {},
+ },
+ relation_types: {
+ DAY_HAS_STOP: { from: "ProductDay", to: "RouteStop" },
+ ROUTE_STOP_NEXT: { from: "RouteStop", to: "RouteStop" },
+ STOP_VISITS_ATTRACTION: { from: "RouteStop", to: "ScenicAttraction" },
+ STOP_USES_DEFAULT_RESOURCE: { from: "RouteStop", to: "TravelItem" },
+ ATTRACTION_NEARBY_RESOURCE: { from: "ScenicAttraction", to: "Hotel" },
+ },
+ };
+
+ assert.deepEqual(buildSchemaGuidance(travelSchema), [
+ "固定路线不可改",
+ "RouteStop 串联景区",
+ "默认资源挂停靠点",
+ "可替换资源走 nearby",
+ "价格统一用 ResourcePriceRule",
+ ]);
+ assert.equal(schemaSupportsLegacyGraphProfile(travelSchema, "travel"), true);
+ assert.equal(schemaSupportsLegacyGraphProfile(travelSchema, "travel_item"), true);
+});
+
+test("generic graph shortcuts come from the active schema", () => {
+ const templates = buildGenericGraphQueryTemplates({ schema_jsonb: genericSchema });
+
+ assert.deepEqual(templates, [
+ { label: "全部节点", cypher: "MATCH (n) RETURN n LIMIT 200" },
+ { label: "全部节点关系", cypher: "MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 2500" },
+ { label: "地点(Place)节点", cypher: "MATCH (n:`Place`) RETURN n LIMIT 100" },
+ { label: "酒店(Hotel)节点", cypher: "MATCH (n:`Hotel`) RETURN n LIMIT 100" },
+ {
+ label: "LOCATED_IN 关系",
+ cypher: "MATCH (a:`Place`)-[r:`LOCATED_IN`]->(b:`Place`) RETURN a,r,b LIMIT 100",
+ },
+ {
+ label: "NEAR 关系",
+ cypher: "MATCH (a:`Hotel`)-[r:`NEAR`]->(b:`Place`) RETURN a,r,b LIMIT 100",
+ },
+ ]);
+ assert.equal(JSON.stringify(templates).includes("花溪"), false);
+ assert.equal(JSON.stringify(templates).includes("Area"), false);
+ assert.equal(genericGraphQueryPlaceholder(genericSchema), "输入自然语言或 Cypher,例如:查询 Place、Hotel 节点");
+});
+
+test("unsafe schema identifiers are never interpolated into shortcuts", () => {
+ const templates = buildGenericGraphQueryTemplates({
+ entity_types: {
+ "Place`) MATCH (secret": {},
+ SafeType: {},
+ },
+ relation_types: {
+ "REL`]->(secret": { from: "SafeType", to: "SafeType" },
+ SAFE_REL: { from: "SafeType", to: "Bad|Union" },
+ },
+ });
+ const allQueries = templates.map(({ cypher }) => cypher).join("\n");
+
+ assert.equal(allQueries.includes("secret"), false);
+ assert.equal(allQueries.includes("Bad|Union"), false);
+ assert.match(allQueries, /`SafeType`/);
+ assert.match(allQueries, /`SAFE_REL`/);
+});
+
+test("graph counts provide safe shortcuts when the schema endpoint is unavailable", () => {
+ assert.deepEqual(buildGenericGraphQueryTemplates({}, {
+ label_counts: [{ label: "Place", count: 2 }, { label: "Hotel", count: 1 }],
+ relationship_type_counts: [{ type: "NEAR", count: 1 }],
+ }), [
+ { label: "全部节点", cypher: "MATCH (n) RETURN n LIMIT 200" },
+ { label: "全部节点关系", cypher: "MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 2500" },
+ { label: "Place 节点", cypher: "MATCH (n:`Place`) RETURN n LIMIT 100" },
+ { label: "Hotel 节点", cypher: "MATCH (n:`Hotel`) RETURN n LIMIT 100" },
+ { label: "NEAR 关系", cypher: "MATCH (a)-[r:`NEAR`]->(b) RETURN a,r,b LIMIT 100" },
+ ]);
+});
diff --git a/admin-web/tests/schemaRequestGuard.test.ts b/admin-web/tests/schemaRequestGuard.test.ts
new file mode 100644
index 0000000..01fa099
--- /dev/null
+++ b/admin-web/tests/schemaRequestGuard.test.ts
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { SchemaRequestGuard } from "../src/panels/modeling/schemaRequestGuard.ts";
+
+const projectA = {
+ tenantId: "tenant-a",
+ projectId: "project-a",
+ graphName: "graph-a",
+};
+
+const projectB = {
+ tenantId: "tenant-b",
+ projectId: "project-b",
+ graphName: "graph-b",
+};
+
+test("a response from project A is stale as soon as project B starts loading", () => {
+ const guard = new SchemaRequestGuard();
+ const requestA = guard.begin(projectA);
+ const requestB = guard.begin(projectB);
+
+ assert.equal(guard.isCurrent(requestA, projectB), false);
+ assert.equal(guard.isCurrent(requestB, projectB), true);
+});
+
+test("an explicit project-context invalidation rejects an in-flight response", () => {
+ const guard = new SchemaRequestGuard();
+ const requestA = guard.begin(projectA);
+
+ guard.invalidate();
+
+ assert.equal(guard.isCurrent(requestA, projectA), false);
+});
+
+test("the captured context also protects against a context change without a new request", () => {
+ const guard = new SchemaRequestGuard();
+ const requestA = guard.begin(projectA);
+
+ assert.equal(guard.isCurrent(requestA, projectB), false);
+});
diff --git a/app/api/data_platform.py b/app/api/data_platform.py
index db48980..026c067 100644
--- a/app/api/data_platform.py
+++ b/app/api/data_platform.py
@@ -3,18 +3,38 @@ from __future__ import annotations
from typing import Any
-from fastapi import APIRouter, HTTPException, Query
+from fastapi import APIRouter, File, HTTPException, Query, Response, UploadFile
from app.auth import CurrentUser
+from app.data_platform.csv_service import (
+ MAX_CSV_BYTES,
+ export_csv_records,
+ import_csv_records,
+ preview_csv_import,
+)
from app.data_platform.record_service import (
create_record,
delete_record,
+ inspect_table,
list_databases,
list_records,
list_tables,
update_record,
)
-from app.data_platform.schema import ensure_all_project_databases
+from app.data_platform.schema import (
+ IDENTIFIER_PATTERN,
+ create_custom_table,
+ create_custom_table_from_sql,
+ create_table_column,
+ delete_custom_table,
+ delete_project_database,
+ delete_table_column,
+ ensure_project_database,
+ rename_custom_table,
+ rename_project_database,
+ update_table_column,
+)
+from app.data_platform.sql_console import execute_project_sql
router = APIRouter(prefix="/data-platform")
@@ -24,12 +44,52 @@ async def databases(_user: CurrentUser):
return await list_databases()
+@router.post("/databases")
+async def add_database(body: dict[str, Any], user: CurrentUser):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以创建关系数据库")
+ database_id = str(body.get("database_id") or body.get("project_id") or "").strip()
+ if not IDENTIFIER_PATTERN.fullmatch(database_id):
+ raise HTTPException(
+ 400,
+ "数据库编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位",
+ )
+ display_name = str(body.get("display_name") or database_id).strip()
+ tenant_id = str(body.get("tenant_id") or database_id).strip()
+ return await ensure_project_database(database_id, tenant_id, display_name)
+
+
@router.post("/databases/initialize")
async def initialize_databases(user: CurrentUser):
if "admin" not in user.get("roles", []):
- raise HTTPException(403, "只有系统管理员可以初始化项目数据库")
- await ensure_all_project_databases()
- return {"ok": True}
+ raise HTTPException(403, "只有系统管理员可以初始化关系数据库")
+ raise HTTPException(410, "图谱项目不再自动初始化关系数据库,请在数据中心独立创建")
+
+
+@router.patch("/databases/{project_id}")
+async def edit_database(project_id: str, body: dict[str, Any], user: CurrentUser):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以修改关系数据库")
+ try:
+ return await rename_project_database(
+ project_id,
+ str(body.get("display_name") or ""),
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@router.delete("/databases/{project_id}")
+async def remove_database(project_id: str, body: dict[str, Any], user: CurrentUser):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以删除关系数据库")
+ try:
+ return await delete_project_database(
+ project_id,
+ str(body.get("confirm_name") or ""),
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
@router.get("/databases/{project_id}/tables")
@@ -37,13 +97,157 @@ async def tables(project_id: str, _user: CurrentUser):
return await list_tables(project_id)
+@router.post("/databases/{project_id}/console/execute")
+async def execute_console_sql(
+ project_id: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以使用 SQL 控制台")
+ try:
+ return await execute_project_sql(project_id, str(body.get("sql") or ""))
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@router.get("/databases/{project_id}/tables/{table_code}/inspection")
+async def table_inspection(project_id: str, table_code: str, _user: CurrentUser):
+ return await inspect_table(project_id, table_code)
+
+
+@router.post("/databases/{project_id}/tables/{table_code}/columns")
+async def add_table_column(
+ project_id: str,
+ table_code: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以新增字段")
+ try:
+ await create_table_column(project_id, table_code, body)
+ return await inspect_table(project_id, table_code)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@router.patch(
+ "/databases/{project_id}/tables/{table_code}/columns/{column_code}"
+)
+async def edit_table_column(
+ project_id: str,
+ table_code: str,
+ column_code: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以修改字段")
+ try:
+ await update_table_column(project_id, table_code, column_code, body)
+ return await inspect_table(project_id, table_code)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@router.delete(
+ "/databases/{project_id}/tables/{table_code}/columns/{column_code}"
+)
+async def remove_table_column(
+ project_id: str,
+ table_code: str,
+ column_code: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以删除字段")
+ try:
+ await delete_table_column(
+ project_id,
+ table_code,
+ column_code,
+ str(body.get("confirm_name") or ""),
+ )
+ return await inspect_table(project_id, table_code)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@router.post("/databases/{project_id}/tables")
+async def add_table(project_id: str, body: dict[str, Any], user: CurrentUser):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以创建数据表")
+ try:
+ definition = await create_custom_table(project_id, body, user["username"])
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return definition.as_dict()
+
+
+@router.post("/databases/{project_id}/tables/from-sql")
+async def add_table_from_sql(project_id: str, body: dict[str, Any], user: CurrentUser):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以通过 SQL 创建数据表")
+ try:
+ definition = await create_custom_table_from_sql(
+ project_id,
+ body,
+ user["username"],
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return definition.as_dict()
+
+
+@router.patch("/databases/{project_id}/tables/{table_code}")
+async def edit_table(
+ project_id: str,
+ table_code: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以修改数据表")
+ try:
+ definition = await rename_custom_table(
+ project_id,
+ table_code,
+ str(body.get("code") or table_code),
+ str(body.get("label") or ""),
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return definition.as_dict()
+
+
+@router.delete("/databases/{project_id}/tables/{table_code}")
+async def remove_table(
+ project_id: str,
+ table_code: str,
+ body: dict[str, Any],
+ user: CurrentUser,
+):
+ if "admin" not in user.get("roles", []):
+ raise HTTPException(403, "只有系统管理员可以删除数据表")
+ try:
+ return await delete_custom_table(
+ project_id,
+ table_code,
+ str(body.get("confirm_name") or ""),
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
@router.get("/databases/{project_id}/tables/{table_code}/records")
async def records(
project_id: str,
table_code: str,
_user: CurrentUser,
page: int = Query(default=1, ge=1),
- page_size: int = Query(default=50, ge=1, le=200),
+ page_size: int = Query(default=50, ge=1, le=5000),
search: str | None = None,
sort_field: str | None = None,
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
@@ -59,6 +263,68 @@ async def records(
)
+async def _read_csv_upload(file: UploadFile) -> tuple[bytes, str]:
+ file_name = str(file.filename or "data.csv").strip()
+ if not file_name.lower().endswith(".csv"):
+ raise HTTPException(400, "仅支持 .csv 文件")
+ content = await file.read(MAX_CSV_BYTES + 1)
+ if len(content) > MAX_CSV_BYTES:
+ raise HTTPException(413, "CSV 文件不能超过 50 MB")
+ return content, file_name
+
+
+@router.post(
+ "/databases/{project_id}/tables/{table_code}/records/import/preview"
+)
+async def preview_records_import(
+ project_id: str,
+ table_code: str,
+ user: CurrentUser,
+ file: UploadFile = File(...),
+):
+ content, file_name = await _read_csv_upload(file)
+ return await preview_csv_import(project_id, table_code, content, file_name)
+
+
+@router.post("/databases/{project_id}/tables/{table_code}/records/import")
+async def import_records(
+ project_id: str,
+ table_code: str,
+ user: CurrentUser,
+ file: UploadFile = File(...),
+):
+ content, file_name = await _read_csv_upload(file)
+ return await import_csv_records(
+ project_id,
+ table_code,
+ content,
+ file_name,
+ user["username"],
+ )
+
+
+@router.get("/databases/{project_id}/tables/{table_code}/records/export")
+async def export_records(
+ project_id: str,
+ table_code: str,
+ _user: CurrentUser,
+ search: str | None = None,
+):
+ content, filename, total = await export_csv_records(
+ project_id,
+ table_code,
+ search,
+ )
+ return Response(
+ content=content,
+ media_type="text/csv; charset=utf-8",
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ "X-Exported-Count": str(total),
+ },
+ )
+
+
@router.post("/databases/{project_id}/tables/{table_code}/records")
async def add_record(
project_id: str,
@@ -88,4 +354,3 @@ async def remove_record(
user: CurrentUser,
):
return await delete_record(project_id, table_code, record_id, user["username"])
-
diff --git a/app/api/plaza.py b/app/api/plaza.py
index 8ee4306..49f779e 100644
--- a/app/api/plaza.py
+++ b/app/api/plaza.py
@@ -22,6 +22,9 @@ from app.project_context import ProjectContext, get_project_context
router = APIRouter()
SPATIAL_GRAPH_NAME = "guiyang_spatial_v1"
+# Libo business zone set: 玉屏街道 12 surveyed city zones (A01–A12),
+# supersedes the earlier 18-anchor ZS-LB-YUPING-V1.
+LIBO_ZONE_SET_ID = "ZS-LB-YUPING-V2"
LIBO_BUS_DATASET = "libo_bus_xlsx_v1"
FOOD_ENRICHMENT_KEYS = (
@@ -419,6 +422,51 @@ async def amap_config(_user: CurrentUser = None):
}
+@router.get("/plaza/weather/hourly")
+async def weather_hourly(_user: CurrentUser = None):
+ """荔波县逐小时气温(未来 24h)。
+
+ 高德天气 API 无逐小时能力,改用和风天气 (QWeather) ``/v7/weather/24h``,
+ KEY 存后端 (``.env``/settings),不暴露给前端。高德的实时+逐天保持不变。
+ """
+ if not settings.qweather_api_key:
+ return {"configured": False, "hourly": []}
+
+ import httpx
+
+ host = settings.qweather_api_host.rstrip("/")
+ url = f"{host}/v7/weather/24h"
+ params = {
+ "location": settings.qweather_libo_location,
+ "key": settings.qweather_api_key,
+ }
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ # 和风响应默认 gzip,httpx 会自动解压。
+ resp = await client.get(url, params=params)
+ payload = resp.json()
+ except Exception as exc: # 天气非核心功能,失败降级为空列表
+ return {"configured": True, "hourly": [], "error": str(exc)}
+
+ if str(payload.get("code")) != "200":
+ return {"configured": True, "hourly": [], "error": payload.get("code")}
+
+ hourly = [
+ {
+ "time": item.get("fxTime", ""),
+ "temp": item.get("temp", ""),
+ "text": item.get("text", ""),
+ "icon": item.get("icon", ""),
+ }
+ for item in (payload.get("hourly") or [])
+ ]
+ return {
+ "configured": True,
+ "update_time": payload.get("updateTime", ""),
+ "hourly": hourly,
+ }
+
+
def _is_enterprise_travel_graph(graph_name: str) -> bool:
lower = graph_name.lower()
return "baixinghui" in lower or ("travel" in lower and graph_name != SPATIAL_GRAPH_NAME)
@@ -671,13 +719,19 @@ async def map_pois(
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
- f"""SELECT element_id, gaode_poi_id, name, type_label, place_type,
- lng, lat, address, district, city
- FROM {s}.amap_spatial_pois
- WHERE graph_name=%s
- ORDER BY type_label, name
+ f"""SELECT p.element_id, p.gaode_poi_id, p.name, p.type_label,
+ p.place_type, p.lng, p.lat, p.address, p.district,
+ p.city, p.towncode, p.town_name,
+ z.zone_id, z.zone_name
+ FROM {s}.amap_spatial_pois p
+ LEFT JOIN {s}.poi_zone_assignments z
+ ON z.graph_name = p.graph_name
+ AND z.gaode_poi_id = p.gaode_poi_id
+ AND z.zone_set_id = %s
+ WHERE p.graph_name=%s
+ ORDER BY p.type_label, p.name
LIMIT 15000""",
- (graph_name,),
+ (LIBO_ZONE_SET_ID, graph_name),
)
rows = await cur.fetchall()
graph_categories = await asyncio.to_thread(
@@ -701,6 +755,10 @@ async def map_pois(
"address": row["address"] or "",
"district": row["district"] or "",
"city": row["city"] or "",
+ "towncode": row["towncode"] or "",
+ "town_name": row["town_name"] or "",
+ "zone_id": row["zone_id"] or "",
+ "zone_name": row["zone_name"] or "",
}
for row in rows
if row.get("lng") is not None and row.get("lat") is not None
@@ -723,6 +781,136 @@ async def map_pois(
}
+@router.get("/plaza/map-zones")
+async def map_zones(
+ context: ProjectContext = Depends(get_project_context),
+ _user: CurrentUser = None,
+):
+ """Return the two-track region tree for the map's 区域图层 selector.
+
+ Track 1 (法定): the 8 towns/subdistricts, keyed by ``towncode``. AMap
+ publishes no town-level boundary vector, so these carry POI counts and a
+ bounding box only — never a boundary line.
+
+ Track 2 (业务): the ``ZS-LB-YUPING-V1`` zone set, which only covers 玉屏街道.
+ Zones carry a ``geometry`` face (B-seamless variant) where one exists.
+ """
+ graph_name = _resolve_spatial_graph_name(context.graph_name)
+ s = settings.db_schema
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT to_regclass(%s) AS reg",
+ (f"{s}.libo_business_zones",),
+ )
+ if not (await cur.fetchone())["reg"]:
+ return {
+ "graph_name": graph_name,
+ "zone_set_id": LIBO_ZONE_SET_ID,
+ "towns": [],
+ "zones": [],
+ }
+
+ await cur.execute(
+ f"""SELECT t.towncode, t.name, t.unit_type, t.civil_adcode,
+ t.community_count, t.village_count,
+ coalesce(p.poi_count, 0) AS poi_count,
+ p.min_lng, p.min_lat, p.max_lng, p.max_lat
+ FROM {s}.libo_admin_towns t
+ LEFT JOIN (
+ SELECT towncode,
+ count(*) AS poi_count,
+ min(lng) AS min_lng, min(lat) AS min_lat,
+ max(lng) AS max_lng, max(lat) AS max_lat
+ FROM {s}.amap_spatial_pois
+ WHERE graph_name=%s AND towncode IS NOT NULL
+ GROUP BY towncode
+ ) p ON p.towncode = t.towncode
+ ORDER BY poi_count DESC, t.towncode""",
+ (graph_name,),
+ )
+ town_rows = await cur.fetchall()
+
+ await cur.execute(
+ f"""SELECT z.zone_id, z.zone_name, z.anchor_name, z.anchor_lng,
+ z.anchor_lat, z.cap_m, z.parent_admin_name,
+ z.geometry, z.geometry_variant, z.anchor_source,
+ z.area_km2, z.dominant_category, z.bank,
+ z.food_count, z.hotel_count, z.transit_count,
+ z.scenic_count,
+ coalesce(a.poi_count, 0) AS poi_count
+ FROM {s}.libo_business_zones z
+ LEFT JOIN (
+ SELECT zone_id, count(*) AS poi_count
+ FROM {s}.poi_zone_assignments
+ WHERE graph_name=%s AND zone_set_id=%s
+ GROUP BY zone_id
+ ) a ON a.zone_id = z.zone_id
+ WHERE z.zone_set_id=%s
+ ORDER BY z.zone_id""",
+ (graph_name, LIBO_ZONE_SET_ID, LIBO_ZONE_SET_ID),
+ )
+ zone_rows = await cur.fetchall()
+
+ def bbox(row: dict[str, Any]) -> list[float] | None:
+ if row["min_lng"] is None:
+ return None
+ return [
+ float(row["min_lng"]),
+ float(row["min_lat"]),
+ float(row["max_lng"]),
+ float(row["max_lat"]),
+ ]
+
+ towns = [
+ {
+ "towncode": row["towncode"],
+ "name": row["name"],
+ "unit_type": row["unit_type"],
+ "civil_adcode": row["civil_adcode"],
+ "community_count": row["community_count"],
+ "village_count": row["village_count"],
+ "poi_count": row["poi_count"],
+ "bbox": bbox(row),
+ # AMap has no town-level boundary vector; see doc §1.4.
+ "has_boundary": False,
+ }
+ for row in town_rows
+ ]
+ zones = [
+ {
+ "zone_id": row["zone_id"],
+ "zone_name": row["zone_name"],
+ "anchor_name": row["anchor_name"],
+ "anchor": (
+ [float(row["anchor_lng"]), float(row["anchor_lat"])]
+ if row["anchor_lng"] is not None
+ else None
+ ),
+ "cap_m": float(row["cap_m"]) if row["cap_m"] is not None else None,
+ "parent_admin_name": row["parent_admin_name"],
+ "anchor_source": row["anchor_source"],
+ "poi_count": row["poi_count"],
+ "area_km2": float(row["area_km2"]) if row["area_km2"] is not None else None,
+ "dominant_category": row["dominant_category"],
+ "bank": row["bank"],
+ "food_count": row["food_count"],
+ "hotel_count": row["hotel_count"],
+ "transit_count": row["transit_count"],
+ "scenic_count": row["scenic_count"],
+ "geometry": row["geometry"],
+ "geometry_variant": row["geometry_variant"],
+ }
+ for row in zone_rows
+ ]
+ return {
+ "graph_name": graph_name,
+ "zone_set_id": LIBO_ZONE_SET_ID,
+ "towns": towns,
+ "zones": zones,
+ }
+
+
@router.get("/plaza/bus-routes")
async def bus_routes(
context: ProjectContext = Depends(get_project_context),
diff --git a/app/api/projects.py b/app/api/projects.py
index 0314c96..f73782d 100644
--- a/app/api/projects.py
+++ b/app/api/projects.py
@@ -1,19 +1,114 @@
"""Projects, Graph Releases, and Ontology Schema endpoints."""
+import asyncio
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
+from psycopg.types.json import Jsonb
from app.auth import CurrentUser
from app.config import settings
from app.db import get_conn
from app.project_context import ProjectContext, get_project_context
+from app.project_lifecycle import (
+ GraphImportError,
+ ProjectValidationError,
+ SAFE_RESOURCE_ID,
+ SAFE_SCHEMA_VERSION,
+ delete_falkor_graph,
+ import_falkor_graph,
+ list_falkor_graphs,
+ normalize_provision_payload,
+)
router = APIRouter()
ROOT_DIR = Path(__file__).resolve().parents[2]
+async def _run_blocking_to_completion(function: Any, *args: Any) -> Any:
+ """Run a blocking FalkorDB call without abandoning its worker on cancellation.
+
+ ``asyncio.to_thread`` keeps running after its awaiting coroutine is
+ cancelled. For graph mutations that would otherwise release an advisory
+ lock too early and potentially affect a newly-created graph with the same
+ name. Shielding and draining the worker keeps the lock owner alive until
+ the exact external operation has actually stopped.
+ """
+
+ worker = asyncio.create_task(asyncio.to_thread(function, *args))
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError as cancelled:
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ except Exception:
+ break
+ # Retrieve a possible worker exception so it is not reported as an
+ # unhandled task. The durable cleanup intent remains pending and the
+ # caller's cancellation remains the externally visible outcome.
+ if worker.done() and not worker.cancelled():
+ try:
+ worker.result()
+ except Exception:
+ pass
+ raise cancelled
+
+
+async def _run_async_cleanup_to_completion(coroutine: Any) -> Any:
+ """Drain an async cleanup task before propagating caller cancellation."""
+
+ worker = asyncio.create_task(coroutine)
+ try:
+ return await asyncio.shield(worker)
+ except asyncio.CancelledError as cancelled:
+ while not worker.done():
+ try:
+ await asyncio.shield(worker)
+ except asyncio.CancelledError:
+ continue
+ except BaseException:
+ break
+ if worker.done() and not worker.cancelled():
+ try:
+ worker.result()
+ except BaseException:
+ pass
+ raise cancelled
+
+
+async def _release_session_locks(conn: Any, lock_keys: list[str]) -> None:
+ """Release session locks or close the physical connection unconditionally."""
+
+ try:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ async with conn.cursor() as cur:
+ for lock_key in reversed(lock_keys):
+ await cur.execute("SELECT pg_advisory_unlock(hashtext(%s))", (lock_key,))
+ await conn.commit()
+ except BaseException:
+ # A failed or cancelled unlock must never return this physical session
+ # to the pool with a lock still attached. Drain close even if a second
+ # cancellation arrives while it is in progress.
+ close_worker = asyncio.create_task(conn.close())
+ while not close_worker.done():
+ try:
+ await asyncio.shield(close_worker)
+ except asyncio.CancelledError:
+ continue
+ except BaseException:
+ break
+ if close_worker.done() and not close_worker.cancelled():
+ try:
+ close_worker.result()
+ except BaseException:
+ pass
+
+
def _as_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
@@ -94,9 +189,16 @@ def _schema_source_from_file(row: dict[str, Any], schema: dict[str, Any]) -> str
project_id = str(row.get("project_id") or "").strip()
namespace = str(row.get("namespace") or schema.get("namespace") or "").strip()
version = str(schema.get("version") or row.get("version") or "").strip()
- if not project_id:
+ if (
+ not SAFE_RESOURCE_ID.fullmatch(project_id)
+ or not SAFE_RESOURCE_ID.fullmatch(namespace)
+ or not SAFE_SCHEMA_VERSION.fullmatch(version)
+ ):
+ return None
+ schema_root = (ROOT_DIR / "schema搭建").resolve()
+ schema_dir = (schema_root / project_id).resolve()
+ if schema_root not in schema_dir.parents:
return None
- schema_dir = ROOT_DIR / "schema搭建" / project_id
version_slug = version.replace(".", "_")
candidates = [
schema_dir / f"{namespace}_schema.current.dsl.md",
@@ -112,19 +214,669 @@ def _schema_source_from_file(row: dict[str, Any], schema: dict[str, Any]) -> str
if path in seen:
continue
seen.add(path)
- if path.exists() and path.is_file():
- return path.read_text(encoding="utf-8")
+ resolved_path = path.resolve()
+ if schema_root not in resolved_path.parents:
+ continue
+ if resolved_path.exists() and resolved_path.is_file():
+ return resolved_path.read_text(encoding="utf-8")
return None
def _attach_schema_source(row: Any) -> dict[str, Any]:
data = dict(row)
schema = _as_dict(data.get("schema_jsonb"))
+ semantic_version = str(schema.get("version") or "").strip()
+ if semantic_version:
+ # Restored legacy databases keep an integer revision in the physical
+ # ``version`` column. The JSON document remains the source of truth for
+ # the user-facing semantic version.
+ data["version"] = semantic_version
data["schema_source"] = _schema_source_from_file(data, schema) or _schema_source_from_json(schema)
data["schema_source_format"] = "dsl"
return data
+# Explicitly reviewed graph-project tables. Relational data-platform registry
+# tables (project_databases/project_table_definitions/project_table_overrides)
+# and every biz_* schema are intentionally absent from this allow-list.
+_PROJECT_SCOPED_TABLES = (
+ "mapping_profiles",
+ "import_templates",
+ "import_batches",
+ "candidate_entities",
+ "candidate_relations",
+ "review_actions",
+ "publish_jobs",
+ "ontology_schema_publish_jobs",
+ "field_proposals",
+ "source_profiles",
+ "question_traces",
+ "simulation_questions",
+ "acquisition_tasks",
+ "inventory_issues",
+ "vocabulary_terms",
+ "agent_call_logs",
+ "kg_evidence_links",
+ "kg_statements",
+ "kg_relations",
+ "kg_events",
+ "kg_concepts",
+ "kg_entities",
+ "kg_schema_proposals",
+ "kg_schema_versions",
+ "kg_place_spatial",
+ "kg_route_metrics",
+ "graph_releases",
+ "ontology_schemas",
+)
+
+_PROJECT_DELETE_ORDER = (
+ "review_actions",
+ "publish_jobs",
+ "candidate_relations",
+ "candidate_entities",
+ "mapping_profiles",
+ "import_templates",
+ "field_proposals",
+ "source_profiles",
+ "inventory_issues",
+ "question_traces",
+ "simulation_questions",
+ "acquisition_tasks",
+ "vocabulary_terms",
+ "agent_call_logs",
+ "kg_evidence_links",
+ "kg_statements",
+ "kg_relations",
+ "kg_events",
+ "kg_concepts",
+ "kg_entities",
+ "kg_schema_proposals",
+ "kg_schema_versions",
+ "kg_place_spatial",
+ "kg_route_metrics",
+ "import_batches",
+ "ontology_schema_publish_jobs",
+ "graph_releases",
+ "ontology_schemas",
+)
+
+_INDIRECT_TABLES = (
+ "raw_records",
+ "validation_issues",
+ "ontology_entity_types",
+ "ontology_fields",
+ "ontology_relation_types",
+ "task_events",
+ "notifications",
+ "super_agent_tasks",
+)
+
+_GRAPH_BOUND_TABLES = (
+ "amap_spatial_collect_tasks",
+ "amap_spatial_pois",
+ "amap_spatial_collect_runs",
+)
+
+
+def _project_where(columns: set[str], tenant_id: str, project_id: str) -> tuple[str, tuple[str, ...]]:
+ if "tenant_id" in columns:
+ return "tenant_id=%s AND project_id=%s", (tenant_id, project_id)
+ return "project_id=%s", (project_id,)
+
+
+def _require_project_admin(user: dict[str, Any]) -> None:
+ if "admin" not in set(user.get("roles") or []):
+ raise HTTPException(403, "只有系统管理员可以创建、重命名或删除知识图谱项目")
+
+
+def _resolve_graph_name(project_id: str, explicit_graph_name: Any = None) -> str:
+ """Use one stable implicit graph namespace across lifecycle endpoints."""
+
+ return str(explicit_graph_name or "").strip() or project_id
+
+
+_INTEGER_VERSION_TYPES = frozenset({"smallint", "integer", "bigint", "int2", "int4", "int8"})
+_TEXT_VERSION_TYPES = frozenset({"text", "character varying", "character", "varchar", "bpchar"})
+
+
+async def _ontology_schema_storage_version(
+ cur: Any,
+ *,
+ tenant_id: str,
+ project_id: str,
+ namespace: str,
+ semantic_version: str,
+) -> str | int:
+ """Adapt a semantic schema version to either legacy integer or text storage.
+
+ Legacy snapshots use an integer revision column while the normalized JSON
+ already carries the actual semantic version. A project-scoped advisory
+ lock is held by every caller, so allocating the next integer revision here
+ is deterministic without changing existing rows or their schema ids.
+ """
+
+ await cur.execute(
+ """SELECT data_type, udt_name
+ FROM information_schema.columns
+ WHERE table_schema=%s AND table_name='ontology_schemas' AND column_name='version'""",
+ (settings.db_schema,),
+ )
+ column = await cur.fetchone()
+ if not column:
+ raise RuntimeError("ontology_schemas.version 列不存在")
+
+ column_types = {
+ str(column.get("data_type") or "").lower(),
+ str(column.get("udt_name") or "").lower(),
+ }
+ if column_types & _INTEGER_VERSION_TYPES:
+ await cur.execute(
+ f"""SELECT COALESCE(MAX(version), 0) + 1 AS next_version
+ FROM {settings.db_schema}.ontology_schemas
+ WHERE tenant_id=%s AND project_id=%s AND namespace=%s""",
+ (tenant_id, project_id, namespace),
+ )
+ next_row = await cur.fetchone()
+ return int((next_row or {}).get("next_version") or 1)
+ if column_types & _TEXT_VERSION_TYPES:
+ return semantic_version
+ rendered_type = str(column.get("data_type") or column.get("udt_name") or "unknown")
+ raise RuntimeError(f"ontology_schemas.version 列类型 {rendered_type!r} 不受支持")
+
+
+def _graph_resource_conflict_detail(payload: dict[str, Any], state: str) -> str:
+ """Describe a graph namespace conflict in terms used by the project form."""
+
+ project_id = str(payload["project_id"])
+ graph_name = str(payload["graph_name"])
+ if graph_name == project_id:
+ return f"项目英文标识“{project_id}”对应的图谱资源{state},请更换项目英文标识"
+ return (
+ f"项目英文标识“{project_id}”显式指定的图谱资源“{graph_name}”{state},"
+ "请改用未占用的图谱资源标识"
+ )
+
+
+async def _load_table_shapes(cur: Any) -> dict[str, set[str]]:
+ names = list(_PROJECT_SCOPED_TABLES + _INDIRECT_TABLES + _GRAPH_BOUND_TABLES + ("projects",))
+ await cur.execute(
+ """SELECT table_name, array_agg(column_name::text ORDER BY ordinal_position) AS columns
+ FROM information_schema.columns
+ WHERE table_schema=%s AND table_name=ANY(%s)
+ GROUP BY table_name""",
+ (settings.db_schema, names),
+ )
+ return {str(row["table_name"]): set(row["columns"] or []) for row in await cur.fetchall()}
+
+
+async def _load_project(cur: Any, project_id: str, *, for_update: bool = False) -> dict[str, Any]:
+ suffix = " FOR UPDATE" if for_update else ""
+ await cur.execute(
+ f"SELECT * FROM {settings.db_schema}.projects WHERE project_id=%s ORDER BY created_at LIMIT 2{suffix}",
+ (project_id,),
+ )
+ rows = await cur.fetchall()
+ if not rows:
+ raise HTTPException(404, "项目不存在")
+ if len(rows) > 1:
+ raise HTTPException(409, "存在多个同 project_id 的租户项目,无法安全执行该操作")
+ return dict(rows[0])
+
+
+async def _fetch_ids(
+ cur: Any,
+ table: str,
+ id_column: str,
+ columns: set[str],
+ tenant_id: str,
+ project_id: str,
+) -> list[Any]:
+ if not columns or id_column not in columns or "project_id" not in columns:
+ return []
+ where, params = _project_where(columns, tenant_id, project_id)
+ await cur.execute(
+ f"SELECT {id_column} AS value FROM {settings.db_schema}.{table} WHERE {where}",
+ params,
+ )
+ return [row["value"] for row in await cur.fetchall()]
+
+
+async def _dependency_ids(
+ cur: Any,
+ shapes: dict[str, set[str]],
+ tenant_id: str,
+ project_id: str,
+) -> dict[str, list[Any]]:
+ batch_columns = shapes.get("import_batches", set())
+ batch_key = "id" if "id" in batch_columns else "batch_id"
+ return {
+ "schema_ids": await _fetch_ids(
+ cur, "ontology_schemas", "id", shapes.get("ontology_schemas", set()), tenant_id, project_id
+ ),
+ "batch_ids": await _fetch_ids(
+ cur, "import_batches", batch_key, batch_columns, tenant_id, project_id
+ ),
+ "template_ids": await _fetch_ids(
+ cur,
+ "import_templates",
+ "template_id",
+ shapes.get("import_templates", set()),
+ tenant_id,
+ project_id,
+ ),
+ "candidate_entity_ids": await _fetch_ids(
+ cur, "candidate_entities", "id", shapes.get("candidate_entities", set()), tenant_id, project_id
+ ),
+ "candidate_relation_ids": await _fetch_ids(
+ cur, "candidate_relations", "id", shapes.get("candidate_relations", set()), tenant_id, project_id
+ ),
+ "task_ids": await _fetch_ids(
+ cur, "acquisition_tasks", "id", shapes.get("acquisition_tasks", set()), tenant_id, project_id
+ ),
+ }
+
+
+async def _entity_type_ids(
+ cur: Any,
+ shapes: dict[str, set[str]],
+ schema_ids: list[Any],
+) -> list[Any]:
+ columns = shapes.get("ontology_entity_types", set())
+ if not schema_ids or not {"id", "schema_id"}.issubset(columns):
+ return []
+ await cur.execute(
+ f"SELECT id AS value FROM {settings.db_schema}.ontology_entity_types WHERE schema_id=ANY(%s)",
+ (schema_ids,),
+ )
+ return [row["value"] for row in await cur.fetchall()]
+
+
+async def _graph_references(
+ cur: Any,
+ tenant_id: str,
+ project_id: str,
+) -> list[dict[str, Any]]:
+ await cur.execute(
+ f"SELECT DISTINCT graph_name FROM {settings.db_schema}.graph_releases "
+ "WHERE tenant_id=%s AND project_id=%s AND graph_name IS NOT NULL ORDER BY graph_name",
+ (tenant_id, project_id),
+ )
+ graph_names = [str(row["graph_name"]) for row in await cur.fetchall()]
+ result: list[dict[str, Any]] = []
+ for graph_name in graph_names:
+ await cur.execute(
+ f"""SELECT DISTINCT project_id
+ FROM {settings.db_schema}.graph_releases
+ WHERE graph_name=%s
+ AND NOT (tenant_id=%s AND project_id=%s)
+ ORDER BY project_id""",
+ (graph_name, tenant_id, project_id),
+ )
+ other_projects = [str(row["project_id"]) for row in await cur.fetchall()]
+ result.append(
+ {
+ "graph_name": graph_name,
+ "shared": bool(other_projects),
+ "other_project_ids": other_projects,
+ "will_delete": not other_projects,
+ }
+ )
+ return result
+
+
+async def _count_direct_table(
+ cur: Any,
+ table: str,
+ columns: set[str],
+ tenant_id: str,
+ project_id: str,
+) -> int:
+ if "project_id" not in columns:
+ return 0
+ where, params = _project_where(columns, tenant_id, project_id)
+ await cur.execute(f"SELECT count(*) AS count FROM {settings.db_schema}.{table} WHERE {where}", params)
+ return int((await cur.fetchone())["count"])
+
+
+async def _count_any(cur: Any, table: str, column: str, values: list[Any]) -> int:
+ if not values:
+ return 0
+ await cur.execute(
+ f"SELECT count(*) AS count FROM {settings.db_schema}.{table} WHERE {column}=ANY(%s)",
+ (values,),
+ )
+ return int((await cur.fetchone())["count"])
+
+
+async def _count_validation_issues(
+ cur: Any,
+ columns: set[str],
+ ids: dict[str, list[Any]],
+) -> int:
+ clauses: list[str] = []
+ params: list[list[Any]] = []
+ candidates = (
+ ("batch_id", ids["batch_ids"]),
+ ("candidate_entity_id", ids["candidate_entity_ids"]),
+ ("candidate_relation_id", ids["candidate_relation_ids"]),
+ ("candidate_id", ids["candidate_entity_ids"]),
+ )
+ for column, values in candidates:
+ if column in columns and values:
+ clauses.append(f"{column}=ANY(%s)")
+ params.append(values)
+ if not clauses:
+ return 0
+ await cur.execute(
+ f"SELECT count(*) AS count FROM {settings.db_schema}.validation_issues WHERE " + " OR ".join(clauses),
+ tuple(params),
+ )
+ return int((await cur.fetchone())["count"])
+
+
+async def _count_legacy_review_actions(
+ cur: Any,
+ columns: set[str],
+ ids: dict[str, list[Any]],
+) -> int:
+ if "project_id" in columns or not {"candidate_id", "candidate_type"}.issubset(columns):
+ return 0
+ clauses: list[str] = []
+ params: list[Any] = []
+ if ids["candidate_entity_ids"]:
+ clauses.append("candidate_type IN ('entity', 'candidate_entity') AND candidate_id=ANY(%s)")
+ params.append(ids["candidate_entity_ids"])
+ if ids["candidate_relation_ids"]:
+ clauses.append("candidate_type IN ('relation', 'candidate_relation') AND candidate_id=ANY(%s)")
+ params.append(ids["candidate_relation_ids"])
+ if not clauses:
+ return 0
+ await cur.execute(
+ f"SELECT count(*) AS count FROM {settings.db_schema}.review_actions WHERE "
+ + " OR ".join(f"({clause})" for clause in clauses),
+ tuple(params),
+ )
+ return int((await cur.fetchone())["count"])
+
+
+async def _build_deletion_impact(
+ cur: Any,
+ project: dict[str, Any],
+) -> tuple[dict[str, Any], dict[str, set[str]], dict[str, list[Any]]]:
+ tenant_id = str(project["tenant_id"])
+ project_id = str(project["project_id"])
+ shapes = await _load_table_shapes(cur)
+ ids = await _dependency_ids(cur, shapes, tenant_id, project_id)
+ entity_type_ids = await _entity_type_ids(cur, shapes, ids["schema_ids"])
+ ids["entity_type_ids"] = entity_type_ids
+ counts: dict[str, int] = {"projects": 1}
+
+ for table in _PROJECT_SCOPED_TABLES:
+ if table in shapes:
+ count = await _count_direct_table(cur, table, shapes[table], tenant_id, project_id)
+ if count:
+ counts[table] = count
+
+ indirect_specs = (
+ ("mapping_profiles", "template_id", ids["template_ids"]),
+ ("raw_records", "batch_id", ids["batch_ids"]),
+ ("ontology_schema_publish_jobs", "schema_id", ids["schema_ids"]),
+ ("ontology_entity_types", "schema_id", ids["schema_ids"]),
+ ("ontology_fields", "entity_type_id", entity_type_ids),
+ ("ontology_relation_types", "schema_id", ids["schema_ids"]),
+ ("task_events", "task_id", ids["task_ids"]),
+ ("notifications", "related_task_id", ids["task_ids"]),
+ ("super_agent_task_links", "related_task_id", ids["task_ids"]),
+ )
+ for result_key, column, values in indirect_specs:
+ table = "super_agent_tasks" if result_key == "super_agent_task_links" else result_key
+ if (
+ table in shapes
+ and column in shapes[table]
+ and not (
+ table in {"mapping_profiles", "ontology_schema_publish_jobs"}
+ and "project_id" in shapes[table]
+ )
+ ):
+ count = await _count_any(cur, table, column, values)
+ if count:
+ counts[result_key] = count
+ if "validation_issues" in shapes:
+ count = await _count_validation_issues(cur, shapes["validation_issues"], ids)
+ if count:
+ counts["validation_issues"] = count
+ if "review_actions" in shapes and "project_id" not in shapes["review_actions"]:
+ count = await _count_legacy_review_actions(cur, shapes["review_actions"], ids)
+ if count:
+ counts["review_actions"] = count
+
+ graphs = await _graph_references(cur, tenant_id, project_id)
+ exclusive_graph_names = [item["graph_name"] for item in graphs if item["will_delete"]]
+ for table in _GRAPH_BOUND_TABLES:
+ if table not in shapes or "graph_name" not in shapes[table] or not exclusive_graph_names:
+ continue
+ count = await _count_any(cur, table, "graph_name", exclusive_graph_names)
+ if count:
+ counts[table] = count
+
+ impact = {
+ "project_id": project_id,
+ "tenant_id": tenant_id,
+ "display_name": project.get("display_name") or project_id,
+ "counts": counts,
+ "graphs": graphs,
+ "protected_resources": [
+ "PostgreSQL 数据中心注册信息(project_databases)",
+ "数据中心表定义与字段配置",
+ "全部 biz_* Schema、业务表与业务记录",
+ "被其他知识图谱项目共享的 FalkorDB 图",
+ ],
+ }
+ return impact, shapes, ids
+
+
+async def _ensure_cleanup_table(cur: Any) -> None:
+ await cur.execute(
+ f"""CREATE TABLE IF NOT EXISTS {settings.db_schema}.graph_cleanup_tasks (
+ id BIGSERIAL PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ graph_name TEXT NOT NULL,
+ operation TEXT NOT NULL DEFAULT 'delete',
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )"""
+ )
+
+
+async def _record_cleanup_task(
+ cur: Any,
+ project_id: str,
+ graph_name: str,
+ *,
+ operation: str = "delete",
+) -> int:
+ await _ensure_cleanup_table(cur)
+ await cur.execute(
+ f"""INSERT INTO {settings.db_schema}.graph_cleanup_tasks
+ (project_id, graph_name, operation, status)
+ VALUES (%s, %s, %s, 'pending')
+ RETURNING id""",
+ (project_id, graph_name, operation),
+ )
+ return int((await cur.fetchone())["id"])
+
+
+async def _set_cleanup_task(cur: Any, task_id: int, *, error: str | None = None) -> None:
+ await cur.execute(
+ f"""UPDATE {settings.db_schema}.graph_cleanup_tasks
+ SET status=%s, attempts=attempts+1, last_error=%s, updated_at=now()
+ WHERE id=%s""",
+ ("pending" if error else "completed", error, task_id),
+ )
+
+
+async def _finish_cleanup_task(
+ task_id: int,
+ *,
+ error: str | None = None,
+ conn: Any | None = None,
+) -> None:
+ """Persist cleanup state, optionally reusing a lock-owning connection."""
+
+ if conn is not None:
+ async with conn.cursor() as cur:
+ await _set_cleanup_task(cur, task_id, error=error)
+ await conn.commit()
+ return
+
+ async with get_conn() as task_conn:
+ async with task_conn.cursor() as cur:
+ await _set_cleanup_task(cur, task_id, error=error)
+ await task_conn.commit()
+
+
+async def _delete_any(cur: Any, table: str, column: str, values: list[Any]) -> None:
+ if values:
+ await cur.execute(
+ f"DELETE FROM {settings.db_schema}.{table} WHERE {column}=ANY(%s)",
+ (values,),
+ )
+
+
+async def _delete_validation_issues(
+ cur: Any,
+ columns: set[str],
+ ids: dict[str, list[Any]],
+) -> None:
+ clauses: list[str] = []
+ params: list[list[Any]] = []
+ for column, values in (
+ ("batch_id", ids["batch_ids"]),
+ ("candidate_entity_id", ids["candidate_entity_ids"]),
+ ("candidate_relation_id", ids["candidate_relation_ids"]),
+ ("candidate_id", ids["candidate_entity_ids"]),
+ ):
+ if column in columns and values:
+ clauses.append(f"{column}=ANY(%s)")
+ params.append(values)
+ if clauses:
+ await cur.execute(
+ f"DELETE FROM {settings.db_schema}.validation_issues WHERE " + " OR ".join(clauses),
+ tuple(params),
+ )
+
+
+async def _delete_project_resources(
+ cur: Any,
+ project: dict[str, Any],
+ impact: dict[str, Any],
+ shapes: dict[str, set[str]],
+ ids: dict[str, list[Any]],
+) -> dict[str, int]:
+ """Delete only reviewed graph-side resources inside the current transaction."""
+
+ tenant_id = str(project["tenant_id"])
+ project_id = str(project["project_id"])
+ before = dict(impact["counts"])
+
+ question_columns = shapes.get("question_traces", set())
+ if "acquisition_task_id" in question_columns and "project_id" in question_columns:
+ where, params = _project_where(question_columns, tenant_id, project_id)
+ await cur.execute(
+ f"UPDATE {settings.db_schema}.question_traces SET acquisition_task_id=NULL WHERE {where}",
+ params,
+ )
+ task_columns = shapes.get("acquisition_tasks", set())
+ reset_columns = [
+ column
+ for column in ("triggered_by_trace_id", "result_batch_id")
+ if column in task_columns
+ ]
+ if reset_columns and "project_id" in task_columns:
+ where, params = _project_where(task_columns, tenant_id, project_id)
+ assignments = ", ".join(f"{column}=NULL" for column in reset_columns)
+ await cur.execute(
+ f"UPDATE {settings.db_schema}.acquisition_tasks SET {assignments} WHERE {where}",
+ params,
+ )
+
+ task_ids = ids["task_ids"]
+ if "notifications" in shapes and "related_task_id" in shapes["notifications"]:
+ await _delete_any(cur, "notifications", "related_task_id", task_ids)
+ if "task_events" in shapes and "task_id" in shapes["task_events"]:
+ await _delete_any(cur, "task_events", "task_id", task_ids)
+ if "super_agent_tasks" in shapes and "related_task_id" in shapes["super_agent_tasks"] and task_ids:
+ # Preserve the autonomous run ledger; only remove its dangling task link.
+ await cur.execute(
+ f"UPDATE {settings.db_schema}.super_agent_tasks SET related_task_id=NULL "
+ "WHERE related_task_id=ANY(%s)",
+ (task_ids,),
+ )
+
+ if "validation_issues" in shapes:
+ await _delete_validation_issues(cur, shapes["validation_issues"], ids)
+ if "review_actions" in shapes and "project_id" not in shapes["review_actions"]:
+ review_columns = shapes["review_actions"]
+ if {"candidate_id", "candidate_type"}.issubset(review_columns):
+ clauses: list[str] = []
+ params: list[Any] = []
+ if ids["candidate_entity_ids"]:
+ clauses.append("candidate_type IN ('entity', 'candidate_entity') AND candidate_id=ANY(%s)")
+ params.append(ids["candidate_entity_ids"])
+ if ids["candidate_relation_ids"]:
+ clauses.append("candidate_type IN ('relation', 'candidate_relation') AND candidate_id=ANY(%s)")
+ params.append(ids["candidate_relation_ids"])
+ if clauses:
+ await cur.execute(
+ f"DELETE FROM {settings.db_schema}.review_actions WHERE "
+ + " OR ".join(f"({clause})" for clause in clauses),
+ tuple(params),
+ )
+
+ if "raw_records" in shapes and "batch_id" in shapes["raw_records"]:
+ await _delete_any(cur, "raw_records", "batch_id", ids["batch_ids"])
+ if (
+ "mapping_profiles" in shapes
+ and "project_id" not in shapes["mapping_profiles"]
+ and "template_id" in shapes["mapping_profiles"]
+ ):
+ await _delete_any(cur, "mapping_profiles", "template_id", ids["template_ids"])
+ if "ontology_fields" in shapes and "entity_type_id" in shapes["ontology_fields"]:
+ await _delete_any(cur, "ontology_fields", "entity_type_id", ids["entity_type_ids"])
+ if "ontology_relation_types" in shapes and "schema_id" in shapes["ontology_relation_types"]:
+ await _delete_any(cur, "ontology_relation_types", "schema_id", ids["schema_ids"])
+ if "ontology_entity_types" in shapes and "schema_id" in shapes["ontology_entity_types"]:
+ await _delete_any(cur, "ontology_entity_types", "schema_id", ids["schema_ids"])
+ schema_job_columns = shapes.get("ontology_schema_publish_jobs", set())
+ if schema_job_columns and "project_id" not in schema_job_columns and "schema_id" in schema_job_columns:
+ await _delete_any(cur, "ontology_schema_publish_jobs", "schema_id", ids["schema_ids"])
+
+ for table in _PROJECT_DELETE_ORDER:
+ columns = shapes.get(table, set())
+ if "project_id" not in columns:
+ continue
+ where, params = _project_where(columns, tenant_id, project_id)
+ await cur.execute(f"DELETE FROM {settings.db_schema}.{table} WHERE {where}", params)
+
+ exclusive_graph_names = [item["graph_name"] for item in impact["graphs"] if item["will_delete"]]
+ for table in _GRAPH_BOUND_TABLES:
+ columns = shapes.get(table, set())
+ if "graph_name" in columns and exclusive_graph_names:
+ await _delete_any(cur, table, "graph_name", exclusive_graph_names)
+
+ await cur.execute(
+ f"DELETE FROM {settings.db_schema}.projects WHERE tenant_id=%s AND project_id=%s",
+ (tenant_id, project_id),
+ )
+ if cur.rowcount != 1:
+ raise RuntimeError("项目元数据删除数量异常,事务已中止")
+ return before
+
+
@router.get("/projects")
async def list_projects(_user: CurrentUser):
async with get_conn() as conn:
@@ -143,16 +895,405 @@ async def list_projects(_user: CurrentUser):
return await cur.fetchall()
+@router.post("/projects/provision")
+async def provision_project(body: dict, _user: CurrentUser):
+ """Atomically provision project metadata, ontology, release, and a new graph."""
+
+ _require_project_admin(_user)
+ try:
+ payload = normalize_provision_payload(body)
+ except ProjectValidationError as exc:
+ raise HTTPException(422, {"message": "项目数据校验失败", "errors": exc.errors}) from exc
+
+ try:
+ existing_graphs = await asyncio.to_thread(list_falkor_graphs)
+ except Exception as exc:
+ raise HTTPException(503, f"FalkorDB 不可用,未写入任何项目数据:{exc}") from exc
+ if payload["graph_name"] in existing_graphs:
+ raise HTTPException(409, _graph_resource_conflict_detail(payload, "已存在,不能覆盖"))
+
+ s = settings.db_schema
+ graph_created = False
+ cleanup_pending = False
+ cleanup_error: Exception | None = None
+ provision_task_id: int | None = None
+ project_row: dict[str, Any] | None = None
+ schema_row: dict[str, Any] | None = None
+ release_row: dict[str, Any] | None = None
+ graph_counts: dict[str, int] = {"nodes": 0, "relations": 0}
+
+ # Session-level locks let us durably commit a cleanup intent before the
+ # external graph write, while retaining the same project/graph exclusion
+ # across the following PostgreSQL transaction. The fixed project→graph
+ # order is shared with delete and legacy release creation.
+ lock_keys = [
+ f"kg-project:{payload['project_id']}",
+ f"kg-graph:{payload['graph_name']}",
+ ]
+ acquired_lock_keys: list[str] = []
+
+ async with get_conn() as conn:
+ try:
+ async with conn.cursor() as cur:
+ for lock_key in lock_keys:
+ acquired_lock_keys.append(lock_key)
+ await cur.execute("SELECT pg_advisory_lock(hashtext(%s))", (lock_key,))
+ await conn.commit()
+
+ # Recheck every namespace under the session locks and persist the
+ # compensation outbox before FalkorDB can be changed.
+ async with conn.cursor() as cur:
+ await cur.execute(
+ f"SELECT 1 FROM {s}.projects WHERE project_id=%s LIMIT 1",
+ (payload["project_id"],),
+ )
+ if await cur.fetchone():
+ raise HTTPException(409, "项目英文标识已存在,不能覆盖")
+ await cur.execute(
+ f"SELECT 1 FROM {s}.graph_releases WHERE graph_name=%s LIMIT 1",
+ (payload["graph_name"],),
+ )
+ if await cur.fetchone():
+ raise HTTPException(
+ 409,
+ _graph_resource_conflict_detail(payload, "已被现有项目使用"),
+ )
+ await cur.execute(
+ f"SELECT 1 FROM {s}.graph_project_tombstones WHERE project_id=%s",
+ (payload["project_id"],),
+ )
+ if await cur.fetchone():
+ raise HTTPException(
+ 409,
+ "项目英文标识曾被删除,为隔离历史写请求不能复用,请使用新的英文标识",
+ )
+ await cur.execute(
+ f"SELECT 1 FROM {s}.graph_name_tombstones WHERE graph_name=%s",
+ (payload["graph_name"],),
+ )
+ if await cur.fetchone():
+ raise HTTPException(
+ 409,
+ _graph_resource_conflict_detail(payload, "曾被删除,为隔离历史写请求不能复用"),
+ )
+
+ try:
+ locked_graphs = await _run_blocking_to_completion(list_falkor_graphs)
+ except Exception as exc:
+ raise HTTPException(503, f"FalkorDB 不可用,未写入项目数据:{exc}") from exc
+ if payload["graph_name"] in locked_graphs:
+ raise HTTPException(
+ 409,
+ _graph_resource_conflict_detail(payload, "已存在,不能覆盖"),
+ )
+
+ provision_task_id = await _record_cleanup_task(
+ cur,
+ payload["project_id"],
+ payload["graph_name"],
+ operation="rollback_provision",
+ )
+ # This commit is the durable intent boundary. Session locks remain
+ # owned by this physical connection after the transaction ends.
+ await conn.commit()
+
+ async with conn.cursor() as cur:
+ schema = payload["schema"]
+ schema_storage_version = await _ontology_schema_storage_version(
+ cur,
+ tenant_id=payload["tenant_id"],
+ project_id=payload["project_id"],
+ namespace=schema["namespace"],
+ semantic_version=schema["version"],
+ )
+ await cur.execute(
+ f"""INSERT INTO {s}.projects (
+ tenant_id, project_id, display_name, description, status,
+ default_namespace, metadata_jsonb, created_by, updated_at
+ )
+ VALUES (%s, %s, %s, %s, 'provisioning', %s, %s, %s, now())
+ RETURNING *""",
+ (
+ payload["tenant_id"],
+ payload["project_id"],
+ payload["display_name"],
+ payload["description"],
+ payload["schema"]["namespace"],
+ Jsonb({"provision_source": "admin-project-wizard"}),
+ _user["username"],
+ ),
+ )
+ project_row = dict(await cur.fetchone())
+
+ await cur.execute(
+ f"""INSERT INTO {s}.ontology_schemas (
+ tenant_id, project_id, namespace, version, display_name,
+ description, status, schema_jsonb, created_by, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, now())
+ RETURNING *""",
+ (
+ payload["tenant_id"],
+ payload["project_id"],
+ schema["namespace"],
+ schema_storage_version,
+ schema.get("display_name") or f"{payload['display_name']} Schema",
+ schema.get("description") or schema.get("purpose"),
+ Jsonb(schema),
+ _user["username"],
+ ),
+ )
+ schema_row = dict(await cur.fetchone())
+ schema_id = schema_row["id"]
+
+ for entity_type, entity_meta in schema["entity_types"].items():
+ natural_key_rule = entity_meta.get("natural_key_rule") or {}
+ if not natural_key_rule and entity_meta.get("primary_key"):
+ natural_key_rule = {"fields": [entity_meta["primary_key"]]}
+ await cur.execute(
+ f"""INSERT INTO {s}.ontology_entity_types (
+ schema_id, entity_type, display_name, description, is_core,
+ extends_type, natural_key_rule_jsonb, recommendable,
+ evidence_required, metadata_jsonb, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
+ RETURNING id""",
+ (
+ schema_id,
+ entity_type,
+ entity_meta.get("label") or entity_meta.get("display_name") or entity_type,
+ entity_meta.get("description") or entity_meta.get("definition"),
+ bool(entity_meta.get("is_core", False)),
+ entity_meta.get("extends") or entity_meta.get("extends_type"),
+ Jsonb(natural_key_rule),
+ bool(entity_meta.get("recommendable", False)),
+ bool(entity_meta.get("evidence_required", False)),
+ Jsonb(entity_meta.get("metadata") or {}),
+ ),
+ )
+ entity_type_id = (await cur.fetchone())["id"]
+ for field_name, field_meta in entity_meta.get("fields", {}).items():
+ enum_values = field_meta.get("enum_values") or field_meta.get("enum") or []
+ await cur.execute(
+ f"""INSERT INTO {s}.ontology_fields (
+ entity_type_id, field_name, display_name, value_type,
+ required, multi_value, default_value_jsonb,
+ enum_values_jsonb, validation_rules_jsonb,
+ recommendable, evidence_required, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())""",
+ (
+ entity_type_id,
+ field_name,
+ field_meta.get("label") or field_meta.get("display_name") or field_name,
+ field_meta.get("type") or "string",
+ bool(field_meta.get("required", False)),
+ bool(field_meta.get("multi_value", False)),
+ Jsonb(field_meta["default"]) if "default" in field_meta else None,
+ Jsonb(enum_values),
+ Jsonb(field_meta.get("validation") or field_meta.get("validation_rules") or {}),
+ bool(field_meta.get("recommendable", False)),
+ bool(field_meta.get("evidence_required", False)),
+ ),
+ )
+
+ for relation_type, relation_meta in schema["relation_types"].items():
+ await cur.execute(
+ f"""INSERT INTO {s}.ontology_relation_types (
+ schema_id, relation_type, source_entity_type,
+ target_entity_type, display_name, cardinality, required,
+ evidence_required, publish_as, metadata_jsonb, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())""",
+ (
+ schema_id,
+ relation_type,
+ relation_meta["from"],
+ relation_meta["to"],
+ relation_meta.get("label") or relation_meta.get("display_name") or relation_type,
+ relation_meta.get("cardinality") or "many_to_many",
+ bool(relation_meta.get("required", False)),
+ bool(relation_meta.get("evidence_required", False)),
+ relation_meta.get("publish_as"),
+ Jsonb({"properties": relation_meta.get("properties") or {}}),
+ ),
+ )
+
+ graph_release_id = f"{payload['project_id']}_v{schema['version']}"
+ await cur.execute(
+ f"""INSERT INTO {s}.graph_releases (
+ tenant_id, project_id, graph_release_id, graph_name, alias,
+ status, schema_id, metadata_jsonb, created_by, updated_at
+ )
+ VALUES (%s, %s, %s, %s, 'active', 'provisioning', %s, %s, %s, now())
+ RETURNING *""",
+ (
+ payload["tenant_id"],
+ payload["project_id"],
+ graph_release_id,
+ payload["graph_name"],
+ schema_id,
+ Jsonb(payload["counts"]),
+ _user["username"],
+ ),
+ )
+ release_row = dict(await cur.fetchone())
+
+ try:
+ graph_counts = await _run_blocking_to_completion(
+ import_falkor_graph,
+ payload["graph_name"],
+ payload["graph_data"],
+ )
+ except FileExistsError as exc:
+ raise HTTPException(
+ 409,
+ _graph_resource_conflict_detail(payload, "已存在,不能覆盖"),
+ ) from exc
+ graph_created = graph_counts["nodes"] > 0
+ if graph_counts["nodes"] != payload["counts"]["nodes"]:
+ raise RuntimeError("FalkorDB 节点数量校验失败")
+ if graph_counts["relations"] != payload["counts"]["relations"]:
+ raise RuntimeError("FalkorDB 关系数量校验失败")
+
+ await cur.execute(
+ f"""UPDATE {s}.ontology_schemas
+ SET status='active', published_by=%s, published_at=now(), updated_at=now()
+ WHERE id=%s RETURNING *""",
+ (_user["username"], schema_id),
+ )
+ schema_row = dict(await cur.fetchone())
+ await cur.execute(
+ f"""UPDATE {s}.graph_releases
+ SET status='active', metadata_jsonb=%s, published_at=now(),
+ activated_at=now(), updated_at=now()
+ WHERE id=%s RETURNING *""",
+ (Jsonb({**payload["counts"], "graph_counts": graph_counts}), release_row["id"]),
+ )
+ release_row = dict(await cur.fetchone())
+ await cur.execute(
+ f"""UPDATE {s}.projects
+ SET status='active', metadata_jsonb=%s, updated_at=now()
+ WHERE tenant_id=%s AND project_id=%s RETURNING *""",
+ (
+ Jsonb(
+ {
+ "provision_source": "admin-project-wizard",
+ "schema_id": schema_id,
+ "graph_name": payload["graph_name"],
+ **payload["counts"],
+ }
+ ),
+ payload["tenant_id"],
+ payload["project_id"],
+ ),
+ )
+ project_row = dict(await cur.fetchone())
+ await _set_cleanup_task(cur, provision_task_id)
+ # Metadata activation and outbox completion become visible together.
+ await conn.commit()
+ except HTTPException:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ if provision_task_id is not None:
+ try:
+ await _finish_cleanup_task(provision_task_id, conn=conn)
+ except Exception:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ raise
+ except asyncio.CancelledError:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ # The durable pending intent is deliberately retained. A retry can
+ # safely delete an unreferenced graph or complete an already-absent
+ # task after this cancelled request releases its session locks.
+ raise
+ except Exception as exc:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ cleanup_error = exc.cleanup_error if isinstance(exc, GraphImportError) else None
+ if graph_created:
+ try:
+ await _run_blocking_to_completion(delete_falkor_graph, payload["graph_name"])
+ except Exception as cleanup_exc:
+ cleanup_pending = True
+ cleanup_error = cleanup_exc
+ elif isinstance(exc, GraphImportError) and exc.cleanup_required:
+ cleanup_pending = True
+
+ if provision_task_id is not None:
+ task_error = cleanup_error or RuntimeError("FalkorDB 图需要补偿清理")
+ try:
+ await _finish_cleanup_task(
+ provision_task_id,
+ error=str(task_error) if cleanup_pending else None,
+ conn=conn,
+ )
+ except Exception as task_exc:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ cleanup_pending = True
+ cleanup_error = task_exc
+
+ detail: dict[str, Any] = {
+ "message": f"项目创建失败,PostgreSQL 元数据已回滚:{exc}",
+ "graph_name": payload["graph_name"],
+ }
+ if cleanup_pending:
+ detail["message"] += ";FalkorDB 补偿清理已进入待重试状态"
+ detail["cleanup_task_id"] = provision_task_id
+ detail["cleanup_error"] = str(cleanup_error or "待补偿清理")
+ raise HTTPException(500, detail) from exc
+ finally:
+ if acquired_lock_keys:
+ await _run_async_cleanup_to_completion(
+ _release_session_locks(conn, acquired_lock_keys)
+ )
+
+ if schema_row is not None:
+ schema_row["version"] = payload["schema"]["version"]
+ actual_graph_name = str((release_row or {}).get("graph_name") or payload["graph_name"])
+ return {
+ "project": project_row,
+ "schema": schema_row,
+ "graph_release": release_row,
+ "graph": {"graph_name": actual_graph_name, **graph_counts},
+ "counts": payload["counts"],
+ }
+
+
@router.post("/projects")
async def create_project(body: dict, _user: CurrentUser):
+ _require_project_admin(_user)
s = settings.db_schema
project_id = str(body.get("project_id") or "").strip()
- if not project_id:
- raise HTTPException(400, "project_id required")
+ if not SAFE_RESOURCE_ID.fullmatch(project_id):
+ raise HTTPException(422, "project_id 只能包含安全字符")
tenant_id = str(body.get("tenant_id") or project_id).strip()
+ if not SAFE_RESOURCE_ID.fullmatch(tenant_id):
+ raise HTTPException(422, "tenant_id 只能包含安全字符")
display_name = str(body.get("display_name") or body.get("name") or project_id).strip()
+ if not display_name:
+ raise HTTPException(422, "display_name 不能为空")
async with get_conn() as conn:
async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT pg_advisory_xact_lock(hashtext(%s))",
+ (f"kg-project:{project_id}",),
+ )
+ await cur.execute(
+ f"SELECT 1 FROM {s}.projects WHERE project_id=%s LIMIT 1",
+ (project_id,),
+ )
+ if await cur.fetchone():
+ raise HTTPException(409, "project_id 已存在,不允许覆盖")
+ await cur.execute(
+ f"SELECT 1 FROM {s}.graph_project_tombstones WHERE project_id=%s",
+ (project_id,),
+ )
+ if await cur.fetchone():
+ raise HTTPException(409, "project_id 曾被删除,为隔离历史写请求不能复用,请使用新 ID")
await cur.execute(
f"""INSERT INTO {s}.projects (
tenant_id, project_id, display_name, description, status,
@@ -162,14 +1303,7 @@ async def create_project(body: dict, _user: CurrentUser):
%(tenant_id)s, %(project_id)s, %(display_name)s, %(description)s, %(status)s,
%(default_namespace)s, %(metadata_jsonb)s, %(created_by)s, now()
)
- ON CONFLICT (tenant_id, project_id) DO UPDATE
- SET tenant_id=EXCLUDED.tenant_id,
- display_name=EXCLUDED.display_name,
- description=EXCLUDED.description,
- status=EXCLUDED.status,
- default_namespace=EXCLUDED.default_namespace,
- metadata_jsonb=EXCLUDED.metadata_jsonb,
- updated_at=now()
+ ON CONFLICT (tenant_id, project_id) DO NOTHING
RETURNING *""",
{
"project_id": project_id,
@@ -178,18 +1312,320 @@ async def create_project(body: dict, _user: CurrentUser):
"description": body.get("description"),
"status": body.get("status") or "active",
"default_namespace": body.get("default_namespace") or project_id,
- "metadata_jsonb": body.get("metadata_jsonb") or {},
+ "metadata_jsonb": Jsonb(body.get("metadata_jsonb") or {}),
"created_by": _user["username"],
},
)
row = await cur.fetchone()
+ if not row:
+ raise HTTPException(409, "项目已存在,不允许覆盖")
await conn.commit()
- from app.data_platform.schema import ensure_project_database
-
- await ensure_project_database(project_id, tenant_id, display_name)
return row
+@router.patch("/projects/{project_id}")
+async def rename_project(project_id: str, body: dict, _user: CurrentUser):
+ _require_project_admin(_user)
+ extra_fields = set(body) - {"display_name"}
+ if extra_fields:
+ raise HTTPException(422, f"只允许修改 display_name;禁止字段:{', '.join(sorted(extra_fields))}")
+ display_name = str(body.get("display_name") or "").strip()
+ if not display_name:
+ raise HTTPException(422, "display_name 不能为空")
+ if len(display_name) > 200:
+ raise HTTPException(422, "display_name 不能超过 200 个字符")
+
+ async with get_conn() as conn:
+ try:
+ async with conn.cursor() as cur:
+ project = await _load_project(cur, project_id, for_update=True)
+ await cur.execute(
+ f"""UPDATE {settings.db_schema}.projects
+ SET display_name=%s, updated_at=now()
+ WHERE tenant_id=%s AND project_id=%s
+ RETURNING *""",
+ (display_name, project["tenant_id"], project_id),
+ )
+ row = await cur.fetchone()
+ await conn.commit()
+ except Exception:
+ await conn.rollback()
+ raise
+ return row
+
+
+@router.get("/projects/{project_id}/deletion-impact")
+async def project_deletion_impact(project_id: str, _user: CurrentUser):
+ _require_project_admin(_user)
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ project = await _load_project(cur, project_id)
+ impact, _shapes, _ids = await _build_deletion_impact(cur, project)
+
+ try:
+ existing_graphs = await asyncio.to_thread(list_falkor_graphs)
+ for item in impact["graphs"]:
+ item["exists_in_falkordb"] = item["graph_name"] in existing_graphs
+ except Exception as exc:
+ impact["falkordb_error"] = str(exc)
+ for item in impact["graphs"]:
+ item["exists_in_falkordb"] = None
+ return impact
+
+
+@router.delete("/projects/{project_id}")
+async def delete_project(
+ project_id: str,
+ confirm_project_id: str,
+ _user: CurrentUser,
+):
+ _require_project_admin(_user)
+ if confirm_project_id != project_id:
+ raise HTTPException(400, "确认项目 ID 不匹配,未执行任何删除")
+
+ impact: dict[str, Any]
+ cleanup_task_ids: dict[str, int] = {}
+ locked_resource_keys: list[str] = []
+ deleted_graphs: list[str] = []
+ absent_graphs: list[str] = []
+ cleanup_pending: list[dict[str, Any]] = []
+ async with get_conn() as conn:
+ try:
+ async with conn.cursor() as cur:
+ project_lock_key = f"kg-project:{project_id}"
+ locked_resource_keys.append(project_lock_key)
+ await cur.execute(
+ "SELECT pg_advisory_lock(hashtext(%s))",
+ (project_lock_key,),
+ )
+ project = await _load_project(cur, project_id, for_update=True)
+ preliminary_graphs = await _graph_references(
+ cur,
+ str(project["tenant_id"]),
+ project_id,
+ )
+ for item in preliminary_graphs:
+ graph_lock_key = f"kg-graph:{item['graph_name']}"
+ locked_resource_keys.append(graph_lock_key)
+ await cur.execute(
+ # A session lock intentionally survives the PostgreSQL
+ # commit below and remains held until the corresponding
+ # Falkor delete finishes. This prevents a new project
+ # from reusing an absent graph name in that small gap.
+ "SELECT pg_advisory_lock(hashtext(%s))",
+ (graph_lock_key,),
+ )
+
+ # Recompute every count and shared-graph decision while all
+ # relevant transaction locks are held. The earlier GET result
+ # is deliberately never trusted for deletion.
+ impact, shapes, ids = await _build_deletion_impact(cur, project)
+ for item in impact["graphs"]:
+ if item["will_delete"]:
+ cleanup_task_ids[item["graph_name"]] = await _record_cleanup_task(
+ cur,
+ project_id,
+ item["graph_name"],
+ )
+ await _delete_project_resources(cur, project, impact, shapes, ids)
+ await cur.execute(
+ f"""INSERT INTO {settings.db_schema}.graph_project_tombstones
+ (project_id, tenant_id, deleted_by, deleted_at)
+ VALUES (%s, %s, %s, now())
+ ON CONFLICT (project_id) DO UPDATE
+ SET tenant_id=EXCLUDED.tenant_id,
+ deleted_by=EXCLUDED.deleted_by,
+ deleted_at=now()""",
+ (project_id, project["tenant_id"], _user["username"]),
+ )
+ for item in impact["graphs"]:
+ if not item["will_delete"]:
+ continue
+ await cur.execute(
+ f"""INSERT INTO {settings.db_schema}.graph_name_tombstones
+ (graph_name, project_id, deleted_at)
+ VALUES (%s, %s, now())
+ ON CONFLICT (graph_name) DO UPDATE
+ SET project_id=EXCLUDED.project_id, deleted_at=now()""",
+ (item["graph_name"], project_id),
+ )
+ await conn.commit()
+ for graph_name, task_id in cleanup_task_ids.items():
+ try:
+ deleted = await _run_blocking_to_completion(delete_falkor_graph, graph_name)
+ if deleted:
+ deleted_graphs.append(graph_name)
+ else:
+ absent_graphs.append(graph_name)
+ await _finish_cleanup_task(task_id, conn=conn)
+ except Exception as exc:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ error = str(exc)
+ cleanup_pending.append(
+ {"task_id": task_id, "graph_name": graph_name, "error": error}
+ )
+ try:
+ await _finish_cleanup_task(task_id, error=error, conn=conn)
+ except Exception:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ except Exception:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ raise
+ finally:
+ if locked_resource_keys:
+ await _run_async_cleanup_to_completion(
+ _release_session_locks(conn, locked_resource_keys)
+ )
+
+ shared_graphs = [item["graph_name"] for item in impact["graphs"] if item["shared"]]
+ return {
+ "status": "deleted_with_cleanup_pending" if cleanup_pending else "deleted",
+ "project_id": project_id,
+ "deleted_counts": impact["counts"],
+ "deleted_graphs": deleted_graphs,
+ "already_absent_graphs": absent_graphs,
+ "shared_graphs_preserved": shared_graphs,
+ "cleanup_pending": cleanup_pending,
+ "protected_resources": impact["protected_resources"],
+ }
+
+
+@router.get("/projects/cleanup-tasks")
+async def list_project_graph_cleanup_tasks(
+ _user: CurrentUser,
+ status: str = "pending",
+ project_id: str | None = None,
+):
+ """List lifecycle compensation tasks so failed cleanup is operable."""
+
+ _require_project_admin(_user)
+ if status not in {"pending", "completed", "all"}:
+ raise HTTPException(422, "status 只能是 pending、completed 或 all")
+ if project_id is not None and not SAFE_RESOURCE_ID.fullmatch(project_id):
+ raise HTTPException(422, "project_id 包含不安全字符")
+
+ clauses: list[str] = []
+ params: list[Any] = []
+ if status != "all":
+ clauses.append("status=%s")
+ params.append(status)
+ if project_id is not None:
+ clauses.append("project_id=%s")
+ params.append(project_id)
+ where = " WHERE " + " AND ".join(clauses) if clauses else ""
+
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await _ensure_cleanup_table(cur)
+ await cur.execute(
+ f"""SELECT id, project_id, graph_name, operation, status,
+ attempts, last_error, created_at, updated_at
+ FROM {settings.db_schema}.graph_cleanup_tasks{where}
+ ORDER BY updated_at DESC, id DESC
+ LIMIT 200""",
+ tuple(params),
+ )
+ return await cur.fetchall()
+
+
+@router.post("/projects/cleanup-tasks/{task_id}/retry")
+async def retry_project_graph_cleanup(task_id: int, _user: CurrentUser):
+ """Retry one recorded FalkorDB compensation without trusting stale ownership."""
+
+ _require_project_admin(_user)
+ async with get_conn() as conn:
+ try:
+ async with conn.cursor() as cur:
+ await _ensure_cleanup_table(cur)
+ await cur.execute(
+ f"SELECT * FROM {settings.db_schema}.graph_cleanup_tasks WHERE id=%s FOR UPDATE",
+ (task_id,),
+ )
+ task = await cur.fetchone()
+ if not task:
+ raise HTTPException(404, "补偿清理任务不存在")
+ if task["status"] == "completed":
+ await conn.commit()
+ return {
+ "status": "completed",
+ "task_id": task_id,
+ "graph_name": task["graph_name"],
+ "already_completed": True,
+ }
+ await cur.execute(
+ "SELECT pg_advisory_xact_lock(hashtext(%s))",
+ (f"kg-graph:{task['graph_name']}",),
+ )
+ await cur.execute(
+ f"SELECT count(*) AS count FROM {settings.db_schema}.graph_releases WHERE graph_name=%s",
+ (task["graph_name"],),
+ )
+ if int((await cur.fetchone())["count"]):
+ # A crash can occur after metadata commit but before a
+ # rollback-provision intent is observed as completed. Any
+ # current release reference makes deletion unsafe, so
+ # preserve the graph and resolve the stale cleanup task.
+ await cur.execute(
+ f"""UPDATE {settings.db_schema}.graph_cleanup_tasks
+ SET status='completed', attempts=attempts+1,
+ last_error='图已被 GraphRelease 引用,已安全保留', updated_at=now()
+ WHERE id=%s""",
+ (task_id,),
+ )
+ await conn.commit()
+ return {
+ "status": "completed",
+ "task_id": task_id,
+ "graph_name": task["graph_name"],
+ "deleted": False,
+ "preserved_because_referenced": True,
+ }
+ try:
+ deleted = await _run_blocking_to_completion(
+ delete_falkor_graph,
+ str(task["graph_name"]),
+ )
+ except Exception as exc:
+ await cur.execute(
+ f"""UPDATE {settings.db_schema}.graph_cleanup_tasks
+ SET status='pending', attempts=attempts+1, last_error=%s, updated_at=now()
+ WHERE id=%s""",
+ (str(exc), task_id),
+ )
+ await conn.commit()
+ raise HTTPException(
+ 503,
+ {
+ "message": f"FalkorDB 清理仍未完成,可稍后重试:{exc}",
+ "cleanup_task_id": task_id,
+ "graph_name": task["graph_name"],
+ },
+ ) from exc
+ await cur.execute(
+ f"""UPDATE {settings.db_schema}.graph_cleanup_tasks
+ SET status='completed', attempts=attempts+1, last_error=NULL, updated_at=now()
+ WHERE id=%s""",
+ (task_id,),
+ )
+ await conn.commit()
+ return {
+ "status": "completed",
+ "task_id": task_id,
+ "graph_name": task["graph_name"],
+ "deleted": deleted,
+ }
+ except HTTPException:
+ if conn.info.transaction_status != 0:
+ await conn.rollback()
+ raise
+ except Exception:
+ await conn.rollback()
+ raise
+
+
@router.get("/projects/{project_id}/graph-releases")
async def list_graph_releases(project_id: str, _user: CurrentUser):
async with get_conn() as conn:
@@ -207,20 +1643,56 @@ async def list_graph_releases(project_id: str, _user: CurrentUser):
@router.post("/projects/{project_id}/graph-releases")
async def create_graph_release(project_id: str, body: dict, _user: CurrentUser):
+ _require_project_admin(_user)
s = settings.db_schema
- graph_name = str(body.get("graph_name") or f"{project_id}_graph").strip()
+ graph_name = _resolve_graph_name(project_id, body.get("graph_name"))
alias = str(body.get("alias") or "active").strip()
tenant_id = str(body.get("tenant_id") or "").strip()
status = str(body.get("status") or "active").strip()
+ if not SAFE_RESOURCE_ID.fullmatch(project_id) or not SAFE_RESOURCE_ID.fullmatch(graph_name):
+ raise HTTPException(422, "project_id 或 graph_name 包含不安全字符")
+ if tenant_id and not SAFE_RESOURCE_ID.fullmatch(tenant_id):
+ raise HTTPException(422, "tenant_id 包含不安全字符")
+ if not SAFE_RESOURCE_ID.fullmatch(alias):
+ raise HTTPException(422, "GraphRelease alias 包含不安全字符")
async with get_conn() as conn:
async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT pg_advisory_xact_lock(hashtext(%s))",
+ (f"kg-project:{project_id}",),
+ )
+ await cur.execute(
+ "SELECT pg_advisory_xact_lock(hashtext(%s))",
+ (f"kg-graph:{graph_name}",),
+ )
if not tenant_id:
await cur.execute(
f"SELECT tenant_id FROM {s}.projects WHERE project_id=%s LIMIT 1",
(project_id,),
)
project = await cur.fetchone()
- tenant_id = project["tenant_id"] if project else project_id
+ if not project:
+ raise HTTPException(404, "项目不存在,不能创建孤立 GraphRelease")
+ tenant_id = project["tenant_id"]
+ else:
+ await cur.execute(
+ f"SELECT 1 FROM {s}.projects WHERE tenant_id=%s AND project_id=%s LIMIT 1",
+ (tenant_id, project_id),
+ )
+ if not await cur.fetchone():
+ raise HTTPException(404, "项目不存在,不能创建孤立 GraphRelease")
+ try:
+ existing_graphs = await asyncio.to_thread(list_falkor_graphs)
+ except Exception as exc:
+ raise HTTPException(503, f"FalkorDB 不可用,未创建 GraphRelease:{exc}") from exc
+ if graph_name not in existing_graphs:
+ raise HTTPException(409, "FalkorDB 图不存在,请使用项目创建向导完成原子创建")
+ await cur.execute(
+ f"SELECT 1 FROM {s}.graph_name_tombstones WHERE graph_name=%s",
+ (graph_name,),
+ )
+ if await cur.fetchone():
+ raise HTTPException(409, "graph_name 曾被删除,为隔离历史写请求不能复用,请使用新名称")
await cur.execute(
f"""INSERT INTO {s}.graph_releases (
tenant_id, project_id, graph_release_id, graph_name, alias, status,
@@ -230,13 +1702,7 @@ async def create_graph_release(project_id: str, body: dict, _user: CurrentUser):
%(tenant_id)s, %(project_id)s, %(graph_release_id)s, %(graph_name)s, %(alias)s, %(status)s,
%(metadata_jsonb)s, %(created_by)s, now(), now()
)
- ON CONFLICT (tenant_id, project_id, alias) DO UPDATE
- SET graph_release_id=EXCLUDED.graph_release_id,
- graph_name=EXCLUDED.graph_name,
- status=EXCLUDED.status,
- metadata_jsonb=EXCLUDED.metadata_jsonb,
- activated_at=now(),
- updated_at=now()
+ ON CONFLICT (tenant_id, project_id, alias) DO NOTHING
RETURNING *""",
{
"tenant_id": tenant_id,
@@ -245,11 +1711,13 @@ async def create_graph_release(project_id: str, body: dict, _user: CurrentUser):
"graph_name": graph_name,
"alias": alias,
"status": status,
- "metadata_jsonb": body.get("metadata_jsonb") or {},
+ "metadata_jsonb": Jsonb(body.get("metadata_jsonb") or {}),
"created_by": _user["username"],
},
)
row = await cur.fetchone()
+ if not row:
+ raise HTTPException(409, "该项目的 GraphRelease alias 已存在,不允许覆盖")
await conn.commit()
return row
diff --git a/app/config.py b/app/config.py
index 884e16e..361819c 100644
--- a/app/config.py
+++ b/app/config.py
@@ -39,6 +39,11 @@ class Settings(BaseSettings):
amap_js_key: str = ""
amap_security_jscode: str = ""
+ # QWeather / 和风天气 (逐小时预报,高德无此能力,仅用于逐小时气温)
+ qweather_api_key: str = ""
+ qweather_api_host: str = "https://devapi.qweather.com"
+ qweather_libo_location: str = "101260412" # 荔波县 LocationID
+
# 阿里云 OCR (ocr-api.cn-hangzhou.aliyuncs.com, 2021-07-07)
# 控制台 → AccessKey 管理 处生成;推荐用 RAM 子账号,仅授权 AliyunOCRFullAccess
aliyun_ocr_access_key_id: str = ""
diff --git a/app/data_platform/csv_service.py b/app/data_platform/csv_service.py
new file mode 100644
index 0000000..6156d77
--- /dev/null
+++ b/app/data_platform/csv_service.py
@@ -0,0 +1,675 @@
+"""CSV preview, partial-safe batch import, and export for project data tables."""
+from __future__ import annotations
+
+import csv
+import io
+import json
+import re
+import uuid
+from datetime import date, datetime, timezone
+from decimal import Decimal
+from typing import Any
+
+from fastapi import HTTPException
+from psycopg import sql
+from psycopg.types.json import Jsonb
+
+from app.data_platform.record_service import (
+ _database_or_404,
+ _table_or_404,
+ _validated_payload,
+)
+from app.data_platform.registry import FieldDefinition, TableDefinition
+from app.db import get_conn
+
+MAX_CSV_BYTES = 50 * 1024 * 1024
+MAX_IMPORT_ROWS = 50_000
+MAX_EXPORT_ROWS = 200_000
+PREVIEW_ROWS = 5
+MAX_REPORTED_ERRORS = 100
+
+_SYSTEM_COLUMN_ALIASES = {
+ "id",
+ "记录 id",
+ "记录id",
+ "tenant_id",
+ "租户 id",
+ "租户id",
+ "project_id",
+ "项目 id",
+ "项目id",
+ "created_at",
+ "创建时间",
+ "updated_at",
+ "更新时间",
+ "deleted_at",
+ "deleted_by",
+}
+
+
+def _decode_csv(content: bytes) -> tuple[str, str]:
+ if not content:
+ raise HTTPException(400, "CSV 文件为空")
+ if len(content) > MAX_CSV_BYTES:
+ raise HTTPException(413, "CSV 文件不能超过 50 MB")
+ for encoding in ("utf-8-sig", "utf-8", "gb18030"):
+ try:
+ return content.decode(encoding), encoding
+ except UnicodeDecodeError:
+ continue
+ raise HTTPException(400, "CSV 编码无法识别,请使用 UTF-8 或 GB18030")
+
+
+def _normalize_header(value: str | None) -> str:
+ return str(value or "").replace("\ufeff", "").strip()
+
+
+def _csv_reader(text: str) -> csv.DictReader:
+ sample = text[:8192]
+ try:
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
+ except csv.Error:
+ dialect = csv.excel
+ return csv.DictReader(io.StringIO(text, newline=""), dialect=dialect)
+
+
+def _safe_value(value: Any) -> Any:
+ if isinstance(value, Jsonb):
+ return _safe_value(value.obj)
+ if isinstance(value, Decimal):
+ return float(value)
+ if isinstance(value, uuid.UUID):
+ return str(value)
+ if isinstance(value, (date, datetime)):
+ return value.isoformat()
+ if isinstance(value, dict):
+ return {key: _safe_value(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_safe_value(item) for item in value]
+ return value
+
+
+def _csv_value(value: Any) -> str:
+ value = _safe_value(value)
+ if value is None:
+ return ""
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+ return str(value)
+
+
+def _signature_value(value: Any) -> tuple[str, Any]:
+ """Return a stable, hashable value used for conservative exact-row deduplication."""
+ value = _safe_value(value)
+ if value is None:
+ return ("null", None)
+ if isinstance(value, bool):
+ return ("boolean", value)
+ if isinstance(value, (int, float, Decimal)):
+ try:
+ return ("number", format(Decimal(str(value)).normalize(), "f"))
+ except Exception:
+ return ("number", str(value))
+ if isinstance(value, str):
+ return ("text", value.strip().casefold())
+ if isinstance(value, (dict, list)):
+ return (
+ "json",
+ json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
+ )
+ return (type(value).__name__, str(value))
+
+
+def _payload_signature(
+ payload: dict[str, Any],
+ field_codes: list[str],
+) -> tuple[tuple[str, tuple[str, Any]], ...]:
+ return tuple(
+ (field_code, _signature_value(payload.get(field_code)))
+ for field_code in field_codes
+ )
+
+
+def _strict_csv_value(field: FieldDefinition, raw_value: str) -> Any:
+ value = raw_value.strip()
+ if not value:
+ return None
+ if field.data_type == "boolean":
+ lowered = value.lower()
+ if lowered in {"true", "1", "yes", "y", "是", "启用"}:
+ return True
+ if lowered in {"false", "0", "no", "n", "否", "禁用"}:
+ return False
+ raise HTTPException(400, f"{field.label}必须是 true/false、1/0 或 是/否")
+ if field.data_type == "integer" or "INTEGER" in field.sql_type.upper():
+ try:
+ decimal_value = Decimal(value)
+ except Exception as exc:
+ raise HTTPException(400, f"{field.label}必须是整数") from exc
+ if decimal_value != decimal_value.to_integral_value():
+ raise HTTPException(400, f"{field.label}必须是整数")
+ return int(decimal_value)
+ if field.data_type == "number":
+ try:
+ return Decimal(value)
+ except Exception as exc:
+ raise HTTPException(400, f"{field.label}必须是数字") from exc
+ if field.data_type == "json":
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(400, f"{field.label}不是合法 JSON") from exc
+ if field.data_type == "date":
+ try:
+ date.fromisoformat(value)
+ except ValueError as exc:
+ raise HTTPException(400, f"{field.label}必须使用 YYYY-MM-DD 格式") from exc
+ if field.data_type == "datetime":
+ try:
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise HTTPException(400, f"{field.label}不是合法日期时间") from exc
+ if field.sql_type.upper().startswith("UUID"):
+ try:
+ uuid.UUID(value)
+ except ValueError as exc:
+ raise HTTPException(400, f"{field.label}不是合法 UUID") from exc
+ varchar_match = re.match(r"^(?:VAR)?CHAR(?:ACTER VARYING)?\((\d+)\)", field.sql_type.upper())
+ if varchar_match and len(value) > int(varchar_match.group(1)):
+ raise HTTPException(
+ 400,
+ f"{field.label}不能超过 {varchar_match.group(1)} 个字符",
+ )
+ if field.options and value not in field.options:
+ raise HTTPException(400, f"{field.label}可选值为:{', '.join(field.options)}")
+ return value
+
+
+def _mapping_for_headers(
+ table: TableDefinition,
+ headers: list[str],
+) -> tuple[list[dict[str, Any]], dict[str, FieldDefinition], list[str]]:
+ code_lookup = {field.code.lower(): field for field in table.fields}
+ label_lookup = {field.label: field for field in table.fields}
+ mappings: list[dict[str, Any]] = []
+ mapped_headers: dict[str, FieldDefinition] = {}
+ schema_errors: list[str] = []
+ mapped_codes: set[str] = set()
+
+ if not headers or not any(headers):
+ return [], {}, ["CSV 缺少表头"]
+
+ duplicate_headers = sorted({header for header in headers if headers.count(header) > 1})
+ if duplicate_headers:
+ schema_errors.append(f"CSV 存在重复列:{', '.join(duplicate_headers)}")
+
+ for header in headers:
+ normalized = header.strip()
+ field = code_lookup.get(normalized.lower()) or label_lookup.get(normalized)
+ if field:
+ if field.code in mapped_codes:
+ mappings.append(
+ {
+ "source_header": header,
+ "field_code": field.code,
+ "field_label": field.label,
+ "status": "duplicate",
+ }
+ )
+ schema_errors.append(f"字段“{field.label}”在 CSV 中重复映射")
+ continue
+ mapped_codes.add(field.code)
+ mapped_headers[header] = field
+ mappings.append(
+ {
+ "source_header": header,
+ "field_code": field.code,
+ "field_label": field.label,
+ "status": "mapped",
+ }
+ )
+ continue
+ if normalized.lower() in _SYSTEM_COLUMN_ALIASES:
+ mappings.append(
+ {
+ "source_header": header,
+ "field_code": None,
+ "field_label": "平台自动生成",
+ "status": "ignored",
+ }
+ )
+ continue
+ mappings.append(
+ {
+ "source_header": header,
+ "field_code": None,
+ "field_label": None,
+ "status": "unknown",
+ }
+ )
+ schema_errors.append(f"无法识别 CSV 列“{header}”")
+
+ missing_required = [
+ field.label
+ for field in table.fields
+ if field.required and field.code not in mapped_codes
+ ]
+ if missing_required:
+ schema_errors.append(f"缺少必填列:{', '.join(missing_required)}")
+ if not mapped_headers:
+ schema_errors.append("CSV 没有可导入的业务字段")
+ return mappings, mapped_headers, schema_errors
+
+
+def _parse_csv(
+ table: TableDefinition,
+ content: bytes,
+ file_name: str,
+) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
+ text, encoding = _decode_csv(content)
+ reader = _csv_reader(text)
+ headers = [_normalize_header(header) for header in (reader.fieldnames or [])]
+ mappings, mapped_headers, schema_errors = _mapping_for_headers(table, headers)
+ rows: list[dict[str, Any]] = []
+ errors: list[dict[str, Any]] = []
+ error_count = 0
+ total_rows = 0
+
+ for physical_row, source_row in enumerate(reader, start=2):
+ normalized_source = {
+ _normalize_header(key): value
+ for key, value in source_row.items()
+ if key is not None
+ }
+ if None in source_row and source_row[None]:
+ error_count += 1
+ if len(errors) < MAX_REPORTED_ERRORS:
+ errors.append(
+ {
+ "row": physical_row,
+ "field": "",
+ "kind": "invalid",
+ "message": "该行列数多于表头,请检查分隔符或引号",
+ }
+ )
+ continue
+ if not any(str(value or "").strip() for value in normalized_source.values()):
+ continue
+ total_rows += 1
+ if total_rows > MAX_IMPORT_ROWS:
+ raise HTTPException(400, f"单次最多导入 {MAX_IMPORT_ROWS:,} 条记录")
+
+ raw_payload: dict[str, Any] = {}
+ row_error: str | None = None
+ row_field = ""
+ for header, field in mapped_headers.items():
+ raw_value = str(normalized_source.get(header) or "")
+ try:
+ coerced = _strict_csv_value(field, raw_value)
+ except HTTPException as exc:
+ row_error = str(exc.detail)
+ row_field = field.label
+ break
+ if coerced is not None:
+ raw_payload[field.code] = coerced
+ if row_error is None:
+ try:
+ payload = _validated_payload(table, raw_payload, create=True)
+ except HTTPException as exc:
+ row_error = str(exc.detail)
+ payload = {}
+ else:
+ payload = {}
+ if row_error:
+ error_count += 1
+ if len(errors) < MAX_REPORTED_ERRORS:
+ errors.append(
+ {
+ "row": physical_row,
+ "field": row_field,
+ "kind": "invalid",
+ "message": row_error,
+ }
+ )
+ continue
+ if not payload:
+ error_count += 1
+ if len(errors) < MAX_REPORTED_ERRORS:
+ errors.append(
+ {
+ "row": physical_row,
+ "field": "",
+ "kind": "invalid",
+ "message": "该行没有可导入的业务字段",
+ }
+ )
+ continue
+ rows.append({"row": physical_row, "payload": payload})
+
+ if total_rows == 0:
+ schema_errors.append("CSV 没有可导入的数据行")
+ dedupe_fields = [
+ str(item["field_code"])
+ for item in mappings
+ if item["status"] == "mapped" and item.get("field_code")
+ ]
+ preview = {
+ "file_name": file_name,
+ "encoding": encoding,
+ "total_rows": total_rows,
+ "valid_rows": len(rows),
+ "invalid_rows": error_count,
+ "duplicate_rows": 0,
+ "file_duplicate_rows": 0,
+ "existing_duplicate_rows": 0,
+ "skipped_rows": error_count,
+ "columns": mappings,
+ "schema_errors": schema_errors,
+ "errors": errors,
+ "errors_truncated": error_count > len(errors),
+ "preview_rows": [],
+ "can_import": not schema_errors and bool(rows),
+ }
+ return preview, rows, dedupe_fields
+
+
+async def _prepare_csv_import(
+ project_id: str,
+ table: TableDefinition,
+ database: dict[str, Any],
+ content: bytes,
+ file_name: str,
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ preview, candidates, dedupe_fields = _parse_csv(table, content, file_name)
+ if preview["schema_errors"] or not candidates or not dedupe_fields:
+ preview["can_import"] = False
+ return preview, []
+
+ schema_name = str(database["schema_name"])
+ existing_signatures: set[tuple[tuple[str, tuple[str, Any]], ...]] = set()
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("SELECT {} FROM {}.{} WHERE deleted_at IS NULL").format(
+ sql.SQL(", ").join(sql.Identifier(code) for code in dedupe_fields),
+ sql.Identifier(schema_name),
+ sql.Identifier(table.code),
+ )
+ )
+ for existing_row in await cur.fetchall():
+ existing_signatures.add(
+ _payload_signature(dict(existing_row), dedupe_fields)
+ )
+
+ valid_candidates: list[dict[str, Any]] = []
+ seen_file_signatures: dict[
+ tuple[tuple[str, tuple[str, Any]], ...],
+ int,
+ ] = {}
+ duplicate_errors: list[dict[str, Any]] = []
+ file_duplicate_rows = 0
+ existing_duplicate_rows = 0
+
+ for candidate in candidates:
+ signature = _payload_signature(candidate["payload"], dedupe_fields)
+ physical_row = int(candidate["row"])
+ if signature in existing_signatures:
+ existing_duplicate_rows += 1
+ duplicate_errors.append(
+ {
+ "row": physical_row,
+ "field": "",
+ "kind": "duplicate",
+ "message": "与当前数据表中的正式数据重复,已跳过",
+ }
+ )
+ continue
+ if signature in seen_file_signatures:
+ file_duplicate_rows += 1
+ duplicate_errors.append(
+ {
+ "row": physical_row,
+ "field": "",
+ "kind": "duplicate",
+ "message": (
+ f"与 CSV 第 {seen_file_signatures[signature]} 行重复,已跳过"
+ ),
+ }
+ )
+ continue
+ seen_file_signatures[signature] = physical_row
+ valid_candidates.append(candidate)
+
+ duplicate_rows = file_duplicate_rows + existing_duplicate_rows
+ remaining_error_slots = max(0, MAX_REPORTED_ERRORS - len(preview["errors"]))
+ preview["errors"].extend(duplicate_errors[:remaining_error_slots])
+ preview["errors"].sort(key=lambda item: int(item["row"]))
+ preview.update(
+ {
+ "valid_rows": len(valid_candidates),
+ "duplicate_rows": duplicate_rows,
+ "file_duplicate_rows": file_duplicate_rows,
+ "existing_duplicate_rows": existing_duplicate_rows,
+ "skipped_rows": int(preview["invalid_rows"]) + duplicate_rows,
+ "errors_truncated": (
+ int(preview["invalid_rows"]) + duplicate_rows
+ > len(preview["errors"])
+ ),
+ "preview_rows": [
+ {
+ key: _safe_value(value)
+ for key, value in candidate["payload"].items()
+ }
+ for candidate in valid_candidates[:PREVIEW_ROWS]
+ ],
+ "can_import": bool(valid_candidates),
+ }
+ )
+ return preview, valid_candidates
+
+
+async def preview_csv_import(
+ project_id: str,
+ table_code: str,
+ content: bytes,
+ file_name: str,
+) -> dict[str, Any]:
+ table = await _table_or_404(project_id, table_code)
+ if not table.allow_create:
+ raise HTTPException(403, "该表不允许导入新增记录")
+ database = await _database_or_404(project_id)
+ preview, _rows = await _prepare_csv_import(
+ project_id,
+ table,
+ database,
+ content,
+ file_name,
+ )
+ return preview
+
+
+async def import_csv_records(
+ project_id: str,
+ table_code: str,
+ content: bytes,
+ file_name: str,
+ actor: str,
+) -> dict[str, Any]:
+ table = await _table_or_404(project_id, table_code)
+ if not table.allow_create:
+ raise HTTPException(403, "该表不允许导入新增记录")
+ database = await _database_or_404(project_id)
+ preview, rows = await _prepare_csv_import(
+ project_id,
+ table,
+ database,
+ content,
+ file_name,
+ )
+ if not preview["can_import"]:
+ first_schema_error = (preview["schema_errors"] or [None])[0]
+ first_row_error = (preview["errors"] or [None])[0]
+ if first_schema_error:
+ raise HTTPException(400, f"CSV 校验失败:{first_schema_error}")
+ if first_row_error and not preview["duplicate_rows"]:
+ raise HTTPException(
+ 400,
+ f"CSV 校验失败:第 {first_row_error['row']} 行 {first_row_error['message']}",
+ )
+ if preview["duplicate_rows"]:
+ raise HTTPException(400, "没有可导入的新数据,文件中的记录均已存在")
+ raise HTTPException(400, "CSV 没有可导入的有效数据")
+
+ schema_name = str(database["schema_name"])
+ tenant_id = str(database["tenant_id"])
+ imported_count = 0
+ failed_errors: list[dict[str, Any]] = []
+ insert_queries: dict[tuple[str, ...], sql.Composed] = {}
+
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ audit_query = sql.SQL(
+ """
+ INSERT INTO {}.data_change_logs (
+ tenant_id, project_id, table_code, record_id, operation,
+ before_data, after_data, actor
+ ) VALUES (%s, %s, %s, %s, 'import', NULL, %s, %s)
+ """
+ ).format(sql.Identifier(schema_name))
+ for candidate in rows:
+ payload = candidate["payload"]
+ payload_keys = tuple(payload.keys())
+ if payload_keys not in insert_queries:
+ columns = ["id", "tenant_id", "project_id", *payload_keys]
+ insert_queries[payload_keys] = sql.SQL(
+ "INSERT INTO {}.{} ({}) VALUES ({})"
+ ).format(
+ sql.Identifier(schema_name),
+ sql.Identifier(table.code),
+ sql.SQL(", ").join(
+ sql.Identifier(column) for column in columns
+ ),
+ sql.SQL(", ").join(sql.Placeholder() for _ in columns),
+ )
+ record_id = uuid.uuid4()
+ try:
+ async with conn.transaction():
+ await cur.execute(
+ insert_queries[payload_keys],
+ (
+ record_id,
+ tenant_id,
+ project_id,
+ *(payload[key] for key in payload_keys),
+ ),
+ )
+ after_data = {
+ "id": str(record_id),
+ **{
+ key: _safe_value(value)
+ for key, value in payload.items()
+ },
+ }
+ await cur.execute(
+ audit_query,
+ (
+ tenant_id,
+ project_id,
+ table_code,
+ record_id,
+ Jsonb(after_data),
+ actor,
+ ),
+ )
+ imported_count += 1
+ except Exception:
+ if len(failed_errors) < MAX_REPORTED_ERRORS:
+ failed_errors.append(
+ {
+ "row": int(candidate["row"]),
+ "field": "",
+ "kind": "failed",
+ "message": "数据库约束校验失败,已跳过",
+ }
+ )
+ await conn.commit()
+
+ failed_count = len(failed_errors)
+ skipped_count = int(preview["skipped_rows"]) + failed_count
+ return {
+ "ok": True,
+ "file_name": file_name,
+ "imported_count": imported_count,
+ "invalid_count": int(preview["invalid_rows"]),
+ "duplicate_count": int(preview["duplicate_rows"]),
+ "failed_count": failed_count,
+ "skipped_count": skipped_count,
+ "errors": failed_errors,
+ "table_code": table.code,
+ }
+
+
+async def export_csv_records(
+ project_id: str,
+ table_code: str,
+ search: str | None = None,
+) -> tuple[bytes, str, int]:
+ table = await _table_or_404(project_id, table_code)
+ database = await _database_or_404(project_id)
+ schema_name = str(database["schema_name"])
+ columns = ["id", *(field.code for field in table.fields), "created_at", "updated_at"]
+ headers = ["记录 ID", *(field.label for field in table.fields), "创建时间", "更新时间"]
+ where_parts: list[sql.Composable] = [sql.SQL("deleted_at IS NULL")]
+ params: list[Any] = []
+ searchable = [field for field in table.fields if field.searchable]
+ if search and searchable:
+ pattern = f"%{search.strip()}%"
+ where_parts.append(
+ sql.SQL("(")
+ + sql.SQL(" OR ").join(
+ sql.SQL("{}::text ILIKE %s").format(sql.Identifier(field.code))
+ for field in searchable
+ )
+ + sql.SQL(")")
+ )
+ params.extend(pattern for _ in searchable)
+ where_clause = sql.SQL(" AND ").join(where_parts)
+
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("SELECT count(*) AS count FROM {}.{} WHERE {}").format(
+ sql.Identifier(schema_name),
+ sql.Identifier(table.code),
+ where_clause,
+ ),
+ params,
+ )
+ total = int((await cur.fetchone())["count"])
+ if total > MAX_EXPORT_ROWS:
+ raise HTTPException(
+ 400,
+ f"当前数据量为 {total:,} 条,单次最多导出 {MAX_EXPORT_ROWS:,} 条,请先搜索筛选",
+ )
+ await cur.execute(
+ sql.SQL("SELECT {} FROM {}.{} WHERE {} ORDER BY created_at ASC").format(
+ sql.SQL(", ").join(sql.Identifier(column) for column in columns),
+ sql.Identifier(schema_name),
+ sql.Identifier(table.code),
+ where_clause,
+ ),
+ params,
+ )
+ rows = await cur.fetchall()
+
+ output = io.StringIO(newline="")
+ writer = csv.writer(output, lineterminator="\n")
+ writer.writerow(headers)
+ for row in rows:
+ writer.writerow([_csv_value(row[column]) for column in columns])
+ content = ("\ufeff" + output.getvalue()).encode("utf-8")
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{table.code}_{timestamp}.csv"
+ return content, filename, total
diff --git a/app/data_platform/record_service.py b/app/data_platform/record_service.py
index 58a2963..fdb3cd1 100644
--- a/app/data_platform/record_service.py
+++ b/app/data_platform/record_service.py
@@ -10,11 +10,12 @@ from psycopg import sql
from psycopg.types.json import Jsonb
from app.config import settings
-from app.data_platform.registry import TABLE_DEFINITIONS, TABLE_REGISTRY, FieldDefinition, TableDefinition
+from app.data_platform.registry import FieldDefinition, TableDefinition
from app.data_platform.schema import (
- ensure_all_project_databases,
- ensure_project_database,
get_project_database,
+ list_project_table_definitions,
+ list_project_table_entries,
+ resolve_project_table_entry,
)
from app.db import get_conn
@@ -35,34 +36,22 @@ def _row_json(row: dict[str, Any] | None) -> dict[str, Any] | None:
return {key: _json_safe(value) for key, value in dict(row).items()}
-def _table_or_404(table_code: str) -> TableDefinition:
- table = TABLE_REGISTRY.get(table_code)
- if not table:
- raise HTTPException(404, "数据表未注册")
- return table
+async def _table_or_404(project_id: str, table_code: str) -> TableDefinition:
+ entry = await resolve_project_table_entry(project_id, table_code)
+ if entry:
+ return entry[0]
+ raise HTTPException(404, "数据表未注册")
+
+
+async def _project_table_definitions(project_id: str) -> tuple[TableDefinition, ...]:
+ return await list_project_table_definitions(project_id)
async def _database_or_404(project_id: str) -> dict[str, Any]:
database = await get_project_database(project_id)
if database:
return database
- async with get_conn() as conn:
- async with conn.cursor() as cur:
- await cur.execute(
- sql.SQL(
- "SELECT project_id, tenant_id, display_name FROM {}.projects "
- "WHERE project_id=%s AND status <> 'archived'"
- ).format(sql.Identifier(settings.db_schema)),
- (project_id,),
- )
- project = await cur.fetchone()
- if not project:
- raise HTTPException(404, "项目数据库不存在")
- return await ensure_project_database(
- str(project["project_id"]),
- str(project["tenant_id"]),
- str(project["display_name"]),
- )
+ raise HTTPException(404, "关系数据库不存在或已收藏")
def _coerce_value(field: FieldDefinition, value: Any) -> Any:
@@ -120,30 +109,29 @@ def _validated_payload(
async def list_databases() -> list[dict[str, Any]]:
- await ensure_all_project_databases()
admin_schema = sql.Identifier(settings.db_schema)
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
sql.SQL(
"""
- SELECT d.*, p.status AS project_status
+ SELECT d.*
FROM {}.project_databases d
- JOIN {}.projects p ON p.project_id=d.project_id
- WHERE p.status <> 'archived'
+ WHERE d.status='ready'
ORDER BY
CASE WHEN d.project_id='yunyou_libo' THEN 0 ELSE 1 END,
- p.created_at DESC
+ d.created_at DESC
"""
- ).format(admin_schema, admin_schema)
+ ).format(admin_schema)
)
databases = await cur.fetchall()
result: list[dict[str, Any]] = []
for database in databases:
schema_name = str(database["schema_name"])
+ definitions = await _project_table_definitions(str(database["project_id"]))
table_counts: list[int] = []
latest_updates: list[Any] = []
- for table in TABLE_DEFINITIONS:
+ for table in definitions:
await cur.execute(
sql.SQL(
"SELECT count(*) AS count, max(updated_at) AS updated_at "
@@ -160,7 +148,7 @@ async def list_databases() -> list[dict[str, Any]]:
row = _row_json(dict(database)) or {}
row.update(
{
- "table_count": len(TABLE_DEFINITIONS),
+ "table_count": len(definitions),
"record_count": sum(table_counts),
"updated_at": _json_safe(max(latest_updates)) if latest_updates else _json_safe(database["updated_at"]),
}
@@ -172,10 +160,11 @@ async def list_databases() -> list[dict[str, Any]]:
async def list_tables(project_id: str) -> dict[str, Any]:
database = await _database_or_404(project_id)
schema_name = str(database["schema_name"])
+ entries = await list_project_table_entries(project_id)
async with get_conn() as conn:
async with conn.cursor() as cur:
tables: list[dict[str, Any]] = []
- for definition in TABLE_DEFINITIONS:
+ for definition, origin, _source_code in entries:
await cur.execute(
sql.SQL(
"SELECT count(*) AS count, max(updated_at) AS updated_at "
@@ -191,12 +180,259 @@ async def list_tables(project_id: str) -> dict[str, Any]:
{
"record_count": int(summary["count"] or 0),
"updated_at": _json_safe(summary["updated_at"]),
+ "is_custom": origin == "custom",
+ "origin": origin,
}
)
tables.append(item)
return {"database": _row_json(database), "tables": tables}
+async def inspect_table(project_id: str, table_code: str) -> dict[str, Any]:
+ """Return the physical PostgreSQL structure and foreign-key relationships."""
+ table = await _table_or_404(project_id, table_code)
+ database = await _database_or_404(project_id)
+ schema_name = str(database["schema_name"])
+ definitions = await _project_table_definitions(project_id)
+ table_labels = {definition.code: definition.label for definition in definitions}
+ field_definitions = {field.code: field for field in table.fields}
+ system_labels = {
+ "id": "记录 ID",
+ "tenant_id": "租户 ID",
+ "project_id": "数据库编码",
+ "created_at": "创建时间",
+ "updated_at": "更新时间",
+ "deleted_at": "删除时间",
+ "deleted_by": "删除人",
+ }
+ action_labels = {
+ "a": "NO ACTION",
+ "r": "RESTRICT",
+ "c": "CASCADE",
+ "n": "SET NULL",
+ "d": "SET DEFAULT",
+ }
+
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ """
+ SELECT
+ a.attnum AS ordinal_position,
+ a.attname AS name,
+ format_type(a.atttypid, a.atttypmod) AS sql_type,
+ NOT a.attnotnull AS nullable,
+ pg_get_expr(ad.adbin, ad.adrelid) AS default_value,
+ col_description(c.oid, a.attnum) AS comment
+ FROM pg_attribute a
+ JOIN pg_class c ON c.oid = a.attrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ LEFT JOIN pg_attrdef ad
+ ON ad.adrelid = c.oid AND ad.adnum = a.attnum
+ WHERE n.nspname = %s
+ AND c.relname = %s
+ AND a.attnum > 0
+ AND NOT a.attisdropped
+ ORDER BY a.attnum
+ """,
+ (schema_name, table.code),
+ )
+ physical_columns = await cur.fetchall()
+
+ await cur.execute(
+ """
+ SELECT
+ con.conname AS name,
+ CASE con.contype
+ WHEN 'p' THEN 'primary_key'
+ WHEN 'u' THEN 'unique'
+ WHEN 'f' THEN 'foreign_key'
+ WHEN 'c' THEN 'check'
+ ELSE con.contype::text
+ END AS type,
+ ARRAY(
+ SELECT att.attname
+ FROM unnest(con.conkey) WITH ORDINALITY AS key(attnum, ord)
+ JOIN pg_attribute att
+ ON att.attrelid = con.conrelid
+ AND att.attnum = key.attnum
+ ORDER BY key.ord
+ ) AS columns,
+ pg_get_constraintdef(con.oid, true) AS definition
+ FROM pg_constraint con
+ JOIN pg_class rel ON rel.oid = con.conrelid
+ JOIN pg_namespace ns ON ns.oid = rel.relnamespace
+ WHERE ns.nspname = %s AND rel.relname = %s
+ ORDER BY
+ CASE con.contype WHEN 'p' THEN 0 WHEN 'f' THEN 1 ELSE 2 END,
+ con.conname
+ """,
+ (schema_name, table.code),
+ )
+ constraint_rows = await cur.fetchall()
+
+ await cur.execute(
+ """
+ SELECT
+ idx.relname AS name,
+ i.indisunique AS unique,
+ i.indisprimary AS primary,
+ pg_get_indexdef(i.indexrelid) AS definition
+ FROM pg_index i
+ JOIN pg_class rel ON rel.oid = i.indrelid
+ JOIN pg_namespace ns ON ns.oid = rel.relnamespace
+ JOIN pg_class idx ON idx.oid = i.indexrelid
+ WHERE ns.nspname = %s AND rel.relname = %s
+ ORDER BY i.indisprimary DESC, i.indisunique DESC, idx.relname
+ """,
+ (schema_name, table.code),
+ )
+ index_rows = await cur.fetchall()
+
+ await cur.execute(
+ """
+ SELECT
+ con.conname AS name,
+ src_ns.nspname AS source_schema,
+ src.relname AS source_table,
+ ARRAY(
+ SELECT att.attname
+ FROM unnest(con.conkey) WITH ORDINALITY AS key(attnum, ord)
+ JOIN pg_attribute att
+ ON att.attrelid = con.conrelid
+ AND att.attnum = key.attnum
+ ORDER BY key.ord
+ ) AS source_columns,
+ tgt_ns.nspname AS target_schema,
+ tgt.relname AS target_table,
+ ARRAY(
+ SELECT att.attname
+ FROM unnest(con.confkey) WITH ORDINALITY AS key(attnum, ord)
+ JOIN pg_attribute att
+ ON att.attrelid = con.confrelid
+ AND att.attnum = key.attnum
+ ORDER BY key.ord
+ ) AS target_columns,
+ con.confupdtype::text AS on_update,
+ con.confdeltype::text AS on_delete
+ FROM pg_constraint con
+ JOIN pg_class src ON src.oid = con.conrelid
+ JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace
+ JOIN pg_class tgt ON tgt.oid = con.confrelid
+ JOIN pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
+ WHERE con.contype = 'f'
+ AND (
+ (src_ns.nspname = %s AND src.relname = %s)
+ OR
+ (tgt_ns.nspname = %s AND tgt.relname = %s)
+ )
+ ORDER BY con.conname
+ """,
+ (schema_name, table.code, schema_name, table.code),
+ )
+ relationship_rows = await cur.fetchall()
+
+ constraints = [
+ {
+ "name": str(row["name"]),
+ "type": str(row["type"]),
+ "columns": list(row["columns"] or []),
+ "definition": str(row["definition"] or ""),
+ }
+ for row in constraint_rows
+ ]
+ primary_columns = {
+ column
+ for constraint in constraints
+ if constraint["type"] == "primary_key"
+ for column in constraint["columns"]
+ }
+ unique_columns = {
+ column
+ for constraint in constraints
+ if constraint["type"] in {"primary_key", "unique"}
+ and len(constraint["columns"]) == 1
+ for column in constraint["columns"]
+ }
+ foreign_columns = {
+ column
+ for constraint in constraints
+ if constraint["type"] == "foreign_key"
+ for column in constraint["columns"]
+ }
+ columns: list[dict[str, Any]] = []
+ for row in physical_columns:
+ code = str(row["name"])
+ definition = field_definitions.get(code)
+ columns.append(
+ {
+ "ordinal_position": int(row["ordinal_position"]),
+ "name": code,
+ "label": definition.label if definition else system_labels.get(code, code),
+ "data_type": definition.data_type if definition else "system",
+ "sql_type": str(row["sql_type"]),
+ "nullable": bool(row["nullable"]),
+ "default": row["default_value"],
+ "primary_key": code in primary_columns,
+ "unique": code in unique_columns,
+ "foreign_key": code in foreign_columns,
+ "editable": bool(definition.editable) if definition else False,
+ "structure_editable": bool(definition) and code not in system_labels,
+ "searchable": bool(definition.searchable) if definition else False,
+ "sortable": bool(definition.sortable) if definition else False,
+ "visible_in_list": bool(definition.visible_in_list) if definition else False,
+ "comment": row["comment"],
+ }
+ )
+
+ relationships = []
+ for row in relationship_rows:
+ source_table = str(row["source_table"])
+ target_table = str(row["target_table"])
+ relationships.append(
+ {
+ "name": str(row["name"]),
+ "direction": "outgoing" if source_table == table.code else "incoming",
+ "source_schema": str(row["source_schema"]),
+ "source_table": source_table,
+ "source_table_label": table_labels.get(source_table, source_table),
+ "source_columns": list(row["source_columns"] or []),
+ "target_schema": str(row["target_schema"]),
+ "target_table": target_table,
+ "target_table_label": table_labels.get(target_table, target_table),
+ "target_columns": list(row["target_columns"] or []),
+ "on_update": action_labels.get(str(row["on_update"]), str(row["on_update"])),
+ "on_delete": action_labels.get(str(row["on_delete"]), str(row["on_delete"])),
+ }
+ )
+
+ indexes = [
+ {
+ "name": str(row["name"]),
+ "unique": bool(row["unique"]),
+ "primary": bool(row["primary"]),
+ "definition": str(row["definition"] or ""),
+ }
+ for row in index_rows
+ ]
+ return {
+ "table": {
+ "schema_name": schema_name,
+ "table_code": table.code,
+ "label": table.label,
+ "description": table.description,
+ "column_count": len(columns),
+ "index_count": len(indexes),
+ "constraint_count": len(constraints),
+ "primary_key": list(primary_columns),
+ },
+ "columns": columns,
+ "constraints": constraints,
+ "indexes": indexes,
+ "relationships": relationships,
+ }
+
+
async def list_records(
project_id: str,
table_code: str,
@@ -207,11 +443,11 @@ async def list_records(
sort_field: str | None,
sort_order: str,
) -> dict[str, Any]:
- table = _table_or_404(table_code)
+ table = await _table_or_404(project_id, table_code)
database = await _database_or_404(project_id)
schema_name = str(database["schema_name"])
page = max(1, page)
- page_size = max(1, min(200, page_size))
+ page_size = max(1, min(5000, page_size))
offset = (page - 1) * page_size
allowed_sort = {"id", "created_at", "updated_at", *(field.code for field in table.fields if field.sortable)}
order_field = sort_field if sort_field in allowed_sort else "updated_at"
@@ -301,17 +537,6 @@ async def _write_audit(
actor,
),
)
- record_version = int((after_data or before_data or {}).get("version") or 1)
- await cur.execute(
- sql.SQL(
- """
- INSERT INTO {}.graph_sync_queue (
- tenant_id, project_id, table_code, record_id, operation, record_version
- ) VALUES (%s, %s, %s, %s, %s, %s)
- """
- ).format(sql.Identifier(schema_name)),
- (tenant_id, project_id, table_code, record_id, operation, record_version),
- )
async def create_record(
@@ -320,7 +545,7 @@ async def create_record(
body: dict[str, Any],
actor: str,
) -> dict[str, Any]:
- table = _table_or_404(table_code)
+ table = await _table_or_404(project_id, table_code)
if not table.allow_create:
raise HTTPException(403, "该表不允许新增")
database = await _database_or_404(project_id)
@@ -368,7 +593,7 @@ async def update_record(
body: dict[str, Any],
actor: str,
) -> dict[str, Any]:
- table = _table_or_404(table_code)
+ table = await _table_or_404(project_id, table_code)
if not table.allow_update:
raise HTTPException(403, "该表不允许修改")
try:
@@ -435,7 +660,7 @@ async def delete_record(
record_id: str,
actor: str,
) -> dict[str, bool]:
- table = _table_or_404(table_code)
+ table = await _table_or_404(project_id, table_code)
if not table.allow_delete:
raise HTTPException(403, "该表不允许删除")
try:
@@ -482,4 +707,3 @@ async def delete_record(
)
await conn.commit()
return {"ok": True}
-
diff --git a/app/data_platform/registry.py b/app/data_platform/registry.py
index cf115d6..ab73beb 100644
--- a/app/data_platform/registry.py
+++ b/app/data_platform/registry.py
@@ -16,6 +16,7 @@ class FieldDefinition:
editable: bool = True
visible_in_list: bool = True
default_sql: str | None = None
+ unique: bool = False
options: tuple[str, ...] = ()
def as_dict(self) -> dict[str, Any]:
@@ -28,6 +29,7 @@ class FieldDefinition:
"sortable": self.sortable,
"editable": self.editable,
"visible_in_list": self.visible_in_list,
+ "unique": self.unique,
"options": list(self.options),
}
@@ -55,8 +57,10 @@ class TableDefinition:
return "id"
def as_dict(self) -> dict[str, Any]:
- system_fields = (
+ identity_fields = (
FieldDefinition("id", "记录 ID", "UUID", editable=False),
+ )
+ audit_fields = (
FieldDefinition("created_at", "创建时间", "TIMESTAMPTZ", "datetime", editable=False),
FieldDefinition("updated_at", "更新时间", "TIMESTAMPTZ", "datetime", editable=False),
)
@@ -70,7 +74,10 @@ class TableDefinition:
"allow_create": self.allow_create,
"allow_update": self.allow_update,
"allow_delete": self.allow_delete,
- "fields": [field.as_dict() for field in (*system_fields, *self.fields)],
+ "fields": [
+ field.as_dict()
+ for field in (*identity_fields, *self.fields, *audit_fields)
+ ],
}
@@ -361,4 +368,3 @@ TABLE_DEFINITIONS: tuple[TableDefinition, ...] = (
)
TABLE_REGISTRY: dict[str, TableDefinition] = {table.code: table for table in TABLE_DEFINITIONS}
-
diff --git a/app/data_platform/schema.py b/app/data_platform/schema.py
index 5edd0af..863560b 100644
--- a/app/data_platform/schema.py
+++ b/app/data_platform/schema.py
@@ -4,11 +4,81 @@ import hashlib
import re
from typing import Any
-from psycopg import sql
+from psycopg import Error as PsycopgError, sql
+from psycopg.types.json import Jsonb
from app.config import settings
from app.db import get_conn
-from app.data_platform.registry import TABLE_DEFINITIONS, TableDefinition
+from app.data_platform.registry import (
+ TABLE_DEFINITIONS,
+ TABLE_REGISTRY,
+ FieldDefinition,
+ TableDefinition,
+)
+
+
+CUSTOM_FIELD_TYPES: dict[str, tuple[str, str]] = {
+ "text": ("TEXT", "text"),
+ "long_text": ("TEXT", "long_text"),
+ "integer": ("INTEGER", "number"),
+ "number": ("NUMERIC(18,4)", "number"),
+ "boolean": ("BOOLEAN", "boolean"),
+ "date": ("DATE", "date"),
+ "datetime": ("TIMESTAMPTZ", "datetime"),
+ "json": ("JSONB", "json"),
+ "url": ("TEXT", "url"),
+}
+SYSTEM_COLUMN_CODES = {
+ "id",
+ "tenant_id",
+ "project_id",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "deleted_by",
+}
+IDENTIFIER_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,62}$")
+CREATE_TABLE_PATTERN = re.compile(
+ r"""
+ ^\s*CREATE\s+TABLE\s+
+ (?:IF\s+NOT\s+EXISTS\s+)?
+ (?P"?[A-Za-z][A-Za-z0-9_]*"?)
+ \s*\((?P.*)\)\s*$
+ """,
+ re.IGNORECASE | re.DOTALL | re.VERBOSE,
+)
+SQL_TYPE_PATTERN = re.compile(
+ r"""
+ ^(?P
+ character\s+varying(?:\s*\(\s*\d+\s*\))?
+ |varchar(?:\s*\(\s*\d+\s*\))?
+ |character(?:\s*\(\s*\d+\s*\))?
+ |char(?:\s*\(\s*\d+\s*\))?
+ |text
+ |smallint|int2|integer|int|int4|bigint|int8|smallserial|serial|bigserial
+ |numeric(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?
+ |decimal(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?
+ |real|float4|double\s+precision|float8
+ |boolean|bool
+ |date
+ |timestamp(?:\s*\(\s*\d+\s*\))?(?:\s+(?:with|without)\s+time\s+zone)?
+ |timestamptz(?:\s*\(\s*\d+\s*\))?
+ |jsonb|json
+ |uuid
+ )(?=\s|$)(?P.*)$
+ """,
+ re.IGNORECASE | re.DOTALL | re.VERBOSE,
+)
+SAFE_DEFAULT_PATTERN = re.compile(
+ r"""
+ ^(?:
+ NULL|TRUE|FALSE|CURRENT_TIMESTAMP|CURRENT_DATE|NOW\(\)|GEN_RANDOM_UUID\(\)
+ |[-+]?(?:\d+(?:\.\d*)?|\.\d+)
+ |'(?:[^']|'')*'(?:\s*::\s*(?:text|varchar|json|jsonb|uuid|date|timestamptz))?
+ )$
+ """,
+ re.IGNORECASE | re.VERBOSE,
+)
def _slug(value: str) -> str:
@@ -38,6 +108,8 @@ def _column_sql(table: TableDefinition) -> sql.Composed:
]
if item.required:
parts.append(sql.SQL("NOT NULL"))
+ if item.unique:
+ parts.append(sql.SQL("UNIQUE"))
if item.default_sql:
parts.extend((sql.SQL("DEFAULT"), sql.SQL(item.default_sql)))
columns.append(sql.SQL(" ").join(parts))
@@ -72,9 +144,991 @@ async def ensure_platform_registry() -> None:
"""
).format(admin_schema)
)
+ await cur.execute(
+ sql.SQL(
+ """
+ CREATE TABLE IF NOT EXISTS {}.project_table_definitions (
+ project_id TEXT NOT NULL,
+ table_code TEXT NOT NULL,
+ label TEXT NOT NULL,
+ group_name TEXT NOT NULL DEFAULT '自定义',
+ description TEXT NOT NULL DEFAULT '',
+ fields_jsonb JSONB NOT NULL DEFAULT '[]'::jsonb,
+ allow_create BOOLEAN NOT NULL DEFAULT true,
+ allow_update BOOLEAN NOT NULL DEFAULT true,
+ allow_delete BOOLEAN NOT NULL DEFAULT true,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_by TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (project_id, table_code)
+ )
+ """
+ ).format(admin_schema)
+ )
+ await cur.execute(
+ sql.SQL(
+ """
+ CREATE TABLE IF NOT EXISTS {}.project_table_overrides (
+ project_id TEXT NOT NULL,
+ source_code TEXT NOT NULL,
+ table_code TEXT NOT NULL,
+ label TEXT NOT NULL,
+ fields_jsonb JSONB,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (project_id, source_code)
+ )
+ """
+ ).format(admin_schema)
+ )
+ await cur.execute(
+ sql.SQL(
+ "ALTER TABLE {}.project_table_overrides "
+ "ADD COLUMN IF NOT EXISTS fields_jsonb JSONB"
+ ).format(admin_schema)
+ )
await conn.commit()
+def _custom_table_from_row(row: dict[str, Any]) -> TableDefinition:
+ fields = _field_definitions_from_rows(row.get("fields_jsonb") or [])
+ return TableDefinition(
+ code=str(row["table_code"]),
+ label=str(row["label"]),
+ group=str(row.get("group_name") or "自定义"),
+ description=str(row.get("description") or ""),
+ fields=fields,
+ allow_create=bool(row.get("allow_create", True)),
+ allow_update=bool(row.get("allow_update", True)),
+ allow_delete=bool(row.get("allow_delete", True)),
+ )
+
+
+def _field_definitions_from_rows(rows: list[dict[str, Any]]) -> tuple[FieldDefinition, ...]:
+ return tuple(
+ FieldDefinition(
+ code=str(item["code"]),
+ label=str(item["label"]),
+ sql_type=str(item["sql_type"]),
+ data_type=str(item.get("data_type") or "text"),
+ required=bool(item.get("required")),
+ searchable=bool(item.get("searchable")),
+ sortable=bool(item.get("sortable", True)),
+ editable=bool(item.get("editable", True)),
+ visible_in_list=bool(item.get("visible_in_list", True)),
+ default_sql=str(item["default_sql"]) if item.get("default_sql") else None,
+ unique=bool(item.get("unique")),
+ options=tuple(str(value) for value in item.get("options") or ()),
+ )
+ for item in rows
+ )
+
+
+async def list_custom_table_definitions(project_id: str) -> tuple[TableDefinition, ...]:
+ await ensure_platform_registry()
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL(
+ """
+ SELECT *
+ FROM {}.project_table_definitions
+ WHERE project_id=%s AND status='active'
+ ORDER BY created_at, table_code
+ """
+ ).format(sql.Identifier(settings.db_schema)),
+ (project_id,),
+ )
+ return tuple(_custom_table_from_row(dict(row)) for row in await cur.fetchall())
+
+
+def _table_with_identity(
+ table: TableDefinition,
+ *,
+ code: str,
+ label: str,
+ fields: tuple[FieldDefinition, ...] | None = None,
+) -> TableDefinition:
+ return TableDefinition(
+ code=code,
+ label=label,
+ group=table.group,
+ description=table.description,
+ fields=fields if fields is not None else table.fields,
+ allow_create=table.allow_create,
+ allow_update=table.allow_update,
+ allow_delete=table.allow_delete,
+ )
+
+
+async def _builtin_table_overrides(project_id: str) -> dict[str, dict[str, Any]]:
+ await ensure_platform_registry()
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL(
+ """
+ SELECT *
+ FROM {}.project_table_overrides
+ WHERE project_id=%s
+ """
+ ).format(sql.Identifier(settings.db_schema)),
+ (project_id,),
+ )
+ return {
+ str(row["source_code"]): dict(row)
+ for row in await cur.fetchall()
+ }
+
+
+async def list_project_table_entries(
+ project_id: str,
+) -> tuple[tuple[TableDefinition, str, str], ...]:
+ """Return effective definitions together with origin and stable source code."""
+ overrides = await _builtin_table_overrides(project_id)
+ entries: list[tuple[TableDefinition, str, str]] = []
+ for table in TABLE_DEFINITIONS:
+ override = overrides.get(table.code)
+ if override and str(override["status"]) == "deleted":
+ continue
+ effective = (
+ _table_with_identity(
+ table,
+ code=str(override["table_code"]),
+ label=str(override["label"]),
+ fields=(
+ _field_definitions_from_rows(override["fields_jsonb"])
+ if override.get("fields_jsonb") is not None
+ else table.fields
+ ),
+ )
+ if override
+ else table
+ )
+ entries.append((effective, "builtin", table.code))
+ entries.extend(
+ (table, "custom", table.code)
+ for table in await list_custom_table_definitions(project_id)
+ )
+ return tuple(entries)
+
+
+async def list_project_table_definitions(
+ project_id: str,
+) -> tuple[TableDefinition, ...]:
+ return tuple(
+ definition
+ for definition, _origin, _source_code in await list_project_table_entries(project_id)
+ )
+
+
+async def resolve_project_table_entry(
+ project_id: str,
+ table_code: str,
+) -> tuple[TableDefinition, str, str] | None:
+ return next(
+ (
+ entry
+ for entry in await list_project_table_entries(project_id)
+ if entry[0].code == table_code
+ ),
+ None,
+ )
+
+
+def _custom_definition_from_payload(body: dict[str, Any]) -> tuple[TableDefinition, list[dict[str, Any]]]:
+ table_code = str(body.get("code") or "").strip()
+ if not IDENTIFIER_PATTERN.fullmatch(table_code):
+ raise ValueError("表名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位")
+ if table_code in TABLE_REGISTRY or table_code in {"data_change_logs", "graph_sync_queue"}:
+ raise ValueError("该表名已被系统占用")
+ label = str(body.get("label") or "").strip()
+ if not label:
+ raise ValueError("请输入数据表中文名称")
+ group = str(body.get("group") or "自定义").strip() or "自定义"
+ raw_fields = body.get("fields")
+ if not isinstance(raw_fields, list) or not raw_fields:
+ raise ValueError("请至少定义一个业务字段")
+
+ seen: set[str] = set()
+ fields: list[FieldDefinition] = []
+ field_rows: list[dict[str, Any]] = []
+ for raw in raw_fields:
+ if not isinstance(raw, dict):
+ raise ValueError("字段定义格式错误")
+ code = str(raw.get("code") or "").strip()
+ if not IDENTIFIER_PATTERN.fullmatch(code):
+ raise ValueError(f"字段名 {code or '(空)'} 格式错误")
+ if code in SYSTEM_COLUMN_CODES:
+ raise ValueError(f"字段名 {code} 是系统保留字段")
+ if code in seen:
+ raise ValueError(f"字段名 {code} 重复")
+ seen.add(code)
+ field_label = str(raw.get("label") or "").strip()
+ if not field_label:
+ raise ValueError(f"字段 {code} 缺少中文名称")
+ type_key = str(raw.get("type_key") or "text").strip()
+ if type_key not in CUSTOM_FIELD_TYPES:
+ raise ValueError(f"字段 {field_label} 的类型不受支持")
+ sql_type, data_type = CUSTOM_FIELD_TYPES[type_key]
+ options = tuple(
+ str(value).strip()
+ for value in (raw.get("options") or [])
+ if str(value).strip()
+ )
+ definition = FieldDefinition(
+ code=code,
+ label=field_label,
+ sql_type=sql_type,
+ data_type=data_type,
+ required=bool(raw.get("required")),
+ searchable=bool(raw.get("searchable")),
+ sortable=bool(raw.get("sortable", True)),
+ editable=True,
+ visible_in_list=bool(raw.get("visible_in_list", True)),
+ unique=bool(raw.get("unique")),
+ options=options,
+ )
+ fields.append(definition)
+ field_rows.append(
+ {
+ "code": definition.code,
+ "label": definition.label,
+ "sql_type": definition.sql_type,
+ "data_type": definition.data_type,
+ "required": definition.required,
+ "searchable": definition.searchable,
+ "sortable": definition.sortable,
+ "editable": definition.editable,
+ "visible_in_list": definition.visible_in_list,
+ "unique": definition.unique,
+ "options": list(definition.options),
+ }
+ )
+ return (
+ TableDefinition(
+ code=table_code,
+ label=label,
+ group=group,
+ description=str(body.get("description") or "").strip(),
+ fields=tuple(fields),
+ allow_create=bool(body.get("allow_create", True)),
+ allow_update=bool(body.get("allow_update", True)),
+ allow_delete=bool(body.get("allow_delete", True)),
+ ),
+ field_rows,
+ )
+
+
+def _normalize_sql_identifier(value: str, *, kind: str) -> str:
+ identifier = value.strip()
+ if identifier.startswith('"') and identifier.endswith('"'):
+ identifier = identifier[1:-1]
+ if '"' in identifier:
+ raise ValueError(f"{kind}不支持转义引号")
+ else:
+ identifier = identifier.lower()
+ if not IDENTIFIER_PATTERN.fullmatch(identifier):
+ raise ValueError(
+ f"{kind} {identifier or '(空)'} 格式错误:必须以小写字母开头,"
+ "只能包含小写字母、数字和下划线,长度为 2–63 位"
+ )
+ return identifier
+
+
+def _split_sql_definition_items(value: str) -> list[str]:
+ items: list[str] = []
+ start = 0
+ depth = 0
+ quote: str | None = None
+ index = 0
+ while index < len(value):
+ char = value[index]
+ if quote:
+ if char == quote:
+ if index + 1 < len(value) and value[index + 1] == quote:
+ index += 2
+ continue
+ quote = None
+ elif char in {"'", '"'}:
+ quote = char
+ elif char == "(":
+ depth += 1
+ elif char == ")":
+ depth -= 1
+ if depth < 0:
+ raise ValueError("CREATE TABLE 字段括号不匹配")
+ elif char == "," and depth == 0:
+ item = value[start:index].strip()
+ if not item:
+ raise ValueError("CREATE TABLE 中存在空字段定义")
+ items.append(item)
+ start = index + 1
+ index += 1
+ if quote or depth != 0:
+ raise ValueError("CREATE TABLE 中的引号或括号不匹配")
+ item = value[start:].strip()
+ if item:
+ items.append(item)
+ return items
+
+
+def _split_sql_column(item: str) -> tuple[str, str]:
+ value = item.strip()
+ if value.startswith('"'):
+ end = value.find('"', 1)
+ if end < 0:
+ raise ValueError("字段名引号不完整")
+ identifier = value[: end + 1]
+ remainder = value[end + 1 :].strip()
+ else:
+ match = re.match(r"^([A-Za-z][A-Za-z0-9_]*)\s+(.+)$", value, re.DOTALL)
+ if not match:
+ raise ValueError(f"无法识别字段定义:{value[:80]}")
+ identifier = match.group(1)
+ remainder = match.group(2).strip()
+ if not remainder:
+ raise ValueError(f"字段 {identifier} 缺少数据类型")
+ return _normalize_sql_identifier(identifier, kind="字段名"), remainder
+
+
+def _canonical_sql_type(raw_type: str, field_code: str) -> tuple[str, str]:
+ value = re.sub(r"\s+", " ", raw_type.strip()).lower()
+ sized = re.fullmatch(
+ r"(character varying|varchar|character|char)\s*\(\s*(\d+)\s*\)",
+ value,
+ )
+ if sized:
+ length = int(sized.group(2))
+ if not 1 <= length <= 10000:
+ raise ValueError(f"字段 {field_code} 的字符长度必须在 1–10000 之间")
+ base = "VARCHAR" if sized.group(1) in {"character varying", "varchar"} else "CHAR"
+ data_type = "url" if field_code == "url" or field_code.endswith("_url") else "text"
+ return f"{base}({length})", data_type
+ if value in {"character varying", "varchar", "character", "char"}:
+ data_type = "url" if field_code == "url" or field_code.endswith("_url") else "text"
+ return ("VARCHAR" if value in {"character varying", "varchar"} else "CHAR"), data_type
+ if value == "text":
+ data_type = "url" if field_code == "url" or field_code.endswith("_url") else "long_text"
+ return "TEXT", data_type
+ integer_types = {
+ "smallint": "SMALLINT",
+ "int2": "SMALLINT",
+ "integer": "INTEGER",
+ "int": "INTEGER",
+ "int4": "INTEGER",
+ "bigint": "BIGINT",
+ "int8": "BIGINT",
+ "smallserial": "SMALLSERIAL",
+ "serial": "SERIAL",
+ "bigserial": "BIGSERIAL",
+ }
+ if value in integer_types:
+ return integer_types[value], "number"
+ numeric = re.fullmatch(
+ r"(numeric|decimal)(?:\s*\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?",
+ value,
+ )
+ if numeric:
+ precision = int(numeric.group(2)) if numeric.group(2) else None
+ scale = int(numeric.group(3)) if numeric.group(3) else None
+ if precision is not None and not 1 <= precision <= 1000:
+ raise ValueError(f"字段 {field_code} 的数字精度必须在 1–1000 之间")
+ if scale is not None and (scale < 0 or scale > (precision or 0)):
+ raise ValueError(f"字段 {field_code} 的小数位数不能超过数字精度")
+ if precision is None:
+ return "NUMERIC", "number"
+ if scale is None:
+ return f"NUMERIC({precision})", "number"
+ return f"NUMERIC({precision},{scale})", "number"
+ if value in {"real", "float4"}:
+ return "REAL", "number"
+ if value in {"double precision", "float8"}:
+ return "DOUBLE PRECISION", "number"
+ if value in {"boolean", "bool"}:
+ return "BOOLEAN", "boolean"
+ if value == "date":
+ return "DATE", "date"
+ timestamp = re.fullmatch(
+ r"(timestamp|timestamptz)(?:\s*\(\s*(\d+)\s*\))?"
+ r"(?:\s+(with|without)\s+time\s+zone)?",
+ value,
+ )
+ if timestamp:
+ precision = int(timestamp.group(2)) if timestamp.group(2) else None
+ if precision is not None and not 0 <= precision <= 6:
+ raise ValueError(f"字段 {field_code} 的时间精度必须在 0–6 之间")
+ with_timezone = timestamp.group(1) == "timestamptz" or timestamp.group(3) == "with"
+ base = "TIMESTAMPTZ" if with_timezone else "TIMESTAMP"
+ return (f"{base}({precision})" if precision is not None else base), "datetime"
+ if value in {"json", "jsonb"}:
+ return "JSONB", "json"
+ if value == "uuid":
+ return "UUID", "text"
+ raise ValueError(f"字段 {field_code} 的 SQL 类型 {raw_type} 暂不支持")
+
+
+def _parse_sql_column_constraints(
+ value: str,
+ field_code: str,
+) -> tuple[bool, str | None, bool]:
+ remaining = value.strip()
+ required = False
+ unique = False
+ default_sql: str | None = None
+ while remaining:
+ not_null = re.match(r"^NOT\s+NULL(?:\s+|$)", remaining, re.IGNORECASE)
+ if not_null:
+ required = True
+ remaining = remaining[not_null.end() :].strip()
+ continue
+ nullable = re.match(r"^NULL(?:\s+|$)", remaining, re.IGNORECASE)
+ if nullable:
+ remaining = remaining[nullable.end() :].strip()
+ continue
+ unique_constraint = re.match(r"^UNIQUE(?:\s+|$)", remaining, re.IGNORECASE)
+ if unique_constraint:
+ unique = True
+ remaining = remaining[unique_constraint.end() :].strip()
+ continue
+ default = re.match(r"^DEFAULT(?:\s+|$)", remaining, re.IGNORECASE)
+ if default:
+ expression_and_rest = remaining[default.end() :].strip()
+ expression_match = re.match(
+ r"^(NULL|TRUE|FALSE|CURRENT_TIMESTAMP|CURRENT_DATE|NOW\(\)|"
+ r"GEN_RANDOM_UUID\(\)|[-+]?(?:\d+(?:\.\d*)?|\.\d+)|"
+ r"'(?:[^']|'')*'(?:\s*::\s*(?:text|varchar|json|jsonb|uuid|date|timestamptz))?)"
+ r"(?=\s|$)",
+ expression_and_rest,
+ re.IGNORECASE,
+ )
+ if not expression_match:
+ raise ValueError(
+ f"字段 {field_code} 的 DEFAULT 只支持文本、数字、布尔值、NULL、"
+ "CURRENT_DATE、CURRENT_TIMESTAMP、now() 或 gen_random_uuid()"
+ )
+ expression = expression_match.group(1)
+ if not SAFE_DEFAULT_PATTERN.fullmatch(expression):
+ raise ValueError(f"字段 {field_code} 的 DEFAULT 不安全")
+ default_sql = expression
+ remaining = expression_and_rest[expression_match.end() :].strip()
+ continue
+ raise ValueError(
+ f"字段 {field_code} 含暂不支持的约束:{remaining[:80]}。"
+ "目前支持 NOT NULL、NULL、UNIQUE 和安全 DEFAULT;"
+ "MySQL AUTO_INCREMENT 与 ON UPDATE 仅用于平台托管的 id/updated_at 字段"
+ )
+ return required, default_sql, unique
+
+
+def _field_row(definition: FieldDefinition) -> dict[str, Any]:
+ return {
+ "code": definition.code,
+ "label": definition.label,
+ "sql_type": definition.sql_type,
+ "data_type": definition.data_type,
+ "required": definition.required,
+ "searchable": definition.searchable,
+ "sortable": definition.sortable,
+ "editable": definition.editable,
+ "visible_in_list": definition.visible_in_list,
+ "default_sql": definition.default_sql,
+ "unique": definition.unique,
+ "options": list(definition.options),
+ }
+
+
+def _structure_field_from_payload(
+ body: dict[str, Any],
+ *,
+ current: FieldDefinition | None = None,
+) -> FieldDefinition:
+ code = str(body.get("code") or (current.code if current else "")).strip()
+ if not IDENTIFIER_PATTERN.fullmatch(code):
+ raise ValueError("字段名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位")
+ if code in SYSTEM_COLUMN_CODES:
+ raise ValueError(f"字段名 {code} 是系统保留字段")
+ label = str(body.get("label") or (current.label if current else "")).strip()
+ if not label:
+ raise ValueError("请输入字段名称")
+ type_key = str(body.get("type_key") or "").strip()
+ if type_key:
+ if type_key not in CUSTOM_FIELD_TYPES:
+ raise ValueError("字段类型不受支持")
+ sql_type, data_type = CUSTOM_FIELD_TYPES[type_key]
+ elif current:
+ # A label/default-only edit must not silently change the physical precision
+ # of VARCHAR/NUMERIC columns that do not exactly match the form presets.
+ sql_type, data_type = current.sql_type, current.data_type
+ else:
+ sql_type, data_type = CUSTOM_FIELD_TYPES["text"]
+ default_value = body.get("default_sql", current.default_sql if current else None)
+ default_sql = str(default_value).strip() if default_value not in (None, "") else None
+ if default_sql and not SAFE_DEFAULT_PATTERN.fullmatch(default_sql):
+ raise ValueError(
+ "默认值只支持文本、数字、布尔值、NULL、CURRENT_DATE、"
+ "CURRENT_TIMESTAMP、now() 或 gen_random_uuid()"
+ )
+ return FieldDefinition(
+ code=code,
+ label=label,
+ sql_type=sql_type,
+ data_type=data_type,
+ required=bool(body.get("required", current.required if current else False)),
+ searchable=bool(body.get("searchable", current.searchable if current else False)),
+ sortable=bool(body.get("sortable", current.sortable if current else True)),
+ editable=True,
+ visible_in_list=bool(
+ body.get("visible_in_list", current.visible_in_list if current else True)
+ ),
+ default_sql=default_sql,
+ unique=bool(body.get("unique", current.unique if current else False)),
+ options=current.options if current else (),
+ )
+
+
+async def _persist_effective_fields(
+ cur: Any,
+ *,
+ project_id: str,
+ current: TableDefinition,
+ origin: str,
+ source_code: str,
+ fields: tuple[FieldDefinition, ...],
+) -> None:
+ admin_schema = sql.Identifier(settings.db_schema)
+ field_rows = Jsonb([_field_row(field) for field in fields])
+ if origin == "custom":
+ await cur.execute(
+ sql.SQL(
+ """
+ UPDATE {}.project_table_definitions
+ SET fields_jsonb=%s, updated_at=now()
+ WHERE project_id=%s AND table_code=%s AND status='active'
+ """
+ ).format(admin_schema),
+ (field_rows, project_id, current.code),
+ )
+ if cur.rowcount != 1:
+ raise ValueError("数据表字段元数据不存在")
+ return
+ await cur.execute(
+ sql.SQL(
+ """
+ INSERT INTO {}.project_table_overrides (
+ project_id, source_code, table_code, label, fields_jsonb, status, updated_at
+ )
+ VALUES (%s, %s, %s, %s, %s, 'active', now())
+ ON CONFLICT (project_id, source_code) DO UPDATE
+ SET table_code=EXCLUDED.table_code,
+ label=EXCLUDED.label,
+ fields_jsonb=EXCLUDED.fields_jsonb,
+ status='active',
+ updated_at=now()
+ """
+ ).format(admin_schema),
+ (project_id, source_code, current.code, current.label, field_rows),
+ )
+
+
+def _database_error_message(exc: PsycopgError) -> str:
+ primary = getattr(getattr(exc, "diag", None), "message_primary", None)
+ return f"PostgreSQL 拒绝结构修改:{primary or str(exc)}"
+
+
+async def create_table_column(
+ project_id: str,
+ table_code: str,
+ body: dict[str, Any],
+) -> TableDefinition:
+ await ensure_platform_registry()
+ database = await get_project_database(project_id)
+ entry = await resolve_project_table_entry(project_id, table_code)
+ if not database or not entry:
+ raise ValueError("数据表不存在")
+ current, origin, source_code = entry
+ definition = _structure_field_from_payload(body)
+ if any(field.code == definition.code for field in current.fields):
+ raise ValueError("当前数据表已存在同名字段")
+ if definition.required and not definition.default_sql:
+ raise ValueError("新增非空字段必须设置默认值,避免现有记录无法补值")
+
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ parts: list[sql.Composable] = [
+ sql.Identifier(definition.code),
+ sql.SQL(definition.sql_type),
+ ]
+ if definition.default_sql:
+ parts.extend((sql.SQL("DEFAULT"), sql.SQL(definition.default_sql)))
+ if definition.required:
+ parts.append(sql.SQL("NOT NULL"))
+ next_fields = (*current.fields, definition)
+ try:
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} ADD COLUMN {}").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.SQL(" ").join(parts),
+ )
+ )
+ await cur.execute(
+ sql.SQL("COMMENT ON COLUMN {}.{}.{} IS {}").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(definition.code),
+ sql.Literal(definition.label),
+ )
+ )
+ await _persist_effective_fields(
+ cur,
+ project_id=project_id,
+ current=current,
+ origin=origin,
+ source_code=source_code,
+ fields=next_fields,
+ )
+ await conn.commit()
+ except PsycopgError as exc:
+ raise ValueError(_database_error_message(exc)) from exc
+ return _table_with_identity(
+ current,
+ code=current.code,
+ label=current.label,
+ fields=next_fields,
+ )
+
+
+async def update_table_column(
+ project_id: str,
+ table_code: str,
+ column_code: str,
+ body: dict[str, Any],
+) -> TableDefinition:
+ await ensure_platform_registry()
+ if column_code in SYSTEM_COLUMN_CODES:
+ raise ValueError("系统字段不可修改")
+ database = await get_project_database(project_id)
+ entry = await resolve_project_table_entry(project_id, table_code)
+ if not database or not entry:
+ raise ValueError("数据表不存在")
+ current, origin, source_code = entry
+ current_field = next((field for field in current.fields if field.code == column_code), None)
+ if not current_field:
+ raise ValueError("业务字段不存在或不可修改")
+ definition = _structure_field_from_payload(body, current=current_field)
+ if definition.code != column_code and any(
+ field.code == definition.code for field in current.fields
+ ):
+ raise ValueError("当前数据表已存在同名字段")
+
+ next_fields = tuple(
+ definition if field.code == column_code else field
+ for field in current.fields
+ )
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ try:
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ if current_field.sql_type != definition.sql_type:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} ALTER COLUMN {} DROP DEFAULT").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ )
+ )
+ await cur.execute(
+ sql.SQL(
+ "ALTER TABLE {}.{} ALTER COLUMN {} TYPE {} USING {}::{}"
+ ).format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ sql.SQL(definition.sql_type),
+ sql.Identifier(column_code),
+ sql.SQL(definition.sql_type),
+ )
+ )
+ if definition.default_sql:
+ await cur.execute(
+ sql.SQL(
+ "ALTER TABLE {}.{} ALTER COLUMN {} SET DEFAULT {}"
+ ).format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ sql.SQL(definition.default_sql),
+ )
+ )
+ else:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} ALTER COLUMN {} DROP DEFAULT").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ )
+ )
+ await cur.execute(
+ sql.SQL(
+ "ALTER TABLE {}.{} ALTER COLUMN {} {} NOT NULL"
+ ).format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ sql.SQL("SET" if definition.required else "DROP"),
+ )
+ )
+ if definition.code != column_code:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} RENAME COLUMN {} TO {}").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ sql.Identifier(definition.code),
+ )
+ )
+ await cur.execute(
+ sql.SQL("COMMENT ON COLUMN {}.{}.{} IS {}").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(definition.code),
+ sql.Literal(definition.label),
+ )
+ )
+ await _persist_effective_fields(
+ cur,
+ project_id=project_id,
+ current=current,
+ origin=origin,
+ source_code=source_code,
+ fields=next_fields,
+ )
+ await conn.commit()
+ except PsycopgError as exc:
+ raise ValueError(_database_error_message(exc)) from exc
+ return _table_with_identity(
+ current,
+ code=current.code,
+ label=current.label,
+ fields=next_fields,
+ )
+
+
+async def delete_table_column(
+ project_id: str,
+ table_code: str,
+ column_code: str,
+ confirm_name: str,
+) -> TableDefinition:
+ await ensure_platform_registry()
+ if column_code in SYSTEM_COLUMN_CODES:
+ raise ValueError("系统字段不可删除")
+ if confirm_name.strip() != column_code:
+ raise ValueError("确认字段名不正确,未执行删除")
+ database = await get_project_database(project_id)
+ entry = await resolve_project_table_entry(project_id, table_code)
+ if not database or not entry:
+ raise ValueError("数据表不存在")
+ current, origin, source_code = entry
+ if not any(field.code == column_code for field in current.fields):
+ raise ValueError("业务字段不存在或不可删除")
+ next_fields = tuple(field for field in current.fields if field.code != column_code)
+ if not next_fields:
+ raise ValueError("数据表至少需要保留一个业务字段")
+
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ try:
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} DROP COLUMN {} RESTRICT").format(
+ project_schema,
+ sql.Identifier(current.code),
+ sql.Identifier(column_code),
+ )
+ )
+ await _persist_effective_fields(
+ cur,
+ project_id=project_id,
+ current=current,
+ origin=origin,
+ source_code=source_code,
+ fields=next_fields,
+ )
+ await conn.commit()
+ except PsycopgError as exc:
+ raise ValueError(_database_error_message(exc)) from exc
+ return _table_with_identity(
+ current,
+ code=current.code,
+ label=current.label,
+ fields=next_fields,
+ )
+
+
+def custom_definition_from_create_sql(
+ body: dict[str, Any],
+) -> tuple[TableDefinition, list[dict[str, Any]]]:
+ statement = str(body.get("sql") or "").strip()
+ if not statement:
+ raise ValueError("请输入 CREATE TABLE SQL")
+ if "--" in statement or "/*" in statement or "*/" in statement:
+ raise ValueError("SQL 建表暂不支持注释,请删除 -- 或 /* */ 注释")
+ if statement.endswith(";"):
+ statement = statement[:-1].rstrip()
+ if ";" in statement:
+ raise ValueError("一次只能执行一条 CREATE TABLE,禁止多语句")
+ match = CREATE_TABLE_PATTERN.fullmatch(statement)
+ if not match:
+ raise ValueError("仅支持 CREATE TABLE table_name (...) 格式")
+ table_code = _normalize_sql_identifier(match.group("table"), kind="表名")
+ if table_code in TABLE_REGISTRY or table_code in {"data_change_logs", "graph_sync_queue"}:
+ raise ValueError("该表名已被系统占用")
+
+ seen: set[str] = set()
+ fields: list[FieldDefinition] = []
+ for item in _split_sql_definition_items(match.group("columns")):
+ upper = item.lstrip().upper()
+ if re.fullmatch(r'PRIMARY\s+KEY\s*\(\s*"?id"?\s*\)', item, re.IGNORECASE):
+ continue
+ if upper.startswith(
+ ("CONSTRAINT ", "PRIMARY KEY", "UNIQUE ", "FOREIGN KEY", "CHECK ", "EXCLUDE ")
+ ):
+ raise ValueError("暂不支持表级约束;平台会自动创建 UUID 主键 id")
+ code, remainder = _split_sql_column(item)
+ if code in SYSTEM_COLUMN_CODES:
+ continue
+ if code in seen:
+ raise ValueError(f"字段名 {code} 重复")
+ seen.add(code)
+ type_match = SQL_TYPE_PATTERN.fullmatch(remainder)
+ if not type_match:
+ raise ValueError(f"字段 {code} 的数据类型或定义无法识别")
+ sql_type, data_type = _canonical_sql_type(type_match.group("type"), code)
+ required, default_sql, unique = _parse_sql_column_constraints(
+ type_match.group("constraints"),
+ code,
+ )
+ field_definition = FieldDefinition(
+ code=code,
+ label=code.replace("_", " "),
+ sql_type=sql_type,
+ data_type=data_type,
+ required=required,
+ searchable=data_type in {"text", "long_text", "url"},
+ sortable=data_type != "json",
+ editable=True,
+ visible_in_list=True,
+ default_sql=default_sql,
+ unique=unique,
+ )
+ fields.append(field_definition)
+ if not fields:
+ raise ValueError("请至少定义一个非系统业务字段")
+
+ definition = TableDefinition(
+ code=table_code,
+ label=str(body.get("label") or table_code).strip() or table_code,
+ group=str(body.get("group") or "自定义").strip() or "自定义",
+ description=str(body.get("description") or "").strip(),
+ fields=tuple(fields),
+ allow_create=bool(body.get("allow_create", True)),
+ allow_update=bool(body.get("allow_update", True)),
+ allow_delete=bool(body.get("allow_delete", True)),
+ )
+ return definition, [_field_row(field) for field in definition.fields]
+
+
+async def _create_custom_table_definition(
+ project_id: str,
+ definition: TableDefinition,
+ field_rows: list[dict[str, Any]],
+ actor: str,
+) -> TableDefinition:
+ await ensure_platform_registry()
+ database = await get_project_database(project_id)
+ if not database:
+ raise ValueError("关系数据库不存在或已收藏")
+ existing_tables = await list_project_table_definitions(project_id)
+ if any(item.code == definition.code for item in existing_tables):
+ raise ValueError("当前项目中已存在同名数据表")
+
+ admin_schema = sql.Identifier(settings.db_schema)
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("CREATE TABLE {}.{} ({})").format(
+ project_schema,
+ sql.Identifier(definition.code),
+ _column_sql(definition),
+ )
+ )
+ await cur.execute(
+ sql.SQL("CREATE INDEX {} ON {}.{} (updated_at DESC)").format(
+ sql.Identifier(f"{definition.code}_updated_idx"),
+ project_schema,
+ sql.Identifier(definition.code),
+ )
+ )
+ await cur.execute(
+ sql.SQL(
+ "CREATE INDEX {} ON {}.{} (project_id) WHERE deleted_at IS NULL"
+ ).format(
+ sql.Identifier(f"{definition.code}_project_idx"),
+ project_schema,
+ sql.Identifier(definition.code),
+ )
+ )
+ await cur.execute(
+ sql.SQL(
+ """
+ INSERT INTO {}.project_table_definitions (
+ project_id, table_code, label, group_name, description,
+ fields_jsonb, allow_create, allow_update, allow_delete, created_by
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """
+ ).format(admin_schema),
+ (
+ project_id,
+ definition.code,
+ definition.label,
+ definition.group,
+ definition.description,
+ Jsonb(field_rows),
+ definition.allow_create,
+ definition.allow_update,
+ definition.allow_delete,
+ actor,
+ ),
+ )
+ await conn.commit()
+ return definition
+
+
+async def create_custom_table(
+ project_id: str,
+ body: dict[str, Any],
+ actor: str,
+) -> TableDefinition:
+ definition, field_rows = _custom_definition_from_payload(body)
+ return await _create_custom_table_definition(project_id, definition, field_rows, actor)
+
+
+async def create_custom_table_from_sql(
+ project_id: str,
+ body: dict[str, Any],
+ actor: str,
+) -> TableDefinition:
+ definition, field_rows = custom_definition_from_create_sql(body)
+ return await _create_custom_table_definition(project_id, definition, field_rows, actor)
+
+
async def ensure_project_database(
project_id: str,
tenant_id: str,
@@ -88,6 +1142,13 @@ async def ensure_project_database(
async with get_conn() as conn:
async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL(
+ "SELECT 1 FROM {}.project_databases WHERE project_id=%s"
+ ).format(admin_schema),
+ (project_id,),
+ )
+ database_exists = await cur.fetchone() is not None
await cur.execute(
sql.SQL(
"""
@@ -110,32 +1171,33 @@ async def ensure_project_database(
database_row = await cur.fetchone()
await cur.execute(sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(project_schema))
- for table in TABLE_DEFINITIONS:
- await cur.execute(
- sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format(
- project_schema,
- sql.Identifier(table.code),
- _column_sql(table),
+ if not database_exists:
+ for table in TABLE_DEFINITIONS:
+ await cur.execute(
+ sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format(
+ project_schema,
+ sql.Identifier(table.code),
+ _column_sql(table),
+ )
)
- )
- await cur.execute(
- sql.SQL(
- "CREATE INDEX IF NOT EXISTS {} ON {}.{} (updated_at DESC)"
- ).format(
- sql.Identifier(f"{table.code}_updated_idx"),
- project_schema,
- sql.Identifier(table.code),
+ await cur.execute(
+ sql.SQL(
+ "CREATE INDEX IF NOT EXISTS {} ON {}.{} (updated_at DESC)"
+ ).format(
+ sql.Identifier(f"{table.code}_updated_idx"),
+ project_schema,
+ sql.Identifier(table.code),
+ )
)
- )
- await cur.execute(
- sql.SQL(
- "CREATE INDEX IF NOT EXISTS {} ON {}.{} (project_id) WHERE deleted_at IS NULL"
- ).format(
- sql.Identifier(f"{table.code}_project_idx"),
- project_schema,
- sql.Identifier(table.code),
+ await cur.execute(
+ sql.SQL(
+ "CREATE INDEX IF NOT EXISTS {} ON {}.{} (project_id) WHERE deleted_at IS NULL"
+ ).format(
+ sql.Identifier(f"{table.code}_project_idx"),
+ project_schema,
+ sql.Identifier(table.code),
+ )
)
- )
await cur.execute(
sql.SQL(
"""
@@ -154,51 +1216,13 @@ async def ensure_project_database(
"""
).format(project_schema)
)
- await cur.execute(
- sql.SQL(
- """
- CREATE TABLE IF NOT EXISTS {}.graph_sync_queue (
- id BIGSERIAL PRIMARY KEY,
- tenant_id TEXT NOT NULL,
- project_id TEXT NOT NULL,
- table_code TEXT NOT NULL,
- record_id UUID NOT NULL,
- operation TEXT NOT NULL,
- record_version INTEGER NOT NULL DEFAULT 1,
- status TEXT NOT NULL DEFAULT 'pending',
- retry_count INTEGER NOT NULL DEFAULT 0,
- error_message TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- completed_at TIMESTAMPTZ
- )
- """
- ).format(project_schema)
- )
await conn.commit()
return dict(database_row)
async def ensure_all_project_databases() -> None:
+ """Deprecated: relational databases are no longer derived from graph projects."""
await ensure_platform_registry()
- async with get_conn() as conn:
- async with conn.cursor() as cur:
- await cur.execute(
- sql.SQL(
- """
- SELECT project_id, tenant_id, display_name
- FROM {}.projects
- WHERE status <> 'archived'
- ORDER BY created_at
- """
- ).format(sql.Identifier(settings.db_schema))
- )
- projects = await cur.fetchall()
- for project in projects:
- await ensure_project_database(
- str(project["project_id"]),
- str(project["tenant_id"]),
- str(project["display_name"]),
- )
async def get_project_database(project_id: str) -> dict[str, Any] | None:
@@ -206,10 +1230,247 @@ async def get_project_database(project_id: str) -> dict[str, Any] | None:
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
- sql.SQL("SELECT * FROM {}.project_databases WHERE project_id=%s").format(
- sql.Identifier(settings.db_schema)
- ),
+ sql.SQL(
+ "SELECT * FROM {}.project_databases "
+ "WHERE project_id=%s AND status='ready'"
+ ).format(sql.Identifier(settings.db_schema)),
(project_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
+
+
+async def rename_project_database(project_id: str, display_name: str) -> dict[str, Any]:
+ """Rename the logical database while keeping its stable code and schema."""
+ await ensure_platform_registry()
+ next_name = display_name.strip()
+ if not next_name:
+ raise ValueError("请输入数据库名称")
+ if len(next_name) > 100:
+ raise ValueError("数据库名称不能超过 100 个字符")
+
+ admin_schema = sql.Identifier(settings.db_schema)
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL(
+ """
+ UPDATE {}.project_databases
+ SET display_name=%s, updated_at=now()
+ WHERE project_id=%s AND status='ready'
+ RETURNING *
+ """
+ ).format(admin_schema),
+ (next_name, project_id),
+ )
+ row = await cur.fetchone()
+ if not row:
+ raise ValueError("关系数据库不存在或已收藏")
+ await conn.commit()
+ return dict(row)
+
+
+async def delete_project_database(project_id: str, confirm_name: str) -> dict[str, str]:
+ """Delete one PostgreSQL data-center database without touching graph data."""
+ await ensure_platform_registry()
+ database = await get_project_database(project_id)
+ if not database:
+ raise ValueError("关系数据库不存在或已收藏")
+ expected_names = {
+ str(database["display_name"]),
+ str(database["project_id"]),
+ str(database["database_name"]),
+ }
+ if confirm_name.strip() not in expected_names:
+ raise ValueError("确认名称不正确,未执行删除")
+
+ admin_schema = sql.Identifier(settings.db_schema)
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(project_schema))
+ await cur.execute(
+ sql.SQL(
+ "DELETE FROM {}.project_table_definitions WHERE project_id=%s"
+ ).format(admin_schema),
+ (project_id,),
+ )
+ await cur.execute(
+ sql.SQL(
+ "DELETE FROM {}.project_table_overrides WHERE project_id=%s"
+ ).format(admin_schema),
+ (project_id,),
+ )
+ await cur.execute(
+ sql.SQL(
+ "DELETE FROM {}.project_databases WHERE project_id=%s"
+ ).format(admin_schema),
+ (project_id,),
+ )
+ await conn.commit()
+ return {"project_id": project_id, "status": "deleted"}
+
+
+async def rename_custom_table(
+ project_id: str,
+ table_code: str,
+ new_code: str,
+ new_label: str,
+) -> TableDefinition:
+ """Rename any managed PostgreSQL table without touching graph data."""
+ await ensure_platform_registry()
+ next_code = new_code.strip()
+ next_label = new_label.strip()
+ if not IDENTIFIER_PATTERN.fullmatch(next_code):
+ raise ValueError("表名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位")
+ if next_code in {"data_change_logs", "graph_sync_queue"}:
+ raise ValueError("该表名已被系统占用")
+ if not next_label:
+ raise ValueError("请输入数据表名称")
+
+ database = await get_project_database(project_id)
+ if not database:
+ raise ValueError("关系数据库不存在或已收藏")
+ current_entry = await resolve_project_table_entry(project_id, table_code)
+ if not current_entry:
+ raise ValueError("数据表不存在")
+ current, origin, source_code = current_entry
+ if next_code in TABLE_REGISTRY and next_code != source_code:
+ raise ValueError("该表名已被系统占用")
+ effective_tables = await list_project_table_definitions(project_id)
+ if next_code != table_code and any(item.code == next_code for item in effective_tables):
+ raise ValueError("当前数据库中已存在同名数据表")
+
+ admin_schema = sql.Identifier(settings.db_schema)
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ if next_code != table_code:
+ await cur.execute(
+ sql.SQL("ALTER TABLE {}.{} RENAME TO {}").format(
+ project_schema,
+ sql.Identifier(table_code),
+ sql.Identifier(next_code),
+ )
+ )
+ await cur.execute(
+ sql.SQL("ALTER INDEX IF EXISTS {}.{} RENAME TO {}").format(
+ project_schema,
+ sql.Identifier(f"{table_code}_updated_idx"),
+ sql.Identifier(f"{next_code}_updated_idx"),
+ )
+ )
+ await cur.execute(
+ sql.SQL("ALTER INDEX IF EXISTS {}.{} RENAME TO {}").format(
+ project_schema,
+ sql.Identifier(f"{table_code}_project_idx"),
+ sql.Identifier(f"{next_code}_project_idx"),
+ )
+ )
+ await cur.execute(
+ sql.SQL(
+ "UPDATE {}.data_change_logs SET table_code=%s WHERE table_code=%s"
+ ).format(project_schema),
+ (next_code, table_code),
+ )
+ if origin == "custom":
+ await cur.execute(
+ sql.SQL(
+ """
+ UPDATE {}.project_table_definitions
+ SET table_code=%s, label=%s, updated_at=now()
+ WHERE project_id=%s AND table_code=%s AND status='active'
+ """
+ ).format(admin_schema),
+ (next_code, next_label, project_id, table_code),
+ )
+ if cur.rowcount != 1:
+ raise ValueError("数据表不存在")
+ else:
+ await cur.execute(
+ sql.SQL(
+ """
+ INSERT INTO {}.project_table_overrides (
+ project_id, source_code, table_code, label, status, updated_at
+ )
+ VALUES (%s, %s, %s, %s, 'active', now())
+ ON CONFLICT (project_id, source_code) DO UPDATE
+ SET table_code=EXCLUDED.table_code,
+ label=EXCLUDED.label,
+ status='active',
+ updated_at=now()
+ """
+ ).format(admin_schema),
+ (project_id, source_code, next_code, next_label),
+ )
+ await conn.commit()
+
+ return _table_with_identity(
+ current,
+ code=next_code,
+ label=next_label,
+ )
+
+
+async def delete_custom_table(
+ project_id: str,
+ table_code: str,
+ confirm_name: str,
+) -> dict[str, str]:
+ """Drop any managed PostgreSQL table without touching graph data."""
+ await ensure_platform_registry()
+ database = await get_project_database(project_id)
+ if not database:
+ raise ValueError("关系数据库不存在或已收藏")
+ current_entry = await resolve_project_table_entry(project_id, table_code)
+ if not current_entry:
+ raise ValueError("数据表不存在")
+ current, origin, source_code = current_entry
+ if confirm_name.strip() not in {table_code, current.label}:
+ raise ValueError("确认名称不正确,未执行删除")
+
+ admin_schema = sql.Identifier(settings.db_schema)
+ project_schema = sql.Identifier(str(database["schema_name"]))
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ sql.SQL("DROP TABLE {}.{}").format(
+ project_schema,
+ sql.Identifier(table_code),
+ )
+ )
+ if origin == "custom":
+ await cur.execute(
+ sql.SQL(
+ """
+ DELETE FROM {}.project_table_definitions
+ WHERE project_id=%s AND table_code=%s
+ """
+ ).format(admin_schema),
+ (project_id, table_code),
+ )
+ else:
+ await cur.execute(
+ sql.SQL(
+ """
+ INSERT INTO {}.project_table_overrides (
+ project_id, source_code, table_code, label, status, updated_at
+ )
+ VALUES (%s, %s, %s, %s, 'deleted', now())
+ ON CONFLICT (project_id, source_code) DO UPDATE
+ SET table_code=EXCLUDED.table_code,
+ label=EXCLUDED.label,
+ status='deleted',
+ updated_at=now()
+ """
+ ).format(admin_schema),
+ (project_id, source_code, table_code, current.label),
+ )
+ await cur.execute(
+ sql.SQL(
+ "DELETE FROM {}.data_change_logs WHERE table_code=%s"
+ ).format(project_schema),
+ (table_code,),
+ )
+ await conn.commit()
+ return {"project_id": project_id, "table_code": table_code, "status": "deleted"}
diff --git a/app/data_platform/sql_console.py b/app/data_platform/sql_console.py
new file mode 100644
index 0000000..fbdaaaa
--- /dev/null
+++ b/app/data_platform/sql_console.py
@@ -0,0 +1,150 @@
+"""Project-scoped SQL console execution for the relational data center."""
+from __future__ import annotations
+
+import re
+import time
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from fastapi import HTTPException
+from psycopg import Error, sql
+
+from app.config import settings
+from app.data_platform.schema import get_project_database
+from app.db import get_conn
+
+
+MAX_SQL_LENGTH = 50_000
+MAX_RESULT_ROWS = 500
+STATEMENT_TIMEOUT_MS = 15_000
+ALLOWED_STATEMENTS = {"select", "with", "insert", "update", "delete", "explain"}
+FORBIDDEN_SQL_PATTERN = re.compile(
+ r"\b(?:"
+ r"copy|call|do|grant|revoke|prepare|execute|deallocate|listen|unlisten|notify|"
+ r"vacuum|analyze|cluster|reindex|refresh|reset|show|"
+ r"create|alter|drop|truncate|comment|security|lock"
+ r")\b",
+ re.IGNORECASE,
+)
+
+
+def _json_safe(value: Any) -> Any:
+ if isinstance(value, Decimal):
+ return float(value)
+ if isinstance(value, uuid.UUID):
+ return str(value)
+ if hasattr(value, "isoformat"):
+ return value.isoformat()
+ if isinstance(value, (bytes, bytearray, memoryview)):
+ return bytes(value).hex()
+ return value
+
+
+def validate_console_sql(raw_sql: str, schema_name: str) -> tuple[str, str]:
+ """Return a safe, single DML/query statement and its leading keyword."""
+ statement = raw_sql.strip()
+ if not statement:
+ raise ValueError("请输入要执行的 SQL")
+ if len(statement) > MAX_SQL_LENGTH:
+ raise ValueError("单次 SQL 不能超过 50,000 个字符")
+ if "--" in statement or "/*" in statement or "*/" in statement:
+ raise ValueError("控制台暂不支持 SQL 注释,请删除注释后重试")
+
+ if statement.endswith(";"):
+ statement = statement[:-1].rstrip()
+ if ";" in statement:
+ raise ValueError("一次只能执行一条 SQL 语句")
+
+ match = re.match(r"^\s*([a-z]+)\b", statement, re.IGNORECASE)
+ keyword = match.group(1).lower() if match else ""
+ if keyword not in ALLOWED_STATEMENTS:
+ raise ValueError(
+ "控制台仅支持 SELECT、WITH、INSERT、UPDATE、DELETE 和 EXPLAIN;"
+ "建表或修改结构请使用“新建数据表 / 表结构”"
+ )
+ if keyword == "with" and not re.search(
+ r"\b(?:select|insert|update|delete)\b", statement, re.IGNORECASE
+ ):
+ raise ValueError("WITH 语句必须包含 SELECT、INSERT、UPDATE 或 DELETE")
+ if FORBIDDEN_SQL_PATTERN.search(statement):
+ raise ValueError("SQL 包含控制台不允许执行的结构或权限操作")
+
+ lowered = statement.lower()
+ protected_schemas = {
+ "public",
+ "information_schema",
+ "pg_catalog",
+ settings.db_schema.lower(),
+ }
+ for protected in protected_schemas:
+ if re.search(rf"(? dict[str, Any]:
+ database = await get_project_database(project_id)
+ if not database:
+ raise HTTPException(404, "关系数据库不存在或已收藏")
+
+ schema_name = str(database["schema_name"])
+ statement, keyword = validate_console_sql(raw_sql, schema_name)
+ started_at = time.perf_counter()
+
+ async with get_conn() as conn:
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ "SELECT set_config('statement_timeout', %s, true)",
+ (str(STATEMENT_TIMEOUT_MS),),
+ )
+ await cur.execute(
+ sql.SQL("SET LOCAL search_path TO {}, pg_catalog").format(
+ sql.Identifier(schema_name)
+ )
+ )
+ await cur.execute(statement)
+
+ columns: list[str] = []
+ rows: list[dict[str, Any]] = []
+ truncated = False
+ if cur.description:
+ columns = [item.name for item in cur.description]
+ fetched = await cur.fetchmany(MAX_RESULT_ROWS + 1)
+ truncated = len(fetched) > MAX_RESULT_ROWS
+ for row in fetched[:MAX_RESULT_ROWS]:
+ rows.append({key: _json_safe(value) for key, value in dict(row).items()})
+
+ affected_rows = max(int(cur.rowcount or 0), 0)
+ command = str(cur.statusmessage or keyword.upper())
+ await conn.commit()
+ except Error as exc:
+ await conn.rollback()
+ detail = getattr(exc, "diag", None)
+ primary = getattr(detail, "message_primary", None) if detail else None
+ raise ValueError(f"PostgreSQL 执行失败:{primary or str(exc)}") from exc
+
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ return {
+ "database": {
+ "project_id": project_id,
+ "display_name": database["display_name"],
+ "database_name": database["database_name"],
+ "schema_name": schema_name,
+ },
+ "statement_type": keyword,
+ "command": command,
+ "columns": columns,
+ "rows": rows,
+ "returned_rows": len(rows),
+ "affected_rows": affected_rows,
+ "truncated": truncated,
+ "max_result_rows": MAX_RESULT_ROWS,
+ "duration_ms": duration_ms,
+ }
diff --git a/app/db.py b/app/db.py
index 0944e90..97e9677 100644
--- a/app/db.py
+++ b/app/db.py
@@ -15,6 +15,267 @@ from app.security import hash_password
_pool: AsyncConnectionPool | None = None
+_LIFECYCLE_PROJECT_GUARDED_TABLES = (
+ "mapping_profiles",
+ "import_templates",
+ "import_batches",
+ "candidate_entities",
+ "candidate_relations",
+ "review_actions",
+ "publish_jobs",
+ "ontology_schema_publish_jobs",
+ "field_proposals",
+ "source_profiles",
+ "question_traces",
+ "simulation_questions",
+ "acquisition_tasks",
+ "inventory_issues",
+ "vocabulary_terms",
+ "agent_call_logs",
+ "kg_evidence_links",
+ "kg_statements",
+ "kg_relations",
+ "kg_events",
+ "kg_concepts",
+ "kg_entities",
+ "kg_schema_proposals",
+ "kg_schema_versions",
+ "kg_place_spatial",
+ "kg_route_metrics",
+ "graph_releases",
+ "ontology_schemas",
+)
+
+_LIFECYCLE_GRAPH_GUARDED_TABLES = (
+ "amap_spatial_collect_tasks",
+ "amap_spatial_pois",
+ "amap_spatial_collect_runs",
+)
+
+
+def _project_lifecycle_guard_statements() -> list[str]:
+ """DDL for durable cleanup state and post-delete write guards.
+
+ Only the explicitly reviewed graph-side tables are eligible. Relational
+ data-center registry tables and every ``biz_*`` schema are intentionally
+ outside both trigger allow-lists.
+ """
+
+ s = settings.db_schema
+ project_tables = ", ".join(f"'{name}'" for name in _LIFECYCLE_PROJECT_GUARDED_TABLES)
+ graph_tables = ", ".join(f"'{name}'" for name in _LIFECYCLE_GRAPH_GUARDED_TABLES)
+ return [
+ f"""CREATE TABLE IF NOT EXISTS {s}.graph_cleanup_tasks (
+ id BIGSERIAL PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ graph_name TEXT NOT NULL,
+ operation TEXT NOT NULL DEFAULT 'delete',
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )""",
+ f"""CREATE TABLE IF NOT EXISTS {s}.graph_project_tombstones (
+ project_id TEXT PRIMARY KEY,
+ tenant_id TEXT,
+ deleted_by TEXT NOT NULL DEFAULT 'system',
+ deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )""",
+ f"""CREATE TABLE IF NOT EXISTS {s}.graph_name_tombstones (
+ graph_name TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )""",
+ f"""CREATE OR REPLACE FUNCTION {s}.guard_deleted_graph_project()
+ RETURNS trigger LANGUAGE plpgsql AS $guard$
+ BEGIN
+ IF NEW.project_id IS NULL THEN
+ RETURN NEW;
+ END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('kg-project:' || NEW.project_id::text));
+ IF EXISTS (
+ SELECT 1 FROM {s}.graph_project_tombstones
+ WHERE project_id=NEW.project_id::text
+ ) THEN
+ RAISE EXCEPTION '知识图谱项目 % 已删除,拒绝写入图谱侧资源', NEW.project_id
+ USING ERRCODE='foreign_key_violation';
+ END IF;
+ RETURN NEW;
+ END
+ $guard$""",
+ f"""CREATE OR REPLACE FUNCTION {s}.guard_deleted_graph_name()
+ RETURNS trigger LANGUAGE plpgsql AS $guard$
+ BEGIN
+ IF NEW.graph_name IS NULL THEN
+ RETURN NEW;
+ END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('kg-graph:' || NEW.graph_name::text));
+ IF EXISTS (
+ SELECT 1 FROM {s}.graph_name_tombstones
+ WHERE graph_name=NEW.graph_name::text
+ ) THEN
+ RAISE EXCEPTION 'FalkorDB 图 % 已被生命周期删除,拒绝写入图谱侧资源', NEW.graph_name
+ USING ERRCODE='foreign_key_violation';
+ END IF;
+ RETURN NEW;
+ END
+ $guard$""",
+ f"""CREATE OR REPLACE FUNCTION {s}.guard_review_action_parent()
+ RETURNS trigger LANGUAGE plpgsql AS $guard$
+ DECLARE
+ parent_project_id TEXT;
+ normalized_candidate_type TEXT;
+ BEGIN
+ normalized_candidate_type := lower(COALESCE(NEW.candidate_type::text, 'entity'));
+ IF normalized_candidate_type IN ('relation', 'candidate_relation') THEN
+ SELECT project_id::text INTO parent_project_id
+ FROM {s}.candidate_relations WHERE id=NEW.candidate_id;
+ ELSIF normalized_candidate_type IN ('entity', 'candidate_entity') THEN
+ SELECT project_id::text INTO parent_project_id
+ FROM {s}.candidate_entities WHERE id=NEW.candidate_id;
+ ELSE
+ RAISE EXCEPTION '未知候选审核类型 %', NEW.candidate_type
+ USING ERRCODE='foreign_key_violation';
+ END IF;
+
+ IF parent_project_id IS NULL THEN
+ RAISE EXCEPTION '候选记录 % 已不存在,拒绝写入孤立审核记录', NEW.candidate_id
+ USING ERRCODE='foreign_key_violation';
+ END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('kg-project:' || parent_project_id));
+
+ -- The parent can disappear while this trigger waits for a
+ -- concurrent lifecycle delete. Recheck after taking the same
+ -- project lock used by deletion, then consult the tombstone.
+ IF normalized_candidate_type IN ('relation', 'candidate_relation') THEN
+ PERFORM 1 FROM {s}.candidate_relations
+ WHERE id=NEW.candidate_id AND project_id::text=parent_project_id;
+ ELSE
+ PERFORM 1 FROM {s}.candidate_entities
+ WHERE id=NEW.candidate_id AND project_id::text=parent_project_id;
+ END IF;
+ IF NOT FOUND OR EXISTS (
+ SELECT 1 FROM {s}.graph_project_tombstones
+ WHERE project_id=parent_project_id
+ ) THEN
+ RAISE EXCEPTION '候选记录 % 所属项目已删除,拒绝写入孤立审核记录', NEW.candidate_id
+ USING ERRCODE='foreign_key_violation';
+ END IF;
+ RETURN NEW;
+ END
+ $guard$""",
+ f"""DO $guard$
+ DECLARE guarded_table TEXT;
+ BEGIN
+ FOR guarded_table IN
+ SELECT DISTINCT table_name::text
+ FROM information_schema.columns
+ WHERE table_schema='{s}' AND column_name='project_id'
+ AND table_name::text=ANY(ARRAY[{project_tables}]::text[])
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_trigger trigger_row
+ JOIN pg_class table_row ON table_row.oid=trigger_row.tgrelid
+ JOIN pg_namespace namespace_row ON namespace_row.oid=table_row.relnamespace
+ WHERE namespace_row.nspname='{s}'
+ AND table_row.relname=guarded_table
+ AND trigger_row.tgname='guard_deleted_graph_project_write'
+ AND NOT trigger_row.tgisinternal
+ ) THEN
+ EXECUTE format(
+ 'CREATE TRIGGER guard_deleted_graph_project_write '
+ 'BEFORE INSERT OR UPDATE ON %I.%I FOR EACH ROW '
+ 'EXECUTE FUNCTION %I.guard_deleted_graph_project()',
+ '{s}', guarded_table, '{s}'
+ );
+ END IF;
+ END LOOP;
+ END
+ $guard$""",
+ f"""DO $guard$
+ DECLARE guarded_table TEXT;
+ BEGIN
+ FOR guarded_table IN
+ SELECT DISTINCT table_name::text
+ FROM information_schema.columns
+ WHERE table_schema='{s}' AND column_name='graph_name'
+ AND table_name::text=ANY(ARRAY[{graph_tables}]::text[])
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_trigger trigger_row
+ JOIN pg_class table_row ON table_row.oid=trigger_row.tgrelid
+ JOIN pg_namespace namespace_row ON namespace_row.oid=table_row.relnamespace
+ WHERE namespace_row.nspname='{s}'
+ AND table_row.relname=guarded_table
+ AND trigger_row.tgname='guard_deleted_graph_name_write'
+ AND NOT trigger_row.tgisinternal
+ ) THEN
+ EXECUTE format(
+ 'CREATE TRIGGER guard_deleted_graph_name_write '
+ 'BEFORE INSERT OR UPDATE ON %I.%I FOR EACH ROW '
+ 'EXECUTE FUNCTION %I.guard_deleted_graph_name()',
+ '{s}', guarded_table, '{s}'
+ );
+ END IF;
+ END LOOP;
+ END
+ $guard$""",
+ f"""DO $guard$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='review_actions'
+ AND column_name='candidate_id'
+ ) AND EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='review_actions'
+ AND column_name='candidate_type'
+ ) AND NOT EXISTS (
+ SELECT 1
+ FROM pg_trigger trigger_row
+ JOIN pg_class table_row ON table_row.oid=trigger_row.tgrelid
+ JOIN pg_namespace namespace_row ON namespace_row.oid=table_row.relnamespace
+ WHERE namespace_row.nspname='{s}'
+ AND table_row.relname='review_actions'
+ AND trigger_row.tgname='guard_review_action_parent_write'
+ AND NOT trigger_row.tgisinternal
+ ) THEN
+ CREATE TRIGGER guard_review_action_parent_write
+ BEFORE INSERT OR UPDATE ON {s}.review_actions
+ FOR EACH ROW EXECUTE FUNCTION {s}.guard_review_action_parent();
+ END IF;
+ END
+ $guard$""",
+ f"""DO $guard$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='graph_releases'
+ AND column_name='graph_name'
+ ) AND NOT EXISTS (
+ SELECT 1
+ FROM pg_trigger trigger_row
+ JOIN pg_class table_row ON table_row.oid=trigger_row.tgrelid
+ JOIN pg_namespace namespace_row ON namespace_row.oid=table_row.relnamespace
+ WHERE namespace_row.nspname='{s}'
+ AND table_row.relname='graph_releases'
+ AND trigger_row.tgname='guard_graph_release_graph_write'
+ AND NOT trigger_row.tgisinternal
+ ) THEN
+ -- PostgreSQL fires same-kind triggers by name. This name
+ -- sorts after guard_deleted_graph_project_write, preserving
+ -- the global project→graph advisory-lock order.
+ CREATE TRIGGER guard_graph_release_graph_write
+ BEFORE INSERT OR UPDATE ON {s}.graph_releases
+ FOR EACH ROW EXECUTE FUNCTION {s}.guard_deleted_graph_name();
+ END IF;
+ END
+ $guard$""",
+ ]
+
async def init_pool() -> None:
global _pool
@@ -30,6 +291,7 @@ async def init_pool() -> None:
await _run_migrations()
await _seed_rbac()
await _sa_reap_orphans()
+ await ensure_project_lifecycle_support()
async def close_pool() -> None:
@@ -53,6 +315,17 @@ async def _run_migrations() -> None:
await conn.commit()
+async def ensure_project_lifecycle_support() -> None:
+ """Ensure lifecycle safety primitives even when bulk migrations are disabled."""
+
+ async with get_conn() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(f"CREATE SCHEMA IF NOT EXISTS {settings.db_schema}")
+ for statement in _project_lifecycle_guard_statements():
+ await cur.execute(statement)
+ await conn.commit()
+
+
# 3 roles kept by design: 系统管理员 / 运营审核员 / 采集人员
_SEED_ROLES = [
("admin", "系统管理员", "全部权限(含 Schema / 发布 / 用户与权限管理)", True, 10),
@@ -139,18 +412,38 @@ def _ddl_statements() -> list[str]:
return [
# ── Existing tables (CREATE IF NOT EXISTS) ──────────────────────────
f"""CREATE TABLE IF NOT EXISTS {s}.projects (
- project_id TEXT PRIMARY KEY,
+ id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
- name TEXT NOT NULL,
+ project_id TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ description TEXT,
status TEXT NOT NULL DEFAULT 'active',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ default_namespace TEXT NOT NULL DEFAULT 'default',
+ metadata_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ created_by TEXT NOT NULL DEFAULT 'system',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (tenant_id, project_id)
)""",
f"""CREATE TABLE IF NOT EXISTS {s}.graph_releases (
- graph_release_id BIGSERIAL PRIMARY KEY,
- project_id TEXT NOT NULL,
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ project_id TEXT NOT NULL DEFAULT 'default',
+ graph_release_id TEXT NOT NULL,
graph_name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'draft',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ alias TEXT NOT NULL DEFAULT 'dev',
+ status TEXT NOT NULL DEFAULT 'published',
+ schema_id BIGINT,
+ publish_job_id BIGINT,
+ source_dataset_version TEXT,
+ metadata_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ created_by TEXT NOT NULL DEFAULT 'system',
+ published_at TIMESTAMPTZ,
+ activated_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (tenant_id, project_id, graph_release_id),
+ UNIQUE (tenant_id, project_id, alias)
)""",
f"""CREATE TABLE IF NOT EXISTS {s}.import_templates (
template_id BIGSERIAL PRIMARY KEY,
@@ -248,35 +541,287 @@ def _ddl_statements() -> list[str]:
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT 'default',
+ namespace TEXT NOT NULL DEFAULT 'default',
version TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
schema_jsonb JSONB NOT NULL DEFAULT '{{}}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ created_by TEXT NOT NULL DEFAULT 'system',
+ published_by TEXT,
+ published_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (tenant_id, project_id, namespace, version)
)""",
f"""CREATE TABLE IF NOT EXISTS {s}.ontology_entity_types (
id BIGSERIAL PRIMARY KEY,
- schema_id BIGINT,
+ schema_id BIGINT NOT NULL REFERENCES {s}.ontology_schemas(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
- label TEXT,
- fields JSONB NOT NULL DEFAULT '[]',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ display_name TEXT NOT NULL,
+ description TEXT,
+ is_core BOOLEAN NOT NULL DEFAULT FALSE,
+ extends_type TEXT,
+ natural_key_rule_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ recommendable BOOLEAN NOT NULL DEFAULT FALSE,
+ evidence_required BOOLEAN NOT NULL DEFAULT FALSE,
+ metadata_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (schema_id, entity_type)
)""",
f"""CREATE TABLE IF NOT EXISTS {s}.ontology_fields (
id BIGSERIAL PRIMARY KEY,
- entity_type_id BIGINT,
+ entity_type_id BIGINT NOT NULL REFERENCES {s}.ontology_entity_types(id) ON DELETE CASCADE,
field_name TEXT NOT NULL,
- value_type TEXT NOT NULL DEFAULT 'string',
+ display_name TEXT NOT NULL,
+ value_type TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT FALSE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ multi_value BOOLEAN NOT NULL DEFAULT FALSE,
+ default_value_jsonb JSONB,
+ enum_values_jsonb JSONB NOT NULL DEFAULT '[]',
+ validation_rules_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ recommendable BOOLEAN NOT NULL DEFAULT FALSE,
+ evidence_required BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (entity_type_id, field_name)
)""",
f"""CREATE TABLE IF NOT EXISTS {s}.ontology_relation_types (
id BIGSERIAL PRIMARY KEY,
- schema_id BIGINT,
+ schema_id BIGINT NOT NULL REFERENCES {s}.ontology_schemas(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
- source_type TEXT,
- target_type TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ source_entity_type TEXT NOT NULL,
+ target_entity_type TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ cardinality TEXT NOT NULL DEFAULT 'many_to_many',
+ required BOOLEAN NOT NULL DEFAULT FALSE,
+ evidence_required BOOLEAN NOT NULL DEFAULT FALSE,
+ publish_as TEXT,
+ metadata_jsonb JSONB NOT NULL DEFAULT '{{}}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (schema_id, relation_type, source_entity_type, target_entity_type)
)""",
+ f"""CREATE TABLE IF NOT EXISTS {s}.graph_cleanup_tasks (
+ id BIGSERIAL PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ graph_name TEXT NOT NULL,
+ operation TEXT NOT NULL DEFAULT 'delete',
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )""",
+ # ── Idempotent upgrade from the repository's legacy lifecycle DDL ──
+ # CREATE TABLE IF NOT EXISTS does not evolve an already-created table.
+ # Keep these additive/backfill statements so existing installations can
+ # use the same columns as the current projects API without data loss.
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS id BIGSERIAL""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS name TEXT""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS display_name TEXT""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS description TEXT""",
+ f"""DO $migration$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='projects' AND column_name='default_namespace'
+ ) THEN
+ ALTER TABLE {s}.projects ADD COLUMN default_namespace TEXT;
+ UPDATE {s}.projects SET default_namespace=project_id;
+ ALTER TABLE {s}.projects ALTER COLUMN default_namespace SET DEFAULT 'default';
+ END IF;
+ END
+ $migration$""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS metadata_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS created_by TEXT DEFAULT 'system'""",
+ f"""ALTER TABLE {s}.projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.projects
+ SET display_name=COALESCE(NULLIF(display_name, ''), NULLIF(name, ''), project_id),
+ default_namespace=COALESCE(NULLIF(default_namespace, ''), project_id),
+ metadata_jsonb=COALESCE(metadata_jsonb, '{{}}'::jsonb),
+ created_by=COALESCE(NULLIF(created_by, ''), 'system'),
+ updated_at=COALESCE(updated_at, created_at, now())""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN name DROP NOT NULL""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN display_name SET NOT NULL""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN default_namespace SET NOT NULL""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN metadata_jsonb SET NOT NULL""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN created_by SET NOT NULL""",
+ f"""ALTER TABLE {s}.projects ALTER COLUMN updated_at SET NOT NULL""",
+ f"""CREATE UNIQUE INDEX IF NOT EXISTS projects_id_unique ON {s}.projects (id)""",
+ f"""CREATE UNIQUE INDEX IF NOT EXISTS projects_tenant_project_unique
+ ON {s}.projects (tenant_id, project_id)""",
+
+ f"""DO $migration$
+ DECLARE
+ release_id_type TEXT;
+ BEGIN
+ SELECT data_type INTO release_id_type
+ FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='graph_releases'
+ AND column_name='graph_release_id';
+ IF release_id_type IN ('smallint', 'integer', 'bigint')
+ AND NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='graph_releases'
+ AND column_name='id'
+ ) THEN
+ -- In the legacy DDL graph_release_id was the numeric primary
+ -- key. Rename it so historical numeric references (for
+ -- example rollback_target_release_id) keep their identity,
+ -- then add the new stable business release identifier.
+ ALTER TABLE {s}.graph_releases RENAME COLUMN graph_release_id TO id;
+ ALTER TABLE {s}.graph_releases ADD COLUMN graph_release_id TEXT;
+ UPDATE {s}.graph_releases
+ SET graph_release_id=project_id || '_legacy_' || id::text;
+ ALTER TABLE {s}.graph_releases ALTER COLUMN graph_release_id SET NOT NULL;
+ END IF;
+ END
+ $migration$""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS id BIGSERIAL""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS graph_release_id TEXT""",
+ f"""UPDATE {s}.graph_releases
+ SET graph_release_id=project_id || '_legacy_' || id::text
+ WHERE graph_release_id IS NULL OR graph_release_id=''""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN graph_release_id SET NOT NULL""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS tenant_id TEXT""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS alias TEXT""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS schema_id BIGINT""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS publish_job_id BIGINT""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS source_dataset_version TEXT""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS metadata_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS created_by TEXT DEFAULT 'system'""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS activated_at TIMESTAMPTZ""",
+ f"""ALTER TABLE {s}.graph_releases ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.graph_releases gr
+ SET tenant_id=COALESCE(
+ NULLIF(gr.tenant_id, ''),
+ (SELECT p.tenant_id FROM {s}.projects p WHERE p.project_id=gr.project_id LIMIT 1),
+ gr.project_id
+ ),
+ metadata_jsonb=COALESCE(gr.metadata_jsonb, '{{}}'::jsonb),
+ created_by=COALESCE(NULLIF(gr.created_by, ''), 'system'),
+ updated_at=COALESCE(gr.updated_at, gr.created_at, now())""",
+ f"""WITH missing AS (
+ SELECT id, tenant_id, project_id, graph_release_id, status,
+ row_number() OVER (
+ PARTITION BY tenant_id, project_id
+ ORDER BY CASE WHEN status='active' THEN 1 WHEN status='published' THEN 2 ELSE 3 END,
+ created_at DESC, id DESC
+ ) AS row_number
+ FROM {s}.graph_releases
+ WHERE alias IS NULL OR alias=''
+ ), ranked AS (
+ SELECT missing.*,
+ EXISTS (
+ SELECT 1 FROM {s}.graph_releases existing
+ WHERE existing.tenant_id=missing.tenant_id
+ AND existing.project_id=missing.project_id
+ AND existing.alias='active'
+ ) AS has_active_alias
+ FROM missing
+ )
+ UPDATE {s}.graph_releases gr
+ SET alias=CASE
+ WHEN NOT ranked.has_active_alias
+ AND ranked.row_number=1
+ AND ranked.status IN ('active', 'published') THEN 'active'
+ ELSE 'legacy_' || ranked.graph_release_id
+ END
+ FROM ranked WHERE gr.id=ranked.id""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN tenant_id SET NOT NULL""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN alias SET DEFAULT 'dev'""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN alias SET NOT NULL""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN metadata_jsonb SET NOT NULL""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN created_by SET NOT NULL""",
+ f"""ALTER TABLE {s}.graph_releases ALTER COLUMN updated_at SET NOT NULL""",
+ f"""CREATE UNIQUE INDEX IF NOT EXISTS graph_releases_id_unique
+ ON {s}.graph_releases (id)""",
+ f"""CREATE UNIQUE INDEX IF NOT EXISTS graph_releases_project_release_unique
+ ON {s}.graph_releases (tenant_id, project_id, graph_release_id)""",
+ f"""CREATE UNIQUE INDEX IF NOT EXISTS graph_releases_project_alias_unique
+ ON {s}.graph_releases (tenant_id, project_id, alias)""",
+
+ f"""DO $migration$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema='{s}' AND table_name='ontology_schemas' AND column_name='namespace'
+ ) THEN
+ ALTER TABLE {s}.ontology_schemas ADD COLUMN namespace TEXT;
+ UPDATE {s}.ontology_schemas SET namespace=project_id;
+ ALTER TABLE {s}.ontology_schemas ALTER COLUMN namespace SET DEFAULT 'default';
+ END IF;
+ END
+ $migration$""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS display_name TEXT""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS description TEXT""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS created_by TEXT DEFAULT 'system'""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS published_by TEXT""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ""",
+ f"""ALTER TABLE {s}.ontology_schemas ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.ontology_schemas
+ SET namespace=COALESCE(NULLIF(namespace, ''), project_id),
+ display_name=COALESCE(NULLIF(display_name, ''), namespace || ' ' || version),
+ created_by=COALESCE(NULLIF(created_by, ''), 'system'),
+ updated_at=COALESCE(updated_at, created_at, now())""",
+ f"""ALTER TABLE {s}.ontology_schemas ALTER COLUMN namespace SET NOT NULL""",
+ f"""ALTER TABLE {s}.ontology_schemas ALTER COLUMN display_name SET NOT NULL""",
+ f"""ALTER TABLE {s}.ontology_schemas ALTER COLUMN created_by SET NOT NULL""",
+ f"""ALTER TABLE {s}.ontology_schemas ALTER COLUMN updated_at SET NOT NULL""",
+
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS label TEXT""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS display_name TEXT""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS description TEXT""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS is_core BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS extends_type TEXT""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS natural_key_rule_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS recommendable BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS evidence_required BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS metadata_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.ontology_entity_types ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.ontology_entity_types
+ SET display_name=COALESCE(NULLIF(display_name, ''), NULLIF(label, ''), entity_type),
+ natural_key_rule_jsonb=COALESCE(natural_key_rule_jsonb, '{{}}'::jsonb),
+ metadata_jsonb=COALESCE(metadata_jsonb, '{{}}'::jsonb),
+ updated_at=COALESCE(updated_at, created_at, now())""",
+ f"""ALTER TABLE {s}.ontology_entity_types ALTER COLUMN display_name SET NOT NULL""",
+
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS display_name TEXT""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS multi_value BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS default_value_jsonb JSONB""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS enum_values_jsonb JSONB DEFAULT '[]'""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS validation_rules_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS recommendable BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS evidence_required BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_fields ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.ontology_fields
+ SET display_name=COALESCE(NULLIF(display_name, ''), field_name),
+ enum_values_jsonb=COALESCE(enum_values_jsonb, '[]'::jsonb),
+ validation_rules_jsonb=COALESCE(validation_rules_jsonb, '{{}}'::jsonb),
+ updated_at=COALESCE(updated_at, created_at, now())""",
+ f"""ALTER TABLE {s}.ontology_fields ALTER COLUMN display_name SET NOT NULL""",
+
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS source_type TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS target_type TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS source_entity_type TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS target_entity_type TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS display_name TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS cardinality TEXT DEFAULT 'many_to_many'""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS required BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS evidence_required BOOLEAN DEFAULT FALSE""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS publish_as TEXT""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS metadata_jsonb JSONB DEFAULT '{{}}'""",
+ f"""ALTER TABLE {s}.ontology_relation_types ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()""",
+ f"""UPDATE {s}.ontology_relation_types
+ SET source_entity_type=COALESCE(NULLIF(source_entity_type, ''), source_type),
+ target_entity_type=COALESCE(NULLIF(target_entity_type, ''), target_type),
+ display_name=COALESCE(NULLIF(display_name, ''), relation_type),
+ metadata_jsonb=COALESCE(metadata_jsonb, '{{}}'::jsonb),
+ updated_at=COALESCE(updated_at, created_at, now())""",
+ f"""ALTER TABLE {s}.ontology_relation_types ALTER COLUMN display_name SET NOT NULL""",
f"""CREATE TABLE IF NOT EXISTS {s}.field_proposals (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
@@ -599,6 +1144,9 @@ def _ddl_statements() -> list[str]:
)""",
f"""CREATE INDEX IF NOT EXISTS idx_task_events_task
ON {s}.task_events (task_id, created_at)""",
+ # Install graph-lifecycle tombstones and guards only after every
+ # optional graph-side table above has had a chance to be created.
+ *_project_lifecycle_guard_statements(),
]
diff --git a/app/main.py b/app/main.py
index 0ba748f..60dff95 100644
--- a/app/main.py
+++ b/app/main.py
@@ -22,13 +22,49 @@ class SPAStaticFiles(StaticFiles):
navigation or page refresh — instead of returning a 404.
"""
+ @staticmethod
+ def _without_conditional_cache_headers(scope):
+ """Force SPA entry points to return the current index.html body.
+
+ StaticFiles normally honours If-None-Match/If-Modified-Since. That is
+ useful for content-hashed assets, but a cached index.html can otherwise
+ keep pointing at an obsolete JavaScript bundle after a deployment.
+ """
+ fresh_scope = dict(scope)
+ fresh_scope["headers"] = [
+ (name, value)
+ for name, value in scope.get("headers", [])
+ if name.lower() not in {b"if-none-match", b"if-modified-since"}
+ ]
+ return fresh_scope
+
+ @staticmethod
+ def _set_cache_policy(response, *, spa_entry: bool):
+ if spa_entry:
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ else:
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
+ return response
+
async def get_response(self, path: str, scope):
+ spa_entry = path in {"", ".", "index.html"}
+ request_scope = (
+ self._without_conditional_cache_headers(scope) if spa_entry else scope
+ )
try:
- return await super().get_response(path, scope)
+ response = await super().get_response(path, request_scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
- return await super().get_response("index.html", scope)
- raise
+ spa_entry = True
+ response = await super().get_response(
+ "index.html", self._without_conditional_cache_headers(scope)
+ )
+ else:
+ raise
+
+ return self._set_cache_policy(response, spa_entry=spa_entry)
@asynccontextmanager
diff --git a/app/project_lifecycle.py b/app/project_lifecycle.py
new file mode 100644
index 0000000..491ff0b
--- /dev/null
+++ b/app/project_lifecycle.py
@@ -0,0 +1,541 @@
+"""Validation and FalkorDB helpers for graph-project lifecycle operations.
+
+This module deliberately has no dependency on ``app.data_platform``. Graph
+projects and relational data-center databases are separate resources even when
+they happen to use the same project id.
+"""
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Mapping
+from typing import Any
+
+from falkordb import FalkorDB
+
+from app.config import settings
+
+
+SAFE_RESOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
+SAFE_SCHEMA_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
+SAFE_SCHEMA_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
+
+TYPE_ALIASES = {
+ "str": "string",
+ "text": "string",
+ "varchar": "string",
+ "int": "integer",
+ "long": "integer",
+ "float": "number",
+ "double": "number",
+ "decimal": "number",
+ "bool": "boolean",
+ "json": "object",
+ "dict": "object",
+ "list": "array",
+ "timestamp": "datetime",
+}
+SUPPORTED_FIELD_TYPES = {
+ "string",
+ "integer",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "date",
+ "datetime",
+ "any",
+}
+
+
+class ProjectValidationError(ValueError):
+ """Raised when a provision payload is invalid before any write occurs."""
+
+ def __init__(self, errors: list[str]):
+ super().__init__(errors[0] if errors else "项目数据校验失败")
+ self.errors = errors
+
+
+class GraphImportError(RuntimeError):
+ """FalkorDB import failed, optionally leaving a graph needing cleanup."""
+
+ def __init__(self, message: str, *, cleanup_error: Exception | None = None):
+ super().__init__(message)
+ self.cleanup_error = cleanup_error
+ self.cleanup_required = cleanup_error is not None
+
+
+def _as_mapping(value: Any) -> dict[str, Any]:
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _normalize_named_mapping(
+ value: Any,
+ *,
+ kind: str,
+ name_keys: tuple[str, ...],
+ errors: list[str],
+) -> dict[str, dict[str, Any]]:
+ if isinstance(value, Mapping):
+ result: dict[str, dict[str, Any]] = {}
+ for raw_name, raw_meta in value.items():
+ name = str(raw_name or "").strip()
+ if kind == "关系类型" and isinstance(raw_meta, (list, tuple)):
+ if len(raw_meta) < 2:
+ errors.append(f"关系类型 {name or '<未命名>'} 至少需要 from 和 to")
+ continue
+ raw_meta = {
+ "from": raw_meta[0],
+ "to": raw_meta[1],
+ "description": raw_meta[2] if len(raw_meta) > 2 else None,
+ "properties": {},
+ }
+ if not isinstance(raw_meta, Mapping):
+ errors.append(f"{kind} {name or '<未命名>'} 的定义必须是 JSON 对象")
+ continue
+ result[name] = dict(raw_meta)
+ return result
+
+ if isinstance(value, list):
+ result = {}
+ for index, raw_meta in enumerate(value):
+ if not isinstance(raw_meta, Mapping):
+ errors.append(f"{kind}列表第 {index + 1} 项必须是 JSON 对象")
+ continue
+ meta = dict(raw_meta)
+ name = next(
+ (str(meta.get(key) or "").strip() for key in name_keys if meta.get(key)),
+ "",
+ )
+ if not name:
+ errors.append(f"{kind}列表第 {index + 1} 项缺少名称")
+ continue
+ result[name] = meta
+ return result
+
+ errors.append(f"{kind}必须是 JSON 对象或数组")
+ return {}
+
+
+def _normalize_field_definitions(
+ value: Any,
+ *,
+ path: str,
+ errors: list[str],
+) -> dict[str, dict[str, Any]]:
+ if value is None:
+ return {}
+ if isinstance(value, Mapping):
+ raw_items = list(value.items())
+ elif isinstance(value, list):
+ raw_items = []
+ for index, item in enumerate(value):
+ if isinstance(item, str):
+ raw_items.append((item, {"type": "string"}))
+ elif isinstance(item, Mapping):
+ name = str(item.get("name") or item.get("field_name") or "").strip()
+ if not name:
+ errors.append(f"{path} 第 {index + 1} 个字段缺少名称")
+ continue
+ raw_items.append((name, item))
+ else:
+ errors.append(f"{path} 第 {index + 1} 个字段定义无效")
+ else:
+ errors.append(f"{path} 必须是 JSON 对象或数组")
+ return {}
+
+ normalized: dict[str, dict[str, Any]] = {}
+ for raw_name, raw_definition in raw_items:
+ name = str(raw_name or "").strip()
+ if not name:
+ errors.append(f"{path} 包含空字段名")
+ continue
+ if isinstance(raw_definition, str):
+ definition = {"type": raw_definition}
+ elif isinstance(raw_definition, Mapping):
+ definition = dict(raw_definition)
+ else:
+ errors.append(f"{path}.{name} 的字段定义必须是对象或类型字符串")
+ continue
+ raw_type = str(definition.get("type") or definition.get("value_type") or "string").lower().strip()
+ value_type = TYPE_ALIASES.get(raw_type, raw_type)
+ if value_type not in SUPPORTED_FIELD_TYPES:
+ errors.append(f"{path}.{name} 使用了不支持的类型 {raw_type!r}")
+ continue
+ definition["type"] = value_type
+ definition["required"] = bool(definition.get("required", False))
+ normalized[name] = definition
+ return normalized
+
+
+def _matches_field_type(value: Any, expected: str) -> bool:
+ if expected == "any":
+ return True
+ if expected in {"string", "date", "datetime"}:
+ return isinstance(value, str)
+ if expected == "integer":
+ return isinstance(value, int) and not isinstance(value, bool)
+ if expected == "number":
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
+ if expected == "boolean":
+ return isinstance(value, bool)
+ if expected == "object":
+ return isinstance(value, Mapping)
+ if expected == "array":
+ return isinstance(value, list)
+ return False
+
+
+def _endpoint_types(value: Any) -> list[str]:
+ return [part.strip() for part in str(value or "").split("|") if part.strip()]
+
+
+def _validate_properties(
+ properties: dict[str, Any],
+ definitions: dict[str, dict[str, Any]],
+ *,
+ path: str,
+ errors: list[str],
+) -> None:
+ for field_name, definition in definitions.items():
+ value_present = field_name in properties and properties[field_name] is not None
+ if definition.get("required") and not value_present:
+ errors.append(f"{path} 缺少必填属性 {field_name}")
+ continue
+ if not value_present:
+ continue
+ expected = str(definition.get("type") or "string")
+ if not _matches_field_type(properties[field_name], expected):
+ errors.append(f"{path}.{field_name} 应为 {expected} 类型")
+
+
+def normalize_provision_payload(body: Any) -> dict[str, Any]:
+ """Normalize and fully validate a project provision request.
+
+ The function is intentionally pure so validation can be completed before
+ PostgreSQL or FalkorDB is touched.
+ """
+
+ if not isinstance(body, Mapping):
+ raise ProjectValidationError(["请求体必须是 JSON 对象"])
+
+ errors: list[str] = []
+ project_id = str(body.get("project_id") or "").strip()
+ explicit_tenant_id = str(body.get("tenant_id") or "").strip()
+ explicit_graph_name = str(body.get("graph_name") or "").strip()
+ tenant_id = explicit_tenant_id or project_id
+ display_name = str(body.get("display_name") or "").strip()
+ graph_name = explicit_graph_name or project_id
+
+ if not project_id:
+ errors.append("项目英文标识不能为空")
+ elif not SAFE_RESOURCE_ID.fullmatch(project_id):
+ errors.append("项目英文标识只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头")
+ if explicit_tenant_id and not SAFE_RESOURCE_ID.fullmatch(explicit_tenant_id):
+ errors.append("项目隔离标识只能包含安全字符")
+ if not display_name:
+ errors.append("display_name 不能为空")
+ if explicit_graph_name and not SAFE_RESOURCE_ID.fullmatch(explicit_graph_name):
+ errors.append("图谱资源标识只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头")
+
+ raw_schema = body.get("schema")
+ if not isinstance(raw_schema, Mapping):
+ errors.append("schema 必须是 JSON 对象")
+ raw_schema = {}
+ schema = dict(raw_schema)
+ namespace = str(schema.get("namespace") or project_id).strip()
+ version = str(schema.get("version") or "").strip()
+ if not namespace:
+ errors.append("schema.namespace 不能为空")
+ elif not SAFE_RESOURCE_ID.fullmatch(namespace):
+ errors.append("schema.namespace 只能包含安全字符")
+ if not version:
+ errors.append("schema.version 不能为空")
+ elif not SAFE_SCHEMA_VERSION.fullmatch(version):
+ errors.append("schema.version 只能包含字母、数字、点、下划线和连字符,最长 64 个字符")
+
+ entity_types = _normalize_named_mapping(
+ schema.get("entity_types"),
+ kind="实体类型",
+ name_keys=("name", "entity_type"),
+ errors=errors,
+ )
+ if not entity_types:
+ errors.append("schema.entity_types 至少需要一个实体类型")
+
+ normalized_entities: dict[str, dict[str, Any]] = {}
+ for entity_name, raw_meta in entity_types.items():
+ if not SAFE_SCHEMA_IDENTIFIER.fullmatch(entity_name):
+ errors.append(f"实体类型 {entity_name!r} 不是安全的 Schema/Cypher 标识符")
+ continue
+ meta = dict(raw_meta)
+ meta["fields"] = _normalize_field_definitions(
+ meta.get("fields"), path=f"schema.entity_types.{entity_name}.fields", errors=errors
+ )
+ normalized_entities[entity_name] = meta
+
+ relation_types = _normalize_named_mapping(
+ schema.get("relation_types", {}),
+ kind="关系类型",
+ name_keys=("name", "relation_type"),
+ errors=errors,
+ )
+ normalized_relations: dict[str, dict[str, Any]] = {}
+ for relation_name, raw_meta in relation_types.items():
+ if not SAFE_SCHEMA_IDENTIFIER.fullmatch(relation_name):
+ errors.append(f"关系类型 {relation_name!r} 不是安全的 Schema/Cypher 标识符")
+ continue
+ meta = dict(raw_meta)
+ source_type = str(meta.get("from") or meta.get("source") or "").strip()
+ target_type = str(meta.get("to") or meta.get("target") or "").strip()
+ source_types = _endpoint_types(source_type)
+ target_types = _endpoint_types(target_type)
+ if not source_types or any(item not in normalized_entities for item in source_types):
+ errors.append(f"关系类型 {relation_name} 的 from/source 必须引用已定义实体类型")
+ if not target_types or any(item not in normalized_entities for item in target_types):
+ errors.append(f"关系类型 {relation_name} 的 to/target 必须引用已定义实体类型")
+ meta["from"] = source_type
+ meta["to"] = target_type
+ meta["properties"] = _normalize_field_definitions(
+ meta.get("properties"),
+ path=f"schema.relation_types.{relation_name}.properties",
+ errors=errors,
+ )
+ normalized_relations[relation_name] = meta
+
+ raw_graph_data = body.get("graph_data")
+ if raw_graph_data is None:
+ raw_graph_data = {"nodes": [], "relations": []}
+ if not isinstance(raw_graph_data, Mapping):
+ errors.append("graph_data 必须是 JSON 对象")
+ raw_graph_data = {}
+ raw_nodes = raw_graph_data.get("nodes", [])
+ raw_relations = raw_graph_data.get("relations", [])
+ if not isinstance(raw_nodes, list):
+ errors.append("graph_data.nodes 必须是数组")
+ raw_nodes = []
+ if not isinstance(raw_relations, list):
+ errors.append("graph_data.relations 必须是数组")
+ raw_relations = []
+ if not raw_nodes:
+ errors.append("graph_data.nodes 至少需要一个节点,FalkorDB 不支持持久化空图")
+
+ nodes: list[dict[str, Any]] = []
+ node_types: dict[str, str] = {}
+ for index, raw_node in enumerate(raw_nodes):
+ path = f"graph_data.nodes[{index}]"
+ if not isinstance(raw_node, Mapping):
+ errors.append(f"{path} 必须是 JSON 对象")
+ continue
+ node = dict(raw_node)
+ node_id = str(node.get("id") or "").strip()
+ entity_type = str(node.get("type") or node.get("entity_type") or "").strip()
+ properties = node.get("properties", {})
+ if not node_id:
+ errors.append(f"{path}.id 不能为空")
+ elif node_id in node_types:
+ errors.append(f"节点 ID {node_id!r} 重复")
+ if entity_type not in normalized_entities:
+ errors.append(f"{path}.type {entity_type!r} 未在 Schema 中定义")
+ if not isinstance(properties, Mapping):
+ errors.append(f"{path}.properties 必须是 JSON 对象")
+ properties = {}
+ normalized_properties = dict(properties)
+ if entity_type in normalized_entities:
+ _validate_properties(
+ normalized_properties,
+ normalized_entities[entity_type].get("fields", {}),
+ path=path,
+ errors=errors,
+ )
+ if node_id and node_id not in node_types:
+ node_types[node_id] = entity_type
+ nodes.append({"id": node_id, "type": entity_type, "properties": normalized_properties})
+
+ relations: list[dict[str, Any]] = []
+ for index, raw_relation in enumerate(raw_relations):
+ path = f"graph_data.relations[{index}]"
+ if not isinstance(raw_relation, Mapping):
+ errors.append(f"{path} 必须是 JSON 对象")
+ continue
+ relation = dict(raw_relation)
+ relation_type = str(relation.get("type") or relation.get("relation_type") or "").strip()
+ source = str(relation.get("source") or "").strip()
+ target = str(relation.get("target") or "").strip()
+ properties = relation.get("properties", {})
+ definition = normalized_relations.get(relation_type)
+ if not definition:
+ errors.append(f"{path}.type {relation_type!r} 未在 Schema 中定义")
+ if source not in node_types:
+ errors.append(f"{path}.source {source!r} 不存在")
+ if target not in node_types:
+ errors.append(f"{path}.target {target!r} 不存在")
+ if (
+ definition
+ and source in node_types
+ and node_types[source] not in _endpoint_types(definition.get("from"))
+ ):
+ errors.append(f"{path}.source 的节点类型必须是 {definition.get('from')}")
+ if (
+ definition
+ and target in node_types
+ and node_types[target] not in _endpoint_types(definition.get("to"))
+ ):
+ errors.append(f"{path}.target 的节点类型必须是 {definition.get('to')}")
+ if not isinstance(properties, Mapping):
+ errors.append(f"{path}.properties 必须是 JSON 对象")
+ properties = {}
+ normalized_properties = dict(properties)
+ if definition:
+ _validate_properties(
+ normalized_properties,
+ definition.get("properties", {}),
+ path=path,
+ errors=errors,
+ )
+ relations.append(
+ {
+ "type": relation_type,
+ "source": source,
+ "target": target,
+ "properties": normalized_properties,
+ }
+ )
+
+ if errors:
+ raise ProjectValidationError(errors)
+
+ schema["namespace"] = namespace
+ schema["version"] = version
+ schema["entity_types"] = normalized_entities
+ schema["relation_types"] = normalized_relations
+ return {
+ "project_id": project_id,
+ "tenant_id": tenant_id,
+ "display_name": display_name,
+ "description": str(body.get("description") or "").strip() or None,
+ "graph_name": graph_name,
+ "schema": schema,
+ "graph_data": {"nodes": nodes, "relations": relations},
+ "counts": {
+ "entity_types": len(normalized_entities),
+ "relation_types": len(normalized_relations),
+ "nodes": len(nodes),
+ "relations": len(relations),
+ },
+ }
+
+
+def _falkor_client() -> FalkorDB:
+ kwargs: dict[str, Any] = {
+ "host": settings.falkordb_host,
+ "port": settings.falkordb_port,
+ "socket_timeout": 15,
+ "socket_connect_timeout": 5,
+ }
+ if settings.falkordb_password:
+ kwargs["password"] = settings.falkordb_password
+ return FalkorDB(**kwargs)
+
+
+def list_falkor_graphs() -> set[str]:
+ db = _falkor_client()
+ try:
+ return {
+ name.decode("utf-8") if isinstance(name, bytes) else str(name)
+ for name in db.list_graphs()
+ }
+ finally:
+ db.close()
+
+
+def _graph_safe_value(value: Any) -> Any:
+ if value is None:
+ return None
+ if isinstance(value, (str, bool, int, float)):
+ return value
+ if isinstance(value, list) and all(isinstance(item, (str, bool, int, float)) for item in value):
+ return value
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _graph_safe_properties(properties: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ str(key): safe_value
+ for key, value in properties.items()
+ if (safe_value := _graph_safe_value(value)) is not None
+ }
+
+
+def import_falkor_graph(graph_name: str, graph_data: Mapping[str, Any]) -> dict[str, int]:
+ """Create a brand-new FalkorDB graph from already validated data."""
+
+ db = _falkor_client()
+ created = False
+ try:
+ existing = {
+ name.decode("utf-8") if isinstance(name, bytes) else str(name)
+ for name in db.list_graphs()
+ }
+ if graph_name in existing:
+ raise FileExistsError(f"FalkorDB 图 {graph_name!r} 已存在")
+ graph = db.select_graph(graph_name)
+ for node in graph_data.get("nodes", []):
+ label = str(node["type"])
+ properties = _graph_safe_properties(node.get("properties") or {})
+ properties.setdefault("id", str(node["id"]))
+ properties["__kg_node_id"] = str(node["id"])
+ # From this point onward the graph may exist even if the client
+ # raises after the server accepted the command.
+ created = True
+ graph.query(f"CREATE (n:{label}) SET n += $props", {"props": properties})
+ for relation in graph_data.get("relations", []):
+ relation_type = str(relation["type"])
+ properties = _graph_safe_properties(relation.get("properties") or {})
+ graph.query(
+ f"""
+ MATCH (source {{__kg_node_id:$source}}), (target {{__kg_node_id:$target}})
+ CREATE (source)-[relation:{relation_type}]->(target)
+ SET relation += $props
+ """,
+ {
+ "source": str(relation["source"]),
+ "target": str(relation["target"]),
+ "props": properties,
+ },
+ )
+ created = True
+ if not created:
+ return {"nodes": 0, "relations": 0}
+ node_count = int(graph.query("MATCH (n) RETURN count(n)").result_set[0][0])
+ relation_count = int(graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0])
+ return {"nodes": node_count, "relations": relation_count}
+ except FileExistsError:
+ raise
+ except Exception as exc:
+ cleanup_error: Exception | None = None
+ if created:
+ try:
+ db.select_graph(graph_name).delete()
+ except Exception as delete_exc:
+ cleanup_error = delete_exc
+ raise GraphImportError(str(exc), cleanup_error=cleanup_error) from exc
+ finally:
+ db.close()
+
+
+def delete_falkor_graph(graph_name: str) -> bool:
+ """Delete one exact graph name, returning False when it no longer exists."""
+
+ db = _falkor_client()
+ try:
+ existing = {
+ name.decode("utf-8") if isinstance(name, bytes) else str(name)
+ for name in db.list_graphs()
+ }
+ if graph_name not in existing:
+ return False
+ db.select_graph(graph_name).delete()
+ return True
+ finally:
+ db.close()
diff --git a/docs/assets/zones_A_strict.geojson b/docs/assets/zones_A_strict.geojson
new file mode 100644
index 0000000..09f96f4
--- /dev/null
+++ b/docs/assets/zones_A_strict.geojson
@@ -0,0 +1 @@
+{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"zone_id": "Z-LB-01", "zone_name": "大鹏购物中心片区", "anchor": [107.88492, 25.41257], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.884723, 25.40938], [107.887267, 25.409723], [107.887744, 25.409579], [107.889523, 25.41356], [107.885895, 25.416999], [107.8851, 25.417089], [107.88359, 25.416927], [107.882417, 25.416475], [107.881761, 25.41606], [107.881174, 25.415546], [107.880518, 25.414679], [107.881254, 25.412242], [107.884723, 25.40938]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-02", "zone_name": "梨园路片区", "anchor": [107.88124, 25.40889], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.879813, 25.405426], [107.882357, 25.405751], [107.884713, 25.409353], [107.881264, 25.412233], [107.877894, 25.407511], [107.879813, 25.405426]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-03", "zone_name": "荔波古镇片区", "anchor": [107.89207, 25.40995], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.895656, 25.406816], [107.896222, 25.407457], [107.89666, 25.408198], [107.896918, 25.40892], [107.897037, 25.409642], [107.897017, 25.410454], [107.896839, 25.411249], [107.89658, 25.411862], [107.896083, 25.412621], [107.891978, 25.413912], [107.889533, 25.413569], [107.887754, 25.40957], [107.888172, 25.409335], [107.889861, 25.40688], [107.89488, 25.407412], [107.895656, 25.406816]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-04", "zone_name": "司法局片区", "anchor": [107.8779, 25.41085], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.877865, 25.407502], [107.881254, 25.412224], [107.880538, 25.414661], [107.879534, 25.415121], [107.878322, 25.415356], [107.877288, 25.415338], [107.876453, 25.415175], [107.875439, 25.414778], [107.874605, 25.414237], [107.873859, 25.413487], [107.873382, 25.412747], [107.873044, 25.411844], [107.872925, 25.411032], [107.873044, 25.409859], [107.873511, 25.40873], [107.877865, 25.407502]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-05", "zone_name": "荔波汽车站片区", "anchor": [107.8908, 25.41771], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.889533, 25.413569], [107.891988, 25.413921], [107.892982, 25.418488], [107.887824, 25.421332], [107.886879, 25.420492], [107.886204, 25.419445], [107.885846, 25.418163], [107.885886, 25.417008], [107.889533, 25.413569]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-06", "zone_name": "中医院片区", "anchor": [107.89467, 25.41702], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.895994, 25.412684], [107.896968, 25.413009], [107.898041, 25.413695], [107.898866, 25.414589], [107.899383, 25.415564], [107.899621, 25.416557], [107.899602, 25.41764], [107.899264, 25.418759], [107.898697, 25.419671], [107.898588, 25.419066], [107.898319, 25.418443], [107.894006, 25.418515], [107.893032, 25.418515], [107.892962, 25.418452], [107.891988, 25.413903], [107.895994, 25.412684]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-07", "zone_name": "县人民医院片区", "anchor": [107.88223, 25.40246], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.88184, 25.397952], [107.88196, 25.397952], [107.887198, 25.402673], [107.887168, 25.402935], [107.886174, 25.402772], [107.882357, 25.405751], [107.879803, 25.405417], [107.877716, 25.400579], [107.878391, 25.399586], [107.879336, 25.398782], [107.880548, 25.398205], [107.88184, 25.397952]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-08", "zone_name": "气象局片区", "anchor": [107.88592, 25.40637], "cap_m": 400.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.886174, 25.402772], [107.88673, 25.402826], [107.887486, 25.403043], [107.888201, 25.403404], [107.888808, 25.403883], [107.889861, 25.406843], [107.888172, 25.409335], [107.887406, 25.409723], [107.884723, 25.40938], [107.882367, 25.405778], [107.886174, 25.402772]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-09", "zone_name": "荔波大剧院片区", "anchor": [107.89273, 25.4206], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.892952, 25.418497], [107.893101, 25.418615], [107.894871, 25.423255], [107.891581, 25.424997], [107.890786, 25.424762], [107.89013, 25.424455], [107.889006, 25.423598], [107.888211, 25.422496], [107.887834, 25.421323], [107.892952, 25.418497]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-10", "zone_name": "罗家寨小学片区", "anchor": [107.88693, 25.39816], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.88685, 25.393655], [107.887685, 25.393691], [107.888619, 25.393908], [107.889394, 25.394233], [107.890249, 25.394792], [107.890915, 25.395451], [107.891412, 25.396192], [107.891789, 25.397185], [107.891909, 25.398087], [107.891829, 25.398954], [107.891591, 25.399748], [107.891133, 25.400579], [107.890636, 25.401175], [107.889911, 25.401779], [107.889215, 25.402177], [107.888321, 25.402502], [107.887366, 25.402664], [107.887188, 25.402664], [107.88197, 25.397961], [107.882019, 25.397464], [107.88685, 25.393655]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-11", "zone_name": "第二中学片区", "anchor": [107.89473, 25.41997], "cap_m": 400.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.897266, 25.418443], [107.898319, 25.418443], [107.898548, 25.41894], [107.898687, 25.419553], [107.898707, 25.420203], [107.898568, 25.420944], [107.898329, 25.421521], [107.897872, 25.422189], [107.897306, 25.422722], [107.89488, 25.423264], [107.893062, 25.418524], [107.897266, 25.418443]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-12", "zone_name": "中职学校片区", "anchor": [107.89274, 25.40437], "cap_m": 400.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.892714, 25.40075], [107.893847, 25.400895], [107.89504, 25.401418], [107.895944, 25.402222], [107.89656, 25.403341], [107.896719, 25.404569], [107.896481, 25.405616], [107.895884, 25.406591], [107.895457, 25.407015], [107.89488, 25.407412], [107.889911, 25.406907], [107.888808, 25.403919], [107.888927, 25.403323], [107.889265, 25.402601], [107.89009, 25.401671], [107.890646, 25.401292], [107.891283, 25.401003], [107.892058, 25.400804], [107.892714, 25.40075]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-13", "zone_name": "九龄路民宿片区", "anchor": [107.87581, 25.40474], "cap_m": 500.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.875499, 25.400227], [107.876632, 25.400281], [107.877735, 25.400597], [107.879803, 25.405435], [107.877885, 25.407502], [107.873472, 25.40873], [107.872816, 25.408351], [107.87213, 25.407782], [107.871295, 25.406645], [107.871017, 25.405959], [107.870858, 25.405201], [107.870937, 25.40381], [107.871176, 25.403088], [107.871513, 25.402456], [107.871991, 25.401843], [107.872617, 25.401274], [107.873889, 25.40057], [107.874684, 25.400335], [107.875499, 25.400227]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-14", "zone_name": "红船路城北片区", "anchor": [107.89642, 25.42625], "cap_m": 600.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.898478, 25.421169], [107.899393, 25.421548], [107.900387, 25.422198], [107.901271, 25.423092], [107.901868, 25.424031], [107.902245, 25.42506], [107.902385, 25.425963], [107.902325, 25.427064], [107.902007, 25.428165], [107.90151, 25.429086], [107.900854, 25.42988], [107.89983, 25.430702], [107.898797, 25.431225], [107.897862, 25.431514], [107.896868, 25.431659], [107.895596, 25.431623], [107.894284, 25.431316], [107.89325, 25.430846], [107.892455, 25.430305], [107.89175, 25.429628], [107.891213, 25.428905], [107.890716, 25.427858], [107.890477, 25.426793], [107.890477, 25.42571], [107.890676, 25.424771], [107.890726, 25.424726], [107.89166, 25.424997], [107.894861, 25.423264], [107.897306, 25.422722], [107.897991, 25.422045], [107.898478, 25.421169]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-15", "zone_name": "兴旺城南片区", "anchor": [107.88195, 25.39296], "cap_m": 600.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.881482, 25.387553], [107.882914, 25.387607], [107.884285, 25.387968], [107.885418, 25.388546], [107.886422, 25.389367], [107.887158, 25.390306], [107.887655, 25.391353], [107.887893, 25.392418], [107.887893, 25.393502], [107.887844, 25.393727], [107.887366, 25.393655], [107.88685, 25.393655], [107.88675, 25.393727], [107.882019, 25.397464], [107.88196, 25.397952], [107.881264, 25.398024], [107.880508, 25.398223], [107.879435, 25.39788], [107.878481, 25.397374], [107.877497, 25.396571], [107.877, 25.395993], [107.876503, 25.395181], [107.876205, 25.39444], [107.876006, 25.393484], [107.876006, 25.392436], [107.876264, 25.391299], [107.876722, 25.390342], [107.877278, 25.389584], [107.877984, 25.388907], [107.878779, 25.388365], [107.879614, 25.387968], [107.880588, 25.387679], [107.881482, 25.387553]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-17", "zone_name": "绿宝石大道城西片区", "anchor": [107.87045, 25.39632], "cap_m": 600.0, "variant": "strict"}, "geometry": {"type": "Polygon", "coordinates": [[[107.869993, 25.390911], [107.871106, 25.390929], [107.872239, 25.391146], [107.873312, 25.391561], [107.874346, 25.392211], [107.875072, 25.392888], [107.875668, 25.393682], [107.876165, 25.394747], [107.876404, 25.395885], [107.876384, 25.396932], [107.876185, 25.397835], [107.875648, 25.39899], [107.874992, 25.399839], [107.874346, 25.400425], [107.873293, 25.400841], [107.872418, 25.401436], [107.870947, 25.401725], [107.869754, 25.401707], [107.868581, 25.401472], [107.867508, 25.401039], [107.866574, 25.400443], [107.865808, 25.39973], [107.865252, 25.39899], [107.864715, 25.397835], [107.864496, 25.396751], [107.864516, 25.395704], [107.864735, 25.394747], [107.865232, 25.393682], [107.865928, 25.392779], [107.866673, 25.39212], [107.867588, 25.391561], [107.86886, 25.391091], [107.869993, 25.390911]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-99", "zone_name": "城郊兜底区", "anchor": null, "cap_m": null, "variant": "strict"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[107.877696, 25.383933], [107.87854, 25.384123], [107.879077, 25.384357], [107.879614, 25.3847], [107.880161, 25.385215], [107.880479, 25.385648], [107.880797, 25.386316], [107.880936, 25.386912], [107.880956, 25.38758], [107.879435, 25.38804], [107.878322, 25.388654], [107.877278, 25.389584], [107.876513, 25.390712], [107.875797, 25.390532], [107.875201, 25.390261], [107.874644, 25.389882], [107.874177, 25.389421], [107.8738, 25.38888], [107.873541, 25.388284], [107.873402, 25.387092], [107.87366, 25.386063], [107.874296, 25.385107], [107.87528, 25.384357], [107.875817, 25.384123], [107.876662, 25.383933], [107.877696, 25.383933]]], [[[107.888768, 25.43526], [107.888331, 25.434719], [107.888032, 25.434141], [107.887814, 25.433022], [107.887058, 25.432444], [107.886601, 25.431884], [107.886263, 25.431216], [107.886124, 25.430621], [107.885587, 25.430151], [107.88523, 25.4297], [107.884971, 25.42923], [107.884752, 25.428544], [107.884713, 25.427588], [107.885011, 25.426558], [107.885647, 25.425656], [107.886194, 25.425195], [107.88674, 25.424879], [107.885577, 25.423733], [107.884862, 25.42357], [107.884246, 25.4233], [107.88359, 25.422848], [107.883113, 25.422343], [107.882278, 25.422036], [107.881761, 25.421711], [107.881174, 25.42116], [107.880767, 25.420555], [107.879753, 25.42014], [107.879057, 25.420104], [107.878302, 25.419924], [107.877626, 25.419617], [107.877149, 25.419292], [107.87535, 25.417279], [107.874972, 25.416629], [107.873859, 25.41587], [107.873303, 25.415148], [107.872468, 25.414498], [107.871832, 25.413885], [107.871513, 25.413433], [107.870599, 25.412639], [107.870062, 25.411736], [107.869883, 25.410978], [107.869098, 25.410734], [107.868442, 25.410391], [107.867776, 25.40984], [107.867319, 25.409245], [107.866941, 25.408324], [107.866862, 25.407385], [107.867021, 25.406591], [107.867359, 25.405887], [107.867836, 25.405291], [107.868661, 25.404668], [107.869476, 25.404325], [107.87043, 25.404162], [107.870877, 25.40419], [107.870838, 25.404966], [107.870957, 25.405742], [107.871176, 25.406392], [107.871613, 25.407168], [107.872458, 25.40808], [107.873501, 25.408739], [107.873203, 25.409353], [107.872984, 25.410129], [107.872925, 25.411032], [107.873004, 25.411664], [107.873203, 25.41235], [107.873521, 25.413], [107.873978, 25.413632], [107.874605, 25.414237], [107.875241, 25.41467], [107.875956, 25.415013], [107.876831, 25.415266], [107.877487, 25.415356], [107.878322, 25.415356], [107.879057, 25.415248], [107.879793, 25.415031], [107.880528, 25.414688], [107.880995, 25.415347], [107.881642, 25.41597], [107.882874, 25.416692], [107.884265, 25.417053], [107.8851, 25.417089], [107.885886, 25.417008], [107.885886, 25.418434], [107.886104, 25.41921], [107.886422, 25.41986], [107.887078, 25.420709], [107.887834, 25.421341], [107.888132, 25.422334], [107.888808, 25.423381], [107.889672, 25.424166], [107.890696, 25.424717], [107.890477, 25.42571], [107.890477, 25.426793], [107.890716, 25.427858], [107.891213, 25.428905], [107.891968, 25.429862], [107.892793, 25.430557], [107.893867, 25.431153], [107.895, 25.431514], [107.895109, 25.431704], [107.895368, 25.432715], [107.895308, 25.433636], [107.89503, 25.434412], [107.894413, 25.43526], [107.888768, 25.43526]]], [[[107.888162, 25.391633], [107.889215, 25.391705], [107.890189, 25.39203], [107.890905, 25.392482], [107.89161, 25.393231], [107.892048, 25.394115], [107.892207, 25.39491], [107.892127, 25.395849], [107.8917, 25.396833], [107.891133, 25.39574], [107.890249, 25.394792], [107.889136, 25.394106], [107.887853, 25.393718], [107.887913, 25.392653], [107.887754, 25.391696], [107.888162, 25.391633]]], [[[107.876095, 25.391832], [107.875986, 25.392671], [107.876086, 25.393989], [107.876443, 25.395054], [107.87696, 25.395939], [107.877497, 25.396571], [107.878481, 25.397374], [107.879435, 25.39788], [107.880499, 25.398214], [107.879574, 25.398638], [107.878938, 25.399071], [107.878133, 25.399893], [107.877706, 25.40057], [107.87693, 25.400335], [107.876115, 25.400227], [107.87528, 25.400245], [107.874376, 25.400398], [107.875092, 25.39973], [107.875648, 25.39899], [107.876046, 25.398214], [107.876324, 25.397293], [107.876404, 25.395885], [107.876205, 25.394874], [107.875668, 25.393682], [107.874972, 25.392798], [107.875439, 25.392283], [107.876095, 25.391832]]], [[[107.891779, 25.397103], [107.892386, 25.397618], [107.892783, 25.398141], [107.893121, 25.398882], [107.893221, 25.399387], [107.893807, 25.39992], [107.894682, 25.400082], [107.895576, 25.400498], [107.896222, 25.401012], [107.896759, 25.401698], [107.897157, 25.402745], [107.897137, 25.403919], [107.897932, 25.404803], [107.898309, 25.405544], [107.898528, 25.40632], [107.898469, 25.40753], [107.897991, 25.408595], [107.898906, 25.410129], [107.899105, 25.410851], [107.899125, 25.411538], [107.899751, 25.411926], [107.900417, 25.412585], [107.900794, 25.413198], [107.900973, 25.413722], [107.90143, 25.4143], [107.901768, 25.41504], [107.901907, 25.415816], [107.901888, 25.416358], [107.90155, 25.417441], [107.902106, 25.418001], [107.902464, 25.418579], [107.902782, 25.41977], [107.902703, 25.420691], [107.902285, 25.421648], [107.902544, 25.422605], [107.902524, 25.423399], [107.902285, 25.424175], [107.902524, 25.42524], [107.902394, 25.426279], [107.902325, 25.425439], [107.902106, 25.424591], [107.901371, 25.423218], [107.900894, 25.422659], [107.900128, 25.422], [107.899254, 25.421476], [107.898488, 25.42116], [107.898687, 25.420384], [107.898687, 25.41968], [107.899343, 25.418579], [107.899621, 25.417495], [107.899582, 25.416286], [107.899244, 25.415239], [107.898747, 25.414426], [107.898041, 25.413695], [107.897147, 25.413099], [107.896023, 25.412675], [107.896739, 25.411519], [107.897037, 25.410256], [107.896998, 25.409299], [107.896759, 25.408432], [107.896322, 25.407602], [107.895666, 25.406843], [107.896401, 25.405796], [107.896699, 25.404767], [107.89664, 25.40363], [107.896202, 25.402583], [107.895646, 25.401897], [107.894801, 25.401274], [107.893847, 25.400895], [107.892714, 25.40075], [107.892058, 25.400804], [107.891283, 25.401003], [107.890646, 25.401292], [107.89009, 25.401671], [107.889265, 25.402601], [107.889006, 25.403106], [107.888798, 25.403874], [107.888082, 25.403332], [107.887178, 25.402944], [107.887207, 25.402664], [107.888321, 25.402502], [107.889593, 25.401978], [107.890249, 25.401527], [107.890915, 25.400868], [107.891412, 25.400127], [107.891789, 25.399134], [107.891909, 25.398087], [107.891779, 25.397103]]], [[[107.872378, 25.401436], [107.871583, 25.402375], [107.870559, 25.401752], [107.871543, 25.401653], [107.872378, 25.401436]]], [[[107.902355, 25.426929], [107.903011, 25.427308], [107.903637, 25.427894], [107.904094, 25.428599], [107.904333, 25.429285], [107.904392, 25.430314], [107.904273, 25.430891], [107.903995, 25.431541], [107.903518, 25.432191], [107.902812, 25.432778], [107.901639, 25.43341], [107.900963, 25.433645], [107.900327, 25.433753], [107.899433, 25.433735], [107.898637, 25.433554], [107.897922, 25.433229], [107.897067, 25.432688], [107.89656, 25.432209], [107.896183, 25.431668], [107.897246, 25.431623], [107.898498, 25.431334], [107.899492, 25.4309], [107.900268, 25.430395], [107.900993, 25.429736], [107.901649, 25.428869], [107.902146, 25.427786], [107.902355, 25.426929]]]]}}]}
\ No newline at end of file
diff --git a/docs/assets/zones_B_seamless.geojson b/docs/assets/zones_B_seamless.geojson
new file mode 100644
index 0000000..5bdebcb
--- /dev/null
+++ b/docs/assets/zones_B_seamless.geojson
@@ -0,0 +1 @@
+{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"zone_id": "Z-LB-01", "zone_name": "大鹏购物中心片区", "anchor": [107.88492, 25.41257], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.884723, 25.40938], [107.887844, 25.409795], [107.889523, 25.413578], [107.881323, 25.421314], [107.880767, 25.420555], [107.879753, 25.42014], [107.878938, 25.420086], [107.878948, 25.419969], [107.881234, 25.412278], [107.884723, 25.40938]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-02", "zone_name": "梨园路片区", "anchor": [107.88124, 25.40889], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.879813, 25.405426], [107.882357, 25.405751], [107.884713, 25.409353], [107.881264, 25.412233], [107.877894, 25.407511], [107.879813, 25.405426]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-03", "zone_name": "荔波古镇片区", "anchor": [107.89207, 25.40995], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.889891, 25.406907], [107.898379, 25.407755], [107.897991, 25.408595], [107.898826, 25.409949], [107.899085, 25.410743], [107.899125, 25.411538], [107.899343, 25.411664], [107.891978, 25.413912], [107.889533, 25.413569], [107.887853, 25.409768], [107.889891, 25.406907]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-04", "zone_name": "司法局片区", "anchor": [107.8779, 25.41085], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.877865, 25.407502], [107.881254, 25.412224], [107.878918, 25.420068], [107.877865, 25.419743], [107.877368, 25.419454], [107.876801, 25.418976], [107.87535, 25.417279], [107.874972, 25.416629], [107.873859, 25.41587], [107.873303, 25.415148], [107.872468, 25.414498], [107.871832, 25.413885], [107.871513, 25.413433], [107.870599, 25.412639], [107.870062, 25.411736], [107.869883, 25.410978], [107.869098, 25.410734], [107.868234, 25.410238], [107.877865, 25.407502]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-05", "zone_name": "荔波汽车站片区", "anchor": [107.8908, 25.41771], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.889533, 25.413569], [107.891988, 25.413921], [107.892982, 25.418488], [107.884246, 25.4233], [107.88359, 25.422848], [107.883113, 25.422343], [107.882178, 25.421982], [107.881333, 25.421305], [107.889533, 25.413569]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-06", "zone_name": "中医院片区", "anchor": [107.89467, 25.41702], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.899333, 25.411673], [107.899969, 25.412106], [107.900417, 25.412585], [107.900794, 25.413198], [107.900973, 25.413722], [107.901589, 25.414589], [107.901848, 25.415347], [107.901907, 25.416123], [107.901808, 25.416773], [107.90155, 25.417441], [107.901987, 25.417856], [107.902345, 25.418362], [107.892992, 25.418497], [107.891988, 25.413903], [107.899333, 25.411673]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-07", "zone_name": "县人民医院片区", "anchor": [107.88223, 25.40246], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.881423, 25.397717], [107.881701, 25.397717], [107.88678, 25.402294], [107.882357, 25.405751], [107.879803, 25.405417], [107.876861, 25.398593], [107.877328, 25.397826], [107.881423, 25.397717]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-08", "zone_name": "气象局片区", "anchor": [107.88592, 25.40637], "cap_m": 400.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.88679, 25.402303], [107.888291, 25.402456], [107.889881, 25.406898], [107.887844, 25.409795], [107.884723, 25.40938], [107.882367, 25.405778], [107.88679, 25.402303]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-09", "zone_name": "荔波大剧院片区", "anchor": [107.89273, 25.4206], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.892952, 25.418497], [107.893101, 25.418615], [107.894871, 25.423255], [107.884802, 25.428698], [107.884713, 25.427588], [107.885011, 25.426558], [107.885647, 25.425656], [107.886194, 25.425195], [107.88674, 25.424879], [107.885577, 25.423733], [107.884862, 25.42357], [107.884256, 25.423291], [107.892952, 25.418497]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-10", "zone_name": "罗家寨小学片区", "anchor": [107.88693, 25.39816], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.889295, 25.391723], [107.88999, 25.39194], [107.890786, 25.392391], [107.891432, 25.392996], [107.891829, 25.393592], [107.892167, 25.394585], [107.892207, 25.395343], [107.892068, 25.396065], [107.89163, 25.396968], [107.892525, 25.39778], [107.893062, 25.398773], [107.888301, 25.402447], [107.88679, 25.402303], [107.881711, 25.397708], [107.889295, 25.391723]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-11", "zone_name": "第二中学片区", "anchor": [107.89473, 25.41997], "cap_m": 400.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.90156, 25.418371], [107.902335, 25.418371], [107.902703, 25.419247], [107.902782, 25.41977], [107.902742, 25.420492], [107.902315, 25.421621], [107.894851, 25.423236], [107.893072, 25.418515], [107.90156, 25.418371]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-12", "zone_name": "中职学校片区", "anchor": [107.89274, 25.40437], "cap_m": 400.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.893072, 25.398764], [107.893221, 25.399387], [107.893807, 25.39992], [107.894682, 25.400082], [107.895457, 25.400425], [107.896222, 25.401012], [107.896759, 25.401698], [107.897157, 25.402745], [107.897137, 25.403919], [107.897932, 25.404803], [107.898488, 25.406103], [107.898548, 25.40706], [107.898379, 25.407755], [107.889861, 25.40688], [107.888291, 25.402456], [107.893072, 25.398764]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-13", "zone_name": "九龄路民宿片区", "anchor": [107.87581, 25.40474], "cap_m": 500.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.876831, 25.398584], [107.879703, 25.405164], [107.879763, 25.405489], [107.877845, 25.407521], [107.868323, 25.410211], [107.868164, 25.410193], [107.867319, 25.409245], [107.867001, 25.408541], [107.866882, 25.407999], [107.866862, 25.407385], [107.866981, 25.406717], [107.86716, 25.406248], [107.867578, 25.40558], [107.868422, 25.404812], [107.869416, 25.404343], [107.87043, 25.404162], [107.871742, 25.404307], [107.872169, 25.402836], [107.87211, 25.402547], [107.871305, 25.402249], [107.870659, 25.401824], [107.876831, 25.398584]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-14", "zone_name": "红船路城北片区", "anchor": [107.89642, 25.42625], "cap_m": 600.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.888768, 25.43526], [107.888331, 25.434719], [107.888032, 25.434141], [107.887814, 25.433022], [107.887307, 25.43267], [107.886701, 25.432029], [107.886263, 25.431216], [107.886124, 25.430621], [107.885587, 25.430151], [107.88523, 25.4297], [107.884792, 25.428707], [107.894861, 25.423264], [107.902255, 25.421621], [107.902544, 25.422605], [107.902524, 25.423399], [107.902285, 25.424175], [107.902484, 25.424897], [107.902524, 25.425584], [107.902444, 25.426125], [107.902186, 25.426811], [107.903011, 25.427308], [107.903438, 25.427678], [107.903856, 25.428183], [107.904174, 25.428779], [107.904372, 25.429483], [107.904392, 25.430314], [107.904273, 25.430891], [107.903995, 25.431541], [107.903597, 25.432101], [107.90307, 25.432597], [107.901639, 25.43341], [107.900486, 25.433735], [107.899293, 25.433717], [107.898121, 25.433338], [107.897266, 25.432832], [107.89666, 25.432318], [107.896023, 25.431379], [107.895755, 25.430395], [107.894602, 25.430377], [107.893728, 25.430124], [107.894473, 25.430747], [107.894831, 25.431198], [107.895169, 25.431848], [107.895348, 25.432534], [107.895368, 25.433238], [107.895228, 25.433924], [107.89491, 25.434629], [107.894413, 25.43526], [107.888768, 25.43526]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-15", "zone_name": "兴旺城南片区", "anchor": [107.88195, 25.39296], "cap_m": 600.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.877696, 25.383933], [107.87854, 25.384123], [107.879355, 25.38452], [107.880061, 25.385107], [107.880479, 25.385648], [107.880797, 25.386316], [107.880956, 25.387092], [107.880876, 25.388067], [107.880508, 25.388979], [107.881224, 25.388979], [107.882138, 25.389196], [107.883093, 25.389268], [107.883828, 25.389485], [107.884345, 25.389737], [107.884902, 25.390135], [107.885488, 25.390775], [107.885846, 25.391425], [107.886035, 25.392048], [107.886551, 25.392084], [107.887068, 25.39185], [107.887943, 25.391651], [107.888659, 25.391633], [107.889285, 25.391732], [107.881741, 25.397699], [107.877706, 25.397826], [107.877318, 25.397817], [107.87539, 25.392364], [107.876235, 25.391759], [107.877149, 25.391452], [107.877477, 25.390757], [107.876612, 25.39073], [107.875797, 25.390532], [107.874962, 25.390117], [107.874455, 25.38971], [107.873442, 25.386876], [107.87372, 25.385937], [107.874296, 25.385107], [107.87528, 25.384357], [107.875817, 25.384123], [107.876662, 25.383933], [107.877696, 25.383933]]]}}, {"type": "Feature", "properties": {"zone_id": "Z-LB-17", "zone_name": "绿宝石大道城西片区", "anchor": [107.87045, 25.39632], "cap_m": 600.0, "variant": "seamless"}, "geometry": {"type": "Polygon", "coordinates": [[[107.873452, 25.386885], [107.874426, 25.389683], [107.873899, 25.389042], [107.873541, 25.388284], [107.873402, 25.387562], [107.873452, 25.386885]], [[107.87538, 25.392337], [107.877318, 25.397853], [107.876851, 25.398584], [107.870649, 25.401815], [107.870142, 25.401337], [107.869734, 25.40075], [107.868939, 25.400534], [107.867985, 25.399992], [107.867478, 25.399514], [107.867001, 25.398809], [107.866703, 25.397907], [107.866703, 25.396968], [107.866544, 25.39621], [107.866604, 25.395397], [107.866862, 25.394639], [107.867419, 25.393827], [107.868283, 25.393132], [107.869297, 25.392716], [107.87051, 25.39259], [107.871603, 25.392788], [107.872597, 25.393276], [107.873402, 25.394025], [107.873939, 25.395018], [107.874267, 25.395045], [107.874296, 25.39426], [107.874515, 25.39352], [107.874853, 25.392924], [107.87538, 25.392337]]]}}]}
\ No newline at end of file
diff --git a/docs/assets/云游荔波_片区面几何_AB对比.png b/docs/assets/云游荔波_片区面几何_AB对比.png
new file mode 100644
index 0000000..cff1920
Binary files /dev/null and b/docs/assets/云游荔波_片区面几何_AB对比.png differ
diff --git a/docs/云游荔波_区域划分与村级归属_方案讨论_20260806.md b/docs/云游荔波_区域划分与村级归属_方案讨论_20260806.md
new file mode 100644
index 0000000..a6172e8
--- /dev/null
+++ b/docs/云游荔波_区域划分与村级归属_方案讨论_20260806.md
@@ -0,0 +1,535 @@
+# 云游荔波 · 区域划分与村级归属 方案讨论记录
+
+> **日期**:2026-08-06
+> **状态**:讨论中,**未写入任何代码、未改动任何数据**
+> **用途**:提交同事评审
+> **相关系统**:`new2` 项目 · `http://localhost:8102/admin/plaza/overview` 知识地图
+
+---
+
+## 0. 需求起点
+
+在馆藏概览的知识地图上,公交图层旁边增加一个**区域划分图层**:
+
+1. 默认全县,可切换到具体区域
+2. 选中区域后,下载得到该区域的 POI 点
+3. **县城区域必须有片区名称,且地图上有明确的边界线条**
+4. 增加 POI 名称模糊搜索,在地图上定位(类似 `/admin/plaza/user` 的搜索体验)
+5. 把已有的"高德 POI 所属区域"数据补充进系统
+
+讨论过程中衍生出两个更根本的问题,占了本文档大部分篇幅:
+
+- **现有 18 个片区的划分方案,科学合理吗?**
+- **远离城区的孤立 POI,能不能挂到村里去?**
+
+---
+
+## 1. 现状核对(已验证事实)
+
+### 1.1 数据一致性 —— 100% 对齐,可直接使用
+
+| 项 | 结果 |
+|---|---|
+| DB `kg_admin_new2.amap_spatial_pois` (graph=`yunyou_libo`) | **2799** 条 |
+| 桌面 `荔波县_POI实体_乡镇归属去重_20260805.csv` | **2799** 条 |
+| `高德POI_ID` 交集 | **2799**(仅DB 0,仅CSV 0,双向零差异) |
+| `荔波县城_POI片区归属.csv` | 1450 条,与 DB **全中** |
+| 业务关系表(一POI多业务标签) | 2814 条(其中 15 条同时是美食+酒店) |
+
+**乡镇分布**
+
+| 乡镇 | POI 数 |
+|---|---|
+| 玉屏街道 | 1450 |
+| 小七孔镇 | 627 |
+| 瑶山瑶族乡 | 335 |
+| 朝阳镇 | 162 |
+| 黎明关水族乡 | 118 |
+| 甲良镇 | 62 |
+| 茂兰镇 | 31 |
+| 佳荣镇 | 14 |
+
+**业务标签分布**:酒店 1205 / 美食 1100 / 交通 379 / 景区 100 / 美食;酒店 15
+
+### 1.2 代码现状
+
+| 位置 | 说明 |
+|---|---|
+| `admin-web/src/panels/plaza/SuperAgentPanel.tsx:1835` | `KnowledgeMap` 组件,地图主体 |
+| `PlazaOverview` | 以 `scope="libo"` 引用 `KnowledgeMap` |
+| `app/api/plaza.py:663` | `GET /plaza/map-pois` |
+| `amap_spatial_pois` 表 | **只有 `district`(区县级),无 towncode、无 zone_id** |
+
+**缺口**:地图这一层完全不知道乡镇和片区的存在。
+
+### 1.3 乡镇归属的来源(重要)
+
+```
+归属方式: 高德逆地理编码 2772 + 高德逆地理编码(缓存)42 = 2814 条
+归属状态: 全部「已归属」
+村社区归属: 0 条有值,全部「待政府边界确认」
+```
+
+**结论**:乡镇一级 100% 来自高德逆地理编码,没有一条是自己算的,**可信**。
+**村一级完全空白**,高德逆地理编码不提供村界。
+
+### 1.4 行政边界矢量的可得性
+
+- 高德 `DistrictSearch` 的 `boundaries` **只到区县级**,不返回街道/乡镇级边界
+- 政府乡镇界/村界矢量目前没有
+- 实测用 JS Key 调 Web 服务 API 直接被拒(`USERKEY_PLAT_NOMATCH`,高德 Key 按平台绑定)
+
+**结论**:**乡镇级和村级都拿不到官方边界矢量。** 任何"区域线条"只能靠自己生成,且必须标注非行政边界。
+
+---
+
+## 2. 片区面几何:从判定规则反推边界线
+
+### 2.1 关键认识
+
+现行「方法 A」的判定规则是:
+
+> 算到全部锚点的距离 → 剔除超出该锚点半径闸的 → 取最近 → 都够不着则落兜底
+
+这条规则在几何上等价于 **Voronoi 单元 ∩ 半径圆盘**(在 `KX=100612 m/°经度`、`KY=110779 m/°纬度` 缩放平面内)。
+
+也就是说:**画在地图上的多边形不是示意图,就是判定规则本身**。点落在哪个多边形内就归哪个片区,前后端永远不会打架。
+
+### 2.2 已验证的生成方法
+
+用栅格化 + 等值线提取 + Douglas-Peucker 简化,无需引入 shapely/scipy:
+
+| 参数 | 值 |
+|---|---|
+| 栅格步长 | 2 m |
+| RDP 简化容差 | 6 m |
+| 回归校验(4m 栅格) | 1449 / 1450 一致 |
+| 唯一不一致 | 荔波叁宝饭庄(距梨园路 359.2m、距县人民医院 363.2m,差 4m = 栅格步长,纯舍入) |
+| 产物体积 | A 版 17.5 KB(17 面)/ B 版 10.3 KB(16 面) |
+
+**原则**:多边形只用于**显示**,归属永远以**判定表**为准。避免点落在边界上时前端与库里不一致。
+
+### 2.3 两个候选版本
+
+
+
+*左 A 严格版 / 右 B 无缝版,底图为高德真实瓦片。黑点 = 1450 条 POI,红叉 = 落兜底的 50 条。*
+
+| | **A 严格版** | **B 无缝版** |
+|---|---|---|
+| 定义 | Voronoi ∩ 半径圆盘 + 城郊兜底面 | 去掉半径闸的纯最近锚点 Voronoi,裁剪到建成区轮廓 |
+| 与判定一致 | ✅ 100% | ❌ 50 条兜底 POI 会落进某个面内 |
+| 视觉 | ❌ 孤立圆圈拼贴,圆与圆之间大片空隙 | ✅ 无缝共边,像正经分区图 |
+| 具体缺陷 | 绿宝石/兴旺城南两个 600m 大圆罩在山地上、几乎无 POI;古镇片区盖到河对岸绿地 | 红船路城北被拉到东北角变形;绿宝石城西被切成两块 |
+
+> **已决策:采用 B 无缝版。** 理由:它是目前唯一能同时做到「无缝色块 + 明确边界线」的画法,满足需求第 3 条。
+
+---
+
+## 3. 现有 18 个片区,科学合理吗?
+
+用三把尺子量(评估样本:玉屏街道 1413 条,排除 3km 外的 37 个真郊外散点)。
+
+### 3.1 尺度检验 —— 18 块是对的
+
+建成区实测跨度 **3.0 × 5.3 km**。
+
+| 分块数 | 每块面积 | 等效半径 | 步行时间 |
+|---|---|---|---|
+| 12 | 0.62 km² | 445 m | 6 分钟 |
+| **18** | **0.41 km²** | **363 m** | **5 分钟** |
+| 24 | 0.31 km² | 314 m | 4 分钟 |
+
+363m / 步行 5 分钟正是商圈与旅游动线的标准粒度(大众点评商区本来就是这个量级)。**18 这个数字本身站得住。**
+
+### 3.2 纯几何角度 —— 数据只认 6~8 块
+
+K-means 轮廓系数扫描:
+
+| k | 轮廓系数 | k | 轮廓系数 |
+|---|---|---|---|
+| 6 | 0.453 | 18 | 0.404 |
+| **8** | **0.458 ← 峰值** | 20 | 0.416 |
+| 10 | 0.427 | 22 | 0.425 |
+| 12 | 0.414 | 24 | 0.426 |
+| 14 | 0.423 | 26 | 0.428 |
+| 16 | 0.414 | 28 | 0.421 |
+
+DBSCAN(不预设块数):
+
+| eps | 自然团数 | 噪声 |
+|---|---|---|
+| 150 m | **8 个** | 60 条 (4%) |
+| 200 m | 4 个 | 41 条 (3%) |
+| 250 m | 4 个 | 36 条 (3%) |
+| 300 m | 3 个 | 24 条 (2%) |
+
+**解读**:荔波县城的 POI 密度只有 6~8 个"真正的团",18 块是在大团内部按功能再切。**切细是业务选择,不是几何错误。**
+
+### 3.3 关键一条 —— 现有方案打赢了机器
+
+| | 轮廓系数 |
+|---|---|
+| **现有 17 片区 + 兜底** | **0.432** |
+| K-means 同样切 18 块 | 0.404 |
+| 两者一致性 ARI | 0.624 |
+
+大众点评商区质心确实抓到了真实商业中心,**人工口径优于纯几何**。这套锚点底子是好的,不需要推倒重来。
+
+### 3.4 但有三处实打实的毛病
+
+#### (a) 该合的没合
+
+锚点最近邻距离(排序):
+
+| 片区 | 最近邻 | 距离 | 诊断 |
+|---|---|---|---|
+| 荔波大剧院片区 | 第二中学片区 | **213 m** | ⚠️ 过近,实质重叠 |
+| 中医院片区 | 第二中学片区 | **327 m** | ⚠️ 过近 |
+| 荔波汽车站片区 | 荔波大剧院片区 | **374 m** | ⚠️ 过近 |
+| 梨园路片区 | 司法局片区 | 400 m | 临界 |
+| 荔波机场片区 | 红船路城北片区 | 7407 m | 间距过大 |
+
+**实测合并验证**:大剧院 + 第二中学合并后,全局轮廓系数 `0.4320 → 0.4367`,**变好**。合并后规模 73 条。
+
+#### (b) 该有的没有
+
+50 个兜底点做 DBSCAN(eps=350m, min_samples=4):**1 个簇 12 条 + 38 个散点**。
+
+- 簇质心 `107.88236, 25.41754`
+- 距大鹏购物中心锚点 608 m(闸 500m,**差 108m**)
+- 距荔波汽车站锚点 849 m(闸 500m,差 349m)
+- 内容:荔锦世纪1号停车场、人才公寓停车场、荔波荔锦客栈、金丰宜居酒店(荔波古镇店)、星奕酒店、万福饭店、荔波漫城民宿、荔波益民糕点坊、荔波沣霖宜佳酒店…
+
+这是一个**真实的商住聚集区**,17 个锚点里根本没有它,整簇掉进兜底。
+
+**50 条兜底点的真实构成**(去掉半径闸后按最近锚点归位):
+
+| 归属 | 条数 | 距锚点范围 |
+|---|---|---|
+| 荔波机场片区 | 15 | 2669~11726 m |
+| 红船路城北片区 | 14 | 788~15905 m |
+| 司法局片区 | 5 | 564~672 m |
+| 绿宝石大道城西片区 | 4 | 1807~3300 m |
+| 荔波汽车站片区 | 4 | 509~684 m |
+| 大鹏购物中心片区 | 3 | 593~618 m |
+| 其余 | 5 | 430~787 m |
+
+**其中距锚点 >1500m 的 30 条属真·郊外**,落兜底是对的;**另外 20 条是城里的漏网**,只差 100~350m 没进闸。
+
+> **结论:兜底区不是全错,是 40% 错——错在方案漏了一个片区,不在画法。**
+
+#### (c) 规模失衡,但大鹏不该拆
+
+| 片区 | POI | 半径闸 |
+|---|---|---|
+| 大鹏购物中心片区 | 269 | 500 m |
+| 梨园路片区 | 215 | 500 m |
+| 荔波古镇片区 | 209 | 500 m |
+| 司法局片区 | 208 | 500 m |
+| 荔波汽车站片区 | 125 | 500 m |
+| 中医院片区 | 86 | 500 m |
+| … | … | … |
+| 绿宝石大道城西片区 | 8 | 600 m |
+| 荔波机场片区 | 7 | 1500 m |
+
+最大/最小 = **269/7 = 38 倍**,基尼系数 **0.535**。
+
+**但拆分验证表明大鹏拆不开**:
+
+| 拆法 | 规模 | 子中心间距 | 结论 |
+|---|---|---|---|
+| 拆 2 块 | 126 / 143 | 331 m | ❌ 太近,硬切 |
+| 拆 3 块 | 105 / 71 / 93 | 267~368 m | ❌ 太近,硬切 |
+
+它就是真的一个大商圈。
+
+#### (d) 一个反直觉的发现
+
+逐片区体检(轮廓系数越低 = 与邻区越粘连):
+
+| 片区 | POI | 轮廓系数 | RMS 半径 |
+|---|---|---|---|
+| 城郊兜底区 | 20 | -0.525 | 1316 m |
+| 荔波大剧院片区 | 41 | 0.301 | 201 m |
+| 中医院片区 | 86 | 0.350 | 171 m |
+| 荔波汽车站片区 | 125 | 0.361 | 211 m |
+| 梨园路片区 | 215 | 0.366 | 209 m |
+| 大鹏购物中心片区 | 269 | 0.429 | 239 m |
+| 司法局片区 | 208 | 0.487 | 198 m |
+| 荔波古镇片区 | 209 | 0.521 | 202 m |
+| 中职学校片区 | 11 | 0.553 | 209 m |
+| 第二中学片区 | 32 | 0.621 | 105 m |
+| 罗家寨小学片区 | 20 | 0.653 | 196 m |
+| 兴旺城南片区 | 12 | 0.660 | 210 m |
+| 绿宝石大道城西片区 | 8 | 0.731 | 204 m |
+
+四个"偏空"片区(绿宝石 8 / 中职 11 / 兴旺城南 12 / 罗家寨 20)**轮廓系数反而最高**,因为它们孤立、边界干净。
+
+**它们不是错误,是城市边缘的独立小组团。** 问题只在于半径闸 600m 把圈画得太大,罩到了山地上。
+
+### 3.5 建议的 V2(净结果仍是 18 个)—— 待决策
+
+| 动作 | 依据 |
+|---|---|
+| 合并 `Z-LB-09` 大剧院 + `Z-LB-11` 二中 → 1 个(`Z-LB-11` 退役,按规则不复用) | 锚点 213m,合并后全局轮廓 0.4320→0.4367 |
+| 新增 `Z-LB-18`,锚点 `107.88236, 25.41754`(暂名"荔锦世纪片区") | 12 条无主 POI 成簇 |
+| 大鹏购物中心**不拆** | 拆 2/3 块子中心均 <400m,是硬切 |
+| **废掉半径闸**,判定改为「建成区内取最近锚点,建成区外为城郊」 | 400/500/600/1500 是拍脑袋的数;废掉后边缘片区不再罩山地,且面无缝、与判定一致 |
+
+预期效果:净片区 17+1 = **18 个**,兜底 50 → 约 30 条(且全在 1.5km 外,图上一看就是郊外)。
+
+按现行升版规则:另存 `ZS-LB-YUPING-V2`,**不覆盖 V1**,附与 V1 的 diff。
+
+---
+
+## 4. 孤立 POI 问题
+
+### 4.1 规模 —— 只有 37 条,2.5%
+
+玉屏街道 1453 条业务关系,按距城区核心距离分层:
+
+| 距离带 | 条数 | 占比 |
+|---|---|---|
+| < 1.5 km 城区核心 | 1355 | 93.3% |
+| 1.5~3 km 城区外围 | 61 | 4.2% |
+| 3~6 km 近郊 | 7 | 0.5% |
+| **> 6 km 远郊/村** | **30** | **2.1%** |
+
+### 4.2 但它们严重污染 B 版几何
+
+```
+建成区轮廓 bbox 含远点(现 B 版): 15.6 × 19.6 km
+ 只用 3km 内的点: 3.0 × 5.3 km
+```
+
+**面积膨胀 19 倍。** 地图上「红船路城北片区」伸向东北的触角、「绿宝石大道城西片区」西边那个瘤,就是被这几个远点撑出来的。
+
+### 4.3 这 37 条的构成 —— 不是噪声,是真东西
+
+**按业务类型**:交通 19 / 景区 7 / 酒店 7 / 美食 4
+
+**A. 真实的乡村旅游组团(约 25 条)**
+
+| 组团 | 距城 | 内容 |
+|---|---|---|
+| 水浦/水甫村 | 13.1~13.7 km | **黔南水族墓群(全国重点文保)**、水甫石棺墓群、水浦古建筑群、水浦里晓溪谷精品民宿 |
+| 水葩古寨 | 11.1~11.2 km | 水葩水寨、荔波墅·水云间庄园、临时停车场 |
+| 水利村 | 11.0 km | 小颍山居、荔波四海客栈 |
+| 水尧村 | 7.4~8.5 km | 阿彩餐馆、香阁生态牛肉馆、瑸琳烧烤、小杨哥餐馆 |
+| 机场 | 9.0~9.1 km | 航站楼、国内出发/到达、停车场×3 |
+| 水春河 | 6.6~9.7 km | 水春河景区、水春河漂流 |
+
+**B. 沿线设施(11 条)**
+
+- 8 个郊区公交站:尧棒 16.8km、拉更 15.2km、水龙 7.0km、月亮湖 5.5km、福利 4.8km、拉鱼 4.0km、水利 11.0km、兴盛达驾校 3.3km(全是荔波 9/10/11/12 路沿线)
+- 荔波服务区停车场 ×2(三荔高速)、312 省道边停车场
+
+### 4.4 建议的三层归属 —— 待决策
+
+1. **城区片区**(1416 条,97.5%)→ B 版 Voronoi,**建成区轮廓只用 3km 内的点算**,触角消失
+2. **郊野组团**(约 25 条)→ 独立编号(水葩古寨、水浦村、水利村、水尧村、水春河,机场沿用 `Z-LB-16`),**不参与县城 Voronoi**,地图上画成独立小圈 + 标签
+3. **沿线设施**(11 条)→ **不给片区**,标记「沿线设施」;公交站属于线路,已有公交图层管理
+
+> 这个思路 V1 其实已经开了头——`Z-LB-16 荔波机场片区`(距城 8km、半径闸 1500m)就是一个郊野组团,只是当时没系统化。
+
+---
+
+## 5. 村级归属:可行性分析
+
+### 5.1 出发点
+
+玉屏街道下辖 **7 社区 + 17 行政村 = 24 个基层单元**。远离城区的 POI 能不能挂到村里?
+
+### 5.2 分层命中率 —— 「12.8%」是被城区稀释的平均数
+
+按距城区核心距离分层做地址/名称文本匹配(玉屏街道内):
+
+| 距离带 | 条数 | 命中村 | 命中率 |
+|---|---|---|---|
+| 城区核心 < 1.5km | 1355 | 149 | 11.0% |
+| 城区外围 1.5~3km | 61 | 22 | 36.1% |
+| 近郊 3~6km | 7 | 4 | **57.1%** |
+| 远郊 > 6km | 30 | 14 | **46.7%** |
+| **全部** | **1453** | **189** | **13.0%** ← 总说明里那个数 |
+| **3km 外单独看** | **37** | **18** | **49%** |
+
+**远点命中率是城区的 4.5 倍。** 原因很直白:城里地址写「民生路81号」,村里只能写「水尧村老场组23号」。
+
+### 5.3 全县分乡镇命中率 —— 非玉屏更高
+
+| 乡镇 | POI | 命中 | 命中率 | 覆盖村数 |
+|---|---|---|---|---|
+| 朝阳镇 | 162 | 119 | **73.5%** | 4/6 |
+| 茂兰镇 | 31 | 21 | 67.7% | 4/10 |
+| 甲良镇 | 62 | 37 | 59.7% | 4/15 |
+| 瑶山瑶族乡 | 335 | 195 | 58.2% | 3/6 |
+| 小七孔镇 | 627 | 333 | 53.1% | 8/13 |
+| 黎明关水族乡 | 118 | 43 | 36.4% | 7/14 |
+| 玉屏街道 | 1450 | 188 | 13.0% | 16/24 |
+| 佳荣镇 | 14 | 1 | 7.1% | 1/12 |
+| **全县** | **2799** | **937** | **33.5%** | |
+
+**非玉屏 1349 条单独看:命中 749 = 55.5%**(地址 719 + 名称 30)
+
+命中最多的村:新街村 205、高桥村 106、瑶山村 86、绿林村 71、朝阳村 51、板麦村 45、拉欧村 35、尧所村 31、甲良村 27、茂兰村 17、洪江村 16、联山村 10
+
+### 5.4 加上空间传播 —— 两层到 96.6%
+
+以已命中的 POI 作种子,未命中的 POI 在**同乡镇内**找最近种子继承其村:
+
+| 方法 | 覆盖 | 全县命中率 | 非玉屏 |
+|---|---|---|---|
+| 第 1 层 地址/名称匹配 | 937 / 2799 | 33.5% | 55.5% |
+| **+ 空间传播 ≤300m** | **2562 / 2799** | **91.5%** ← 收益最大 | 90.7% |
+| + 空间传播 ≤500m | 2675 / 2799 | 95.6% | 93.0% |
+| + 空间传播 ≤800m | 2705 / 2799 | 96.6% | 94.2% |
+| + 空间传播 ≤1200m | 2718 / 2799 | 97.1% (收益衰减) | 95.0% |
+
+**300m 这一跳最值钱**(33.5% → 91.5%)——POI 高度聚集,一个种子能带一大片。
+
+**玉屏 37 条远点:18 → 22 条(59%)**,传播补上的四条都很干净:
+
+| POI | 传播结果 | 依据 |
+|---|---|---|
+| 水葩水寨 | 水甫村 | 130m 外的水云间庄园是种子 |
+| 临时停车场 | 水甫村 | 水葩水寨北侧 |
+| 小杨哥餐馆 | 水尧村 | — |
+| 月亮湖(公交站) | 福利村 | — |
+
+**两层都救不了的 15 条**,性质与 4.3 节的分类完全吻合:
+
+- 机场 7 条(独立枢纽,周边无任何村级 POI 做种子)
+- 高速服务区 / 省道停车场 3 条(路网设施)
+- 郊区公交站 4 条:拉更、水龙、拉鱼、兴盛达驾校(**是自然寨名,不在 24 个行政村名单里**)
+- 水江渔家 1 条(243 国道水江水库边,孤点)
+
+**这些本来就不该按"面"归属。**
+
+### 5.5 补充手段的可得性
+
+查了现有全县 2799 条 POI:
+
+| 目标 | 结果 |
+|---|---|
+| 村委会 / 村民委员会 / 村服务中心 | **0 条** |
+| 村卫生室 | **0 条** |
+| 玉屏 24 单元能找到同名地标 | 13 / 24(且含假阳性,如「水利局小区停车场」≠ 水利村) |
+
+**结论**:现有数据里没有村级锚点。若要做到 100% 覆盖,需**补采一轮**(高德搜「荔波县 XX村村委会」×100,一次性 100 次 API 调用),然后复用现有 `assign_zone.py` 的方法 A —— 只是把 17 个片区锚点换成 100 个村锚点,两套体系完全同构。
+
+### 5.6 四个必须先说清的风险
+
+**① 这是推断,不是判定**
+现行文档明确写了村级「待政府边界确认」。落库必须:
+- 新开字段 `村级归属_方式`(地址匹配 / 空间传播 / 人工指定 / 锚点判定)+ `村级归属_置信度`
+- 状态**保持"推断",不能写"已归属"**
+- 对外只能说"地址线索指向 XX 村",**不得**称"该 POI 属于 XX 村"
+
+**② 跨乡镇重名会大面积错配**
+`高桥村` 在小七孔镇和瑶山瑶族乡**都有**;`尧所村` 在黎明关 / 瑶山 / 朝阳都出现;`拉片村` 同样。
+本次分析是**先锁高德乡镇、只在乡镇内匹配**才避开的。乡镇级是高德给的可信数据,这个前提成立——但**绝不能跳过乡镇直接全县匹配**。
+
+**③ 空间传播会传错,需改成多数投票**
+现在是"跟随最近的 1 个种子",问题:村界处的 POI 会被随机划走;种子本身错了会连锁扩散。
+建议改为:取最近 K=5 个种子的多数票;最近与次近属不同村且距离接近时标"待确认"。
+
+**④ 别名表是刚需**
+`水甫村 ↔ 水浦村` 这一个别名就救了 6 条。
+而拉更、水龙、拉鱼、月亮湖是**村以下的自然寨/组**,24 个行政村名单里没有——要么单独扩表,要么承认村以下不做。
+
+---
+
+## 6. 一个衍生的重要建议:全县应该是两套划分
+
+其他 7 个乡镇的村级结构**比玉屏清晰得多**:新街村 205 条、高桥村 106、瑶山村 86、绿林村 71……小七孔镇 627 条里 333 条直接定位到 8 个村。
+
+**建议全县区域划分不是一套方案,而是两套:**
+
+| 范围 | POI | 划分方式 |
+|---|---|---|
+| 玉屏街道(城区) | 1450 | **18 个业务片区**(B 版 Voronoi) |
+| 其他 7 个乡镇 | 1349 | **直接用村**(村本来就是天然分区,不需要再造"片区"概念) |
+
+对应的「区域图层」层级:
+
+```
+全县(默认)
+ └─ 乡镇(8 个)
+ ├─ 玉屏街道 → 下钻看 18 个业务片区
+ └─ 其他 7 乡镇 → 下钻看村
+```
+
+这完全符合「两轨永不互推」原则——乡镇和村是**法定轨**,片区是**业务轨**,只在玉屏城区叠加。
+
+---
+
+## 7. 落库结构(已决策:分表存)
+
+| 表 | 内容 | 轨 |
+|---|---|---|
+| `amap_spatial_pois` 增列 `towncode` / `town_name` | 法定归属,一对一、稳定、不可手改 | 法定 |
+| `libo_admin_towns`(新建,8 行) | 乡级行政区维表 | 法定 |
+| `libo_admin_villages`(新建,100 行) | 村社区维表 | 法定 |
+| `libo_business_zones`(新建,18 行) | 片区锚点维表,带 `zone_set_id` | 业务 |
+| `poi_zone_assignments`(新建) | 片区归属,主键 `graph_name + gaode_poi_id + zone_set_id` | 业务 |
+| `poi_village_hints`(新建,待定) | 村级归属**推断**,带方式 + 置信度 | 推断 |
+
+**理由**:片区归属单独成表且带 `zone_set_id`,才能做到 V2 与 V1 并存、不覆盖、可 diff——符合现行升版规则。若全塞进 `amap_spatial_pois` 宽表,升版必然覆盖。
+
+---
+
+## 8. 决策状态汇总
+
+### ✅ 已决策
+
+| 项 | 决定 |
+|---|---|
+| 落库结构 | 分表存(法定 / 业务 / 推断三轨) |
+| 片区面画法 | 与判定规则等价的"科学"画法,非示意图 |
+| 片区面版本 | **B 无缝版**(唯一能同时做到无缝色块 + 明确边界线) |
+
+### ⏳ 待决策
+
+| # | 议题 | 选项 |
+|---|---|---|
+| 1 | **片区方案是否升 V2** | 全量走 V2(合并大剧院+二中、新增 Z-LB-18、废半径闸)/ 只废半径闸 / 只新增 Z-LB-18 / 保持 V1 |
+| 2 | **建成区轮廓口径** | 3km 内 POI + 380m 缓冲一次算定固化 / 每次动态重算 / 按道路河流手工划 |
+| 3 | **37 条孤立 POI 处理** | 三层归属(城区片区 / 郊野组团 / 沿线设施)/ 只排除沿线设施 / 全部排出县城体系另建表 |
+| 4 | **村级归属是否做** | 做(两层到 96.6%)/ 做且补采村锚点到 100% / 不做 |
+| 5 | **其他 7 乡镇** | 用村(推荐)/ 也造片区 |
+| 6 | **乡镇层地图表现** | 无边界线只着色+标签 / POI 凹包虚线示意 / 另找矢量 |
+| 7 | **下载导出** | 精简 CSV / 精简+全量双按钮 / CSV+GeoJSON / 暂不做 |
+
+---
+
+## 9. 已产出的中间产物
+
+**已随本文档提交**(`docs/assets/`):
+
+| 文件 | 说明 |
+|---|---|
+| `云游荔波_片区面几何_AB对比.png` | A / B 两版几何叠高德真实底图对比 |
+| `zones_A_strict.geojson` | 17.5 KB,17 个面,已 RDP 简化,可直接上前端 |
+| `zones_B_seamless.geojson` | 10.3 KB,16 个面,已 RDP 简化,可直接上前端 |
+
+**仍在临时目录、未进仓库**(分析脚本,可复现所有数字):
+
+| 文件 | 说明 |
+|---|---|
+| `zone_geom_v2.py` | 几何生成器,改锚点表重跑即可 |
+| `render_on_basemap.py` | 高德瓦片底图 + 面渲染 |
+| `zone_preview.html` | 可交互对比页(本地起 http server 查看) |
+| `yuping_pois.json` | 玉屏 1450 条 POI 点位 |
+
+---
+
+## 10. 尚未开始的三块实现工作
+
+1. **数据补充**:DB 迁移 + 导入脚本(三份 CSV → 六张表)
+2. **区域图层**:公交图层旁增加区域切换,选中高亮 + POI 过滤 + 下载
+3. **POI 搜索**:2799 条已全在前端内存,**纯前端模糊匹配即可**,无需新接口;选中飞入并复用现有 POI 详情弹窗
+
+---
+
+*本文档由 2026-08-06 的方案讨论整理而成。所有数字均为实测,分析脚本可复现。文档生成时未写入任何代码、未改动任何数据。*
diff --git a/docs/关系数据库与通用数据平台技术方案.md b/docs/关系数据库与通用数据平台技术方案.md
index 1462977..dae2876 100644
--- a/docs/关系数据库与通用数据平台技术方案.md
+++ b/docs/关系数据库与通用数据平台技术方案.md
@@ -121,7 +121,7 @@ flowchart TB
| 表 | 作用 |
| --- | --- |
-| `projects` | 项目工作区 |
+| `projects` | 图谱中心 |
| `data_table_registry` | 注册通用后台可管理的业务表 |
| `data_field_registry` | 定义业务表字段显示和校验规则 |
| `data_relation_registry` | 定义表之间的关联 |
diff --git a/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json b/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json
new file mode 100644
index 0000000..f5a9ce4
--- /dev/null
+++ b/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json
@@ -0,0 +1,1128 @@
+{
+ "nodes": [
+ {
+ "id": "area_xiaoqikong",
+ "type": "ScenicArea",
+ "properties": {
+ "name": "小七孔景区",
+ "level": "AAAAA",
+ "rating": 4.9,
+ "open": true,
+ "tags": [
+ "喀斯特",
+ "亲子",
+ "自然风光"
+ ],
+ "geo": {
+ "longitude": 107.741,
+ "latitude": 25.254
+ },
+ "description": "本文件中的业务属性均为创建流程测试样例,不作为真实经营信息。"
+ }
+ },
+ {
+ "id": "area_daqikong",
+ "type": "ScenicArea",
+ "properties": {
+ "name": "大七孔景区",
+ "level": "AAAA",
+ "rating": 4.7,
+ "open": true,
+ "tags": [
+ "峡谷",
+ "天生桥",
+ "徒步"
+ ],
+ "geo": {
+ "longitude": 107.727,
+ "latitude": 25.287
+ },
+ "description": "复杂图谱手工测试使用的景区节点。"
+ }
+ },
+ {
+ "id": "spot_wolongtan",
+ "type": "ScenicSpot",
+ "properties": {
+ "name": "卧龙潭",
+ "spot_type": "waterfall",
+ "visit_minutes": 70,
+ "indoor": false,
+ "best_seasons": [
+ "spring",
+ "summer",
+ "autumn"
+ ],
+ "description": "用于测试瀑布类景点和附近资源关系。"
+ }
+ },
+ {
+ "id": "spot_laya_waterfall",
+ "type": "ScenicSpot",
+ "properties": {
+ "name": "拉雅瀑布",
+ "spot_type": "waterfall",
+ "visit_minutes": 45,
+ "indoor": false,
+ "best_seasons": [
+ "summer",
+ "autumn"
+ ],
+ "description": "用于测试摄影活动与路线游览顺序。"
+ }
+ },
+ {
+ "id": "spot_yuanyang_lake",
+ "type": "ScenicSpot",
+ "properties": {
+ "name": "鸳鸯湖",
+ "spot_type": "lake",
+ "visit_minutes": 100,
+ "indoor": false,
+ "best_seasons": [
+ "spring",
+ "summer"
+ ],
+ "description": "用于测试游船活动。"
+ }
+ },
+ {
+ "id": "spot_tiansheng_bridge",
+ "type": "ScenicSpot",
+ "properties": {
+ "name": "天生桥",
+ "spot_type": "geological_landscape",
+ "visit_minutes": 90,
+ "indoor": false,
+ "best_seasons": [
+ "spring",
+ "autumn",
+ "winter"
+ ],
+ "description": "用于测试地质景观节点。"
+ }
+ },
+ {
+ "id": "spot_yaofeng_cave",
+ "type": "ScenicSpot",
+ "properties": {
+ "name": "妖风洞测试点",
+ "spot_type": "cave",
+ "visit_minutes": 55,
+ "indoor": true,
+ "best_seasons": [
+ "all_year"
+ ],
+ "description": "虚构测试点,仅用于验证室内景点和布尔属性。"
+ }
+ },
+ {
+ "id": "hotel_libo_mountain",
+ "type": "Hotel",
+ "properties": {
+ "name": "测试·荔波山水酒店",
+ "stars": 4,
+ "price_from": 468.0,
+ "family_friendly": true,
+ "amenities": [
+ "早餐",
+ "停车场",
+ "接送服务"
+ ],
+ "address": "荔波县测试路 1 号",
+ "contact": {
+ "phone": "0854-0000001",
+ "service": "07:00-23:00"
+ }
+ }
+ },
+ {
+ "id": "hotel_xiaoqikong_family",
+ "type": "Hotel",
+ "properties": {
+ "name": "测试·小七孔亲子酒店",
+ "stars": 5,
+ "price_from": 688.0,
+ "family_friendly": true,
+ "amenities": [
+ "亲子房",
+ "儿童餐",
+ "洗衣房"
+ ],
+ "address": "小七孔东门测试区 8 号",
+ "contact": {
+ "phone": "0854-0000002",
+ "service": "24h"
+ }
+ }
+ },
+ {
+ "id": "hotel_daqikong_riverside",
+ "type": "Hotel",
+ "properties": {
+ "name": "测试·大七孔河畔酒店",
+ "stars": 3,
+ "price_from": 328.0,
+ "family_friendly": false,
+ "amenities": [
+ "早餐",
+ "行李寄存"
+ ],
+ "address": "大七孔测试区 3 号",
+ "contact": {
+ "phone": "0854-0000003",
+ "service": "06:30-22:00"
+ }
+ }
+ },
+ {
+ "id": "restaurant_sour_fish",
+ "type": "Restaurant",
+ "properties": {
+ "name": "测试·荔波酸汤鱼馆",
+ "cuisine": "黔菜",
+ "avg_price": 92.0,
+ "specialties": [
+ "酸汤鱼",
+ "丝娃娃"
+ ],
+ "vegetarian_options": true,
+ "opening_hours": "10:30-21:30"
+ }
+ },
+ {
+ "id": "restaurant_xiaoqikong_ecology",
+ "type": "Restaurant",
+ "properties": {
+ "name": "测试·小七孔生态餐厅",
+ "cuisine": "本地家常菜",
+ "avg_price": 68.0,
+ "specialties": [
+ "豆花烤鱼",
+ "时蔬拼盘"
+ ],
+ "vegetarian_options": true,
+ "opening_hours": "09:30-20:30"
+ }
+ },
+ {
+ "id": "restaurant_yaoshan_flavor",
+ "type": "Restaurant",
+ "properties": {
+ "name": "测试·瑶山风味馆",
+ "cuisine": "瑶族风味",
+ "avg_price": 118.0,
+ "specialties": [
+ "长桌宴",
+ "竹筒饭"
+ ],
+ "vegetarian_options": false,
+ "opening_hours": "11:00-22:00"
+ }
+ },
+ {
+ "id": "hub_libo_railway",
+ "type": "TransportHub",
+ "properties": {
+ "name": "荔波高铁站",
+ "hub_type": "railway_station",
+ "service_hours": "06:00-23:00",
+ "accessible": true,
+ "facilities": [
+ "无障碍通道",
+ "行李寄存",
+ "旅游咨询"
+ ],
+ "geo": {
+ "longitude": 107.887,
+ "latitude": 25.401
+ }
+ }
+ },
+ {
+ "id": "hub_libo_bus",
+ "type": "TransportHub",
+ "properties": {
+ "name": "荔波汽车站",
+ "hub_type": "bus_station",
+ "service_hours": "06:30-20:30",
+ "accessible": true,
+ "facilities": [
+ "售票厅",
+ "候车室"
+ ],
+ "geo": {
+ "longitude": 107.882,
+ "latitude": 25.413
+ }
+ }
+ },
+ {
+ "id": "hub_xiaoqikong_east",
+ "type": "TransportHub",
+ "properties": {
+ "name": "小七孔东门游客中心",
+ "hub_type": "visitor_center",
+ "service_hours": "07:30-18:30",
+ "accessible": true,
+ "facilities": [
+ "换乘车",
+ "卫生间",
+ "母婴室"
+ ],
+ "geo": {
+ "longitude": 107.748,
+ "latitude": 25.251
+ }
+ }
+ },
+ {
+ "id": "route_family_3d",
+ "type": "TourRoute",
+ "properties": {
+ "name": "荔波亲子自然三日测试线路",
+ "days": 3,
+ "theme": "family_nature",
+ "suitable_for": [
+ "亲子家庭",
+ "自然爱好者"
+ ],
+ "base_price": 1980.0,
+ "active": true,
+ "extra_metadata": {
+ "test_batch": "manual-20260805",
+ "difficulty": 2
+ }
+ }
+ },
+ {
+ "id": "route_depth_2d",
+ "type": "TourRoute",
+ "properties": {
+ "name": "荔波地貌深度两日测试线路",
+ "days": 2,
+ "theme": "geology_hiking",
+ "suitable_for": [
+ "摄影爱好者",
+ "徒步游客"
+ ],
+ "base_price": 1360.0,
+ "active": true,
+ "extra_metadata": "允许自由扩展的测试文本"
+ }
+ },
+ {
+ "id": "day_family_1",
+ "type": "RouteDay",
+ "properties": {
+ "title": "高铁抵达与瀑布初体验",
+ "day_no": 1,
+ "summary": "抵达荔波后游览卧龙潭和拉雅瀑布。",
+ "estimated_cost": 520.0
+ }
+ },
+ {
+ "id": "day_family_2",
+ "type": "RouteDay",
+ "properties": {
+ "title": "鸳鸯湖亲子体验",
+ "day_no": 2,
+ "summary": "安排鸳鸯湖游船和自然课堂。",
+ "estimated_cost": 680.0
+ }
+ },
+ {
+ "id": "day_family_3",
+ "type": "RouteDay",
+ "properties": {
+ "title": "大七孔地貌探索",
+ "day_no": 3,
+ "summary": "游览天生桥并完成返程。",
+ "estimated_cost": 430.0
+ }
+ },
+ {
+ "id": "day_depth_1",
+ "type": "RouteDay",
+ "properties": {
+ "title": "小七孔摄影深度日",
+ "day_no": 1,
+ "summary": "按卧龙潭、拉雅瀑布、鸳鸯湖顺序深度游览。",
+ "estimated_cost": 620.0
+ }
+ },
+ {
+ "id": "day_depth_2",
+ "type": "RouteDay",
+ "properties": {
+ "title": "大七孔峡谷徒步日",
+ "day_no": 2,
+ "summary": "天生桥和洞穴测试点组合徒步。",
+ "estimated_cost": 510.0
+ }
+ },
+ {
+ "id": "activity_boat",
+ "type": "Activity",
+ "properties": {
+ "name": "鸳鸯湖测试游船",
+ "activity_type": "boat",
+ "duration_minutes": 45,
+ "min_age": 6,
+ "booking_required": true,
+ "equipment": [
+ "救生衣"
+ ]
+ }
+ },
+ {
+ "id": "activity_photography",
+ "type": "Activity",
+ "properties": {
+ "name": "瀑布慢门摄影体验",
+ "activity_type": "photography",
+ "duration_minutes": 60,
+ "min_age": 12,
+ "booking_required": false,
+ "equipment": [
+ "三脚架",
+ "减光镜"
+ ]
+ }
+ },
+ {
+ "id": "activity_stargazing",
+ "type": "Activity",
+ "properties": {
+ "name": "河畔星空讲解",
+ "activity_type": "education",
+ "duration_minutes": 50,
+ "min_age": 8,
+ "booking_required": true,
+ "equipment": [
+ "望远镜",
+ "防潮垫"
+ ]
+ }
+ },
+ {
+ "id": "activity_nature_class",
+ "type": "Activity",
+ "properties": {
+ "name": "喀斯特亲子自然课堂",
+ "activity_type": "family_education",
+ "duration_minutes": 80,
+ "min_age": 5,
+ "booking_required": true,
+ "equipment": [
+ "观察手册",
+ "放大镜"
+ ]
+ }
+ },
+ {
+ "id": "ticket_xiaoqikong",
+ "type": "TicketPolicy",
+ "properties": {
+ "name": "小七孔 2026 测试票务政策",
+ "adult_price": 170.0,
+ "child_price": 85.0,
+ "free_policy": "测试规则:身高 1.2 米以下儿童免票。",
+ "valid_from": "2026-01-01",
+ "refundable": true
+ }
+ },
+ {
+ "id": "ticket_daqikong",
+ "type": "TicketPolicy",
+ "properties": {
+ "name": "大七孔 2026 测试票务政策",
+ "adult_price": 90.0,
+ "child_price": 45.0,
+ "free_policy": "测试规则:证件优惠以现场为准。",
+ "valid_from": "2026-01-01",
+ "refundable": false
+ }
+ },
+ {
+ "id": "tip_rainy",
+ "type": "TravelTip",
+ "properties": {
+ "title": "雨季步道安全测试提示",
+ "content": "雨天路面湿滑,请穿防滑鞋并预留更长游览时间。",
+ "severity": "warning",
+ "audiences": [
+ "老人",
+ "儿童",
+ "徒步游客"
+ ],
+ "updated_at": "2026-08-05T09:00:00+08:00"
+ }
+ },
+ {
+ "id": "tip_peak_booking",
+ "type": "TravelTip",
+ "properties": {
+ "title": "高峰期预约测试提示",
+ "content": "节假日建议提前预约门票、酒店和体验活动。",
+ "severity": "info",
+ "audiences": [
+ "全部游客"
+ ],
+ "updated_at": "2026-08-05T09:15:00+08:00"
+ }
+ },
+ {
+ "id": "tip_family",
+ "type": "TravelTip",
+ "properties": {
+ "title": "亲子装备测试提示",
+ "content": "建议携带替换衣物、驱蚊用品和儿童饮水。",
+ "severity": "info",
+ "audiences": [
+ "亲子家庭"
+ ],
+ "updated_at": "2026-08-05T09:30:00+08:00"
+ }
+ }
+ ],
+ "relations": [
+ {
+ "type": "CONTAINS",
+ "source": "area_xiaoqikong",
+ "target": "spot_wolongtan",
+ "properties": {
+ "recommended_order": 1
+ }
+ },
+ {
+ "type": "CONTAINS",
+ "source": "area_xiaoqikong",
+ "target": "spot_laya_waterfall",
+ "properties": {
+ "recommended_order": 2
+ }
+ },
+ {
+ "type": "CONTAINS",
+ "source": "area_xiaoqikong",
+ "target": "spot_yuanyang_lake",
+ "properties": {
+ "recommended_order": 3
+ }
+ },
+ {
+ "type": "CONTAINS",
+ "source": "area_daqikong",
+ "target": "spot_tiansheng_bridge",
+ "properties": {
+ "recommended_order": 1
+ }
+ },
+ {
+ "type": "CONTAINS",
+ "source": "area_daqikong",
+ "target": "spot_yaofeng_cave",
+ "properties": {
+ "recommended_order": 2
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_wolongtan",
+ "target": "hotel_xiaoqikong_family",
+ "properties": {
+ "distance_km": 6.8,
+ "drive_minutes": 15,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_wolongtan",
+ "target": "restaurant_xiaoqikong_ecology",
+ "properties": {
+ "distance_km": 5.2,
+ "drive_minutes": 12,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_laya_waterfall",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "distance_km": 4.0,
+ "drive_minutes": 10,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_laya_waterfall",
+ "target": "restaurant_xiaoqikong_ecology",
+ "properties": {
+ "distance_km": 4.8,
+ "drive_minutes": 11,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_yuanyang_lake",
+ "target": "hotel_xiaoqikong_family",
+ "properties": {
+ "distance_km": 3.2,
+ "drive_minutes": 8,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_yuanyang_lake",
+ "target": "restaurant_xiaoqikong_ecology",
+ "properties": {
+ "distance_km": 3.6,
+ "drive_minutes": 9,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_tiansheng_bridge",
+ "target": "hotel_daqikong_riverside",
+ "properties": {
+ "distance_km": 4.5,
+ "drive_minutes": 10,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_tiansheng_bridge",
+ "target": "restaurant_yaoshan_flavor",
+ "properties": {
+ "distance_km": 12.0,
+ "drive_minutes": 22,
+ "walkable": false
+ }
+ },
+ {
+ "type": "NEARBY",
+ "source": "spot_yaofeng_cave",
+ "target": "hub_libo_bus",
+ "properties": {
+ "distance_km": 26.0,
+ "drive_minutes": 40,
+ "walkable": false
+ }
+ },
+ {
+ "type": "ROUTE_HAS_DAY",
+ "source": "route_family_3d",
+ "target": "day_family_1",
+ "properties": {
+ "order": 1
+ }
+ },
+ {
+ "type": "ROUTE_HAS_DAY",
+ "source": "route_family_3d",
+ "target": "day_family_2",
+ "properties": {
+ "order": 2
+ }
+ },
+ {
+ "type": "ROUTE_HAS_DAY",
+ "source": "route_family_3d",
+ "target": "day_family_3",
+ "properties": {
+ "order": 3
+ }
+ },
+ {
+ "type": "ROUTE_HAS_DAY",
+ "source": "route_depth_2d",
+ "target": "day_depth_1",
+ "properties": {
+ "order": 1
+ }
+ },
+ {
+ "type": "ROUTE_HAS_DAY",
+ "source": "route_depth_2d",
+ "target": "day_depth_2",
+ "properties": {
+ "order": 2
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_family_1",
+ "target": "spot_wolongtan",
+ "properties": {
+ "order": 1,
+ "stay_minutes": 70,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_family_1",
+ "target": "spot_laya_waterfall",
+ "properties": {
+ "order": 2,
+ "stay_minutes": 45,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_family_2",
+ "target": "spot_yuanyang_lake",
+ "properties": {
+ "order": 1,
+ "stay_minutes": 120,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_family_3",
+ "target": "spot_tiansheng_bridge",
+ "properties": {
+ "order": 1,
+ "stay_minutes": 90,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_family_3",
+ "target": "spot_yaofeng_cave",
+ "properties": {
+ "order": 2,
+ "stay_minutes": 55,
+ "must_visit": false
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_depth_1",
+ "target": "spot_wolongtan",
+ "properties": {
+ "order": 1,
+ "stay_minutes": 100,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_depth_1",
+ "target": "spot_laya_waterfall",
+ "properties": {
+ "order": 2,
+ "stay_minutes": 80,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_depth_1",
+ "target": "spot_yuanyang_lake",
+ "properties": {
+ "order": 3,
+ "stay_minutes": 110,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_depth_2",
+ "target": "spot_tiansheng_bridge",
+ "properties": {
+ "order": 1,
+ "stay_minutes": 120,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_VISITS",
+ "source": "day_depth_2",
+ "target": "spot_yaofeng_cave",
+ "properties": {
+ "order": 2,
+ "stay_minutes": 70,
+ "must_visit": true
+ }
+ },
+ {
+ "type": "DAY_STAYS_AT",
+ "source": "day_family_1",
+ "target": "hotel_xiaoqikong_family",
+ "properties": {
+ "check_in": "18:30",
+ "nights": 1
+ }
+ },
+ {
+ "type": "DAY_STAYS_AT",
+ "source": "day_family_2",
+ "target": "hotel_xiaoqikong_family",
+ "properties": {
+ "check_in": "19:00",
+ "nights": 1
+ }
+ },
+ {
+ "type": "DAY_STAYS_AT",
+ "source": "day_family_3",
+ "target": "hotel_libo_mountain",
+ "properties": {
+ "check_in": "17:30",
+ "nights": 1
+ }
+ },
+ {
+ "type": "DAY_STAYS_AT",
+ "source": "day_depth_1",
+ "target": "hotel_libo_mountain",
+ "properties": {
+ "check_in": "20:00",
+ "nights": 1
+ }
+ },
+ {
+ "type": "DAY_STAYS_AT",
+ "source": "day_depth_2",
+ "target": "hotel_daqikong_riverside",
+ "properties": {
+ "check_in": "18:00",
+ "nights": 1
+ }
+ },
+ {
+ "type": "DAY_DINES_AT",
+ "source": "day_family_1",
+ "target": "restaurant_sour_fish",
+ "properties": {
+ "meal": "dinner",
+ "reservation_required": true
+ }
+ },
+ {
+ "type": "DAY_DINES_AT",
+ "source": "day_family_2",
+ "target": "restaurant_xiaoqikong_ecology",
+ "properties": {
+ "meal": "lunch",
+ "reservation_required": false
+ }
+ },
+ {
+ "type": "DAY_DINES_AT",
+ "source": "day_family_3",
+ "target": "restaurant_yaoshan_flavor",
+ "properties": {
+ "meal": "lunch",
+ "reservation_required": true
+ }
+ },
+ {
+ "type": "DAY_DINES_AT",
+ "source": "day_depth_1",
+ "target": "restaurant_xiaoqikong_ecology",
+ "properties": {
+ "meal": "dinner",
+ "reservation_required": false
+ }
+ },
+ {
+ "type": "DAY_DINES_AT",
+ "source": "day_depth_2",
+ "target": "restaurant_sour_fish",
+ "properties": {
+ "meal": "dinner",
+ "reservation_required": true
+ }
+ },
+ {
+ "type": "DAY_USES_HUB",
+ "source": "day_family_1",
+ "target": "hub_libo_railway",
+ "properties": {
+ "mode": "high_speed_rail",
+ "departure_time": "10:20"
+ }
+ },
+ {
+ "type": "DAY_USES_HUB",
+ "source": "day_family_2",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "mode": "scenic_shuttle",
+ "departure_time": "08:00"
+ }
+ },
+ {
+ "type": "DAY_USES_HUB",
+ "source": "day_family_3",
+ "target": "hub_libo_bus",
+ "properties": {
+ "mode": "coach",
+ "departure_time": "16:30"
+ }
+ },
+ {
+ "type": "DAY_USES_HUB",
+ "source": "day_depth_1",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "mode": "scenic_shuttle",
+ "departure_time": "07:30"
+ }
+ },
+ {
+ "type": "DAY_USES_HUB",
+ "source": "day_depth_2",
+ "target": "hub_libo_railway",
+ "properties": {
+ "mode": "chartered_bus",
+ "departure_time": "17:20"
+ }
+ },
+ {
+ "type": "OFFERS_ACTIVITY",
+ "source": "spot_yuanyang_lake",
+ "target": "activity_boat",
+ "properties": {
+ "season": "summer",
+ "extra_fee": 60.0
+ }
+ },
+ {
+ "type": "OFFERS_ACTIVITY",
+ "source": "spot_laya_waterfall",
+ "target": "activity_photography",
+ "properties": {
+ "season": "all_year",
+ "extra_fee": 0.0
+ }
+ },
+ {
+ "type": "OFFERS_ACTIVITY",
+ "source": "spot_tiansheng_bridge",
+ "target": "activity_stargazing",
+ "properties": {
+ "season": "autumn",
+ "extra_fee": 88.0
+ }
+ },
+ {
+ "type": "OFFERS_ACTIVITY",
+ "source": "spot_wolongtan",
+ "target": "activity_nature_class",
+ "properties": {
+ "season": "spring",
+ "extra_fee": 120.0
+ }
+ },
+ {
+ "type": "HAS_TICKET_POLICY",
+ "source": "area_xiaoqikong",
+ "target": "ticket_xiaoqikong",
+ "properties": {
+ "channel": "official_test_channel"
+ }
+ },
+ {
+ "type": "HAS_TICKET_POLICY",
+ "source": "area_daqikong",
+ "target": "ticket_daqikong",
+ "properties": {
+ "channel": "official_test_channel"
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_rainy",
+ "target": "area_xiaoqikong",
+ "properties": {
+ "context": "rainy_season",
+ "priority": 1
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_rainy",
+ "target": "spot_tiansheng_bridge",
+ "properties": {
+ "context": "wet_trail",
+ "priority": 1
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_peak_booking",
+ "target": "route_family_3d",
+ "properties": {
+ "context": "public_holiday",
+ "priority": 2
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_peak_booking",
+ "target": "hotel_xiaoqikong_family",
+ "properties": {
+ "context": "room_inventory",
+ "priority": 2
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_family",
+ "target": "route_family_3d",
+ "properties": {
+ "context": "family_trip",
+ "priority": 3
+ }
+ },
+ {
+ "type": "APPLIES_TIP",
+ "source": "tip_family",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "context": "visitor_center",
+ "priority": 3
+ }
+ },
+ {
+ "type": "ROUTE_STARTS_AT",
+ "source": "route_family_3d",
+ "target": "hub_libo_railway",
+ "properties": {
+ "departure_time": "10:30"
+ }
+ },
+ {
+ "type": "ROUTE_STARTS_AT",
+ "source": "route_depth_2d",
+ "target": "hub_libo_bus",
+ "properties": {
+ "departure_time": "07:00"
+ }
+ },
+ {
+ "type": "ROUTE_ENDS_AT",
+ "source": "route_family_3d",
+ "target": "hub_libo_railway",
+ "properties": {
+ "arrival_time": "18:10"
+ }
+ },
+ {
+ "type": "ROUTE_ENDS_AT",
+ "source": "route_depth_2d",
+ "target": "hub_libo_railway",
+ "properties": {
+ "arrival_time": "17:40"
+ }
+ },
+ {
+ "type": "ALTERNATIVE_TO",
+ "source": "hotel_xiaoqikong_family",
+ "target": "hotel_libo_mountain",
+ "properties": {
+ "reason": "亲子房满房时可替换为县城酒店。",
+ "score": 0.82
+ }
+ },
+ {
+ "type": "ALTERNATIVE_TO",
+ "source": "restaurant_xiaoqikong_ecology",
+ "target": "restaurant_sour_fish",
+ "properties": {
+ "reason": "景区餐厅满位时可切换到县城餐厅。",
+ "score": 0.76
+ }
+ },
+ {
+ "type": "CONNECTS",
+ "source": "hub_libo_railway",
+ "target": "hub_libo_bus",
+ "properties": {
+ "mode": "city_bus",
+ "duration_minutes": 25,
+ "distance_km": 14.5
+ }
+ },
+ {
+ "type": "CONNECTS",
+ "source": "hub_libo_bus",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "mode": "tourist_coach",
+ "duration_minutes": 45,
+ "distance_km": 29.0
+ }
+ },
+ {
+ "type": "CONNECTS",
+ "source": "hub_libo_railway",
+ "target": "hub_xiaoqikong_east",
+ "properties": {
+ "mode": "direct_shuttle",
+ "duration_minutes": 55,
+ "distance_km": 36.0
+ }
+ },
+ {
+ "type": "CONNECTS",
+ "source": "hub_xiaoqikong_east",
+ "target": "hub_libo_railway",
+ "properties": {
+ "mode": "return_shuttle",
+ "duration_minutes": 60,
+ "distance_km": 36.0
+ }
+ },
+ {
+ "type": "RECOMMENDS",
+ "source": "route_family_3d",
+ "target": "activity_boat",
+ "properties": {
+ "priority": 1,
+ "reason": "适合亲子共同参与。"
+ }
+ },
+ {
+ "type": "RECOMMENDS",
+ "source": "route_family_3d",
+ "target": "activity_nature_class",
+ "properties": {
+ "priority": 2,
+ "reason": "补充自然科普体验。"
+ }
+ },
+ {
+ "type": "RECOMMENDS",
+ "source": "route_depth_2d",
+ "target": "activity_photography",
+ "properties": {
+ "priority": 1,
+ "reason": "符合摄影主题。"
+ }
+ },
+ {
+ "type": "RECOMMENDS",
+ "source": "route_depth_2d",
+ "target": "activity_stargazing",
+ "properties": {
+ "priority": 2,
+ "reason": "适合作为徒步后的夜间活动。"
+ }
+ }
+ ]
+}
diff --git a/qa/graph-creation-fixtures/libo_complex_schema.v1.json b/qa/graph-creation-fixtures/libo_complex_schema.v1.json
new file mode 100644
index 0000000..ac3f5d1
--- /dev/null
+++ b/qa/graph-creation-fixtures/libo_complex_schema.v1.json
@@ -0,0 +1,512 @@
+{
+ "namespace": "libo_complex_manual_test_20260805_01",
+ "version": "1.0.0",
+ "display_name": "荔波旅游复杂测试模型",
+ "description": "用于手工验证图谱创建、Schema 校验、节点导入、复杂关系、图谱浏览和安全删除的综合测试模型。",
+ "enums": {
+ "Season": [
+ "spring",
+ "summer",
+ "autumn",
+ "winter",
+ "all_year"
+ ],
+ "TipSeverity": [
+ "info",
+ "warning",
+ "critical"
+ ],
+ "MealType": [
+ "breakfast",
+ "lunch",
+ "dinner"
+ ]
+ },
+ "design_principles": [
+ "TourRoute 通过 RouteDay 表达有序的多日行程。",
+ "RouteDay 分别关联游览点、酒店、餐厅和交通枢纽。",
+ "ScenicArea 与 ScenicSpot 分层表达景区和景点。",
+ "票务政策和旅行提示作为独立实体,便于复用和版本管理。"
+ ],
+ "entity_types": {
+ "ScenicArea": {
+ "label": "景区",
+ "description": "可包含多个具体景点的景区级实体。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true,
+ "description": "景区名称"
+ },
+ "level": {
+ "type": "string",
+ "description": "景区等级"
+ },
+ "rating": {
+ "type": "number",
+ "description": "综合评分"
+ },
+ "open": {
+ "type": "boolean",
+ "description": "是否开放"
+ },
+ "tags": {
+ "type": "array",
+ "description": "景区标签"
+ },
+ "geo": {
+ "type": "object",
+ "description": "经纬度对象"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ },
+ "ScenicSpot": {
+ "label": "景点",
+ "description": "景区内可独立游览的景点。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "spot_type": {
+ "type": "string",
+ "required": true
+ },
+ "visit_minutes": {
+ "type": "integer"
+ },
+ "indoor": {
+ "type": "boolean"
+ },
+ "best_seasons": {
+ "type": "array"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ },
+ "Hotel": {
+ "label": "酒店",
+ "description": "行程住宿资源。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "stars": {
+ "type": "integer",
+ "required": true
+ },
+ "price_from": {
+ "type": "number"
+ },
+ "family_friendly": {
+ "type": "boolean"
+ },
+ "amenities": {
+ "type": "array"
+ },
+ "address": {
+ "type": "string"
+ },
+ "contact": {
+ "type": "object"
+ }
+ }
+ },
+ "Restaurant": {
+ "label": "餐厅",
+ "description": "行程餐饮资源。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "cuisine": {
+ "type": "string",
+ "required": true
+ },
+ "avg_price": {
+ "type": "number"
+ },
+ "specialties": {
+ "type": "array"
+ },
+ "vegetarian_options": {
+ "type": "boolean"
+ },
+ "opening_hours": {
+ "type": "string"
+ }
+ }
+ },
+ "TransportHub": {
+ "label": "交通枢纽",
+ "description": "高铁站、汽车站或游客中心等交通节点。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "hub_type": {
+ "type": "string",
+ "required": true
+ },
+ "service_hours": {
+ "type": "string"
+ },
+ "accessible": {
+ "type": "boolean"
+ },
+ "facilities": {
+ "type": "array"
+ },
+ "geo": {
+ "type": "object"
+ }
+ }
+ },
+ "TourRoute": {
+ "label": "旅游线路",
+ "description": "由多个有序行程日组成的旅游产品线路。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "days": {
+ "type": "integer",
+ "required": true
+ },
+ "theme": {
+ "type": "string"
+ },
+ "suitable_for": {
+ "type": "array"
+ },
+ "base_price": {
+ "type": "number"
+ },
+ "active": {
+ "type": "boolean"
+ },
+ "extra_metadata": {
+ "type": "any",
+ "description": "用于验证 any 类型的扩展信息"
+ }
+ }
+ },
+ "RouteDay": {
+ "label": "行程日",
+ "description": "线路中的某一天。",
+ "fields": {
+ "title": {
+ "type": "string",
+ "required": true
+ },
+ "day_no": {
+ "type": "integer",
+ "required": true
+ },
+ "summary": {
+ "type": "string"
+ },
+ "estimated_cost": {
+ "type": "number"
+ }
+ }
+ },
+ "Activity": {
+ "label": "体验活动",
+ "description": "景点可提供的附加体验活动。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "activity_type": {
+ "type": "string",
+ "required": true
+ },
+ "duration_minutes": {
+ "type": "integer"
+ },
+ "min_age": {
+ "type": "integer"
+ },
+ "booking_required": {
+ "type": "boolean"
+ },
+ "equipment": {
+ "type": "array"
+ }
+ }
+ },
+ "TicketPolicy": {
+ "label": "票务政策",
+ "description": "景区或景点的票价和退改政策。",
+ "primary_key": "name",
+ "fields": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "adult_price": {
+ "type": "number",
+ "required": true
+ },
+ "child_price": {
+ "type": "number"
+ },
+ "free_policy": {
+ "type": "string"
+ },
+ "valid_from": {
+ "type": "date"
+ },
+ "refundable": {
+ "type": "boolean"
+ }
+ }
+ },
+ "TravelTip": {
+ "label": "旅行提示",
+ "description": "可应用到景区、资源或线路的提醒。",
+ "primary_key": "title",
+ "fields": {
+ "title": {
+ "type": "string",
+ "required": true
+ },
+ "content": {
+ "type": "string",
+ "required": true
+ },
+ "severity": {
+ "type": "string",
+ "required": true
+ },
+ "audiences": {
+ "type": "array"
+ },
+ "updated_at": {
+ "type": "datetime"
+ }
+ }
+ }
+ },
+ "relation_types": {
+ "CONTAINS": {
+ "label": "包含景点",
+ "from": "ScenicArea",
+ "to": "ScenicSpot",
+ "properties": {
+ "recommended_order": {
+ "type": "integer"
+ }
+ }
+ },
+ "NEARBY": {
+ "label": "附近资源",
+ "from": "ScenicSpot",
+ "to": "Hotel|Restaurant|TransportHub",
+ "properties": {
+ "distance_km": {
+ "type": "number",
+ "required": true
+ },
+ "drive_minutes": {
+ "type": "integer"
+ },
+ "walkable": {
+ "type": "boolean"
+ }
+ }
+ },
+ "ROUTE_HAS_DAY": {
+ "label": "线路包含行程日",
+ "from": "TourRoute",
+ "to": "RouteDay",
+ "properties": {
+ "order": {
+ "type": "integer",
+ "required": true
+ }
+ }
+ },
+ "DAY_VISITS": {
+ "label": "当日游览",
+ "from": "RouteDay",
+ "to": "ScenicSpot",
+ "properties": {
+ "order": {
+ "type": "integer",
+ "required": true
+ },
+ "stay_minutes": {
+ "type": "integer"
+ },
+ "must_visit": {
+ "type": "boolean"
+ }
+ }
+ },
+ "DAY_STAYS_AT": {
+ "label": "当日住宿",
+ "from": "RouteDay",
+ "to": "Hotel",
+ "properties": {
+ "check_in": {
+ "type": "string",
+ "required": true
+ },
+ "nights": {
+ "type": "integer"
+ }
+ }
+ },
+ "DAY_DINES_AT": {
+ "label": "当日用餐",
+ "from": "RouteDay",
+ "to": "Restaurant",
+ "properties": {
+ "meal": {
+ "type": "string",
+ "required": true
+ },
+ "reservation_required": {
+ "type": "boolean"
+ }
+ }
+ },
+ "DAY_USES_HUB": {
+ "label": "当日使用交通枢纽",
+ "from": "RouteDay",
+ "to": "TransportHub",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "required": true
+ },
+ "departure_time": {
+ "type": "string"
+ }
+ }
+ },
+ "OFFERS_ACTIVITY": {
+ "label": "提供活动",
+ "from": "ScenicSpot",
+ "to": "Activity",
+ "properties": {
+ "season": {
+ "type": "string"
+ },
+ "extra_fee": {
+ "type": "number"
+ }
+ }
+ },
+ "HAS_TICKET_POLICY": {
+ "label": "采用票务政策",
+ "from": "ScenicArea|ScenicSpot",
+ "to": "TicketPolicy",
+ "properties": {
+ "channel": {
+ "type": "string"
+ }
+ }
+ },
+ "APPLIES_TIP": {
+ "label": "提示适用于",
+ "from": "TravelTip",
+ "to": "ScenicArea|ScenicSpot|Hotel|Restaurant|TransportHub|TourRoute",
+ "properties": {
+ "context": {
+ "type": "string"
+ },
+ "priority": {
+ "type": "integer"
+ }
+ }
+ },
+ "ROUTE_STARTS_AT": {
+ "label": "线路起点",
+ "from": "TourRoute",
+ "to": "TransportHub",
+ "properties": {
+ "departure_time": {
+ "type": "string",
+ "required": true
+ }
+ }
+ },
+ "ROUTE_ENDS_AT": {
+ "label": "线路终点",
+ "from": "TourRoute",
+ "to": "TransportHub",
+ "properties": {
+ "arrival_time": {
+ "type": "string",
+ "required": true
+ }
+ }
+ },
+ "ALTERNATIVE_TO": {
+ "label": "可替代资源",
+ "from": "Hotel|Restaurant",
+ "to": "Hotel|Restaurant",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "required": true
+ },
+ "score": {
+ "type": "number"
+ }
+ }
+ },
+ "CONNECTS": {
+ "label": "交通连接",
+ "from": "TransportHub",
+ "to": "TransportHub",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "required": true
+ },
+ "duration_minutes": {
+ "type": "integer",
+ "required": true
+ },
+ "distance_km": {
+ "type": "number"
+ }
+ }
+ },
+ "RECOMMENDS": {
+ "label": "线路推荐活动",
+ "from": "TourRoute",
+ "to": "Activity",
+ "properties": {
+ "priority": {
+ "type": "integer",
+ "required": true
+ },
+ "reason": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
diff --git a/qa/graph-creation-fixtures/使用说明.md b/qa/graph-creation-fixtures/使用说明.md
new file mode 100644
index 0000000..8429a49
--- /dev/null
+++ b/qa/graph-creation-fixtures/使用说明.md
@@ -0,0 +1,70 @@
+# 复杂图谱创建手工测试
+
+这套数据只用于验证创建流程,价格、电话、地址和经营信息均为测试样例。
+
+## 创建时填写
+
+- 项目名称(中文):`荔波旅游复杂图谱手工测试`
+- 项目标识(英文):`libo_complex_manual_test_20260805_01`
+
+## 操作顺序
+
+1. 打开 `http://localhost:8102/admin/projects`,点击“创建项目”。
+2. 填写上面的中文名称和英文标识,点击“下一步”。
+3. 上传或粘贴 `libo_complex_schema.v1.json`。
+4. 上传或粘贴 `libo_complex_graph_data.v1.json`。
+5. 进入确认页后检查统计并提交创建。
+
+## 预期统计
+
+- 实体类型:10
+- 关系类型:15
+- 节点:32
+- 关系:70
+- Schema 版本:1.0.0
+- 字段类型:覆盖 `string`、`integer`、`number`、`boolean`、`object`、`array`、`date`、`datetime`、`any`
+
+## 创建后建议查询
+
+全部节点:
+
+```cypher
+MATCH (n) RETURN n LIMIT 100
+```
+
+全部节点和关系:
+
+```cypher
+MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 200
+```
+
+线路、行程日与景点:
+
+```cypher
+MATCH (route:TourRoute)-[r1:ROUTE_HAS_DAY]->(day:RouteDay)-[r2:DAY_VISITS]->(spot:ScenicSpot)
+RETURN route,r1,day,r2,spot LIMIT 100
+```
+
+景点附近的酒店、餐厅和交通枢纽:
+
+```cypher
+MATCH (spot:ScenicSpot)-[r:NEARBY]->(resource)
+RETURN spot,r,resource LIMIT 100
+```
+
+适合亲子的酒店:
+
+```cypher
+MATCH (hotel:Hotel)
+WHERE hotel.family_friendly = true
+RETURN hotel LIMIT 50
+```
+
+## 重新测试时注意
+
+项目删除后系统会保留安全墓碑,原英文标识不能再次使用。重新测试时请同时修改:
+
+- 项目标识,例如改为 `libo_complex_manual_test_20260805_02`
+- Schema JSON 顶层的 `namespace`,改成相同的新标识
+
+图谱数据文件无需修改。
diff --git a/scripts/build_travel_agency_project.py b/scripts/build_travel_agency_project.py
index 51c61b3..5317359 100644
--- a/scripts/build_travel_agency_project.py
+++ b/scripts/build_travel_agency_project.py
@@ -1861,7 +1861,7 @@ def write_outputs(builder: KGBuilder, schema: dict[str, Any], qa: list[dict[str,
"",
"## 页面与系统建议",
"- 已按 `project_id=travel_agency`、`tenant_id=travel_agency`、`graph_name=travel_agency` 独立建项目,避免污染城市图谱。",
- "- 建议首页保留“项目工作区”,进入项目后所有列表、图谱浏览、人工录入都默认带当前项目头。",
+ "- 建议首页保留“图谱中心”,进入项目后所有列表、图谱浏览、人工录入都默认带当前项目头。",
"- 后续做客服工作台时,建议把 `TravelDemand` 抽取表单放在左侧,把命中的 `TourProduct/TourVariant/Hotel/Restaurant/Vehicle` 放在右侧,最终生成 `ItineraryPlan`。",
"- 客服问答不要直接让模型自由编线路,应先从 `TravelDemand -> ItineraryPlan -> TourProduct/TourVariant` 找证据,再让模型组织话术。",
"",
diff --git a/scripts/import_libo_zones.py b/scripts/import_libo_zones.py
new file mode 100644
index 0000000..7ed1136
--- /dev/null
+++ b/scripts/import_libo_zones.py
@@ -0,0 +1,537 @@
+#!/usr/bin/env python3
+"""Import Libo administrative units and business zones into Postgres.
+
+Two tracks are kept physically separate and are never derived from each other:
+
+* Statutory (法定) — from AMap reverse geocoding + civil-affairs code tables.
+ Keyed by ``towncode``. Read-only reference data.
+* Business (业务) — the玉屏街道 city zone set. Keyed by ``zone_id``. Has no
+ administrative standing. Zones are irregular polygons; POI membership is
+ decided by point-in-polygon (NOT bounding boxes — 48% of the 12 zone bboxes
+ overlap, which would misassign ~37% of POIs).
+
+Active zone set: ``ZS-LB-YUPING-V2`` — 12 surveyed city zones (A01–A12),
+replacing the earlier 18-anchor V1 scheme.
+
+Sources (2026-08-06 revised delivery, ``~/Desktop/荔波行政区与片区/``):
+
+* ``1_荔波县_乡镇街道.csv`` -> libo_admin_towns (8)
+* ``2_荔波县_村社区.csv`` -> libo_admin_villages (100)
+* ``3_玉屏街道_片区.csv`` -> libo_business_zones (12, attrs)
+* ``4_玉屏街道_片区边界.geojson`` -> libo_business_zones.geometry + POI 落区
+* town-level POI assignment -> amap_spatial_pois.towncode
+
+Usage::
+
+ python scripts/import_libo_zones.py --graph-name yunyou_libo
+ python scripts/import_libo_zones.py --dry-run
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import psycopg
+from psycopg.rows import dict_row
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from app.config import settings # noqa: E402
+
+HOME = Path.home()
+DEFAULT_ZONE_DIR = HOME / "Desktop" / "荔波行政区与片区"
+# Statutory town assignment for the full county (all 8 towns), one row per POI.
+DEFAULT_TOWN_POI_CSV = (
+ HOME
+ / "Documents"
+ / "云游荔波"
+ / "outputs"
+ / "libo_admin_grid_verified_20260805"
+ / "不同业务POI乡镇归属"
+ / "荔波县_POI实体_乡镇归属去重_20260805.csv"
+)
+
+ZONE_SET_ID = "ZS-LB-YUPING-V2"
+# Zone sets superseded by the current import; cleared for the target graph.
+LEGACY_ZONE_SET_IDS = ("ZS-LB-YUPING-V1",)
+YUPING_TOWNCODE = "522722001000"
+
+SCHEMA_SQL = """
+CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_admin_towns (
+ town_uid TEXT PRIMARY KEY,
+ civil_adcode TEXT NOT NULL,
+ name TEXT NOT NULL,
+ unit_type TEXT NOT NULL,
+ towncode TEXT NOT NULL UNIQUE,
+ stat_code TEXT,
+ community_count INTEGER NOT NULL DEFAULT 0,
+ village_count INTEGER NOT NULL DEFAULT 0,
+ unit_total INTEGER NOT NULL DEFAULT 0,
+ code_note TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_admin_villages (
+ village_uid TEXT PRIMARY KEY,
+ parent_civil_adcode TEXT NOT NULL,
+ town_name TEXT NOT NULL,
+ parent_type TEXT,
+ name TEXT NOT NULL,
+ unit_type TEXT NOT NULL,
+ alias TEXT,
+ stat_code TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_libo_admin_villages_parent
+ ON __SCHEMA__.libo_admin_villages (parent_civil_adcode);
+
+CREATE TABLE IF NOT EXISTS __SCHEMA__.libo_business_zones (
+ zone_set_id TEXT NOT NULL,
+ zone_id TEXT NOT NULL,
+ crs TEXT NOT NULL DEFAULT 'GCJ-02',
+ zone_name TEXT NOT NULL,
+ anchor_name TEXT,
+ anchor_lng DOUBLE PRECISION,
+ anchor_lat DOUBLE PRECISION,
+ cap_m DOUBLE PRECISION,
+ parent_admin_unit_id TEXT,
+ parent_admin_name TEXT,
+ poi_count INTEGER NOT NULL DEFAULT 0,
+ food_count INTEGER NOT NULL DEFAULT 0,
+ hotel_count INTEGER NOT NULL DEFAULT 0,
+ transit_count INTEGER NOT NULL DEFAULT 0,
+ scenic_count INTEGER NOT NULL DEFAULT 0,
+ area_km2 DOUBLE PRECISION,
+ dominant_category TEXT,
+ bank TEXT,
+ anchor_source TEXT,
+ geometry JSONB,
+ geometry_variant TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (zone_set_id, zone_id)
+);
+
+CREATE TABLE IF NOT EXISTS __SCHEMA__.poi_zone_assignments (
+ graph_name TEXT NOT NULL,
+ gaode_poi_id TEXT NOT NULL,
+ zone_set_id TEXT NOT NULL,
+ zone_id TEXT NOT NULL,
+ zone_name TEXT NOT NULL,
+ method TEXT,
+ distance_m DOUBLE PRECISION,
+ review_status TEXT,
+ computed_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (graph_name, gaode_poi_id, zone_set_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_poi_zone_assignments_zone
+ ON __SCHEMA__.poi_zone_assignments (graph_name, zone_set_id, zone_id);
+"""
+
+# Newer schema installs need columns the earlier table version lacked.
+ZONE_EXTRA_COLUMNS = (
+ "scenic_count INTEGER NOT NULL DEFAULT 0",
+ "area_km2 DOUBLE PRECISION",
+ "dominant_category TEXT",
+ "bank TEXT",
+)
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ with path.open(encoding="utf-8-sig", newline="") as handle:
+ return list(csv.DictReader(handle))
+
+
+def as_int(value: str | None) -> int:
+ try:
+ return int(float(str(value).strip()))
+ except (TypeError, ValueError):
+ return 0
+
+
+def as_float(value: str | None) -> float | None:
+ try:
+ return float(str(value).strip())
+ except (TypeError, ValueError):
+ return None
+
+
+# ── Point-in-polygon (GCJ-02, ray casting) ──────────────────────────────────
+def _in_ring(ring: list[list[float]], x: float, y: float) -> bool:
+ n = len(ring)
+ inside = False
+ for i in range(n):
+ x1, y1 = ring[i]
+ x2, y2 = ring[(i + 1) % n]
+ if (y1 > y) != (y2 > y):
+ xt = (x2 - x1) * (y - y1) / (y2 - y1) + x1
+ if x < xt:
+ inside = not inside
+ return inside
+
+
+def load_zone_geometry(path: Path) -> dict[str, dict[str, Any]]:
+ """zone_id -> {geometry, polys, variant, anchors, area_ha, bank}."""
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ result: dict[str, dict[str, Any]] = {}
+ for feature in payload.get("features", []):
+ props = feature.get("properties") or {}
+ zone_id = props.get("zone_id")
+ if not zone_id:
+ continue
+ geom = feature.get("geometry") or {}
+ polys = (
+ geom.get("coordinates")
+ if geom.get("type") == "MultiPolygon"
+ else [geom.get("coordinates")]
+ )
+ result[str(zone_id)] = {
+ "geometry": geom,
+ "polys": [p for p in (polys or []) if p],
+ "variant": props.get("scheme") or props.get("status") or "surveyed",
+ "anchors": props.get("anchors_gcj02") or [],
+ "bank": props.get("bank"),
+ }
+ return result
+
+
+def assign_zone(
+ lng: float,
+ lat: float,
+ ordered_zones: list[tuple[str, str, list]],
+) -> tuple[str, str]:
+ for zone_id, zone_name, polys in ordered_zones:
+ for rings in polys:
+ if _in_ring(rings[0], lng, lat) and not any(
+ _in_ring(hole, lng, lat) for hole in rings[1:]
+ ):
+ return zone_id, zone_name
+ return "", ""
+
+
+def import_towns(cur, schema: str, rows: list[dict[str, str]]) -> int:
+ for row in rows:
+ cur.execute(
+ f"""INSERT INTO {schema}.libo_admin_towns
+ (town_uid, civil_adcode, name, unit_type, towncode, stat_code,
+ community_count, village_count, unit_total, code_note, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, now())
+ ON CONFLICT (town_uid) DO UPDATE SET
+ civil_adcode = EXCLUDED.civil_adcode,
+ name = EXCLUDED.name,
+ unit_type = EXCLUDED.unit_type,
+ towncode = EXCLUDED.towncode,
+ stat_code = EXCLUDED.stat_code,
+ community_count = EXCLUDED.community_count,
+ village_count = EXCLUDED.village_count,
+ unit_total = EXCLUDED.unit_total,
+ code_note = EXCLUDED.code_note,
+ updated_at = now()""",
+ (
+ row["内部行政区ID"],
+ row["民政行政区划代码9位"],
+ row["名称"],
+ row["类型"],
+ row["高德towncode12位"],
+ row.get("2023统计用乡级代码12位"),
+ as_int(row.get("社区数")),
+ as_int(row.get("行政村数")),
+ as_int(row.get("基层单元合计")),
+ row.get("代码差异提示"),
+ ),
+ )
+ return len(rows)
+
+
+def import_villages(cur, schema: str, rows: list[dict[str, str]]) -> int:
+ for row in rows:
+ cur.execute(
+ f"""INSERT INTO {schema}.libo_admin_villages
+ (village_uid, parent_civil_adcode, town_name, parent_type,
+ name, unit_type, alias, stat_code, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s, now())
+ ON CONFLICT (village_uid) DO UPDATE SET
+ parent_civil_adcode = EXCLUDED.parent_civil_adcode,
+ town_name = EXCLUDED.town_name,
+ parent_type = EXCLUDED.parent_type,
+ name = EXCLUDED.name,
+ unit_type = EXCLUDED.unit_type,
+ alias = EXCLUDED.alias,
+ stat_code = EXCLUDED.stat_code,
+ updated_at = now()""",
+ (
+ row["基层单元ID"],
+ row["父级民政代码"],
+ row["乡镇/街道"],
+ row.get("父级类型"),
+ row["现行名称"],
+ row["单元类型"],
+ row.get("别名/旧名线索"),
+ row.get("统计用区划代码12位"),
+ ),
+ )
+ return len(rows)
+
+
+def import_zones(
+ cur,
+ schema: str,
+ rows: list[dict[str, str]],
+ geometry: dict[str, dict[str, Any]],
+) -> tuple[int, list[str]]:
+ without_face: list[str] = []
+ for row in rows:
+ zone_id = row["片区编号"]
+ face = geometry.get(zone_id)
+ if not face:
+ without_face.append(zone_id)
+ anchors = face["anchors"] if face else []
+ # 面标签落点:多锚点取均值,否则留空。
+ if anchors:
+ anchor_lng = sum(a[0] for a in anchors) / len(anchors)
+ anchor_lat = sum(a[1] for a in anchors) / len(anchors)
+ else:
+ anchor_lng = anchor_lat = None
+ cur.execute(
+ f"""INSERT INTO {schema}.libo_business_zones
+ (zone_set_id, zone_id, crs, zone_name, anchor_name,
+ anchor_lng, anchor_lat, cap_m, parent_admin_unit_id,
+ parent_admin_name, poi_count, food_count, hotel_count,
+ transit_count, scenic_count, area_km2, dominant_category,
+ bank, anchor_source, geometry, geometry_variant, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, now())
+ ON CONFLICT (zone_set_id, zone_id) DO UPDATE SET
+ crs = EXCLUDED.crs,
+ zone_name = EXCLUDED.zone_name,
+ anchor_name = EXCLUDED.anchor_name,
+ anchor_lng = EXCLUDED.anchor_lng,
+ anchor_lat = EXCLUDED.anchor_lat,
+ cap_m = EXCLUDED.cap_m,
+ parent_admin_unit_id = EXCLUDED.parent_admin_unit_id,
+ parent_admin_name = EXCLUDED.parent_admin_name,
+ poi_count = EXCLUDED.poi_count,
+ food_count = EXCLUDED.food_count,
+ hotel_count = EXCLUDED.hotel_count,
+ transit_count = EXCLUDED.transit_count,
+ scenic_count = EXCLUDED.scenic_count,
+ area_km2 = EXCLUDED.area_km2,
+ dominant_category = EXCLUDED.dominant_category,
+ bank = EXCLUDED.bank,
+ anchor_source = EXCLUDED.anchor_source,
+ geometry = EXCLUDED.geometry,
+ geometry_variant = EXCLUDED.geometry_variant,
+ updated_at = now()""",
+ (
+ ZONE_SET_ID,
+ zone_id,
+ "GCJ-02",
+ row["片区名称"],
+ None,
+ anchor_lng,
+ anchor_lat,
+ None, # cap_m: 面几何为真多边形,不再有半径闸
+ "LB-522722001",
+ row.get("所属街道") or "玉屏街道",
+ as_int(row.get("POI数")),
+ as_int(row.get("美食")),
+ as_int(row.get("酒店")),
+ as_int(row.get("交通")),
+ as_int(row.get("景区")),
+ as_float(row.get("面积km²")),
+ row.get("主导业态"),
+ face["bank"] if face else None,
+ "玉屏街道城区实测划分(A方案)",
+ json.dumps(face["geometry"], ensure_ascii=False) if face else None,
+ face["variant"] if face else None,
+ ),
+ )
+ return len(rows), without_face
+
+
+def import_assignments(
+ cur,
+ schema: str,
+ graph_name: str,
+ geometry: dict[str, dict[str, Any]],
+ zone_names: dict[str, str],
+) -> tuple[int, int]:
+ """Reassign every project POI to a city zone by point-in-polygon."""
+ ordered_zones = [
+ (zone_id, zone_names.get(zone_id, ""), geometry[zone_id]["polys"])
+ for zone_id in sorted(geometry)
+ ]
+ cur.execute(
+ f"""SELECT gaode_poi_id, lng, lat
+ FROM {schema}.amap_spatial_pois
+ WHERE graph_name=%s AND lng IS NOT NULL AND lat IS NOT NULL""",
+ (graph_name,),
+ )
+ poi_rows = cur.fetchall()
+
+ # Rebuild the active set cleanly: drop old rows for this graph+set first.
+ cur.execute(
+ f"""DELETE FROM {schema}.poi_zone_assignments
+ WHERE graph_name=%s AND zone_set_id=%s""",
+ (graph_name, ZONE_SET_ID),
+ )
+ assigned = 0
+ for row in poi_rows:
+ zone_id, zone_name = assign_zone(
+ float(row["lng"]), float(row["lat"]), ordered_zones
+ )
+ if not zone_id:
+ continue
+ cur.execute(
+ f"""INSERT INTO {schema}.poi_zone_assignments
+ (graph_name, gaode_poi_id, zone_set_id, zone_id, zone_name,
+ method, review_status, computed_at, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s, now(), now())
+ ON CONFLICT (graph_name, gaode_poi_id, zone_set_id) DO UPDATE SET
+ zone_id = EXCLUDED.zone_id,
+ zone_name = EXCLUDED.zone_name,
+ method = EXCLUDED.method,
+ review_status = EXCLUDED.review_status,
+ computed_at = now(),
+ updated_at = now()""",
+ (
+ graph_name,
+ row["gaode_poi_id"],
+ ZONE_SET_ID,
+ zone_id,
+ zone_name,
+ "point_in_polygon",
+ "自动归属",
+ ),
+ )
+ assigned += 1
+ return len(poi_rows), assigned
+
+
+def backfill_towncode(
+ cur,
+ schema: str,
+ graph_name: str,
+ rows: list[dict[str, str]],
+) -> tuple[int, int]:
+ """Write the statutory town assignment onto the POI table."""
+ cur.execute(
+ f"ALTER TABLE {schema}.amap_spatial_pois "
+ "ADD COLUMN IF NOT EXISTS towncode TEXT"
+ )
+ cur.execute(
+ f"ALTER TABLE {schema}.amap_spatial_pois "
+ "ADD COLUMN IF NOT EXISTS town_name TEXT"
+ )
+ cur.execute(
+ f"CREATE INDEX IF NOT EXISTS idx_amap_spatial_pois_towncode "
+ f"ON {schema}.amap_spatial_pois (graph_name, towncode)"
+ )
+ updated = 0
+ for row in rows:
+ towncode = (row.get("高德towncode") or "").strip()
+ town_name = (row.get("高德乡镇街道") or "").strip()
+ if not towncode or not town_name:
+ continue
+ cur.execute(
+ f"""UPDATE {schema}.amap_spatial_pois
+ SET towncode=%s, town_name=%s
+ WHERE graph_name=%s AND gaode_poi_id=%s""",
+ (towncode, town_name, graph_name, row["高德POI_ID"]),
+ )
+ updated += cur.rowcount
+ cur.execute(
+ f"""SELECT count(*) AS n FROM {schema}.amap_spatial_pois
+ WHERE graph_name=%s AND towncode IS NULL""",
+ (graph_name,),
+ )
+ missing = cur.fetchone()["n"]
+ return updated, missing
+
+
+def purge_legacy_zone_sets(cur, schema: str, graph_name: str) -> None:
+ """Remove superseded zone sets so no wrong V1 data lingers."""
+ for legacy in LEGACY_ZONE_SET_IDS:
+ cur.execute(
+ f"DELETE FROM {schema}.poi_zone_assignments "
+ "WHERE graph_name=%s AND zone_set_id=%s",
+ (graph_name, legacy),
+ )
+ cur.execute(
+ f"DELETE FROM {schema}.libo_business_zones WHERE zone_set_id=%s",
+ (legacy,),
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--graph-name", default="yunyou_libo")
+ parser.add_argument("--zone-dir", type=Path, default=DEFAULT_ZONE_DIR)
+ parser.add_argument("--town-poi-csv", type=Path, default=DEFAULT_TOWN_POI_CSV)
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args()
+
+ schema = settings.db_schema
+ towns_csv = args.zone_dir / "1_荔波县_乡镇街道.csv"
+ villages_csv = args.zone_dir / "2_荔波县_村社区.csv"
+ zones_csv = args.zone_dir / "3_玉屏街道_片区.csv"
+ geojson = args.zone_dir / "4_玉屏街道_片区边界.geojson"
+
+ for path in (towns_csv, villages_csv, zones_csv, geojson, args.town_poi_csv):
+ if not path.exists():
+ print(f"! 源文件不存在: {path}")
+ return 1
+
+ towns = read_csv(towns_csv)
+ villages = read_csv(villages_csv)
+ zones = read_csv(zones_csv)
+ town_pois = read_csv(args.town_poi_csv)
+ geometry = load_zone_geometry(geojson)
+ zone_names = {row["片区编号"]: row["片区名称"] for row in zones}
+
+ print(
+ f"读取: 乡级 {len(towns)} · 村社区 {len(villages)} · 片区 {len(zones)} · "
+ f"面几何 {len(geometry)} · 乡镇归属 {len(town_pois)} · 目标集 {ZONE_SET_ID}"
+ )
+ if args.dry_run:
+ print("dry-run,未写库")
+ return 0
+
+ with psycopg.connect(settings.database_url, row_factory=dict_row) as connection:
+ with connection.cursor() as cur:
+ cur.execute(SCHEMA_SQL.replace("__SCHEMA__", schema))
+ for ddl in ZONE_EXTRA_COLUMNS:
+ cur.execute(
+ f"ALTER TABLE {schema}.libo_business_zones "
+ f"ADD COLUMN IF NOT EXISTS {ddl}"
+ )
+ n_towns = import_towns(cur, schema, towns)
+ n_villages = import_villages(cur, schema, villages)
+ n_zones, without_face = import_zones(cur, schema, zones, geometry)
+ n_pois, n_assign = import_assignments(
+ cur, schema, args.graph_name, geometry, zone_names
+ )
+ updated, missing = backfill_towncode(
+ cur, schema, args.graph_name, town_pois
+ )
+ purge_legacy_zone_sets(cur, schema, args.graph_name)
+ connection.commit()
+
+ print(f"✓ libo_admin_towns {n_towns}")
+ print(f"✓ libo_admin_villages {n_villages}")
+ print(f"✓ libo_business_zones {n_zones}(无面几何: {', '.join(without_face) or '无'})")
+ print(f"✓ poi_zone_assignments {n_assign} / {n_pois} 落区(其余为城区外)")
+ print(f"✓ amap_spatial_pois 乡镇回填 {updated} 行,仍缺 towncode {missing} 行")
+ print(f"✓ 已清除旧片区集: {', '.join(LEGACY_ZONE_SET_IDS)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..534d52e
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Project test package for default unittest discovery."""
diff --git a/tests/test_data_platform_sql_create.py b/tests/test_data_platform_sql_create.py
new file mode 100644
index 0000000..36ccd7f
--- /dev/null
+++ b/tests/test_data_platform_sql_create.py
@@ -0,0 +1,48 @@
+import unittest
+
+from app.data_platform.schema import (
+ _column_sql,
+ custom_definition_from_create_sql,
+)
+
+
+MYSQL_STYLE_USERS_SQL = """
+CREATE TABLE users (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ username VARCHAR(50) NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ email VARCHAR(100) UNIQUE,
+ phone VARCHAR(20),
+ age INT,
+ gender VARCHAR(10),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+"""
+
+
+class SqlCreateCompatibilityTests(unittest.TestCase):
+ def test_mysql_style_managed_columns_and_unique_are_supported(self) -> None:
+ definition, rows = custom_definition_from_create_sql(
+ {"sql": MYSQL_STYLE_USERS_SQL}
+ )
+
+ self.assertEqual(definition.code, "users")
+ self.assertEqual(
+ [field.code for field in definition.fields],
+ ["username", "password", "email", "phone", "age", "gender"],
+ )
+ email = next(field for field in definition.fields if field.code == "email")
+ self.assertTrue(email.unique)
+ self.assertTrue(next(row for row in rows if row["code"] == "email")["unique"])
+
+ physical_sql = _column_sql(definition).as_string()
+ self.assertIn('"email" VARCHAR(100) UNIQUE', physical_sql)
+ self.assertIn("id UUID PRIMARY KEY", physical_sql)
+ self.assertIn("updated_at TIMESTAMPTZ NOT NULL DEFAULT now()", physical_sql)
+ self.assertNotIn("AUTO_INCREMENT", physical_sql)
+ self.assertNotIn("ON UPDATE", physical_sql)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_project_lifecycle.py b/tests/test_project_lifecycle.py
new file mode 100644
index 0000000..7561c74
--- /dev/null
+++ b/tests/test_project_lifecycle.py
@@ -0,0 +1,497 @@
+import asyncio
+import threading
+import unittest
+from unittest.mock import patch
+
+from fastapi import HTTPException
+
+from app import db as app_db
+from app.api import projects as projects_api
+from app.project_lifecycle import (
+ GraphImportError,
+ ProjectValidationError,
+ import_falkor_graph,
+ normalize_provision_payload,
+)
+
+
+def valid_payload() -> dict:
+ return {
+ "project_id": "lifecycle_test",
+ "tenant_id": "test_tenant",
+ "display_name": "生命周期测试",
+ "graph_name": "lifecycle_test_graph",
+ "schema": {
+ "namespace": "lifecycle_test",
+ "version": "1.0.0",
+ "entity_types": {
+ "Place": {
+ "label": "地点",
+ "fields": {
+ "name": {"type": "string", "required": True},
+ "rating": {"type": "number"},
+ },
+ }
+ },
+ "relation_types": {
+ "RELATED_TO": {
+ "from": "Place",
+ "to": "Place",
+ "properties": {"weight": {"type": "number"}},
+ }
+ },
+ },
+ "graph_data": {
+ "nodes": [
+ {"id": "p1", "type": "Place", "properties": {"name": "甲", "rating": 4.8}},
+ {"id": "p2", "type": "Place", "properties": {"name": "乙"}},
+ ],
+ "relations": [
+ {
+ "type": "RELATED_TO",
+ "source": "p1",
+ "target": "p2",
+ "properties": {"weight": 0.9},
+ }
+ ],
+ },
+ }
+
+
+class ProjectPayloadValidationTests(unittest.TestCase):
+ def test_valid_payload_is_normalized_and_counted(self) -> None:
+ payload = normalize_provision_payload(valid_payload())
+
+ self.assertEqual(payload["tenant_id"], "test_tenant")
+ self.assertEqual(payload["graph_name"], "lifecycle_test_graph")
+ self.assertEqual(payload["counts"], {
+ "entity_types": 1,
+ "relation_types": 1,
+ "nodes": 2,
+ "relations": 1,
+ })
+ self.assertEqual(
+ payload["schema"]["entity_types"]["Place"]["fields"]["rating"]["type"],
+ "number",
+ )
+
+ def test_hidden_resource_ids_default_to_project_id(self) -> None:
+ body = valid_payload()
+ body.pop("tenant_id")
+ body.pop("graph_name")
+
+ payload = normalize_provision_payload(body)
+
+ self.assertEqual(payload["tenant_id"], "lifecycle_test")
+ self.assertEqual(payload["graph_name"], "lifecycle_test")
+
+ def test_blank_resource_ids_also_default_to_project_id(self) -> None:
+ body = valid_payload()
+ body["tenant_id"] = " "
+ body["graph_name"] = " "
+
+ payload = normalize_provision_payload(body)
+
+ self.assertEqual(payload["tenant_id"], "lifecycle_test")
+ self.assertEqual(payload["graph_name"], "lifecycle_test")
+
+ def test_unsafe_project_and_graph_names_are_rejected(self) -> None:
+ body = valid_payload()
+ body["project_id"] = "../../biz_secret"
+ body["graph_name"] = "graph name"
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ joined = ";".join(raised.exception.errors)
+ self.assertIn("项目英文标识", joined)
+ self.assertIn("图谱资源标识", joined)
+ self.assertNotIn("graph_name", joined)
+
+ def test_unknown_relation_endpoint_type_is_rejected(self) -> None:
+ body = valid_payload()
+ body["schema"]["relation_types"]["RELATED_TO"]["to"] = "MissingType"
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ self.assertTrue(any("to/target" in error for error in raised.exception.errors))
+
+ def test_duplicate_node_and_missing_endpoint_are_rejected(self) -> None:
+ body = valid_payload()
+ body["graph_data"]["nodes"][1]["id"] = "p1"
+ body["graph_data"]["relations"][0]["target"] = "missing"
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ joined = ";".join(raised.exception.errors)
+ self.assertIn("重复", joined)
+ self.assertIn("不存在", joined)
+
+ def test_required_and_property_types_are_checked(self) -> None:
+ body = valid_payload()
+ body["graph_data"]["nodes"][0]["properties"] = {"rating": "excellent"}
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ joined = ";".join(raised.exception.errors)
+ self.assertIn("必填属性 name", joined)
+ self.assertIn("rating 应为 number", joined)
+
+ def test_data_center_tables_are_not_in_delete_allow_lists(self) -> None:
+ protected = {
+ "project_databases",
+ "project_table_definitions",
+ "project_table_overrides",
+ }
+ self.assertTrue(protected.isdisjoint(projects_api._PROJECT_SCOPED_TABLES))
+ self.assertTrue(protected.isdisjoint(projects_api._PROJECT_DELETE_ORDER))
+ self.assertFalse(any(name.startswith("biz_") for name in projects_api._PROJECT_DELETE_ORDER))
+ self.assertTrue(protected.isdisjoint(app_db._LIFECYCLE_PROJECT_GUARDED_TABLES))
+ self.assertFalse(any(name.startswith("biz_") for name in app_db._LIFECYCLE_PROJECT_GUARDED_TABLES))
+
+ def test_empty_graph_is_rejected(self) -> None:
+ body = valid_payload()
+ body["graph_data"] = {"nodes": [], "relations": []}
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ self.assertTrue(any("至少需要一个节点" in error for error in raised.exception.errors))
+
+ def test_tuple_relation_and_union_endpoints_are_normalized(self) -> None:
+ body = valid_payload()
+ body["schema"]["entity_types"]["Area"] = {
+ "fields": {"name": "text"},
+ }
+ body["schema"]["relation_types"]["RELATED_TO"] = [
+ "Place|Area",
+ "Place|Area",
+ "联合端点关系",
+ ]
+ body["graph_data"]["nodes"].append(
+ {"id": "a1", "type": "Area", "properties": {"name": "区域"}}
+ )
+ body["graph_data"]["relations"][0]["target"] = "a1"
+
+ payload = normalize_provision_payload(body)
+
+ self.assertEqual(payload["schema"]["relation_types"]["RELATED_TO"]["from"], "Place|Area")
+ self.assertEqual(
+ payload["schema"]["entity_types"]["Area"]["fields"]["name"]["type"],
+ "string",
+ )
+
+ def test_unsafe_schema_identifier_and_version_are_rejected(self) -> None:
+ body = valid_payload()
+ body["schema"]["version"] = "../../1"
+ body["schema"]["entity_types"] = {"Bad Label": {"fields": {}}}
+
+ with self.assertRaises(ProjectValidationError) as raised:
+ normalize_provision_payload(body)
+
+ joined = ";".join(raised.exception.errors)
+ self.assertIn("schema.version", joined)
+ self.assertIn("Schema/Cypher", joined)
+
+ def test_schema_source_path_rejects_unsafe_components(self) -> None:
+ row = {"project_id": "../outside", "namespace": "safe", "version": "1.0.0"}
+ self.assertIsNone(projects_api._schema_source_from_file(row, {}))
+
+ def test_graph_release_uses_project_id_as_its_implicit_graph_name(self) -> None:
+ self.assertEqual(projects_api._resolve_graph_name("stable-project"), "stable-project")
+ self.assertEqual(projects_api._resolve_graph_name("stable-project", " "), "stable-project")
+ self.assertEqual(
+ projects_api._resolve_graph_name("stable-project", "legacy_graph"),
+ "legacy_graph",
+ )
+
+ def test_schema_response_prefers_json_semantic_version_over_legacy_revision(self) -> None:
+ row = {
+ "id": 42,
+ "project_id": "stable-project",
+ "namespace": "stable-project",
+ "version": 7,
+ "schema_jsonb": {
+ "namespace": "stable-project",
+ "version": "1.0.0",
+ "entity_types": {},
+ "relation_types": {},
+ },
+ }
+
+ with patch("app.api.projects._schema_source_from_file", return_value=None):
+ result = projects_api._attach_schema_source(row)
+
+ self.assertEqual(result["id"], 42)
+ self.assertEqual(result["version"], "1.0.0")
+
+
+class _ScriptedCursor:
+ def __init__(self, *rows: dict):
+ self.rows = list(rows)
+ self.calls: list[tuple[str, object]] = []
+
+ async def execute(self, query: str, params=None) -> None:
+ self.calls.append((query, params))
+
+ async def fetchone(self):
+ return self.rows.pop(0) if self.rows else None
+
+
+class SchemaVersionStorageTests(unittest.IsolatedAsyncioTestCase):
+ async def test_integer_column_allocates_revision_for_semantic_version(self) -> None:
+ cursor = _ScriptedCursor(
+ {"data_type": "integer", "udt_name": "int4"},
+ {"next_version": 7},
+ )
+
+ stored_version = await projects_api._ontology_schema_storage_version(
+ cursor,
+ tenant_id="tenant",
+ project_id="project",
+ namespace="namespace",
+ semantic_version="1.0.0",
+ )
+
+ self.assertEqual(stored_version, 7)
+ self.assertEqual(len(cursor.calls), 2)
+ self.assertIn("MAX(version)", cursor.calls[1][0])
+ self.assertEqual(cursor.calls[1][1], ("tenant", "project", "namespace"))
+
+ async def test_text_column_stores_semantic_version_directly(self) -> None:
+ cursor = _ScriptedCursor({"data_type": "text", "udt_name": "text"})
+
+ stored_version = await projects_api._ontology_schema_storage_version(
+ cursor,
+ tenant_id="tenant",
+ project_id="project",
+ namespace="namespace",
+ semantic_version="1.0.0",
+ )
+
+ self.assertEqual(stored_version, "1.0.0")
+ self.assertEqual(len(cursor.calls), 1)
+
+ async def test_unknown_version_column_type_fails_before_insert(self) -> None:
+ cursor = _ScriptedCursor({"data_type": "uuid", "udt_name": "uuid"})
+
+ with self.assertRaisesRegex(RuntimeError, "不受支持"):
+ await projects_api._ontology_schema_storage_version(
+ cursor,
+ tenant_id="tenant",
+ project_id="project",
+ namespace="namespace",
+ semantic_version="1.0.0",
+ )
+
+ self.assertEqual(len(cursor.calls), 1)
+
+
+class _Result:
+ def __init__(self, count: int):
+ self.result_set = [[count]]
+
+
+class _FakeGraph:
+ def __init__(self, *, fail_on_query: int | None = None, delete_fails: bool = False):
+ self.queries: list[tuple[str, dict | None]] = []
+ self.fail_on_query = fail_on_query
+ self.delete_fails = delete_fails
+ self.deleted = False
+
+ def query(self, query: str, params: dict | None = None):
+ self.queries.append((query, params))
+ if self.fail_on_query and len(self.queries) == self.fail_on_query:
+ raise RuntimeError("write failed")
+ if "MATCH (n) RETURN count(n)" in query:
+ return _Result(2)
+ if "MATCH ()-[r]->() RETURN count(r)" in query:
+ return _Result(1)
+ return _Result(0)
+
+ def delete(self):
+ if self.delete_fails:
+ raise RuntimeError("cleanup failed")
+ self.deleted = True
+
+
+class _FakeDb:
+ def __init__(self, graph: _FakeGraph, existing: list[str] | None = None):
+ self.graph = graph
+ self.existing = existing or []
+ self.closed = False
+
+ def list_graphs(self):
+ return self.existing
+
+ def select_graph(self, _name: str):
+ return self.graph
+
+ def close(self):
+ self.closed = True
+
+
+class FalkorLifecycleTests(unittest.TestCase):
+ def test_import_creates_validated_nodes_and_relations(self) -> None:
+ payload = normalize_provision_payload(valid_payload())
+ graph = _FakeGraph()
+ database = _FakeDb(graph)
+
+ with patch("app.project_lifecycle._falkor_client", return_value=database):
+ counts = import_falkor_graph("lifecycle_test_graph", payload["graph_data"])
+
+ self.assertEqual(counts, {"nodes": 2, "relations": 1})
+ self.assertEqual(len([query for query, _ in graph.queries if "CREATE (n:Place)" in query]), 2)
+ self.assertTrue(any("[relation:RELATED_TO]" in query for query, _ in graph.queries))
+ self.assertFalse(graph.deleted)
+ self.assertTrue(database.closed)
+
+ def test_import_failure_compensates_only_the_new_graph(self) -> None:
+ payload = normalize_provision_payload(valid_payload())
+ graph = _FakeGraph(fail_on_query=2)
+ database = _FakeDb(graph)
+
+ with patch("app.project_lifecycle._falkor_client", return_value=database):
+ with self.assertRaises(GraphImportError) as raised:
+ import_falkor_graph("lifecycle_test_graph", payload["graph_data"])
+
+ self.assertTrue(graph.deleted)
+ self.assertFalse(raised.exception.cleanup_required)
+
+ def test_failed_compensation_is_reported_as_retryable(self) -> None:
+ payload = normalize_provision_payload(valid_payload())
+ graph = _FakeGraph(fail_on_query=2, delete_fails=True)
+ database = _FakeDb(graph)
+
+ with patch("app.project_lifecycle._falkor_client", return_value=database):
+ with self.assertRaises(GraphImportError) as raised:
+ import_falkor_graph("lifecycle_test_graph", payload["graph_data"])
+
+ self.assertTrue(raised.exception.cleanup_required)
+
+ def test_existing_graph_is_never_overwritten_or_deleted(self) -> None:
+ payload = normalize_provision_payload(valid_payload())
+ graph = _FakeGraph()
+ database = _FakeDb(graph, existing=["lifecycle_test_graph"])
+
+ with patch("app.project_lifecycle._falkor_client", return_value=database):
+ with self.assertRaises(FileExistsError):
+ import_falkor_graph("lifecycle_test_graph", payload["graph_data"])
+
+ self.assertFalse(graph.queries)
+ self.assertFalse(graph.deleted)
+
+
+class LifecycleWriteGuardTests(unittest.IsolatedAsyncioTestCase):
+ async def test_blocking_graph_worker_is_drained_before_cancellation_returns(self) -> None:
+ started = threading.Event()
+ release = threading.Event()
+ finished = threading.Event()
+
+ def blocking_mutation() -> str:
+ started.set()
+ release.wait(timeout=2)
+ finished.set()
+ return "done"
+
+ task = asyncio.create_task(
+ projects_api._run_blocking_to_completion(blocking_mutation)
+ )
+ await asyncio.to_thread(started.wait, 1)
+ task.cancel()
+ await asyncio.sleep(0.02)
+ self.assertFalse(task.done())
+ release.set()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+ self.assertTrue(finished.is_set())
+
+ async def test_async_lock_cleanup_is_drained_before_cancellation_returns(self) -> None:
+ started = asyncio.Event()
+ release = asyncio.Event()
+ finished = asyncio.Event()
+
+ async def cleanup() -> None:
+ started.set()
+ await release.wait()
+ finished.set()
+
+ task = asyncio.create_task(
+ projects_api._run_async_cleanup_to_completion(cleanup())
+ )
+ await started.wait()
+ task.cancel()
+ await asyncio.sleep(0)
+ self.assertFalse(task.done())
+ release.set()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+ self.assertTrue(finished.is_set())
+
+ async def test_invalid_provision_payload_performs_no_external_calls(self) -> None:
+ body = valid_payload()
+ body["project_id"] = "../unsafe"
+ user = {"username": "admin", "roles": ["admin"]}
+
+ with patch("app.api.projects.get_conn") as get_conn, patch(
+ "app.api.projects.list_falkor_graphs"
+ ) as list_graphs:
+ with self.assertRaises(HTTPException) as raised:
+ await projects_api.provision_project(body, user)
+
+ self.assertEqual(raised.exception.status_code, 422)
+ get_conn.assert_not_called()
+ list_graphs.assert_not_called()
+
+ async def test_derived_graph_collision_uses_project_id_message_before_database_access(self) -> None:
+ body = valid_payload()
+ body.pop("tenant_id")
+ body.pop("graph_name")
+ user = {"username": "admin", "roles": ["admin"]}
+
+ with patch("app.api.projects.get_conn") as get_conn, patch(
+ "app.api.projects.list_falkor_graphs",
+ return_value={"lifecycle_test"},
+ ):
+ with self.assertRaises(HTTPException) as raised:
+ await projects_api.provision_project(body, user)
+
+ self.assertEqual(raised.exception.status_code, 409)
+ self.assertIn("项目英文标识“lifecycle_test”", str(raised.exception.detail))
+ self.assertNotIn("graph_name", str(raised.exception.detail))
+ self.assertNotIn("FalkorDB", str(raised.exception.detail))
+ get_conn.assert_not_called()
+
+ async def test_delete_confirmation_mismatch_performs_no_write(self) -> None:
+ user = {"username": "admin", "roles": ["admin"]}
+
+ with patch("app.api.projects.get_conn") as get_conn, patch(
+ "app.api.projects.delete_falkor_graph"
+ ) as delete_graph:
+ with self.assertRaises(HTTPException) as raised:
+ await projects_api.delete_project("expected", "wrong", user)
+
+ self.assertEqual(raised.exception.status_code, 400)
+ get_conn.assert_not_called()
+ delete_graph.assert_not_called()
+
+ async def test_rename_rejects_stable_fields_before_database_access(self) -> None:
+ user = {"username": "admin", "roles": ["admin"]}
+
+ with patch("app.api.projects.get_conn") as get_conn:
+ with self.assertRaises(HTTPException) as raised:
+ await projects_api.rename_project(
+ "stable-id",
+ {"display_name": "新名称", "graph_name": "forbidden"},
+ user,
+ )
+
+ self.assertEqual(raised.exception.status_code, 422)
+ get_conn.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()