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

# -*- 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()