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