| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- """Read-only DMS metadata diagnosis using the project's production auth/config contract."""
- import argparse
- from contextlib import redirect_stderr
- import json
- import os
- from pathlib import Path
- import socket
- import ssl
- import sys
- import time
- from http.client import HTTPException
- from urllib.error import HTTPError, URLError
- from urllib.parse import urlencode, quote, quote_plus, urlsplit
- from urllib.request import Request, build_opener, ProxyHandler
- ROOT = Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(ROOT))
- try:
- from src.dms_schema import ALIASES, load_schema, names, expected_fields
- from src.step5_session_analysis.dms import DmsClient, DmsError, NoRedirect, clean, load_dms_config
- from src.step3_question_answer.dms_company import BASIC_FIELDS, HONOR_FIELDS
- except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
- print(json.dumps({'ok': False, 'stage': 'project_import', 'error_type': type(exc).__name__,
- 'hint': '请将文件放入现有项目scripts目录,检查项目依赖和config/dms_schema.json;不输出配置正文。'}, ensure_ascii=False))
- raise SystemExit(1) from None
- CONFIG_KEYS = {'DMS_BASE_URL', 'DMS_LOGIN_URL', 'DMS_USERNAME', 'DMS_PASSWORD', 'DMS_TOKEN',
- 'DMS_RECORDS_COLUMN_ID', 'DMS_SESSIONS_COLUMN_ID', 'DMS_DEMANDS_COLUMN_ID',
- 'DMS_STRICT_SCOPE', 'DMS_ALLOWED_SCOPES_JSON', 'DMS_SCAN_TIMEOUT_SECONDS', 'AUTH_PORT', 'DMS_PORT'}
- REQUIRED = {
- 'records': {'c_session_id', 'c_question', 'c_answer', 'c_created_at'},
- 'sessions': {'c_session_id', 'qpyszx', 'qyzt', 'rzqpyx', 'c_focus_points',
- 'c_settlement_intent_level', 'c_core_needs', 'jygjbm', 'summary',
- 'c_description', 'c_title', 'c_created_at', 'c_updated_at'},
- 'demands': {'c_session_id', 'c_word', 'c_created_at'},
- 'basic': {'c_' + key for key in BASIC_FIELDS} | {'c_area', 'c_revoke_info',
- 'c_original_name', 'c_designated_representative_list'},
- 'honor': {'c_' + key for key in HONOR_FIELDS} | {'c_credit_code'},
- }
- class DiagnosticTransport:
- def __init__(self, timeout=20, no_env_proxy=False):
- self.timeout = timeout
- self.calls = []
- self.secrets = set()
- self.opener = build_opener(NoRedirect(), *([ProxyHandler({})] if no_env_proxy else []))
- def __call__(self, url, form, headers):
- self.secrets.update(str(v) for k, v in headers.items() if k.lower() == 'token' and v)
- row = {'endpoint': url, 'method': 'POST', 'form_keys': sorted(form),
- 'token_header_present': bool(headers.get('token')),
- 'form': {key: '[REDACTED]' if key in {'userName', 'password'} else value
- for key, value in form.items()},
- 'headers': {key: '[REDACTED]' if key.lower() == 'token' else value
- for key, value in headers.items()}, 'timeout_seconds': self.timeout}
- self.calls.append(row)
- emit('request_start', row, self.secrets)
- started = time.monotonic()
- try:
- request = Request(url, urlencode(form).encode(), headers)
- with self.opener.open(request, timeout=self.timeout) as response:
- row.update(http_status=response.status, content_type=response.headers.get('Content-Type', ''))
- body = response.read(4_000_001)
- row['received_bytes'] = len(body)
- if len(body) > 4_000_000:
- raise DmsError('dms_response_too_large')
- if getattr(response, 'length', None) not in (None, 0):
- raise DmsError('dms_response_incomplete')
- try:
- envelope = json.loads(body)
- except (ValueError, UnicodeError):
- raise DmsError('dms_invalid_json') from None
- if isinstance(envelope, dict):
- code = envelope.get('code')
- row['provider_code'] = code if type(code) is int else 'invalid_or_missing'
- if 'userName' in form and 'password' in form and code == 200:
- token = envelope.get('message')
- if isinstance(token, str) and token:
- self.secrets.add(token)
- return envelope
- except HTTPError as exc:
- row.update(http_status=exc.code, content_type=exc.headers.get('Content-Type', ''))
- exc.close()
- code = {401: 'dms_token_invalid', 403: 'dms_permission_denied'}.get(exc.code, 'dms_http_error')
- row['error'] = code
- raise DmsError(code) from None
- except (URLError, OSError, HTTPException) as exc:
- reason = exc.reason if isinstance(exc, URLError) else exc
- code = ('dns_failed' if isinstance(reason, socket.gaierror) else
- 'tls_failed' if isinstance(reason, ssl.SSLError) else
- 'timeout' if isinstance(reason, TimeoutError) else
- 'connection_refused' if isinstance(reason, ConnectionRefusedError) else
- 'response_incomplete' if isinstance(reason, HTTPException) else 'connection_failed')
- row.update(error=code, error_type=type(reason).__name__)
- if isinstance(getattr(reason, 'errno', None), int):
- row['errno'] = reason.errno
- raise DmsError('dms_' + code) from None
- except DmsError as exc:
- row['error'] = str(exc)
- raise
- finally:
- row['elapsed_seconds'] = round(time.monotonic() - started, 3)
- emit('request_end', row, self.secrets)
- def field_differences(schema, alias, model):
- raw = model.get('fieldList') if isinstance(model, dict) else None
- try:
- raw = json.loads(raw) if isinstance(raw, str) else raw
- if not isinstance(raw, dict):
- raise ValueError
- raw = {k: json.loads(v) if isinstance(v, str) else v for k, v in raw.items()}
- if not all(isinstance(v, dict) for v in raw.values()):
- raise ValueError
- except (ValueError, TypeError):
- raise DmsError('dms_invalid_field_list') from None
- spec = schema['columns'][alias]
- expected = expected_fields(schema, alias)
- rows = []
- for logical, actual in spec['fields'].items():
- found = raw.get(actual, {}).get('type')
- rows.append({'logical_field': logical, 'actual_field': actual,
- 'expected_type': expected[actual], 'actual_type': found if isinstance(found, str) else None,
- 'status': 'missing' if actual not in raw else
- 'type_mismatch' if found != expected[actual] else 'matched'})
- missing = sorted(REQUIRED[alias] - set(spec['fields']))
- return {'ok': not missing and all(row['status'] == 'matched' for row in rows),
- 'missing_local_mappings': missing, 'fields': rows,
- 'remote_field_count': len(raw),
- 'hint': '只核对配置字段;额外远端字段不判失败。不验证业务记录值或写权限。'}
- def inspect_column(client, alias):
- config = client.config
- spec = config.schema['columns'][alias]
- identity = {'records': config.records_id, 'sessions': config.sessions_id,
- 'demands': config.demands_id}.get(alias, spec['id'])
- report = {'ok': False, 'column_id': identity, 'expected_name': spec['name'], 'stage': 'column'}
- try:
- column = client.call('/column/getColumnById', {'id': identity})
- if not isinstance(column, dict):
- raise DmsError('dms_invalid_column')
- report.update(actual_name=clean(column.get('title')), actual_column_type=column.get('type'))
- if clean(column.get('title')) != spec['name'] or column.get('type') != 1:
- raise DmsError('dms_column_mismatch')
- report['stage'] = 'parent'
- parent_id = column.get('parentId')
- if type(parent_id) is not int or parent_id <= 0:
- raise DmsError('dms_invalid_parent_id')
- parent = client.call('/column/getColumnById', {'id': parent_id})
- report.update(parent_id=parent_id, expected_parent=config.schema['parent'],
- actual_parent=clean(parent.get('title')) if isinstance(parent, dict) else None)
- if report['actual_parent'] != config.schema['parent']:
- raise DmsError('dms_parent_mismatch')
- report['stage'] = 'model'
- model_id = column.get('modelId')
- if type(model_id) is not int or model_id <= 0:
- raise DmsError('dms_invalid_model_id')
- report['model_id'] = model_id
- model = client.call('/model/getModelById', {'modelId': model_id})
- report['stage'] = 'fields'
- report.update(field_differences(config.schema, alias, model))
- report['code'] = 'matched' if report['ok'] else 'dms_schema_mismatch'
- except DmsError as exc:
- report['code'] = str(exc)
- return report
- def redact_report(value, secrets):
- text = json.dumps(value, ensure_ascii=False, indent=2)
- for secret in sorted({s for s in secrets if s}, key=len, reverse=True):
- for variant in {secret, quote(secret, safe=''), quote_plus(secret),
- json.dumps(secret, ensure_ascii=False)[1:-1], json.dumps(secret)[1:-1]}:
- text = text.replace(variant, '[REDACTED]')
- return text
- def emit(event, details, secrets=()):
- print(redact_report({'event': event, **details}, secrets), file=sys.stderr, flush=True)
- def validate_override(value):
- if value is None:
- return
- try:
- parts = urlsplit(value)
- if (not value or any(c.isspace() or ord(c) < 33 for c in value) or '\\' in value
- or parts.scheme not in ('http', 'https') or not parts.hostname
- or parts.username is not None or parts.password is not None
- or parts.query or parts.fragment or not parts.path or parts.path == '/'
- or (parts.port is not None and not 1 <= parts.port <= 65535)):
- raise ValueError
- except (ValueError, TypeError):
- raise DmsError('invalid_endpoint_override') from None
- def run(root, aliases, *, env_file_only=False, timeout=20, no_env_proxy=False,
- api_url=None, login_url=None):
- wire = DiagnosticTransport(timeout, no_env_proxy)
- report = {'ok': False, 'env_file_exists': (root / '.env').is_file(),
- 'schema_file_exists': (root / 'config/dms_schema.json').is_file(),
- 'env_file_only': env_file_only,
- 'environment_override_keys': [] if env_file_only else sorted(CONFIG_KEYS & os.environ.keys()),
- 'environment_proxy_keys': sorted(k for k in os.environ if k.lower() in
- {'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'}),
- 'use_environment_proxy': not no_env_proxy, 'checks': {}, 'requests': wire.calls}
- try:
- report['stage'] = 'endpoint_arguments'
- validate_override(api_url)
- validate_override(login_url)
- emit('local_configuration', {'env_file': str(root / '.env'),
- 'schema_file': str(root / 'config/dms_schema.json'),
- 'env_file_only': env_file_only, 'selected_columns': list(aliases),
- 'environment_override_keys': report['environment_override_keys']})
- report['stage'] = 'local_schema'
- schema = load_schema(root / 'config/dms_schema.json')
- report['stage'] = 'configuration_and_scope'
- config = load_dms_config(root / '.env', environ={} if env_file_only else None,
- schema_path=root / 'config/dms_schema.json', scope_names=names(schema, aliases))
- wire.secrets.update(x for x in (config.token, config.username, config.password) if x)
- client = DmsClient(config, transport=wire)
- # Instance-only overrides: compatible with the old DmsConfig without login_url.
- # Both call() retries and _login() use these attributes, never write configuration.
- if api_url is not None:
- client.api = api_url.rstrip('/')
- if login_url is not None:
- client.login_url = login_url
- report.update(api_endpoint=client.api, login_endpoint=client.login_url,
- api_url_source='command_line' if api_url is not None else 'configuration',
- login_url_source='command_line' if login_url is not None else 'configuration',
- strict_scope=config.strict, token_configured=bool(config.token),
- login_credentials_configured=bool(config.username and config.password))
- emit('connection_parameters', {'base_url': config.base_url, 'api_endpoint': client.api,
- 'login_endpoint': client.login_url, 'AUTH_PORT': config.auth_port, 'DMS_PORT': config.dms_port,
- 'api_url_source': report['api_url_source'],
- 'login_url_source': ('command_line' if login_url is not None else
- 'DMS_LOGIN_URL' if getattr(config, 'login_url', '') else 'default_route'),
- 'timeout_seconds': timeout, 'use_environment_proxy': not no_env_proxy,
- 'environment_proxy_keys': report['environment_proxy_keys'],
- 'strict_scope': config.strict, 'allowed_scope_names': list(config.scope_names),
- 'token': '[REDACTED]' if config.token else '[NOT CONFIGURED]',
- 'username': '[REDACTED]' if config.username else '[NOT CONFIGURED]',
- 'password': '[REDACTED]' if config.password else '[NOT CONFIGURED]'}, wire.secrets)
- report['stage'] = 'authentication'
- if not client.token:
- client._login() # Same proven login contract; no content writes.
- for alias in aliases:
- emit('column_start', {'alias': alias, 'expected_name': schema['columns'][alias]['name'],
- 'expected_parent': schema['parent'], 'column_id': {'records': config.records_id,
- 'sessions': config.sessions_id, 'demands': config.demands_id}.get(alias, schema['columns'][alias]['id'])}, wire.secrets)
- start = len(wire.calls)
- report['checks'][alias] = inspect_column(client, alias)
- report['checks'][alias]['request_indexes'] = list(range(start, len(wire.calls)))
- emit('column_end', {'alias': alias, **report['checks'][alias]}, wire.secrets)
- report.update(stage='finished', ok=all(row['ok'] for row in report['checks'].values()))
- except DmsError as exc:
- report['code'] = str(exc)
- except (ValueError, KeyError, TypeError, OSError) as exc:
- report.update(code='invalid_local_configuration', error_type=type(exc).__name__)
- return report, wire.secrets
- def compact_report(report, selected_count):
- checks = report.get('checks', {})
- failed = {alias: row for alias, row in checks.items() if not row['ok']}
- output = {'ok': report['ok'], 'checked_columns': len(checks),
- 'passed_columns': len(checks) - len(failed), 'failed_columns': len(failed),
- 'not_checked_columns': selected_count - len(checks)}
- if report['ok']:
- return output
- for key in ('stage', 'code', 'error_type', 'api_endpoint', 'login_endpoint'):
- if key in report:
- output[key] = report[key]
- output['failed_checks'] = {}
- indexes = set()
- for alias, row in failed.items():
- details = {k: v for k, v in row.items() if k not in ('fields', 'hint', 'request_indexes', 'remote_field_count')}
- if 'fields' in row:
- details['fields'] = [field for field in row['fields'] if field['status'] != 'matched']
- output['failed_checks'][alias] = details
- if row.get('request_indexes'):
- indexes.add(row['request_indexes'][-1])
- requests = report.get('requests', [])
- indexes.update(i for i, row in enumerate(requests) if row.get('error') or
- ('provider_code' in row and row['provider_code'] != 200))
- if not checks and requests:
- indexes.add(len(requests) - 1)
- output['related_requests'] = [{'request_index': i, **requests[i]} for i in sorted(indexes)]
- return output
- def main():
- parser = argparse.ArgumentParser(description='DMS只读连接、鉴权、五栏目和字段类型诊断;不读取业务记录')
- parser.add_argument('--env-file-only', action='store_true')
- parser.add_argument('--column', choices=['all', *ALIASES], default='all')
- parser.add_argument('--timeout', type=float, default=20)
- parser.add_argument('--verbose', action='store_true', help='显示全部请求过程和正常字段;默认只显示汇总及异常')
- parser.add_argument('--api-url', help='仅本次诊断覆盖完整DMS数据入口,不受DMS_PORT覆盖')
- parser.add_argument('--login-url', help='仅本次诊断覆盖完整登录地址,不受AUTH_PORT覆盖')
- parser.add_argument('--no-env-proxy', action='store_true', help='仅用于对比:禁用系统HTTP代理,默认与正式客户端一致')
- args = parser.parse_args()
- if not 0 < args.timeout <= 120:
- parser.error('timeout须在0到120秒之间')
- aliases = ALIASES if args.column == 'all' else (args.column,)
- with open(os.devnull, 'w') as sink, redirect_stderr(sys.stderr if args.verbose else sink):
- report, secrets = run(ROOT, aliases,
- env_file_only=args.env_file_only, timeout=args.timeout, no_env_proxy=args.no_env_proxy,
- api_url=args.api_url, login_url=args.login_url)
- print(redact_report(report if args.verbose else compact_report(report, len(aliases)), secrets))
- return 0 if report['ok'] else 1
- if __name__ == '__main__':
- raise SystemExit(main())
|