diff --git a/CHANGELOG.md b/CHANGELOG.md index ec40fe3..cf48272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ ### Fixed +- 修复钉钉新登记来源仍按旧离线目录身份取件的问题:从当前来源配置读取空间坐标,实时枚举所在目录并严格匹配节点身份;同名文件不替代、目录不完整不误报删除。修复解析监控遗漏 `version_id` 取件任务及缺少业务元数据的失败记录,正确显示失败原因、重试入口与历史。 + - 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。 - **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。 diff --git a/internal/platform/postgres/artifact_fetch_manifest_integration_test.go b/internal/platform/postgres/artifact_fetch_manifest_integration_test.go new file mode 100644 index 0000000..6b424d9 --- /dev/null +++ b/internal/platform/postgres/artifact_fetch_manifest_integration_test.go @@ -0,0 +1,53 @@ +package postgres + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/workflows/artifactfetch" +) + +func TestArtifactManifestCurrentSourceIntegration(t *testing.T) { + url := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL")) + if url == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err = ApplyMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO source_connections(id,source_type,name,base_url) VALUES('conn-live','dingtalk','Renamed KB','spaces/workspace1?space=workspace1'); +INSERT INTO catalog_import_intents(source_connection_id,node_id,visibility,decided_by,relative_path,name,locator_hash,fetch_state) VALUES('conn-live','sn-live','internal','tester','Folder/Manual.adoc','Manual.adoc',repeat('a',64),'pending')`); err != nil { + t.Fatal(err) + } + manifest, err := NewStore(pool).LoadArtifactFetchManifest(ctx, "conn-live", "sn-live") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + script := filepath.Join(dir, "manifest_probe.py") + if err = os.WriteFile(script, []byte("import json,sys\nm=json.loads(sys.argv[1])\nprint(json.dumps({'ok':m.get('source_root')=='spaces/workspace1?space=workspace1' and m.get('connection_id')=='conn-live','node_id':m['node_id']}))\n"), 0600); err != nil { + t.Fatal(err) + } + python, err := exec.LookPath("python3") + if err != nil { + t.Fatal(err) + } + runner, err := artifactfetch.NewCommandRunner(python, script, dir, "", 4096) + if err != nil { + t.Fatal(err) + } + result, err := runner.Run(ctx, manifest) + if err != nil || !result.OK { + t.Fatalf("current source coordinates lost across DB/Go/Python: %#v %v", result, err) + } +} diff --git a/internal/platform/postgres/artifact_fetch_store.go b/internal/platform/postgres/artifact_fetch_store.go index 6eaea42..e0a6c14 100644 --- a/internal/platform/postgres/artifact_fetch_store.go +++ b/internal/platform/postgres/artifact_fetch_store.go @@ -22,12 +22,13 @@ func loadArtifactFetchManifest(ctx context.Context, db interface { var manifest artifactfetch.Manifest var fetchState string err := db.QueryRow(ctx, `SELECT connection.source_type,connection.name,intent.relative_path,intent.name, - intent.size_bytes,intent.modified_at,intent.locator_hash,intent.fetch_state + intent.size_bytes,intent.modified_at,intent.locator_hash,intent.fetch_state, + CASE WHEN connection.source_type='dingtalk' THEN connection.base_url ELSE '' END FROM catalog_import_intents intent JOIN source_connections connection ON connection.id=intent.source_connection_id WHERE intent.source_connection_id=$1 AND intent.node_id=$2`, connectionID, nodeID). Scan(&manifest.SourceType, &manifest.SourceName, &manifest.RelativePath, &manifest.Name, - &manifest.SizeBytes, &manifest.ModifiedAt, &manifest.LocatorHash, &fetchState) + &manifest.SizeBytes, &manifest.ModifiedAt, &manifest.LocatorHash, &fetchState, &manifest.SourceRoot) if errors.Is(err, pgx.ErrNoRows) { return artifactfetch.Manifest{}, artifactfetch.ErrManifestGone } diff --git a/internal/platform/postgres/monitor_tasks.go b/internal/platform/postgres/monitor_tasks.go index 23c52b6..7885c68 100644 --- a/internal/platform/postgres/monitor_tasks.go +++ b/internal/platform/postgres/monitor_tasks.go @@ -54,7 +54,7 @@ const monitorTasksCTE = `WITH history_publications AS ( WHERE $7<>'' AND (hv.source_item_id,hp.test_kb_id)=(SELECT v.source_item_id,p.test_kb_id FROM test_publications p JOIN source_item_versions v ON v.id=p.source_item_version_id WHERE p.id=$7) ), history_events AS ( SELECT updated_at AS at,'版本状态' AS kind,source_item_version_id || ' · ' || processing_state || ' / ' || retrieval_state AS detail FROM history_publications - UNION ALL SELECT f.created_at,'失败记录',f.job_type || ' · ' || f.error_code || ' · 尝试 ' || f.attempt FROM job_failures f WHERE f.publication_id IN(SELECT id FROM history_publications) OR (f.job_type='artifact.fetch' AND f.business_version_id IN(SELECT source_item_version_id FROM history_publications)) + UNION ALL SELECT f.created_at,'失败记录',f.job_type || ' · ' || f.error_code || ' · 尝试 ' || f.attempt FROM job_failures f LEFT JOIN jobs fj ON fj.id=f.job_id WHERE f.publication_id IN(SELECT id FROM history_publications) OR (f.job_type='artifact.fetch' AND COALESCE(NULLIF(f.business_version_id,''),fj.payload->>'version_id') IN(SELECT source_item_version_id FROM history_publications)) UNION ALL SELECT r.created_at,'重试记录',r.reason || ' · ' || r.outcome FROM job_retry_operations r WHERE r.publication_id IN(SELECT id FROM history_publications) ), latest AS ( SELECT DISTINCT ON(v.source_item_id,p.test_kb_id) p.*,v.source_item_id,v.content_object_id @@ -70,14 +70,14 @@ const monitorTasksCTE = `WITH history_publications AS ( CASE WHEN p.retrieval_state IN ('disabling','disabled') OR (p.knowledge_id<>'' AND si.policy_state='blocked') OR j.type='test.remove' THEN 'remove' WHEN EXISTS(SELECT 1 FROM test_publications prev JOIN source_item_versions pv ON pv.id=prev.source_item_version_id WHERE pv.source_item_id=si.id AND prev.test_kb_id=p.test_kb_id AND prev.id<>p.id AND prev.created_at>'publication_id'=p.id OR (j.type='artifact.fetch' AND j.payload->>'source_item_version_id'=p.source_item_version_id)) + (j.payload->>'publication_id'=p.id OR (j.type='artifact.fetch' AND j.payload->>'version_id'=p.source_item_version_id)) AND j.type IN ('artifact.fetch','test.upload','test.poll','test.remove') ORDER BY CASE WHEN j.state IN ('pending','running','retry') THEN 0 ELSE 1 END,j.updated_at DESC,j.id DESC LIMIT 1) j ON TRUE - LEFT JOIN LATERAL(SELECT f.id,f.job_type,f.error_code,f.created_at FROM job_failures f WHERE f.state='open' AND (f.publication_id=p.id OR (f.job_type='artifact.fetch' AND f.business_version_id=p.source_item_version_id)) ORDER BY f.created_at DESC,f.id DESC LIMIT 1) f ON TRUE + LEFT JOIN LATERAL(SELECT f.id,f.job_type,f.error_code,f.last_error,f.created_at FROM job_failures f LEFT JOIN jobs fj ON fj.id=f.job_id WHERE f.state='open' AND (f.publication_id=p.id OR (f.job_type='artifact.fetch' AND COALESCE(NULLIF(f.business_version_id,''),fj.payload->>'version_id')=p.source_item_version_id)) ORDER BY f.created_at DESC,f.id DESC LIMIT 1) f ON TRUE ), classified AS ( SELECT *, CASE WHEN action='remove' AND retrieval_state='disabled' THEN 'completed' @@ -85,7 +85,7 @@ const monitorTasksCTE = `WITH history_publications AS ( WHEN job_state IN ('pending','retry') AND job_attempt>0 THEN 'retrying' WHEN job_state='running' THEN 'running' WHEN job_state IN ('pending','retry') THEN 'waiting' - WHEN failure_id<>'' OR processing_state IN ('failed','stalled') OR ingest_state='failed' OR retrieval_state='failed' THEN 'attention' + WHEN job_state='failed' OR failure_id<>'' OR processing_state IN ('failed','stalled') OR ingest_state='failed' OR retrieval_state='failed' THEN 'attention' WHEN processing_state='cancelled' THEN 'cancelled' WHEN action='remove' THEN 'waiting' WHEN processing_state IN ('processing','finalizing') THEN 'running' diff --git a/internal/platform/postgres/monitor_tasks_integration_test.go b/internal/platform/postgres/monitor_tasks_integration_test.go index d3ba7e7..8c5736e 100644 --- a/internal/platform/postgres/monitor_tasks_integration_test.go +++ b/internal/platform/postgres/monitor_tasks_integration_test.go @@ -102,4 +102,30 @@ func TestMonitorTasksUnifiedPaginationIntegration(t *testing.T) { if err = store.RetryMonitorRemoval(ctx, "mt-remove", "tester", now); err != ErrPublicationStateConflict { t.Fatalf("changed policy accepted: %v", err) } + t.Run("fetch payload identity without business metadata", func(t *testing.T) { + insertReviewFixture(t, ctx, pool, 7, "mt-fetch", now) + exec(`UPDATE test_publications SET processing_state='planned',ingest_state='pending',knowledge_id='' WHERE id='mt-fetch'`) + exec(`INSERT INTO jobs(id,type,idempotency_key,payload,state,attempt,last_error) VALUES('mt-fetch-job','artifact.fetch','mt-fetch-key','{"version_id":"sv-review-7","source_connection_id":"conn-manual","node_id":"sn-fetch"}','failed',1,'DingTalk node not found in current inventory')`) + exec(`INSERT INTO job_failures(id,job_id,job_type,error_code,attempt,max_attempts_at_failure,last_error) VALUES('mt-fetch-failure','mt-fetch-job','artifact.fetch','artifact_invalid',1,8,'DingTalk node not found in current inventory')`) + check := func(status string, retryable int) { + t.Helper() + page, err := store.ListMonitorTasks(ctx, MonitorTaskFilter{ID: "mt-fetch", Status: status, Stage: "fetch", Limit: 1}) + if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Retryable != retryable { + t.Fatalf("fetch status %s: %#v %v", status, page, err) + } + if status == "attention" { + row := page.Items[0] + if row.FailureID != "mt-fetch-failure" || row.RetryKind != "fetch" || row.Detail != "DingTalk node not found in current inventory" || len(row.History) < 2 { + t.Fatalf("failure details/history: %#v", row) + } + } + } + check("attention", 1) + exec(`UPDATE jobs SET state='pending',attempt=0 WHERE id='mt-fetch-job'`) + check("waiting", 0) + exec(`UPDATE jobs SET state='running',attempt=1 WHERE id='mt-fetch-job'`) + check("running", 0) + exec(`UPDATE jobs SET state='retry' WHERE id='mt-fetch-job'`) + check("retrying", 0) + }) } diff --git a/internal/workflows/artifactfetch/handler.go b/internal/workflows/artifactfetch/handler.go index 109b217..59fbb3b 100644 --- a/internal/workflows/artifactfetch/handler.go +++ b/internal/workflows/artifactfetch/handler.go @@ -49,6 +49,7 @@ type Manifest struct { Request SourceType string SourceName string + SourceRoot string RelativePath string Name string SizeBytes *int64 @@ -218,7 +219,7 @@ func stderrDetail(tail string) string { func (runner *CommandRunner) Run(ctx context.Context, manifest Manifest) (Result, error) { manifestJSON, err := json.Marshal(map[string]any{ "spec_id": manifest.VersionID, "node_id": manifest.NodeID, "connection_id": manifest.ConnectionID, - "source_type": manifest.SourceType, "source_name": manifest.SourceName, + "source_type": manifest.SourceType, "source_name": manifest.SourceName, "source_root": manifest.SourceRoot, "relative_path": manifest.RelativePath, "name": manifest.Name, "size_bytes": manifest.SizeBytes, "locator_hash": manifest.LocatorHash, }) diff --git a/scripts/artifact_fetch.py b/scripts/artifact_fetch.py index cbd720b..1c00c2e 100644 --- a/scripts/artifact_fetch.py +++ b/scripts/artifact_fetch.py @@ -371,8 +371,55 @@ def dingtalk_local_export(manifest): return str(candidate), fresh +def dingtalk_live_raw_key(manifest): + """Resolve in the configured source; names locate folders, never file identity.""" + from live_catalog_refresh import dingtalk_root_key + from source_catalog_import import node_id as catalog_node_id, safe_relative_path + from dws_dingtalk_scan import normalise + + match = re.fullmatch(r"spaces/([A-Za-z0-9_-]+)\?space=([A-Za-z0-9_-]+)", manifest['source_root']) + if not match: + raise FetchError('DingTalk source root is invalid', retryable=False, code='source_identity') + root_id, space_id = match.groups() + try: + source_key = 'dingtalk-' + dingtalk_root_key(manifest['connection_id'], space_id) + parts = safe_relative_path(manifest['relative_path']).split('/') + except (OSError, ValueError, RuntimeError): + raise FetchError('DingTalk source identity cannot be resolved', retryable=False, code='source_identity') + folder = '' + for index in range(len(parts)): + drive = space_id.isdigit() + args = ['drive', '+list', '--space-id', space_id] if drive else ['wiki', '+node-list', '--workspace', space_id] + args += ['--page-all', '--limit', '50', '--max-items', '10000', '--page-limit', '200'] + if folder: + args += ['--folder', folder] + payload, verdict = dws_run(args, timeout=180) + if not verdict['ok']: + raise _dws_error(verdict) + data = payload.get('data', {}) if isinstance(payload, dict) else {} + entries = data.get('files' if drive else 'nodes') + if not isinstance(entries, list) or data.get('hasMore') is not False or data.get('autoPageComplete') is not True: + raise FetchError('DingTalk directory response is incomplete', retryable=True, code='source_lookup_incomplete') + matches = [] + for entry in entries: + raw, node, is_folder = normalise(entry, folder or root_id) + if index == len(parts) - 1: + if not is_folder and catalog_node_id(source_key, raw) == manifest['node_id']: + matches.append(raw) + elif is_folder and node['name'] == parts[index]: + matches.append(raw) + if len(matches) != 1: + # A missing/ambiguous path is not evidence that the source file was deleted. + raise FetchError('DingTalk node identity unresolved; refresh 来源目录 before retrying', + retryable=False, code='source_node_unresolved') + folder = matches[0] + return folder, 'file' + + def dingtalk_raw_keys(manifest): - """Recover the dentry uuid behind a node id via the scanner inventory.""" + """Use current source coordinates; retain inventory support for old manifests.""" + if manifest.get('source_root'): + return dingtalk_live_raw_key(manifest) state_path = pathlib.Path("/sources/dingtalk/data/dingtalk-internal/inventory-state.json") state = json.loads(state_path.read_text(encoding="utf-8")) roots = state.get("roots") or {} diff --git a/scripts/artifact_fetch_test.py b/scripts/artifact_fetch_test.py index c25841b..1f5e7bc 100644 --- a/scripts/artifact_fetch_test.py +++ b/scripts/artifact_fetch_test.py @@ -69,6 +69,78 @@ class DingTalkOnlineDocumentExportTest(unittest.TestCase): download.assert_called_once_with("https://oss.example/result.docx", {}, 180, 1_000_000) +class DingTalkCurrentIdentityTest(unittest.TestCase): + def test_current_source_resolves_live_node_not_same_named_legacy_node(self): + from source_catalog_import import node_id, stable_connection_id + source = 'dingtalk-space-workspace1' + manifest = { + 'source_root': 'spaces/workspace1?space=workspace1', + 'connection_id': stable_connection_id(source), + 'source_name': 'Renamed KB', 'node_id': node_id(source, 'current-raw'), + 'relative_path': 'Folder/Manual.adoc', 'name': 'Manual.adoc', + } + legacy = {'roots': {'space-legacy': {'live_space_name': 'Renamed KB', 'nodes': { + 'wrong-raw': {'name': 'Manual.adoc', 'dentry_type': 'file'}, + }}}} + pages = [ + [{'nodeId': 'folder-raw', 'name': 'Folder', 'nodeType': 'folder'}], + [{'nodeId': 'wrong-raw', 'name': 'Manual', 'nodeType': 'file', 'extension': 'adoc'}, + {'nodeId': 'current-raw', 'name': 'Manual', 'nodeType': 'file', 'extension': 'adoc'}], + ] + responses = [({'data': {'nodes': page, 'hasMore': False, 'autoPageComplete': True}}, {'ok': True}) for page in pages] + with mock.patch.object(pathlib.Path, 'read_text', return_value=json.dumps(legacy)), \ + mock.patch.object(artifact_fetch, 'dws_run', side_effect=responses) as listing: + self.assertEqual(artifact_fetch.dingtalk_raw_keys(manifest), ('current-raw', 'file')) + self.assertEqual(listing.call_count, 2) + self.assertIn('workspace1', listing.call_args_list[0].args[0]) + self.assertEqual(listing.call_args_list[1].args[0][-2:], ['--folder', 'folder-raw']) + + + def test_identity_and_incomplete_listing_fail_closed_without_false_deletion(self): + from source_catalog_import import node_id, stable_connection_id + source = 'dingtalk-space-workspace1' + manifest = {'source_root': 'spaces/workspace1?space=workspace1', + 'connection_id': stable_connection_id(source), 'node_id': node_id(source, 'right'), + 'name': 'Manual.adoc', 'relative_path': 'Manual.adoc'} + wrong = {'nodeId': 'wrong', 'name': 'Manual.adoc', 'nodeType': 'file'} + cases = [ + ({'nodes': [wrong], 'hasMore': False, 'autoPageComplete': True}, 'source_node_unresolved'), + ({'nodes': [], 'hasMore': True, 'autoPageComplete': False}, 'source_lookup_incomplete'), + ({'nodes': [], 'hasMore': False}, 'source_lookup_incomplete'), + ] + for data, code in cases: + with self.subTest(code=code, data=data), mock.patch.object(artifact_fetch, 'dws_run', return_value=({'data': data}, {'ok': True})): + with self.assertRaises(artifact_fetch.FetchError) as failure: + artifact_fetch.dingtalk_raw_keys(manifest) + self.assertNotIsInstance(failure.exception, artifact_fetch.MissingSource) + self.assertEqual(failure.exception.code, code) + with mock.patch.object(artifact_fetch, 'dws_run') as call: + with self.assertRaises(artifact_fetch.FetchError): + artifact_fetch.dingtalk_raw_keys({**manifest, 'source_root': 'invalid'}) + call.assert_not_called() + verdict = artifact_fetch.dws_error_map.classify({'error': {'message': 'invalidParameter.authCode.notFound'}}) + with mock.patch.object(artifact_fetch, 'dws_run', return_value=(None, verdict)): + with self.assertRaises(artifact_fetch.FetchError) as failure: + artifact_fetch.dingtalk_raw_keys(manifest) + self.assertEqual(failure.exception.code, 'credentials') + self.assertFalse(failure.exception.retryable) + + def test_legacy_namespace_and_drive_keep_their_own_identity(self): + from source_catalog_import import node_id, stable_connection_id + for space, root_key, drive in [('workspace1', 'space-old', False), ('12345', 'space-12345', True)]: + source = 'dingtalk-' + root_key + manifest = {'source_root': f'spaces/{space}?space={space}', + 'connection_id': stable_connection_id(source), 'node_id': node_id(source, 'right'), + 'name': 'Manual.adoc', 'relative_path': 'Manual.adoc'} + data = {'files' if drive else 'nodes': [{'nodeId': 'right', 'name': 'Manual', 'type': 'file'}], + 'hasMore': False, 'autoPageComplete': True} + with self.subTest(space=space), \ + mock.patch.object(pathlib.Path, 'read_text', return_value=json.dumps({'roots': {'space-old': {}}})), \ + mock.patch.object(artifact_fetch, 'dws_run', return_value=({'data': data}, {'ok': True})) as call: + self.assertEqual(artifact_fetch.dingtalk_raw_keys(manifest), ('right', 'file')) + self.assertEqual(call.call_args.args[0][0], 'drive' if drive else 'wiki') + + class DingTalkDwsFetcherTest(unittest.TestCase): """dws 取件器:路由、回落语义、以及"结果 JSON 形状不得变化"的契约锁定。"""