Files
knowledge-governance/scripts/source_catalog_import.py
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环):

- 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口)
- 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入
  parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截
- 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过):
  * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id)
  * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配
  * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数
  * 回滚 job payload 支持 prior_execution_id(strict 解码)
- 三通道发布+回滚演练实测:publish 4/4 verified,检索命中,
  rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false
- 前端 A·Data-console 风格全站统一,120 用例通过
- 门禁:go vet / go test 全包通过,gofmt 干净
2026-09-30 10:54:06 +08:00

356 lines
20 KiB
Python

"""Build immutable, read-only directory snapshots from existing source inventories.
The pure builders never call source APIs. Database activation is added separately;
raw source identifiers, URLs, host paths and credentials are not emitted.
"""
from __future__ import annotations
import csv
import datetime as dt
import hashlib
import json
import argparse
from pathlib import Path
import subprocess
from typing import Iterable, TextIO
PP_ROOTS = ('工业产品','教育产品','具身产品','多足产品','解决方案','公司','软件','生态&配件')
PP_PUBLIC_EXTENSIONS={'.pdf','.docx','.doc','.xlsx','.xls','.pptx','.ppt','.md','.txt','.csv','.json'}
def digest(value:str)->str:
return hashlib.sha256(value.encode()).hexdigest()
def node_id(source_key:str,raw_key:str)->str:
return 'sn-'+digest(source_key+'\0'+raw_key)[:32]
def safe_text(value,maximum=1024)->str:
if not isinstance(value,str): return ''
symbols={'\n':'␤','\r':'␍','\t':'␉'}
value=''.join(symbols.get(ch,'�' if ord(ch)<32 else ch) for ch in value).strip()
if not value or len(value)>maximum: return ''
return value
def safe_relative_path(value:str)->str:
value=value.strip('/')
if len(value)>4096 or any(ord(ch)<32 for ch in value): raise ValueError('unsafe catalog path')
if any(part in ('.','..') for part in value.split('/')): raise ValueError('unsafe catalog path')
return value
def normalized_source_path(value:str)->str:
parts=[]
for part in value.strip('/').split('/'):
if part:
cleaned=safe_text(part)
if not cleaned:raise ValueError('source path segment is invalid')
parts.append(cleaned)
return safe_relative_path('/'.join(parts))
def iso_from_millis(value)->str|None:
if value in (None,''): return None
return dt.datetime.fromtimestamp(int(value)/1000,dt.timezone.utc).isoformat().replace('+00:00','Z')
def new_node(source_key:str,raw_key:str,parent_raw_key:str|None,node_type:str,name,relative_path:str,size_bytes=None,modified_at=None,sort_order=0,source_item_hash='',source_locator_hash='',content_sha256=''):
name=safe_text(name)
if not name: raise ValueError('catalog node has no safe name')
if content_sha256:
content_sha256=str(content_sha256).lower()
if len(content_sha256)!=64 or any(c not in '0123456789abcdef' for c in content_sha256): raise ValueError('invalid content hash')
return {
'key':node_id(source_key,raw_key),
'parent_key':None if parent_raw_key is None else node_id(source_key,parent_raw_key),
'node_type':node_type,'name':name,'relative_path':safe_relative_path(relative_path),
'source_locator_hash':source_locator_hash or digest(source_key+'\0'+raw_key),
'source_item_hash':source_item_hash,
'content_sha256':content_sha256 or None,
'size_bytes':None if size_bytes in (None,'') else int(size_bytes),
'modified_at':modified_at or None,'sort_order':int(sort_order),
'direct_child_count':0,'subtree_file_count':0,'subtree_bytes':0,
}
def finalize_source(source:dict)->dict:
nodes=source['nodes']; by_key={node['key']:node for node in nodes}
children={key:[] for key in by_key}
roots=[]
for node in nodes:
if node['parent_key'] is None: roots.append(node)
elif node['parent_key'] in children: children[node['parent_key']].append(node)
else: raise ValueError('catalog node has missing parent')
if len(roots)!=1 or roots[0]['node_type']!='folder': raise ValueError('catalog requires one folder root')
visiting=set(); memo={}
def walk(node):
if node['key'] in memo:return memo[node['key']]
if node['key'] in visiting:raise ValueError('catalog cycle')
visiting.add(node['key']); direct=children[node['key']]; node['direct_child_count']=len(direct)
if node['node_type']=='file': identities={node['source_locator_hash']:node['size_bytes'] or 0}
else:
identities={}
for child in direct:identities.update(walk(child))
node['subtree_file_count']=len(identities);node['subtree_bytes']=sum(identities.values())
visiting.remove(node['key']);memo[node['key']]=identities;return identities
walk(roots[0])
if len(memo)!=len(nodes):raise ValueError('catalog contains unreachable nodes')
source['node_count']=len(nodes);source['folder_count']=sum(node['node_type']=='folder' for node in nodes);source['file_count']=roots[0]['subtree_file_count']
canonical=json.dumps([{k:v for k,v in node.items() if k!='source_item_hash'} for node in sorted(nodes,key=lambda n:n['key'])],ensure_ascii=False,sort_keys=True,separators=(',',':'))
source['snapshot_sha256']=digest(canonical)
return source
def build_dingtalk_sources(inventory:dict)->list[dict]:
result=[]
for source_key,root in sorted((inventory.get('roots') or {}).items()):
source_id='dingtalk-'+source_key
root_raw=safe_text(root.get('configured_root_id'))
name=safe_text(root.get('live_space_name'))
if not root_raw or not name:raise ValueError('invalid DingTalk root')
raw_nodes={key:value for key,value in (root.get('nodes') or {}).items() if not value.get('is_in_trash') and not value.get('illegal')}
path_cache={root_raw:''};visiting=set()
def path_for(raw):
if raw in path_cache:return path_cache[raw]
if raw in visiting or raw not in raw_nodes:raise ValueError('DingTalk parent chain is incomplete')
visiting.add(raw);item=raw_nodes[raw];parent=item.get('parent_dentry_uuid')
parent_path=path_for(parent);name_part=safe_text(item.get('name'))
if not name_part:raise ValueError('DingTalk node name is invalid')
path_cache[raw]=safe_relative_path('/'.join(part for part in (parent_path,name_part) if part));visiting.remove(raw);return path_cache[raw]
nodes=[new_node(source_id,root_raw,None,'folder',name,'',sort_order=-1)]
for raw,item in raw_nodes.items():
kind='folder' if item.get('dentry_type')=='folder' else 'file'
parent=item.get('parent_dentry_uuid')
if parent!=root_raw and parent not in raw_nodes:raise ValueError('DingTalk parent is missing')
nodes.append(new_node(source_id,raw,parent,kind,item.get('name'),path_for(raw),item.get('file_size') if kind=='file' else None,iso_from_millis(item.get('content_updated_time') or item.get('updated_time'))))
result.append(finalize_source({'source_key':source_id,'source_type':'dingtalk','name':name,'nodes':nodes}))
return result
def build_alist_source(source_key:str,name:str,directories:TextIO,files:TextIO)->dict:
source_id='alist-'+source_key;name=safe_text(name)
directory_rows=list(csv.DictReader(directories));file_rows=list(csv.DictReader(files))
if not directory_rows or directory_rows[0].get('path')!='/':raise ValueError('AList root directory missing')
nodes=[new_node(source_id,'dir:/',None,'folder',name,'',sort_order=-1)]
directory_paths={'/'}
for row in directory_rows[1:]:
if row.get('status')!='done' or row.get('last_error'):raise ValueError('AList inventory is incomplete')
path='/' + normalized_source_path(row.get('path',''))
parent_raw=row.get('parent_path') or '/';parent='/' if parent_raw=='/' else '/'+normalized_source_path(parent_raw);directory_paths.add(path)
nodes.append(new_node(source_id,'dir:'+path,'dir:'+parent,'folder',Path(path).name,path))
for row in file_rows:
path='/' + normalized_source_path(row.get('path',''));parent_raw=row.get('parent_path') or '/';parent='/' if parent_raw=='/' else '/'+normalized_source_path(parent_raw)
if parent not in directory_paths:raise ValueError('AList file parent missing')
nodes.append(new_node(source_id,'file:'+path,'dir:'+parent,'file',row.get('name'),path,row.get('size'),row.get('modified')))
return finalize_source({'source_key':source_id,'source_type':'alist','name':name,'nodes':nodes})
def build_pp_source(manifest:dict,inventory:dict)->dict:
source_id='pp-download-center';name='PP资料下载中心';root_raw='root';nodes=[new_node(source_id,root_raw,None,'folder',name,'',sort_order=-1)]
by_id={str(item.get('id')):item for item in inventory.get('items',[])}
folders=set()
for order,tag_type in enumerate(PP_ROOTS):
raw='type:'+tag_type;folders.add(raw);nodes.append(new_node(source_id,raw,root_raw,'folder',tag_type,tag_type,sort_order=order))
for result in manifest.get('results',[]):
members=[by_id[str(value)] for value in result.get('source_ids',[]) if str(value) in by_id]
titles=sorted({safe_text(member.get('fileName')) for member in members}-{''})
if not titles:
title=safe_text(Path(result.get('local_path','')).name)
if not title:
from urllib.parse import unquote,urlparse
title=safe_text(Path(unquote(urlparse(str(result.get('url',''))).path)).name)
titles=[title] if title else []
if not titles:raise ValueError('PP public file title missing')
locator=digest(str(result.get('url','')));size=result.get('bytes')
if size is None:size=max((int(member.get('fileSize') or 0) for member in members),default=0)
modified=max((safe_text(member.get('updateTime')) for member in members),default='') or None
tags=set()
for member in members:
for tag in member.get('potalTags') or []:
tag_type=safe_text(tag.get('tagType'));value=safe_text(tag.get('tag'))
if tag_type in PP_ROOTS and value:tags.add((tag_type,value))
content_sha=str(result.get('sha256','') or '').lower() if str(result.get('sha256','') or '') else ''
if not tags:
nodes.append(new_node(source_id,'file:'+locator,root_raw,'file',titles[0],titles[0],size,modified,source_item_hash=locator,source_locator_hash=locator,content_sha256=content_sha))
for tag_type,value in sorted(tags,key=lambda pair:(PP_ROOTS.index(pair[0]),pair[1])):
folder_raw='tag:'+tag_type+':'+value
if folder_raw not in folders:
folders.add(folder_raw);nodes.append(new_node(source_id,folder_raw,'type:'+tag_type,'folder',value,tag_type+'/'+value))
alias_raw='file:'+locator+':'+tag_type+':'+value
nodes.append(new_node(source_id,alias_raw,folder_raw,'file',titles[0],tag_type+'/'+value+'/'+titles[0],size,modified,source_item_hash=locator,source_locator_hash=locator,content_sha256=content_sha))
return finalize_source({'source_key':source_id,'source_type':'pp','name':name,'nodes':nodes})
def build_pp_live_source(inventory:dict,manifest:dict|None=None)->dict:
# P1: the local download manifest is the authoritative content-hash evidence
# for already-fetched bytes; live portal records absent from it stay null.
hashes={}
for entry in (manifest or {}).get('results',[]):
url=str(entry.get('url') or '').strip()
value=str(entry.get('sha256') or '').strip().lower()
if url and len(value)==64 and all(c in '0123456789abcdef' for c in value):
hashes[url]=value
results=[]
for item in inventory.get('items',[]):
url=str(item.get('fileUrl') or '').strip()
suffix=str(item.get('fileSuffix') or '').strip().lower()
if suffix and not suffix.startswith('.'):suffix='.'+suffix
if not suffix and url:
from urllib.parse import unquote,urlparse
suffix=Path(unquote(urlparse(url).path)).suffix.lower()
if item.get('online')!=1 or (item.get('potalGroup') or {}).get('groupName')!='公开' or suffix not in PP_PUBLIC_EXTENSIONS:
continue
if not url:raise ValueError('public PP item has no source URL')
results.append({'url':url,'source_ids':[str(item.get('id'))],'bytes':int(item.get('fileSize') or 0),'sha256':hashes.get(url,'')})
merged={}
for result in results:
existing=merged.get(result['url'])
if existing:
existing['source_ids']=[value for value in dict.fromkeys(existing['source_ids']+result['source_ids'])]
existing['bytes']=existing['bytes'] or result['bytes']
existing['sha256']=existing.get('sha256') or result.get('sha256','')
else:
merged[result['url']]=dict(result)
return build_pp_source({'results':list(merged.values())},inventory)
def validate_source(source:dict)->None:
if source.get('source_type') not in ('dingtalk','alist','pp') or not safe_text(source.get('name')):raise ValueError('invalid source')
nodes=source.get('nodes') or [];keys=set()
for node in nodes:
if node['key'] in keys:raise ValueError('duplicate catalog node')
keys.add(node['key'])
if node['node_type'] not in ('folder','file') or not safe_text(node['name']) or node['relative_path'].startswith('/'):
raise ValueError('invalid catalog node')
safe_relative_path(node['relative_path'])
if len(node['source_locator_hash'])!=64:raise ValueError('invalid source locator')
if node.get('content_sha256') and (len(node['content_sha256'])!=64 or any(c not in '0123456789abcdef' for c in node['content_sha256'])):raise ValueError('invalid content sha256')
if any(node['parent_key'] is not None and node['parent_key'] not in keys for node in nodes):raise ValueError('orphan catalog node')
if source.get('node_count')!=len(nodes) or source.get('folder_count')!=sum(node['node_type']=='folder' for node in nodes):raise ValueError('invalid catalog counts')
def load_json(path:Path,allowed_root:Path):
resolved=path.resolve();root=allowed_root.resolve()
if path.is_symlink() or not path.is_file() or not resolved.is_relative_to(root):raise ValueError('invalid snapshot path')
return json.loads(path.read_text())
def stable_connection_id(source_key:str)->str:
return 'sc-'+digest(source_key)[:32]
def sql_text(value:str)->str:
return "'"+value.replace("'","''")+"'"
def catalog_statements(source:dict,connection_id:str)->str:
validate_source(source)
snapshot_id='ss-'+digest(source['source_key']+'\0'+source['snapshot_sha256'])[:32]
change_set_id='cs-'+digest(connection_id+'\0'+snapshot_id)[:32]
payload=json.dumps(source['nodes'],ensure_ascii=False,separators=(',',':')).replace("'","''")
health=json.dumps({'status':'degraded','reason':'snapshot_only'},separators=(',',':')).replace("'","''")
return f"""
INSERT INTO source_connections (id,source_type,name,base_url,secret_ref,enabled,schedule,last_health,created_at,updated_at)
VALUES ({sql_text(connection_id)},{sql_text(source['source_type'])},{sql_text(source['name'])},'','',TRUE,'{{}}'::jsonb,'{health}'::jsonb,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name,updated_at=NOW();
INSERT INTO source_catalog_snapshots
(id,source_connection_id,snapshot_sha256,source_label,node_count,folder_count,file_count,observed_at)
VALUES ({sql_text(snapshot_id)},{sql_text(connection_id)},{sql_text(source['snapshot_sha256'])},{sql_text(source['name'])},{source['node_count']},{source['folder_count']},{source['file_count']},NOW())
ON CONFLICT (source_connection_id,snapshot_sha256) DO NOTHING;
WITH current_snapshot AS (
SELECT id FROM source_catalog_snapshots
WHERE source_connection_id={sql_text(connection_id)} AND snapshot_sha256={sql_text(source['snapshot_sha256'])}
), nodes AS (
SELECT * FROM jsonb_to_recordset('{payload}'::jsonb) AS node(
key text,parent_key text,node_type text,name text,relative_path text,
source_locator_hash text,source_item_hash text,content_sha256 text,size_bytes bigint,
modified_at text,sort_order integer,direct_child_count integer,
subtree_file_count integer,subtree_bytes bigint
)
)
INSERT INTO source_catalog_nodes
(snapshot_id,node_id,parent_node_id,node_type,name,relative_path,source_locator_hash,
source_item_id,size_bytes,modified_at,sort_order,direct_child_count,subtree_file_count,subtree_bytes,content_sha256)
SELECT snapshot.id,node.key,NULLIF(node.parent_key,''),node.node_type,node.name,node.relative_path,
node.source_locator_hash,item.id,node.size_bytes,NULLIF(node.modified_at,'')::timestamptz,
node.sort_order,node.direct_child_count,node.subtree_file_count,node.subtree_bytes,node.content_sha256
FROM current_snapshot snapshot CROSS JOIN nodes node
LEFT JOIN source_items item ON item.source_connection_id={sql_text(connection_id)}
AND node.source_item_hash<>'' AND item.canonical_uri_hash=node.source_item_hash
ON CONFLICT (snapshot_id,node_id) DO NOTHING;
INSERT INTO source_catalog_change_sets(id,source_connection_id,from_snapshot_id,to_snapshot_id,created_at)
SELECT {sql_text(change_set_id)},{sql_text(connection_id)},
COALESCE(
CASE WHEN head.snapshot_id<>target.id THEN head.snapshot_id END,
(SELECT prior.id FROM source_catalog_snapshots prior
WHERE prior.source_connection_id={sql_text(connection_id)} AND prior.id<>target.id
AND prior.observed_at<=target.observed_at
ORDER BY prior.observed_at DESC,prior.id DESC LIMIT 1)
),
target.id,NOW()
FROM source_catalog_snapshots target
LEFT JOIN source_catalog_heads head ON head.source_connection_id={sql_text(connection_id)}
WHERE target.id={sql_text(snapshot_id)}
ON CONFLICT (to_snapshot_id) DO NOTHING;
SELECT compare_catalog_change_set({sql_text(change_set_id)});
INSERT INTO source_catalog_heads (source_connection_id,snapshot_id,activated_at)
SELECT {sql_text(connection_id)},id,NOW() FROM source_catalog_snapshots
WHERE source_connection_id={sql_text(connection_id)} AND snapshot_sha256={sql_text(source['snapshot_sha256'])}
ON CONFLICT (source_connection_id) DO UPDATE SET snapshot_id=EXCLUDED.snapshot_id,activated_at=EXCLUDED.activated_at;
"""
def catalog_sql(source:dict,connection_id:str,apply=False)->str:
return "BEGIN;\nSET LOCAL standard_conforming_strings=on;\n"+catalog_statements(source,connection_id)+("COMMIT;\n" if apply else "ROLLBACK;\n")
def checked_csv(path:Path,allowed_root:Path):
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(allowed_root.resolve()):raise ValueError('invalid CSV snapshot path')
return path.open(encoding='utf-8-sig',newline='')
def build_local_sources()->list[dict]:
dingtalk_root=Path('/home/zheromiku/dingtalk_kb_download/data/dingtalk-internal')
pp_root=Path('/home/zheromiku/dobot-portal-sync/data')
alist_root=Path('/home/zheromiku/tmp/alist/inventory')
sources=build_dingtalk_sources(load_json(dingtalk_root/'inventory-state.json',dingtalk_root))
for slug,label in (('domestic-industrial','国内工业'),('global-industrial','海外工业'),('domestic-education','国内教育'),('global-education','海外教育')):
with checked_csv(alist_root/slug/'directories.csv',alist_root) as directories,checked_csv(alist_root/slug/'files.csv',alist_root) as files:
sources.append(build_alist_source(slug,label,directories,files))
sources.append(build_pp_source(load_json(pp_root/'public-documents/manifest.json',pp_root),load_json(pp_root/'pp-full/inventory-snapshot.json',pp_root)))
return sources
def psql(sql:str):
command=['docker','compose','exec','-T','postgres','psql','-X','-v','ON_ERROR_STOP=1','-U','governance','-d','governance','-At']
return subprocess.run(command,input=sql,text=True,cwd=Path(__file__).resolve().parents[1],capture_output=True)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--apply',action='store_true');args=parser.parse_args()
sources=build_local_sources()
lookup=psql("SELECT id FROM source_connections WHERE source_type='pp' ORDER BY created_at,id LIMIT 1;\n")
if lookup.returncode:raise SystemExit('could not resolve PP connection (details withheld)')
pp_connection=lookup.stdout.strip() or stable_connection_id('pp-download-center')
statements=[]
for source in sources:
connection_id=pp_connection if source['source_type']=='pp' else stable_connection_id(source['source_key'])
statements.append(catalog_statements(source,connection_id))
sql="BEGIN;\nSET LOCAL standard_conforming_strings=on;\n"+'\n'.join(statements)+("\nCOMMIT;\n" if args.apply else "\nROLLBACK;\n")
result=psql(sql)
if result.returncode:raise SystemExit('catalog import failed; transaction rolled back (details withheld)')
print('mode='+('apply' if args.apply else 'dry-run'))
for source in sources:print(f"{source['source_type']} {source['name']}: nodes={source['node_count']} folders={source['folder_count']} files={source['file_count']}")
print(f"total_sources={len(sources)} total_nodes={sum(s['node_count'] for s in sources)}")
if __name__=='__main__':
main()