You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
790 lines
42 KiB
790 lines
42 KiB
# -*- coding: utf-8 -*-
|
|
"""e2e-graph.py — 联系人图谱 E2E API 套件(contact-graph 票 07)
|
|
|
|
对齐 e2e-incr.py 套件模式:独立夹具(e2c-cg-*)+ 幂等清扫 + specimens 留档 + checks JSON。
|
|
票面 7 条 → 流程映射(修订⑧适配:手动边终态口径,无「自动生成边/否决持久化」):
|
|
G-00 字典种子 W-01 销号复验(job_title tree 5+9 档位 / contact_source enabled-list 4 项)
|
|
G-01 夹具 + 图谱全量读(懒建版本行;quickAdd 字典联动 D1;脱敏回显)
|
|
G-02 边 CRUD + graph/save(建边回读/自连·重复·倒挂·未定级·幽灵 67019/单父 67020/
|
|
版本冲突 67018/空 edges=清空持久化)
|
|
G-03 联系人增强(page 掩码→reveal→reveal_log DB 取证;quickAdd 空行跳过·查重·疑似重复·67021;
|
|
batchEdit 职务联动 D2·手工锁定·phone 三态·原子批零落库·67022;export xlsx)
|
|
G-04 导入三段式(template 表头锚点/upload 预检计数/67023 守门/confirm 轮询 DONE/
|
|
failures clip500/DB 执行面 + bump 信号/67024 状态不允许)
|
|
G-05 并发冲突(A/B 双 session 旧版本保存 → 67018)
|
|
G-06 关系变更日志(票面第 4 条现实适配:graph/save 无专属 oplog,断言 contact 动作
|
|
oplog 链路——quickAdd CONTACT_ADD / batchEdit CONTACT_EDIT「批量编辑联系人【N 条】」)
|
|
错误码断言一律用真实值(CustomerConstants):67018/67019/67020/67021/67022/67023/67024
|
|
(demo 桩自洽码 67017 不出现在本套件;67017 已被战略协议占用)。
|
|
产出:.scratch/customer-contact-graph/e2e-graph-checks.json + e2e-graph-specimens.json
|
|
"""
|
|
import sys, io, json, time, re, os
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
import requests
|
|
import pymysql
|
|
|
|
BASE = 'http://localhost:8080'
|
|
ADMIN = '739564171091247104' # 罗伟健
|
|
BUDDY = '744842318024015872' # 曾偲青
|
|
OUT = '.scratch/customer-contact-graph/'
|
|
PFX = 'e2c-cg'
|
|
TS = str(int(time.time()))[-6:]
|
|
GHOST = 999999999999999 # 幽灵 id(snowflake 量级,不可能真实存在)
|
|
|
|
checks, defects, specimens = [], [], {}
|
|
_skips = set()
|
|
_g = {}
|
|
|
|
|
|
def check(flow, case, verdict, detail=''):
|
|
checks.append({'flow': flow, 'case': case,
|
|
'verdict': {'pass': '✅', 'warn': '⚠', 'fail': '❌'}[verdict], 'detail': detail})
|
|
print(f" {verdict} [{flow}] {case}" + (f' — {detail}' if detail else ''))
|
|
|
|
|
|
def defect(did, severity, title, detail):
|
|
defects.append({'id': did, 'severity': severity, 'title': title, 'detail': detail})
|
|
print(f" ⚑ 登记 {did}({severity}): {title}")
|
|
|
|
|
|
def db():
|
|
return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
|
|
database='crm', charset='utf8mb4', autocommit=True,
|
|
connect_timeout=10, cursorclass=pymysql.cursors.DictCursor)
|
|
|
|
|
|
def dbq(sql, args=None):
|
|
with db() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, args)
|
|
return cur.fetchall()
|
|
|
|
|
|
def dbx(sql, args=None):
|
|
with db() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, args)
|
|
return cur.rowcount
|
|
|
|
|
|
def get_token(uid, tries=3):
|
|
last = None
|
|
for _ in range(tries):
|
|
try:
|
|
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30)
|
|
if r.status_code == 200:
|
|
d = r.json().get('data')
|
|
tok = d if isinstance(d, str) else (d or {}).get('token')
|
|
if tok:
|
|
return tok
|
|
last = f'body={r.text[:120]}'
|
|
else:
|
|
last = f'HTTP {r.status_code} {r.text[:120]}'
|
|
except Exception as e:
|
|
last = str(e)
|
|
time.sleep(2)
|
|
raise RuntimeError(f'debug/token 获取失败 userId={uid}: {last}')
|
|
|
|
|
|
S = requests.Session()
|
|
S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}'
|
|
B = requests.Session()
|
|
B.headers['Authorization'] = f'Bearer {get_token(BUDDY)}'
|
|
|
|
|
|
def _capture(method, path, req, resp_body):
|
|
path = re.sub(r'/\d{15,}', '/{id}', path)
|
|
key = f'{method} {path}'
|
|
if key in _skips:
|
|
return
|
|
_skips.add(key)
|
|
specimens[key] = {
|
|
'request': {'contentType': 'application/json' if isinstance(req, (dict, list)) else
|
|
'application/x-www-form-urlencoded', 'body': req},
|
|
'response': resp_body,
|
|
}
|
|
|
|
|
|
def api(sess, method, path, form=None, params=None, step='', retry401=True):
|
|
url = BASE + path
|
|
try:
|
|
if method == 'POST':
|
|
r = sess.post(url, data=form, params=params, timeout=30)
|
|
else:
|
|
r = sess.request(method, url, params=params, timeout=30)
|
|
except Exception as e:
|
|
print(f' ✘ {step}: 网络异常 {e}')
|
|
return None
|
|
if r.status_code == 401 and retry401:
|
|
uid = BUDDY if sess is B else ADMIN
|
|
sess.headers['Authorization'] = f'Bearer {get_token(uid)}'
|
|
return api(sess, method, path, form, params, step, retry401=False)
|
|
if r.status_code != 200:
|
|
_capture(method, path, form, {'_http': r.status_code, '_raw': r.text[:400]})
|
|
print(f' ✘ {step}: HTTP {r.status_code} {r.text[:140]}')
|
|
return {'_http': r.status_code, 'code': r.status_code, 'message': r.text[:200]}
|
|
try:
|
|
body = r.json()
|
|
except ValueError:
|
|
_capture(method, path, form, {'_raw': r.text[:400]})
|
|
print(f' ✘ {step}: 响应非 JSON')
|
|
return None
|
|
_capture(method, path, form, body)
|
|
return body
|
|
|
|
|
|
def api_raw(sess, method, path, **kw):
|
|
"""不经 JSON 解析的裸请求(multipart 上传 / 字节流下载用)。
|
|
|
|
与 e2e-incr 原版的差异:此处仍捕获 specimens——二进制响应只记字节数+头部 hex,
|
|
JSON 响应记文本片段;保证 scan_calls 静态对账能覆盖 template/upload/export。
|
|
"""
|
|
r = sess.request(method, BASE + path, timeout=60, **kw)
|
|
req = kw.get('data') or {'<multipart>': bool(kw.get('files'))}
|
|
ct = r.headers.get('Content-Type', '')
|
|
if 'json' in ct:
|
|
resp = {'_http': r.status_code, '_json': r.text[:600]}
|
|
else:
|
|
resp = {'_http': r.status_code, '_bytes': len(r.content),
|
|
'_head_hex': r.content[:8].hex() if r.content else ''}
|
|
_capture(method, path, req, resp)
|
|
return r
|
|
|
|
|
|
def data_of(resp):
|
|
return resp.get('data') if isinstance(resp, dict) else None
|
|
|
|
|
|
def code_of(resp):
|
|
return resp.get('code') if isinstance(resp, dict) else None
|
|
|
|
|
|
def expect_code(resp, want, flow, case):
|
|
got = code_of(resp)
|
|
if got is not None and int(got) == want:
|
|
check(flow, case, 'pass', f'code={got} 如预期')
|
|
return True
|
|
check(flow, case, 'fail', f'期望 code={want},实际 code={got} msg={str(resp.get("message") if isinstance(resp, dict) else resp)[:120]}')
|
|
return False
|
|
|
|
|
|
def dict_one(group, parent_id=None):
|
|
try:
|
|
if parent_id:
|
|
rows = dbq("SELECT i.code c FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
|
|
"WHERE g.code=%s AND i.deleted=0 AND i.parent_id=%s ORDER BY i.sort_no LIMIT 1",
|
|
(group, parent_id))
|
|
else:
|
|
rows = dbq("SELECT i.code c FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
|
|
"WHERE g.code=%s AND i.deleted=0 AND i.parent_id IS NULL ORDER BY i.sort_no LIMIT 1",
|
|
(group,))
|
|
return rows[0]['c'] if rows else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
CTYPE = dict_one('customer_type') or 'customer_type_01'
|
|
GOV = dict_one('industry') or 'gov'
|
|
|
|
|
|
# ==================== 夹具 ====================
|
|
|
|
def _sweep_cg():
|
|
"""清历史 e2c-cg-* 残留(幂等重跑)。返回清扫客户组数。"""
|
|
rows = dbq("SELECT id FROM customer WHERE customer_name LIKE %s", (PFX + '-%',))
|
|
for row in rows:
|
|
cid = row['id']
|
|
# contact_import_fail 无 customer_id 列(按 task_id 关联),须先删子表再删任务
|
|
dbx("DELETE FROM contact_import_fail WHERE task_id IN "
|
|
"(SELECT id FROM contact_import_task WHERE customer_id=%s)", (cid,))
|
|
for t in ['customer_contact_edge', 'customer_contact_graph', 'customer_contact_reveal_log',
|
|
'contact_import_task', 'customer_contact', 'customer_oplog']:
|
|
dbx(f"DELETE FROM {t} WHERE customer_id=%s", (cid,))
|
|
dbx("DELETE FROM customer WHERE id=%s", (cid,))
|
|
return len(rows)
|
|
|
|
|
|
def _mk_customer(sess, name):
|
|
"""自建夹具客户(唯一名不触发相似弹窗;needConfirm 兜底重发 confirmSimilar)。"""
|
|
form = {'customerName': name, 'customerType': CTYPE,
|
|
'provinceCode': '440000', 'cityCode': '440100', 'districtCode': '440103',
|
|
'industryCode': GOV, 'customerStarLevel': 3, 'relationStarLevel': 3,
|
|
'isBizNegotiated': 0, 'isChild': 0, 'ownerUserId': ADMIN}
|
|
r = api(sess, 'POST', '/api/customer/create', form=form, step=f'create {name}')
|
|
d = data_of(r)
|
|
if isinstance(d, dict) and d.get('needConfirm'):
|
|
r = api(sess, 'POST', '/api/customer/create', form=dict(form, confirmSimilar='true'),
|
|
step=f'create {name} (confirm)')
|
|
d = data_of(r)
|
|
if not (isinstance(d, dict) and d.get('id')):
|
|
raise RuntimeError(f'夹具客户创建失败 {name}: {str(r)[:160]}')
|
|
return {'id': str(d['id']), 'no': d.get('customerNo') or '', 'name': name}
|
|
|
|
|
|
def _quick_add(sess, cid, rows, step=''):
|
|
"""quickAdd:rows [{'name','jobTitleCode','phone','source','isKeyContact'}] → 表单 indexed 绑定。"""
|
|
form = {'customerId': str(cid)}
|
|
for i, row in enumerate(rows):
|
|
for k, v in row.items():
|
|
if v is not None:
|
|
form[f'rows[{i}].{k}'] = str(v)
|
|
return api(sess, 'POST', '/api/customer/contact/quickAdd', form=form, step=step)
|
|
|
|
|
|
def graph_detail(sess, cid, step=''):
|
|
r = api(sess, 'GET', '/api/customer/contact/graph/detail',
|
|
params={'customerId': str(cid)}, step=step or 'graph detail')
|
|
return data_of(r) or {}
|
|
|
|
|
|
def cur_version(sess, cid):
|
|
return int(graph_detail(sess, cid).get('version') or 0)
|
|
|
|
|
|
def save_graph(sess, cid, version, levels=None, edges=None, step='', pre_read=True):
|
|
"""graph/save 全量快照:levels {contactId(int): level|None=清级};edges [(parent, child)]。
|
|
|
|
nodes 恒为全量快照(后端校验 seenContactIds==byId.size,缺节点 → 67019);
|
|
level 缺省沿用现值;edges=None=不传任何边键(=清空全部边语义)。返回 save 响应。
|
|
"""
|
|
d = graph_detail(sess, cid, step=step + ' pre-read') if pre_read else {}
|
|
form = {'customerId': str(cid), 'version': str(version)}
|
|
for i, n in enumerate(d.get('nodes') or []):
|
|
form[f'nodes[{i}].contactId'] = str(n['contactId'])
|
|
lv = (levels or {}).get(int(n['contactId']), n.get('level'))
|
|
if lv is not None:
|
|
form[f'nodes[{i}].level'] = str(lv)
|
|
for i, (p, c) in enumerate(edges or []):
|
|
form[f'edges[{i}].parentId'] = str(p)
|
|
form[f'edges[{i}].childId'] = str(c)
|
|
return api(sess, 'POST', '/api/customer/contact/graph/save', form=form, step=step)
|
|
|
|
|
|
def _mk_contact_xlsx(path, rows):
|
|
"""5 列联系人导入模板 sheet:姓名/职务(字典编码)/手机号/来源(字典编码)/是否关键联系人(是/否)。"""
|
|
from openpyxl import Workbook
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.append(['姓名', '职务(字典编码)', '手机号', '来源(字典编码)', '是否关键联系人(是/否)'])
|
|
for row in rows:
|
|
ws.append(list(row))
|
|
wb.save(path)
|
|
|
|
|
|
# ==================== G-00 字典种子(W-01 销号复验) ====================
|
|
|
|
def g00():
|
|
print('\n== G-00 字典种子(W-01 销号复验)==')
|
|
F = 'G-00'
|
|
r = api(S, 'GET', '/api/dict/item/tree', params={'groupCode': 'job_title'}, step='job_title tree')
|
|
d = data_of(r) or []
|
|
subs = [(p.get('code'), c) for p in d for c in (p.get('children') or [])]
|
|
codes_top = sorted(n.get('code') for n in d)
|
|
expect_top = sorted(['executive', 'purchase', 'marketing', 'hr', 'other'])
|
|
ok = code_of(r) == 0 and codes_top == expect_top and len(subs) == 9
|
|
check(F, 'job_title tree:一级 5(executive/purchase/marketing/hr/other)+ 二级 9',
|
|
'pass' if ok else 'fail', f'top={codes_top} subs={len(subs)}')
|
|
grade = {c.get('code'): c.get('grade') or c.get('jobLevel') or c.get('level') for _, c in subs}
|
|
sub_codes = sorted(c.get('code') for _, c in subs)
|
|
expect_sub = sorted(['chairman', 'shareholder', 'purchase_manager', 'purchase_specialist',
|
|
'marketing_manager', 'marketing_specialist', 'hr_manager', 'bid_officer', 'other_job'])
|
|
check(F, 'job_title 二级 code 集全等(含 other_job 无档位)',
|
|
'pass' if sub_codes == expect_sub else 'fail', f'{sub_codes}')
|
|
# 档位:grade 字段名以实际 DTO 为准,先探测再断言(找不到字段 → warn 记档)
|
|
gvals = [v for v in grade.values() if v is not None]
|
|
if any(v in (3, 6, 10) for v in gvals):
|
|
ok = (grade.get('chairman') == 10 and grade.get('shareholder') == 10
|
|
and grade.get('purchase_manager') == 6 and grade.get('purchase_specialist') == 3
|
|
and grade.get('marketing_manager') == 6 and grade.get('marketing_specialist') == 3
|
|
and grade.get('hr_manager') == 6 and grade.get('bid_officer') == 3
|
|
and grade.get('other_job') is None)
|
|
check(F, '档位值集(chairman/shareholder=10,*_manager=6,*_specialist/bid_officer=3,other_job=None)',
|
|
'pass' if ok else 'fail', str(grade))
|
|
else:
|
|
check(F, '档位字段探测失败(DTO 无 grade 类字段)→ DB 取证', 'warn', str(grade))
|
|
rows = dbq("SELECT i.code c, i.job_level g FROM dict_item i JOIN dict_group g2 ON i.group_id=g2.id "
|
|
"WHERE g2.code='job_title' AND i.deleted=0 AND i.parent_id IS NOT NULL")
|
|
gmap = {r['c']: r['g'] for r in rows}
|
|
ok = (gmap.get('chairman') == 10 and gmap.get('purchase_manager') == 6
|
|
and gmap.get('bid_officer') == 3 and gmap.get('other_job') is None)
|
|
check(F, 'DB 档位值集(chairman=10 / purchase_manager=6 / bid_officer=3 / other_job=NULL)',
|
|
'pass' if ok else 'fail', str(gmap))
|
|
r = api(S, 'GET', '/api/dict/item/enabled-list', params={'groupCode': 'contact_source'},
|
|
step='contact_source enabled-list')
|
|
d = data_of(r) or []
|
|
codes = sorted(n.get('code') for n in d)
|
|
expect = sorted(['group_meeting', 'group_activity', 'referral', 'other'])
|
|
check(F, 'contact_source enabled-list:4 项(组会/组局/转介绍/其他)',
|
|
'pass' if code_of(r) == 0 and codes == expect else 'fail', f'{codes}')
|
|
|
|
|
|
# ==================== G-01 夹具 + 图谱全量读 ====================
|
|
|
|
def g01():
|
|
print('\n== G-01 夹具 + 图谱全量读 ==')
|
|
F = 'G-01'
|
|
n = _sweep_cg()
|
|
_g['A'] = _mk_customer(S, f'{PFX}-A-{TS}')
|
|
_g['B'] = _mk_customer(S, f'{PFX}-B-{TS}')
|
|
check(F, '夹具客户 A/B 就位', 'pass' if _g['A']['no'] and _g['B']['no'] else 'fail',
|
|
f"A={_g['A']['no']} B={_g['B']['no']} sweep={n}")
|
|
A = _g['A']
|
|
d = graph_detail(S, A['id'], step='first detail')
|
|
v0 = int(d.get('version') or 0)
|
|
g_rows = dbq('SELECT COUNT(*) n FROM customer_contact_graph WHERE customer_id=%s', (int(A['id']),))
|
|
ok = v0 >= 0 and not d.get('nodes') and not d.get('edges') and g_rows and g_rows[0]['n'] == 1
|
|
check(F, '首读懒建版本行:version 初值就绪且 nodes/edges 空 + DB graph 行=1',
|
|
'pass' if ok else 'fail', f'v={v0} nodes={len(d.get("nodes") or [])} edges={len(d.get("edges") or [])} db={g_rows[0]["n"] if g_rows else "?"}')
|
|
_g['v0'] = v0
|
|
# quickAdd 3+1 行(唯一手机号避免查重;other_job 无档位 → level null 右侧栏)
|
|
names = {'c1': f'cg{TS}张总', 'c2': f'cg{TS}李经理', 'c3': f'cg{TS}王专员', 'c4': f'cg{TS}赵四'}
|
|
rows = [
|
|
{'name': names['c1'], 'jobTitleCode': 'chairman', 'phone': '13800001001', 'source': 'group_meeting', 'isKeyContact': 1},
|
|
{'name': names['c2'], 'jobTitleCode': 'marketing_manager', 'phone': '13800001002', 'source': 'group_activity', 'isKeyContact': 0},
|
|
{'name': names['c3'], 'jobTitleCode': 'marketing_specialist', 'phone': '13800001003', 'source': 'referral', 'isKeyContact': 0},
|
|
{'name': names['c4'], 'jobTitleCode': 'other_job', 'phone': '13800001004', 'source': 'other', 'isKeyContact': 0},
|
|
]
|
|
r = _quick_add(S, A['id'], rows, step='quickAdd x4')
|
|
d = data_of(r) or {}
|
|
ok = code_of(r) == 0 and d.get('successCount') == 4 and not d.get('failedRows')
|
|
check(F, 'quickAdd 4 行全成功(部分成功口径 successCount=4/failedRows 空)',
|
|
'pass' if ok else 'fail', str(d)[:160])
|
|
# detail 回读:节点 4、level {10,6,3,None}、脱敏、版本 bump
|
|
d = graph_detail(S, A['id'], step='detail after quickAdd')
|
|
nodes = {int(n['contactId']): n for n in (d.get('nodes') or [])}
|
|
by_name = {nd.get('name'): cid_ for cid_, nd in nodes.items()}
|
|
_g['names'] = names
|
|
_g['n'] = {k: by_name[nm] for k, nm in names.items()} # c1..c4 → contactId
|
|
lv = {k: nodes[v].get('level') for k, v in _g['n'].items()}
|
|
masked = all('*' in (n.get('phoneMasked') or '') for n in nodes.values())
|
|
ok = (len(nodes) == 4 and lv.get('c1') == 10 and lv.get('c2') == 6 and lv.get('c3') == 3
|
|
and lv.get('c4') is None and masked and not d.get('edges'))
|
|
check(F, 'detail 回读:nodes=4 level={10,6,3,None}(D1 字典联动)+ phoneMasked 脱敏 + edges 空',
|
|
'pass' if ok else 'fail', f'lv={lv} masked={masked}')
|
|
check(F, 'quickAdd 后版本 bump(任一行成功即 bump)',
|
|
'pass' if int(d.get('version') or 0) > _g['v0'] else 'fail',
|
|
f"v {_g['v0']} -> {d.get('version')}")
|
|
_g['v1'] = int(d.get('version') or 0)
|
|
|
|
|
|
# ==================== G-02 边 CRUD + graph/save ====================
|
|
|
|
def g02():
|
|
print('\n== G-02 边 CRUD + graph/save ==')
|
|
F = 'G-02'
|
|
A = _g['A']
|
|
id1, id2, id3, id4 = _g['n']['c1'], _g['n']['c2'], _g['n']['c3'], _g['n']['c4']
|
|
# 2.1 建边 1>2 → 回读
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, edges=[(id1, id2)], step='save edge 1>2')
|
|
nv = data_of(r)
|
|
ok = code_of(r) == 0 and isinstance(nv, int) and nv == v + 1
|
|
check(F, 'save 建边 chairman>manager:返回新版本 = v+1(CAS 推进)',
|
|
'pass' if ok else 'fail', f'v={v} ret={nv}')
|
|
d = graph_detail(S, A['id'], step='read back edge1')
|
|
e = [(int(x['parentId']), int(x['childId'])) for x in (d.get('edges') or [])]
|
|
check(F, '回读 edges==[(1,2)]', 'pass' if e == [(id1, id2)] else 'fail', str(e))
|
|
# 2.2 加边 2>3 → 回读 2 条
|
|
v = int(d.get('version') or 0)
|
|
r = save_graph(S, A['id'], v, edges=[(id1, id2), (id2, id3)], step='save +edge 2>3')
|
|
nv = data_of(r)
|
|
d = graph_detail(S, A['id'], step='read back edge2')
|
|
e = [(int(x['parentId']), int(x['childId'])) for x in (d.get('edges') or [])]
|
|
ok = code_of(r) == 0 and sorted(e) == sorted([(id1, id2), (id2, id3)])
|
|
check(F, 'save 追加边 2>3:回读 2 条', 'pass' if ok else 'fail', f'e={e}')
|
|
v_new = int(d.get('version') or 0)
|
|
# 2.3 非法边矩阵(每次现拿最新版本)
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, edges=[(id2, id2)], step='self edge')
|
|
expect_code(r, 67019, F, '自连边 → 67019')
|
|
r = save_graph(S, A['id'], cur_version(S, A['id']), edges=[(id1, id2), (id1, id2)],
|
|
step='dup edge')
|
|
expect_code(r, 67019, F, '快照内重复边(两条 1>2)→ 67019')
|
|
r = save_graph(S, A['id'], cur_version(S, A['id']), edges=[(id3, id1)],
|
|
step='invert edge')
|
|
expect_code(r, 67019, F, '等级倒挂边(3>1,lv3>lv10)→ 67019')
|
|
r = save_graph(S, A['id'], cur_version(S, A['id']), edges=[(id1, id4)],
|
|
step='unleveled edge')
|
|
expect_code(r, 67019, F, '未定级端点边(1>4,赵四 level=null)→ 67019')
|
|
r = save_graph(S, A['id'], cur_version(S, A['id']), edges=[(id1, GHOST)],
|
|
step='ghost edge')
|
|
expect_code(r, 67019, F, '幽灵端点边(contactId=999…)→ 67019')
|
|
r = save_graph(S, A['id'], cur_version(S, A['id']),
|
|
edges=[(id1, id2), (id2, id3), (id1, id3)],
|
|
step='multi-parent')
|
|
expect_code(r, 67020, F, '单父违反(快照内 3 同时有父 2 与父 1)→ 67020')
|
|
# 2.4 版本冲突:先成功保存一次推进版本,再用旧版本 v_new 保存 → 67018
|
|
# (前面六次非法保存全部被拒不 bump 版本,v_new 仍是最新的——必须先推进才能制造过期)
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, edges=[(id1, id2), (id2, id3)], step='advance version')
|
|
check(F, '成功保存推进版本(为冲突用例制造过期)', 'pass' if code_of(r) == 0 else 'fail',
|
|
f'v={v} ret={data_of(r)}')
|
|
r = save_graph(S, A['id'], v_new, edges=[(id1, id2), (id2, id3)],
|
|
step='stale version save')
|
|
expect_code(r, 67018, F, '旧版本保存(v 已推进)→ 67018')
|
|
# 2.5 空 edges = 清空全部边(持久化取证)
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, edges=[], step='clear edges')
|
|
ok = code_of(r) == 0
|
|
d = graph_detail(S, A['id'], step='read back cleared')
|
|
e = d.get('edges') or []
|
|
rows = dbq('SELECT COUNT(*) n FROM customer_contact_edge WHERE customer_id=%s', (int(A['id']),))
|
|
ndb = rows[0]['n'] if rows else '?'
|
|
check(F, '空 edges 保存 = 清空:API edges==[] 且 DB customer_contact_edge=0',
|
|
'pass' if ok and not e and ndb == 0 else 'fail', f'api={len(e)} db={ndb}')
|
|
# 2.6 恢复两条边(供后续流程版本链)
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, edges=[(id1, id2), (id2, id3)], step='restore edges')
|
|
d = graph_detail(S, A['id'], step='read back restored')
|
|
e = [(int(x['parentId']), int(x['childId'])) for x in (d.get('edges') or [])]
|
|
check(F, '恢复边 1>2,2>3:回读 2 条', 'pass' if sorted(e) == sorted([(id1, id2), (id2, id3)]) else 'fail', str(e))
|
|
|
|
|
|
# ==================== G-03 联系人增强 ====================
|
|
|
|
def g03():
|
|
print('\n== G-03 联系人增强(page/reveal/quickAdd 边界/batchEdit/export)==')
|
|
F = 'G-03'
|
|
A = _g['A']
|
|
id1, id2, id3, id4 = _g['n']['c1'], _g['n']['c2'], _g['n']['c3'], _g['n']['c4']
|
|
# 3.1 page 掩码(keyword=夹具客户名 → 命中其联系人)
|
|
r = api(S, 'GET', '/api/customer/contact/page',
|
|
params={'keyword': A['name'], 'pageNum': 1, 'pageSize': 50}, step='page by keyword')
|
|
d = data_of(r) or {}
|
|
rows = d.get('content') or []
|
|
masked = bool(rows) and all('*' in (x.get('phone') or '') for x in rows
|
|
if x.get('customerId') and str(x['customerId']) == A['id'])
|
|
mine = [x for x in rows if str(x.get('customerId') or '') == A['id']]
|
|
check(F, f'page(keyword 客户名):命中 {len(mine)} 行且 phone 全脱敏',
|
|
'pass' if len(mine) >= 4 and masked else 'fail',
|
|
f'rows={len(rows)} mine={len(mine)}')
|
|
# 3.2 reveal 明文 + reveal_log DB 取证
|
|
r = api(S, 'GET', '/api/customer/contact/reveal', params={'id': id3}, step='reveal id3')
|
|
plain = data_of(r)
|
|
ok = code_of(r) == 0 and plain == '13800001003'
|
|
check(F, 'reveal 返回明文 13800001003', 'pass' if ok else 'fail', f'got={plain}')
|
|
logs = dbq('SELECT operator_id, operator_name, ip, op_time FROM customer_contact_reveal_log '
|
|
'WHERE contact_id=%s ORDER BY op_time DESC LIMIT 1', (id3,))
|
|
lg = logs[0] if logs else {}
|
|
ok = bool(logs) and str(lg.get('operator_id')) == ADMIN and lg.get('ip') and lg.get('op_time')
|
|
check(F, 'DB reveal_log 取证:operator_id=ADMIN + ip/op_time 非空',
|
|
'pass' if ok else 'fail', str(lg)[:120])
|
|
# 3.3 quickAdd 边界
|
|
r = _quick_add(S, A['id'], [
|
|
{'name': f'cg{TS}新增', 'jobTitleCode': 'chairman', 'phone': '13800002001', 'source': 'other'},
|
|
{'name': '', 'phone': ''},
|
|
], step='quickAdd good+blank')
|
|
d = data_of(r) or {}
|
|
check(F, 'quickAdd 好行+空行:空行跳过(successCount=1/failedRows 空)',
|
|
'pass' if code_of(r) == 0 and d.get('successCount') == 1 and not d.get('failedRows') else 'fail',
|
|
str(d)[:140])
|
|
r = _quick_add(S, A['id'], [{'name': f'cg{TS}重号', 'jobTitleCode': 'chairman',
|
|
'phone': '13800001003', 'source': 'other'}], step='quickAdd dup phone')
|
|
d = data_of(r) or {}
|
|
fr = d.get('failedRows') or []
|
|
rs = fr[0].get('reason') or '' if fr else ''
|
|
ok = code_of(r) == 0 and d.get('successCount') == 0 and len(fr) == 1 and '已被' in rs
|
|
check(F, 'quickAdd 手机号已存在:不静默创建(successCount=0 + failedRows 查重明细)',
|
|
'pass' if ok else 'fail', str(d)[:160])
|
|
r = _quick_add(S, A['id'], [{'name': _g['names']['c2'], 'jobTitleCode': 'marketing_manager',
|
|
'source': 'other'}], step='quickAdd suspect dup')
|
|
d = data_of(r) or {}
|
|
fr = d.get('failedRows') or []
|
|
ok = code_of(r) == 0 and d.get('successCount') == 0 and len(fr) == 1 and '疑似' in (fr[0].get('reason') or '')
|
|
check(F, 'quickAdd 无手机号同姓名+同职务:疑似重复提示不创建',
|
|
'pass' if ok else 'fail', str(d)[:160])
|
|
r = api(S, 'POST', '/api/customer/contact/quickAdd', form={'customerId': A['id']},
|
|
step='quickAdd empty rows')
|
|
expect_code(r, 67021, F, 'quickAdd 行集空(不传 rows)→ 67021')
|
|
# 3.4 batchEdit:D2 职务联动(id4 other_job→chairman,字典跟随 level_source=1)
|
|
v0 = cur_version(S, A['id'])
|
|
form = {'customerId': A['id'], 'rows[0].id': str(id4), 'rows[0].jobTitleCode': 'chairman'}
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit', form=form, step='batchEdit D2 follow')
|
|
d = data_of(r) or {}
|
|
db1 = dbq('SELECT job_title_name n, job_title_category c, job_title_level l, level_source s '
|
|
'FROM customer_contact WHERE id=%s', (id4,))[0]
|
|
ok = (code_of(r) == 0 and d.get('successCount') == 1 and db1['n'] == '董事长'
|
|
and db1['c'] == '高层管理' and db1['l'] == 10 and db1['s'] == 1)
|
|
check(F, 'batchEdit 职务变更 D2 字典跟随:董事长/高层管理/level 10/level_source=1',
|
|
'pass' if ok else 'fail', f'{d} db={db1}')
|
|
v1 = cur_version(S, A['id'])
|
|
check(F, 'batchEdit 职务联动后版本 bump', 'pass' if v1 > v0 else 'fail', f'{v0}->{v1}')
|
|
# 3.5 D2 手工锁定:save 手工定级 id4=8(level_source→2),再改职务 level 不动
|
|
v = cur_version(S, A['id'])
|
|
r = save_graph(S, A['id'], v, levels={id4: 8}, edges=[(id1, id2), (id2, id3)], step='lock id4 lv8')
|
|
ok = code_of(r) == 0
|
|
db2 = dbq('SELECT job_title_level l, level_source s FROM customer_contact WHERE id=%s', (id4,))[0]
|
|
check(F, 'graph/save 手工定级 id4=8:DB level=8/level_source=2(手工锁定)',
|
|
'pass' if ok and db2['l'] == 8 and db2['s'] == 2 else 'fail', str(db2))
|
|
form = {'customerId': A['id'], 'rows[0].id': str(id4), 'rows[0].jobTitleCode': 'marketing_specialist'}
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit', form=form, step='batchEdit locked')
|
|
db3 = dbq('SELECT job_title_name n, job_title_level l, level_source s '
|
|
'FROM customer_contact WHERE id=%s', (id4,))[0]
|
|
ok = (code_of(r) == 0 and db3['n'] == '市场专员' and db3['l'] == 8 and db3['s'] == 2)
|
|
check(F, 'D2 手工锁定:职务改市场专员但 level 保持 8 不覆盖',
|
|
'pass' if ok else 'fail', str(db3))
|
|
# 3.6 phone 三态(null 不传=不改 / 明文=改 / 空串=清空 / 脱敏回显=不改)
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit',
|
|
form={'customerId': A['id'], 'rows[0].id': str(id3), 'rows[0].phone': '13900002003'},
|
|
step='batchEdit phone plain')
|
|
db4 = dbq('SELECT phone p FROM customer_contact WHERE id=%s', (id3,))[0]
|
|
check(F, 'batchEdit phone 明文=修改(13900002003)',
|
|
'pass' if code_of(r) == 0 and db4['p'] == '13900002003' else 'fail', str(db4))
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit',
|
|
form={'customerId': A['id'], 'rows[0].id': str(id3), 'rows[0].phone': '139****2003'},
|
|
step='batchEdit phone masked')
|
|
db5 = dbq('SELECT phone p FROM customer_contact WHERE id=%s', (id3,))[0]
|
|
check(F, 'batchEdit phone 脱敏回显形态=不改(DB 仍明文)',
|
|
'pass' if db5['p'] == '13900002003' else 'fail', str(db5))
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit',
|
|
form={'customerId': A['id'], 'rows[0].id': str(id3), 'rows[0].phone': ''},
|
|
step='batchEdit phone clear')
|
|
db6 = dbq('SELECT phone p FROM customer_contact WHERE id=%s', (id3,))[0]
|
|
# 实测:Spring 表单绑定把空串转 null → 「空串=清空」分支不可达(Param 注释与运行时不符)
|
|
ok = not db6['p']
|
|
if ok:
|
|
check(F, 'batchEdit phone 空串=清空(DB 已清)', 'pass', str(db6))
|
|
else:
|
|
defect('D-G1', 'P2', 'batchEdit「空串=清空」语义在表单绑定下不可达',
|
|
'ContactBatchEditParam.phone 注释声称空串=清空,且 Service L255 有 isBlank→null 分支;'
|
|
'但 Spring MVC 表单绑定(StringEditor allowEmpty)把空串转 null,'
|
|
'后端收到的恒为 null(=不改)→ 清空手机号无法通过页内编辑表达。'
|
|
'前端需先 reveal 再提明文替代,或后端换绑定策略。缺陷验证模式记档不排查。')
|
|
check(F, 'batchEdit phone 空串提交:实际不变(绑定层空串→null,语义缺口 D-G1)',
|
|
'warn', f'db={db6}')
|
|
# 3.7 原子批:一好一坏 → 零落库
|
|
form = {'customerId': A['id'], 'rows[0].id': str(id3), 'rows[0].name': f'cg{TS}原子改',
|
|
'rows[1].id': str(id4), 'rows[1].phone': '123'}
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit', form=form, step='batchEdit atomic')
|
|
d = data_of(r) or {}
|
|
fr = d.get('failedRows') or []
|
|
db7 = dbq('SELECT name n FROM customer_contact WHERE id=%s', (id3,))[0]
|
|
ok = (d.get('successCount') == 0 and len(fr) >= 1 and fr[0].get('rowIndex') == 1
|
|
and db7['n'] == _g['names']['c3'])
|
|
check(F, 'batchEdit 原子批:任一失败零落库(id3 name 未变 + failedRows 定位 idx=1)',
|
|
'pass' if ok else 'fail', f'{d} db={db7}')
|
|
r = api(S, 'POST', '/api/customer/contact/batchEdit', form={'customerId': A['id']},
|
|
step='batchEdit empty rows')
|
|
expect_code(r, 67022, F, 'batchEdit 行集空 → 67022')
|
|
# 3.8 export xlsx
|
|
r = api_raw(S, 'POST', '/api/customer/contact/export', params={'customerId': A['id']})
|
|
head = r.content[:2] if r.status_code == 200 else b''
|
|
ct = r.headers.get('Content-Type', '')
|
|
ok = r.status_code == 200 and head == b'PK'
|
|
check(F, 'export:200 + xlsx 字节流(PK 头)', 'pass' if ok else 'fail',
|
|
f'http={r.status_code} ct={ct[:60]} head={head!r}')
|
|
|
|
|
|
# ==================== G-04 导入三段式 ====================
|
|
|
|
def g04():
|
|
print('\n== G-04 联系人导入三段式 ==')
|
|
F = 'G-04'
|
|
A = _g['A']
|
|
id2 = _g['n']['c2']
|
|
XLSX = '.scratch/customer-contact-graph/_import-graph.xlsx'
|
|
XLSX_BAD = '.scratch/customer-contact-graph/_import-graph-badhead.xlsx'
|
|
# 4.1 template:字节流 + 表头锚点
|
|
r = api_raw(S, 'GET', '/api/customer/contact/import/template')
|
|
ok = r.status_code == 200 and r.content[:2] == b'PK'
|
|
heads = []
|
|
if ok:
|
|
from openpyxl import load_workbook
|
|
wb = load_workbook(io.BytesIO(r.content))
|
|
heads = [c.value for c in next(wb.active.iter_rows(min_row=1, max_row=1))]
|
|
expect = ['姓名', '职务(字典编码)', '手机号', '来源(字典编码)', '是否关键联系人(是/否)']
|
|
check(F, 'template:200 + PK 头 + 表头 5 列全等(V1 锚点)',
|
|
'pass' if ok and [str(h) for h in heads] == expect else 'fail', f'{heads}')
|
|
# 4.2 upload 预检:3 好 + 2 坏(坏手机号 / 无手机号)。导入场景无手机号 = FAIL
|
|
# 「手机号必填(导入查重键)」——与 quickAdd 的疑似重复语义分叉(批量执行无人在场,无查重键不可 SUSPECT)
|
|
rows = [
|
|
[f'cg{TS}导甲', 'purchase_manager', '13800003001', 'group_meeting', '否'],
|
|
[f'cg{TS}导乙', 'purchase_specialist', '13800003002', 'referral', '是'],
|
|
[f'cg{TS}导丙', 'hr_manager', '13800003003', 'other', '否'],
|
|
[f'cg{TS}导丁', 'chairman', '123', 'other', '否'], # FAIL:手机号非法
|
|
[_g['names']['c2'], 'marketing_manager', '', 'other', ''], # FAIL:无手机号(查重键缺失)
|
|
]
|
|
_mk_contact_xlsx(XLSX, rows)
|
|
v0 = cur_version(S, A['id'])
|
|
with open(XLSX, 'rb') as f:
|
|
r = api_raw(S, 'POST', '/api/customer/contact/import/upload',
|
|
files={'file': (os.path.basename(XLSX), f,
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
|
|
data={'customerId': A['id'], 'importMode': 'APPEND_ONLY'})
|
|
body = r.json() if r.status_code == 200 else {'_http': r.status_code}
|
|
d = data_of(body) or {}
|
|
task_id = d.get('taskId')
|
|
ok = (code_of(body) == 0 and str(d.get('totalCount')) == '5' and str(d.get('insertCount')) == '3'
|
|
and str(d.get('failCount')) == '2' and str(d.get('suspectCount')) == '0')
|
|
check(F, 'upload 预检:t=5 insert=3 fail=2 suspect=0(无手机号=查重键缺失 FAIL)',
|
|
'pass' if ok else 'fail', str(d)[:200])
|
|
verdicts = sorted((x.get('verdict') or '') for x in (d.get('rows') or []))
|
|
check(F, '预检明细 verdict 集含 INSERT/FAIL',
|
|
'pass' if {'INSERT', 'FAIL'} <= set(verdicts) else 'fail', str(verdicts))
|
|
# 4.3 67023 守门:表头不符文件
|
|
from openpyxl import Workbook
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.append(['名字', '职务', '手机号', '来源', '关键联系人'])
|
|
ws.append([f'cg{TS}表头坏', 'chairman', '13800004001', 'other', '否'])
|
|
wb.save(XLSX_BAD)
|
|
with open(XLSX_BAD, 'rb') as f:
|
|
r = api_raw(S, 'POST', '/api/customer/contact/import/upload',
|
|
files={'file': (os.path.basename(XLSX_BAD), f,
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
|
|
data={'customerId': A['id'], 'importMode': 'APPEND_ONLY'})
|
|
body = r.json() if r.status_code == 200 else {'_http': r.status_code}
|
|
expect_code(body, 67023, F, '表头不符文件 → 67023(预校验守门)')
|
|
if not task_id:
|
|
check(F, 'upload 未回 taskId,执行面跳过', 'fail', '')
|
|
return
|
|
# 4.4 confirm → 轮询 DONE
|
|
r = api(S, 'POST', '/api/customer/contact/import/confirm', params={'taskId': task_id}, step='confirm')
|
|
ok = code_of(r) == 0
|
|
check(F, 'confirm 受理', 'pass' if ok else 'fail', f'code={code_of(r)}')
|
|
status, result = None, {}
|
|
for _ in range(30):
|
|
time.sleep(1)
|
|
r = api(S, 'GET', '/api/customer/contact/import/result', params={'taskId': task_id}, step='poll')
|
|
result = data_of(r) or {}
|
|
status = str(result.get('status'))
|
|
if status in ('2', '3'):
|
|
break
|
|
ok = status == '2' and str(result.get('insertCount')) == '3'
|
|
check(F, '轮询终态 DONE:status=2 + insertCount=3',
|
|
'pass' if ok else 'fail', f'status={status} result={str(result)[:160]}')
|
|
# 4.5 failures 明细 + clip500
|
|
r = api(S, 'GET', '/api/customer/contact/import/failures', params={'taskId': task_id}, step='failures')
|
|
fails = data_of(r) or []
|
|
lens_ok = all(len(x.get('failReason') or '') <= 500 for x in fails)
|
|
check(F, f'failures={len(fails)} 行且 failReason 长度<=500(clip500)',
|
|
'pass' if len(fails) == 2 and lens_ok else 'fail',
|
|
str([(x.get('rowNum'), x.get('name'), (x.get('failReason') or '')[:40]) for x in fails]))
|
|
# 4.6 DB 执行面 + bump 信号
|
|
n = dbq('SELECT COUNT(*) n FROM customer_contact WHERE customer_id=%s AND deleted=0',
|
|
(int(A['id']),))[0]['n']
|
|
v1 = cur_version(S, A['id'])
|
|
check(F, 'DB 执行面:customer_contact=8(4 夹具+1 quickAdd 新增+3 导入)',
|
|
'pass' if n == 8 else 'fail', f'n={n}')
|
|
check(F, '导入成功后图谱版本 bump(executor 收尾 bump)', 'pass' if v1 > v0 else 'fail', f'{v0}->{v1}')
|
|
# 4.7 67024:对已完成任务重复 confirm
|
|
r = api(S, 'POST', '/api/customer/contact/import/confirm', params={'taskId': task_id}, step='re-confirm')
|
|
expect_code(r, 67024, F, '已完成任务重复 confirm → 67024(状态不允许)')
|
|
|
|
|
|
# ==================== G-05 并发冲突 ====================
|
|
|
|
def g05():
|
|
print('\n== G-05 并发冲突(同用户双 session,两标签页场景)==')
|
|
F = 'G-05'
|
|
A = _g['A']
|
|
id1, id2, id3 = _g['n']['c1'], _g['n']['c2'], _g['n']['c3']
|
|
# 同一 ADMIN 的第二个 session:CAS 乐观锁按版本比对不按用户,
|
|
# 同用户双标签页即最常见并发场景(跨用户场景被数据可见域隔离,
|
|
# 实测团队成员不在 CustomerScopeEvaluator.visibleToCurrent 口径内 → B 用户 67002)
|
|
S2 = requests.Session()
|
|
S2.headers['Authorization'] = f'Bearer {get_token(ADMIN)}'
|
|
vA = cur_version(S, A['id'])
|
|
vS2 = cur_version(S2, A['id'])
|
|
check(F, '双 session 读到同版本', 'pass' if vA == vS2 and vA > 0 else 'fail', f'{vA}/{vS2}')
|
|
r = save_graph(S, A['id'], vA, edges=[(id1, id2), (id2, id3)], step='S save first')
|
|
ok = code_of(r) == 0
|
|
check(F, 'S 先保存成功', 'pass' if ok else 'fail', f'code={code_of(r)}')
|
|
r = save_graph(S2, A['id'], vS2, edges=[(id1, id2), (id2, id3)], step='S2 save stale')
|
|
expect_code(r, 67018, F, 'S2 用旧版本保存 → 67018(乐观锁冲突)')
|
|
|
|
|
|
# ==================== G-06 关系变更日志(票面第 4 条现实适配) ====================
|
|
|
|
def g06():
|
|
print('\n== G-06 操作日志(contact 动作链路;graph/save 无专属 oplog)==')
|
|
F = 'G-06'
|
|
A = _g['A']
|
|
r = api(S, 'GET', '/api/customer/oplog/page',
|
|
params={'id': A['id'], 'pageNum': 1, 'pageSize': 50}, step='oplog page')
|
|
d = data_of(r) or {}
|
|
rows = d.get('content') or []
|
|
actions = [x.get('action') for x in rows]
|
|
ok = 'CONTACT_ADD' in actions and 'CONTACT_EDIT' in actions
|
|
check(F, 'oplog 含 CONTACT_ADD(quickAdd/导入)与 CONTACT_EDIT(batchEdit)',
|
|
'pass' if ok else 'fail', f'actions={sorted(set(a for a in actions if a))}')
|
|
r = api(S, 'GET', '/api/customer/oplog/page',
|
|
params={'id': A['id'], 'action': 'CONTACT_EDIT', 'pageNum': 1, 'pageSize': 20},
|
|
step='oplog filter CONTACT_EDIT')
|
|
rows = (data_of(r) or {}).get('content') or []
|
|
pure = rows and all(x.get('action') == 'CONTACT_EDIT' for x in rows)
|
|
batch = any('批量编辑联系人' in (x.get('detail') or '') for x in rows)
|
|
check(F, 'action=CONTACT_EDIT 过滤纯度 + 「批量编辑联系人【N 条】」文案',
|
|
'pass' if pure and batch else 'fail',
|
|
f'rows={len(rows)} pure={pure} batchText={batch}')
|
|
|
|
|
|
# ==================== teardown ====================
|
|
|
|
def teardown():
|
|
print('== teardown:正门 archive + DB 兜底清扫 ==')
|
|
for k in ('A', 'B'):
|
|
c = _g.get(k)
|
|
if not c:
|
|
continue
|
|
r = api(S, 'POST', '/api/customer/archive', params={'id': c['id']}, step=f'archive {k}')
|
|
check('TEARDOWN', f'客户 {k} 正门 archive', 'pass' if code_of(r) == 0 else 'warn',
|
|
f"code={code_of(r)}")
|
|
n = _sweep_cg()
|
|
check('TEARDOWN', 'e2c-cg-* DB 兜底清扫(contact/graph/edge/reveal_log/import_task/fail/oplog)',
|
|
'pass', f'客户行 {n} 组')
|
|
|
|
|
|
# ==================== 静态对账 ====================
|
|
|
|
EXPECTED_CALLS = {
|
|
'GET /api/dict/item/tree', 'GET /api/dict/item/enabled-list',
|
|
'POST /api/customer/create', 'GET /api/customer/contact/graph/detail',
|
|
'POST /api/customer/contact/graph/save', 'POST /api/customer/contact/quickAdd',
|
|
'GET /api/customer/contact/page', 'GET /api/customer/contact/reveal',
|
|
'POST /api/customer/contact/batchEdit', 'POST /api/customer/contact/export',
|
|
'GET /api/customer/contact/import/template', 'POST /api/customer/contact/import/upload',
|
|
'POST /api/customer/contact/import/confirm', 'GET /api/customer/contact/import/result',
|
|
'GET /api/customer/contact/import/failures', 'GET /api/customer/oplog/page',
|
|
'POST /api/customer/archive',
|
|
}
|
|
|
|
|
|
def scan_calls():
|
|
print('\n== 静态对账:套件实调端点 vs 票面清单 ==')
|
|
missing = EXPECTED_CALLS - _skips
|
|
extra = _skips - EXPECTED_CALLS
|
|
ok = not missing
|
|
check('SCAN', f'端点覆盖 {len(_skips & EXPECTED_CALLS)}/{len(EXPECTED_CALLS)}(缺 {len(missing)})',
|
|
'pass' if ok else 'fail', f'missing={sorted(missing)} extra={sorted(extra)}')
|
|
|
|
|
|
# ==================== main ====================
|
|
|
|
def main():
|
|
t0 = time.time()
|
|
print(f'== 联系人图谱 E2E 套件(contact-graph 票 07)== TS={TS} PFX={PFX}')
|
|
flows = [g00, g01, g02, g03, g04, g05, g06, teardown, scan_calls]
|
|
for fn in flows:
|
|
try:
|
|
fn()
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
check(fn.__name__.upper(), '流程级异常', 'fail', str(e)[:160])
|
|
el = time.time() - t0
|
|
npass = sum(1 for c in checks if c['verdict'] == '✅')
|
|
nwarn = sum(1 for c in checks if c['verdict'] == '⚠')
|
|
nfail = sum(1 for c in checks if c['verdict'] == '❌')
|
|
print(f'\n== 汇总 == ✅{npass} ⚠{nwarn} ❌{nfail} 耗时 {el:.0f}s specimens={len(specimens)}')
|
|
for d in defects:
|
|
print(f" ⚑ {d['id']}({d['severity']}) {d['title']}")
|
|
os.makedirs(OUT, exist_ok=True)
|
|
json.dump({'checks': checks, 'defects': defects, 'elapsedSec': round(el, 1)},
|
|
open(OUT + 'e2e-graph-checks.json', 'w', encoding='utf-8'),
|
|
ensure_ascii=False, indent=1)
|
|
json.dump(specimens, open(OUT + 'e2e-graph-specimens.json', 'w', encoding='utf-8'),
|
|
ensure_ascii=False, indent=1)
|
|
print(' ✔ 落盘 e2e-graph-checks.json / e2e-graph-specimens.json')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|