test_dms_connection.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """Read-only DMS metadata diagnosis using the project's production auth/config contract."""
  2. import argparse
  3. from contextlib import redirect_stderr
  4. import json
  5. import os
  6. from pathlib import Path
  7. import socket
  8. import ssl
  9. import sys
  10. import time
  11. from http.client import HTTPException
  12. from urllib.error import HTTPError, URLError
  13. from urllib.parse import urlencode, quote, quote_plus, urlsplit
  14. from urllib.request import Request, build_opener, ProxyHandler
  15. ROOT = Path(__file__).resolve().parents[1]
  16. sys.path.insert(0, str(ROOT))
  17. try:
  18. from src.dms_schema import ALIASES, load_schema, names, expected_fields
  19. from src.step5_session_analysis.dms import DmsClient, DmsError, NoRedirect, clean, load_dms_config
  20. from src.step3_question_answer.dms_company import BASIC_FIELDS, HONOR_FIELDS
  21. except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
  22. print(json.dumps({'ok': False, 'stage': 'project_import', 'error_type': type(exc).__name__,
  23. 'hint': '请将文件放入现有项目scripts目录,检查项目依赖和config/dms_schema.json;不输出配置正文。'}, ensure_ascii=False))
  24. raise SystemExit(1) from None
  25. CONFIG_KEYS = {'DMS_BASE_URL', 'DMS_LOGIN_URL', 'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN',
  26. 'DMS_RECORDS_COLUMN_ID', 'DMS_SESSIONS_COLUMN_ID', 'DMS_DEMANDS_COLUMN_ID',
  27. 'DMS_STRICT_SCOPE', 'DMS_ALLOWED_SCOPES_JSON', 'DMS_SCAN_TIMEOUT_SECONDS', 'AUTH_PORT', 'DMS_PORT'}
  28. REQUIRED = {
  29. 'records': {'c_session_id', 'c_question', 'c_answer', 'c_created_at'},
  30. 'sessions': {'c_session_id', 'qpyszx', 'qyzt', 'rzqpyx', 'c_focus_points',
  31. 'c_settlement_intent_level', 'c_core_needs', 'jygjbm', 'summary',
  32. 'c_description', 'c_title', 'c_created_at', 'c_updated_at'},
  33. 'demands': {'c_session_id', 'c_word', 'c_created_at'},
  34. 'basic': {'c_' + key for key in BASIC_FIELDS} | {'c_area', 'c_revoke_info',
  35. 'c_original_name', 'c_designated_representative_list'},
  36. 'honor': {'c_' + key for key in HONOR_FIELDS} | {'c_credit_code'},
  37. }
  38. class DiagnosticTransport:
  39. def __init__(self, timeout=20, no_env_proxy=False):
  40. self.timeout = timeout
  41. self.calls = []
  42. self.secrets = set()
  43. self.opener = build_opener(NoRedirect(), *([ProxyHandler({})] if no_env_proxy else []))
  44. def __call__(self, url, form, headers):
  45. self.secrets.update(str(v) for k, v in headers.items() if k.lower() == 'token' and v)
  46. row = {'endpoint': url, 'method': 'POST', 'form_keys': sorted(form),
  47. 'token_header_present': bool(headers.get('token')),
  48. 'form': {key: '[REDACTED]' if key in {'userName', 'password'} else value
  49. for key, value in form.items()},
  50. 'headers': {key: '[REDACTED]' if key.lower() == 'token' else value
  51. for key, value in headers.items()}, 'timeout_seconds': self.timeout}
  52. self.calls.append(row)
  53. emit('request_start', row, self.secrets)
  54. started = time.monotonic()
  55. try:
  56. request = Request(url, urlencode(form).encode(), headers)
  57. with self.opener.open(request, timeout=self.timeout) as response:
  58. row.update(http_status=response.status, content_type=response.headers.get('Content-Type', ''))
  59. body = response.read(4_000_001)
  60. row['received_bytes'] = len(body)
  61. if len(body) > 4_000_000:
  62. raise DmsError('dms_response_too_large')
  63. if getattr(response, 'length', None) not in (None, 0):
  64. raise DmsError('dms_response_incomplete')
  65. try:
  66. envelope = json.loads(body)
  67. except (ValueError, UnicodeError):
  68. raise DmsError('dms_invalid_json') from None
  69. if isinstance(envelope, dict):
  70. code = envelope.get('code')
  71. row['provider_code'] = code if type(code) is int else 'invalid_or_missing'
  72. if 'userName' in form and 'password' in form and code == 200:
  73. token = envelope.get('message')
  74. if isinstance(token, str) and token:
  75. self.secrets.add(token)
  76. return envelope
  77. except HTTPError as exc:
  78. row.update(http_status=exc.code, content_type=exc.headers.get('Content-Type', ''))
  79. exc.close()
  80. code = {401: 'dms_token_invalid', 403: 'dms_permission_denied'}.get(exc.code, 'dms_http_error')
  81. row['error'] = code
  82. raise DmsError(code) from None
  83. except (URLError, OSError, HTTPException) as exc:
  84. reason = exc.reason if isinstance(exc, URLError) else exc
  85. code = ('dns_failed' if isinstance(reason, socket.gaierror) else
  86. 'tls_failed' if isinstance(reason, ssl.SSLError) else
  87. 'timeout' if isinstance(reason, TimeoutError) else
  88. 'connection_refused' if isinstance(reason, ConnectionRefusedError) else
  89. 'response_incomplete' if isinstance(reason, HTTPException) else 'connection_failed')
  90. row.update(error=code, error_type=type(reason).__name__)
  91. if isinstance(getattr(reason, 'errno', None), int):
  92. row['errno'] = reason.errno
  93. raise DmsError('dms_' + code) from None
  94. except DmsError as exc:
  95. row['error'] = str(exc)
  96. raise
  97. finally:
  98. row['elapsed_seconds'] = round(time.monotonic() - started, 3)
  99. emit('request_end', row, self.secrets)
  100. def field_differences(schema, alias, model):
  101. raw = model.get('fieldList') if isinstance(model, dict) else None
  102. try:
  103. raw = json.loads(raw) if isinstance(raw, str) else raw
  104. if not isinstance(raw, dict):
  105. raise ValueError
  106. raw = {k: json.loads(v) if isinstance(v, str) else v for k, v in raw.items()}
  107. if not all(isinstance(v, dict) for v in raw.values()):
  108. raise ValueError
  109. except (ValueError, TypeError):
  110. raise DmsError('dms_invalid_field_list') from None
  111. spec = schema['columns'][alias]
  112. expected = expected_fields(schema, alias)
  113. rows = []
  114. for logical, actual in spec['fields'].items():
  115. found = raw.get(actual, {}).get('type')
  116. rows.append({'logical_field': logical, 'actual_field': actual,
  117. 'expected_type': expected[actual], 'actual_type': found if isinstance(found, str) else None,
  118. 'status': 'missing' if actual not in raw else
  119. 'type_mismatch' if found != expected[actual] else 'matched'})
  120. missing = sorted(REQUIRED[alias] - set(spec['fields']))
  121. return {'ok': not missing and all(row['status'] == 'matched' for row in rows),
  122. 'missing_local_mappings': missing, 'fields': rows,
  123. 'remote_field_count': len(raw),
  124. 'hint': '只核对配置字段;额外远端字段不判失败。不验证业务记录值或写权限。'}
  125. def inspect_column(client, alias):
  126. config = client.config
  127. spec = config.schema['columns'][alias]
  128. identity = {'records': config.records_id, 'sessions': config.sessions_id,
  129. 'demands': config.demands_id}.get(alias, spec['id'])
  130. report = {'ok': False, 'column_id': identity, 'expected_name': spec['name'], 'stage': 'column'}
  131. try:
  132. column = client.call('/column/getColumnById', {'id': identity})
  133. if not isinstance(column, dict):
  134. raise DmsError('dms_invalid_column')
  135. report.update(actual_name=clean(column.get('title')), actual_column_type=column.get('type'))
  136. if clean(column.get('title')) != spec['name'] or column.get('type') != 1:
  137. raise DmsError('dms_column_mismatch')
  138. report['stage'] = 'parent'
  139. parent_id = column.get('parentId')
  140. if type(parent_id) is not int or parent_id <= 0:
  141. raise DmsError('dms_invalid_parent_id')
  142. parent = client.call('/column/getColumnById', {'id': parent_id})
  143. report.update(parent_id=parent_id, expected_parent=config.schema['parent'],
  144. actual_parent=clean(parent.get('title')) if isinstance(parent, dict) else None)
  145. if report['actual_parent'] != config.schema['parent']:
  146. raise DmsError('dms_parent_mismatch')
  147. report['stage'] = 'model'
  148. model_id = column.get('modelId')
  149. if type(model_id) is not int or model_id <= 0:
  150. raise DmsError('dms_invalid_model_id')
  151. report['model_id'] = model_id
  152. model = client.call('/model/getModelById', {'modelId': model_id})
  153. report['stage'] = 'fields'
  154. report.update(field_differences(config.schema, alias, model))
  155. report['code'] = 'matched' if report['ok'] else 'dms_schema_mismatch'
  156. except DmsError as exc:
  157. report['code'] = str(exc)
  158. return report
  159. def redact_report(value, secrets):
  160. text = json.dumps(value, ensure_ascii=False, indent=2)
  161. for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
  162. for variant in {secret, quote(secret, safe=''), quote_plus(secret),
  163. json.dumps(secret, ensure_ascii=False)[1:-1], json.dumps(secret)[1:-1]}:
  164. text = text.replace(variant, '[REDACTED]')
  165. return text
  166. def emit(event, details, secrets=()):
  167. print(redact_report({'event': event, **details}, secrets), file=sys.stderr, flush=True)
  168. def validate_override(value):
  169. if value is None:
  170. return
  171. try:
  172. parts = urlsplit(value)
  173. if (not value or any(c.isspace() or ord(c) < 33 for c in value) or '\\' in value
  174. or parts.scheme not in ('http', 'https') or not parts.hostname
  175. or parts.username is not None or parts.password is not None
  176. or parts.query or parts.fragment or not parts.path or parts.path == '/'
  177. or (parts.port is not None and not 1 <= parts.port <= 65535)):
  178. raise ValueError
  179. except (ValueError, TypeError):
  180. raise DmsError('invalid_endpoint_override') from None
  181. def run(root, aliases, *, env_file_only=False, timeout=20, no_env_proxy=False,
  182. api_url=None, login_url=None):
  183. wire = DiagnosticTransport(timeout, no_env_proxy)
  184. report = {'ok': False, 'env_file_exists': (root / '.env').is_file(),
  185. 'schema_file_exists': (root / 'config/dms_schema.json').is_file(),
  186. 'env_file_only': env_file_only,
  187. 'environment_override_keys': [] if env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
  188. 'environment_proxy_keys': sorted(k for k in os.environ if k.lower() in
  189. {'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'}),
  190. 'use_environment_proxy': not no_env_proxy, 'checks': {}, 'requests': wire.calls}
  191. try:
  192. report['stage'] = 'endpoint_arguments'
  193. validate_override(api_url)
  194. validate_override(login_url)
  195. emit('local_configuration', {'env_file': str(root / '.env'),
  196. 'schema_file': str(root / 'config/dms_schema.json'),
  197. 'env_file_only': env_file_only, 'selected_columns': list(aliases),
  198. 'environment_override_keys': report['environment_override_keys']})
  199. report['stage'] = 'local_schema'
  200. schema = load_schema(root / 'config/dms_schema.json')
  201. report['stage'] = 'configuration_and_scope'
  202. config = load_dms_config(root / '.env', environ={} if env_file_only else None,
  203. schema_path=root / 'config/dms_schema.json', scope_names=names(schema, aliases))
  204. wire.secrets.update(x for x in (config.token, config.username, config.password) if x)
  205. client = DmsClient(config, transport=wire)
  206. # Instance-only overrides: compatible with the old DmsConfig without login_url.
  207. # Both call() retries and _login() use these attributes, never write configuration.
  208. if api_url is not None:
  209. client.api = api_url.rstrip('/')
  210. if login_url is not None:
  211. client.login_url = login_url
  212. report.update(api_endpoint=client.api, login_endpoint=client.login_url,
  213. api_url_source='command_line' if api_url is not None else 'configuration',
  214. login_url_source='command_line' if login_url is not None else 'configuration',
  215. strict_scope=config.strict, token_configured=bool(config.token),
  216. login_credentials_configured=bool(config.username and config.password))
  217. emit('connection_parameters', {'base_url': config.base_url, 'api_endpoint': client.api,
  218. 'login_endpoint': client.login_url, 'AUTH_PORT': config.auth_port, 'DMS_PORT': config.dms_port,
  219. 'api_url_source': report['api_url_source'],
  220. 'login_url_source': ('command_line' if login_url is not None else
  221. 'DMS_LOGIN_URL' if getattr(config, 'login_url', '') else 'default_route'),
  222. 'timeout_seconds': timeout, 'use_environment_proxy': not no_env_proxy,
  223. 'environment_proxy_keys': report['environment_proxy_keys'],
  224. 'strict_scope': config.strict, 'allowed_scope_names': list(config.scope_names),
  225. 'token': '[REDACTED]' if config.token else '[NOT CONFIGURED]',
  226. 'username': '[REDACTED]' if config.username else '[NOT CONFIGURED]',
  227. 'password': '[REDACTED]' if config.password else '[NOT CONFIGURED]'}, wire.secrets)
  228. report['stage'] = 'authentication'
  229. if not client.token:
  230. client._login() # Same proven login contract; no content writes.
  231. for alias in aliases:
  232. emit('column_start', {'alias': alias, 'expected_name': schema['columns'][alias]['name'],
  233. 'expected_parent': schema['parent'], 'column_id': {'records': config.records_id,
  234. 'sessions': config.sessions_id, 'demands': config.demands_id}.get(alias, schema['columns'][alias]['id'])}, wire.secrets)
  235. start = len(wire.calls)
  236. report['checks'][alias] = inspect_column(client, alias)
  237. report['checks'][alias]['request_indexes'] = list(range(start, len(wire.calls)))
  238. emit('column_end', {'alias': alias, **report['checks'][alias]}, wire.secrets)
  239. report.update(stage='finished', ok=all(row['ok'] for row in report['checks'].values()))
  240. except DmsError as exc:
  241. report['code'] = str(exc)
  242. except (ValueError, KeyError, TypeError, OSError) as exc:
  243. report.update(code='invalid_local_configuration', error_type=type(exc).__name__)
  244. return report, wire.secrets
  245. def compact_report(report, selected_count):
  246. checks = report.get('checks', {})
  247. failed = {alias: row for alias, row in checks.items() if not row['ok']}
  248. output = {'ok': report['ok'], 'checked_columns': len(checks),
  249. 'passed_columns': len(checks) - len(failed), 'failed_columns': len(failed),
  250. 'not_checked_columns': selected_count - len(checks)}
  251. if report['ok']:
  252. return output
  253. for key in ('stage', 'code', 'error_type', 'api_endpoint', 'login_endpoint'):
  254. if key in report:
  255. output[key] = report[key]
  256. output['failed_checks'] = {}
  257. indexes = set()
  258. for alias, row in failed.items():
  259. details = {k: v for k, v in row.items() if k not in ('fields', 'hint', 'request_indexes', 'remote_field_count')}
  260. if 'fields' in row:
  261. details['fields'] = [field for field in row['fields'] if field['status'] != 'matched']
  262. output['failed_checks'][alias] = details
  263. if row.get('request_indexes'):
  264. indexes.add(row['request_indexes'][-1])
  265. requests = report.get('requests', [])
  266. indexes.update(i for i, row in enumerate(requests) if row.get('error') or
  267. ('provider_code' in row and row['provider_code'] != 200))
  268. if not checks and requests:
  269. indexes.add(len(requests) - 1)
  270. output['related_requests'] = [{'request_index': i, **requests[i]} for i in sorted(indexes)]
  271. return output
  272. def main():
  273. parser = argparse.ArgumentParser(description='DMS只读连接、鉴权、五栏目和字段类型诊断;不读取业务记录')
  274. parser.add_argument('--env-file-only', action='store_true')
  275. parser.add_argument('--column', choices=['all', *ALIASES], default='all')
  276. parser.add_argument('--timeout', type=float, default=20)
  277. parser.add_argument('--verbose', action='store_true', help='显示全部请求过程和正常字段;默认只显示汇总及异常')
  278. parser.add_argument('--api-url', help='仅本次诊断覆盖完整DMS数据入口,不受DMS_PORT覆盖')
  279. parser.add_argument('--login-url', help='仅本次诊断覆盖完整登录地址,不受AUTH_PORT覆盖')
  280. parser.add_argument('--no-env-proxy', action='store_true', help='仅用于对比:禁用系统HTTP代理,默认与正式客户端一致')
  281. args = parser.parse_args()
  282. if not 0 < args.timeout <= 120:
  283. parser.error('timeout须在0到120秒之间')
  284. aliases = ALIASES if args.column == 'all' else (args.column,)
  285. with open(os.devnull, 'w') as sink, redirect_stderr(sys.stderr if args.verbose else sink):
  286. report, secrets = run(ROOT, aliases,
  287. env_file_only=args.env_file_only, timeout=args.timeout, no_env_proxy=args.no_env_proxy,
  288. api_url=args.api_url, login_url=args.login_url)
  289. print(redact_report(report if args.verbose else compact_report(report, len(aliases)), secrets))
  290. return 0 if report['ok'] else 1
  291. if __name__ == '__main__':
  292. raise SystemExit(main())