# -*- coding: utf-8 -*- """demo_walkthrough.py — 联调就绪 demo 走查(customer-integration-ready 票 09 收口,用户拍板「demo 走查」) 在 8080 运行态(verify profile,jar 含票 10 全部修复)上按移交报告 §五 逐条现场演示: 鉴权 / 核心CRUD+乐观锁67005 / 脱敏+reveal / 图谱N1+N2+67018 / 关联项目N3 / 文档示例对拍 / 正门清理。 纯 API 级(前端联调即 API 契约);客户夹具 e2c- 前缀 + 正门 archive 清理;项目夹具 DB 直插走查后删。 产出:demo-walkthrough-log.txt(全量请求/响应)。 """ import sys, io, json, time, re sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') import requests import pymysql BASE = 'http://localhost:8080' ADMIN = '739564171091247104' # 罗伟健 DOCS = r'D:\code\crm-api-docs\A4 客户管理' PROJ_ID = 193420000000000001 # 走查项目夹具 id(票 10 probe_n3 同段位,用后即删) LOG = [] print('==== 联调 demo 走查 @', time.strftime('%Y-%m-%d %H:%M:%S'), '====') S = requests.Session() def log(*a): LOG.append(' '.join(str(x) for x in a)) def step(title, ok, detail=''): mark = 'PASS' if ok else 'FAIL' print(f"[{mark}] {title}" + (f" — {detail}" if detail else '')) log(f"\n[{mark}] {title}" + (f" — {detail}" if detail else '')) return ok def api(method, path, params=None, form=None): r = S.request(method, BASE + path, params=params, data=form, timeout=60) log(f"\n>>> {method} {path} params={params} form={form}") try: j = r.json() log('<<<', json.dumps(j, ensure_ascii=False)[:1600]) return j except Exception: log(f'<<< HTTP {r.status_code} (non-json) {r.text[:300]}') return {} def code_of(r): return r.get('code') if isinstance(r, dict) else None def data_of(r): return r.get('data') if isinstance(r, dict) else None 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 dbx(sql, args=None): with db() as conn, conn.cursor() as cur: cur.execute(sql, args) return cur.rowcount def dict_one(group): with db() as conn, conn.cursor() as cur: cur.execute("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,)) row = cur.fetchone() return row['c'] if row else None CTYPE = dict_one('customer_type') or 'customer_type_01' INDUSTRY = dict_one('industry') print(f"字典真值:customer_type={CTYPE} industry={INDUSTRY}") # ---------- 1. 鉴权 ---------- t0 = time.time() r = S.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=30) token = data_of(r.json()) if r.headers.get('content-type', '').startswith('application/json') else None S.headers['Authorization'] = f'Bearer {str(token)}' step('① 鉴权 GET /api/auth/debug/token → Bearer token', bool(token), f"token={str(token)[:20]}… ({time.time()-t0:.2f}s)") # ---------- 2. 核心 CRUD + 乐观锁 ---------- tsn = str(int(time.time()))[-6:] NAME = f'e2c-集成走查-{tsn}' PHONE = '13' + tsn.zfill(9) # 11 位手机号 PHONE2 = '15' + tsn.zfill(9) form = {'customerName': NAME, 'customerType': CTYPE, 'industryCode': INDUSTRY, 'provinceCode': '440000', 'cityCode': '440100', 'districtCode': '440103', 'customerStarLevel': 3, 'relationStarLevel': 3, 'isBizNegotiated': 0, 'isChild': 0, 'ownerUserId': ADMIN} r = api('POST', '/api/customer/create', form=form) d = data_of(r) cid = d.get('id') if isinstance(d, dict) else None if d and d.get('needConfirm'): # 相似名兜底 r = api('POST', '/api/customer/create', form=dict(form, confirmSimilar=1)) d = data_of(r) cid = d.get('id') if isinstance(d, dict) else None step('② 核心CRUD POST /api/customer/create(表单绑定,非 JSON body)', code_of(r) == 0 and bool(cid), f"id={cid}(雪花 id 出参即联调所见)") r = api('GET', '/api/customer/detail', params={'id': cid}) d = data_of(r) or {} ver = d.get('version') keys_ok = all(k in d for k in ('id', 'customerName', 'customerType', 'version', 'createTime')) step('② GET /api/customer/detail 全字段回显(含 version/createTime)', code_of(r) == 0 and keys_ok, f"version={ver} customerName={d.get('customerName')}") edit_form = {'customerName': NAME + '-v2', 'customerType': CTYPE, 'provinceCode': '440000', 'cityCode': '440100', 'districtCode': '440103', 'isBizNegotiated': 0, 'isChild': 0, 'industryCode': INDUSTRY, 'customerStarLevel': 3, 'relationStarLevel': 3, 'version': ver, 'remark': '走查:带 version 编辑成功'} r = api('POST', '/api/customer/edit', params={'id': cid}, form=edit_form) gate = isinstance(data_of(r), dict) and data_of(r).get('needConfirm') if gate: # 相似名闸门:code=0 但未落库,confirmSimilar=1 重发(文档已写明的契约) r = api('POST', '/api/customer/edit', params={'id': cid}, form=dict(edit_form, confirmSimilar=1)) step('② POST /api/customer/edit 带 version → 成功(撞相似名闸门时 confirmSimilar 重发)', code_of(r) == 0, f"code={code_of(r)} needConfirm闸门={gate}(重发已应用,下步 CAS 67005 为证)") r = api('POST', '/api/customer/edit', params={'id': cid}, form=dict(edit_form, customerName=NAME + '-v3', remark='走查:旧版本应被拒', confirmSimilar=1, version=ver)) step('② 乐观锁:edit#1 应用后(version 0→1)再用 0 编辑 → 67005', code_of(r) == 67005, f"code={code_of(r)} msg={r.get('message')}") # ---------- 3. 脱敏 / 明文 ---------- r = api('POST', '/api/customer/contact/create', form={'customerId': cid, 'name': '走查联系人甲', 'jobTitleName': '总经理', 'phone': PHONE}) _d = data_of(r) c1 = _d if isinstance(_d, str) else (_d or {}).get('id') step('③ POST /api/customer/contact/create(phone 必填;data 直出 id 字符串)', code_of(r) == 0 and bool(c1), f"contactId={c1}") r = api('POST', '/api/customer/contact/create', form={'customerId': cid, 'name': '走查联系人乙', 'jobTitleName': '采购总监', 'phone': PHONE2}) _d = data_of(r) c2 = _d if isinstance(_d, str) else (_d or {}).get('id') r = api('GET', '/api/customer/contact/page', params={'customerId': cid, 'current': 1, 'size': 10}) rows = (data_of(r) or {}).get('content') or [] row1 = next((x for x in rows if str(x.get('id')) == str(c1)), {}) masked = row1.get('phone') step('③ 列表出参 phone 字段即脱敏值(前3+****+后4;图谱节点才叫 phoneMasked)', masked == PHONE[:3] + '****' + PHONE[-4:], f"phone={masked}") r = api('GET', '/api/customer/contact/reveal', params={'id': c1}) plain = data_of(r) step('③ GET /api/customer/contact/reveal 返回明文(走 reveal 端点留痕)', plain == PHONE, f"明文={plain}") # ---------- 4. 图谱:N1 悬停字段 / N2 GRAPH_EDIT 日志 / 67018 ---------- r = api('GET', '/api/customer/contact/graph/detail', params={'customerId': cid}) g = data_of(r) or {} gv = g.get('version') node_keys = set((g.get('nodes') or [{}])[0].keys()) if g.get('nodes') else set() step('④ 图谱读:联系人创建即自动落图(version 随 CONTACT_ADD 递增)', gv is not None, f"version={gv} nodes={len(g.get('nodes') or [])}") r = api('POST', '/api/customer/contact/graph/save', form={'customerId': cid, 'version': gv, 'nodes[0].contactId': c1, 'nodes[0].level': 2, 'nodes[1].contactId': c2, 'nodes[1].level': 1, 'edges[0].parentId': c1, 'edges[0].childId': c2}) step('④ POST graph/save 建边(上级等级数值 2 > 下级 1;customerId/version 走表单)', code_of(r) == 0, f"code={code_of(r)}") r = api('GET', '/api/customer/contact/graph/detail', params={'customerId': cid}) g2 = data_of(r) or {} nodes = g2.get('nodes') or [] node_keys = set(nodes[0].keys()) if nodes else set() edge_cnt = len(g2.get('edges') or []) n1_ok = {'isInternal', 'giftRemark'} <= node_keys and edge_cnt == 1 step('④ N1 回读:nodes 含 isInternal/giftRemark(悬停气泡真值)+ 边落库', n1_ok, f"node 字段={sorted(node_keys)} edges={edge_cnt}") r = api('GET', '/api/customer/oplog/page', params={'id': cid, 'current': 1, 'size': 20}) logs = (data_of(r) or {}).get('content') or [] ge = next((x for x in logs if x.get('action') == 'GRAPH_EDIT'), None) step('④ N2 图谱保存落操作日志 action=GRAPH_EDIT', ge is not None, f"actions={[x.get('action') for x in logs][:8]}") r = api('POST', '/api/customer/contact/graph/save', form={'customerId': cid, 'version': gv, # gv 是保存前版本 → 冲突 'nodes[0].contactId': c1, 'nodes[0].level': 2, 'nodes[1].contactId': c2, 'nodes[1].level': 1, 'edges[0].parentId': c1, 'edges[0].childId': c2}) step('④ 乐观锁:旧 version 保存图谱 → 67018', code_of(r) == 67018, f"code={code_of(r)} msg={r.get('message')}") # ---------- 5. 关联项目 N3(项目夹具 DB 直插,绑走查客户,走查后删) ---------- dbx('INSERT INTO crm_project (id, project_name, project_stage, project_status, filing_status, bid_result, ' 'scheme_card_id, customer_id, owner_user_id, owner_name_snapshot, project_amount, creator_id, updater_id, ' 'deleted, create_time, update_time) ' 'VALUES (%s, %s, 0, 1, 1, 0, 193400000000000002, %s, %s, %s, 123456.78, %s, %s, 0, NOW(), NOW())', (PROJ_ID, f'e2c-走查-项目-{tsn}', cid, ADMIN, '罗伟健', ADMIN, ADMIN)) r = api('GET', '/api/customer/project/page', params={'id': cid, 'current': 1, 'size': 10}) d = data_of(r) or {} rows = d.get('content') or [] ok = code_of(r) == 0 and rows and str(rows[0].get('id')) == str(PROJ_ID) and rows[0].get('stageName') step('⑤ N3 GET /api/customer/project/page 反查 crm-project 真数据', bool(ok), f"rows={[(x.get('projectName'), x.get('stageName'), x.get('statusName'), x.get('filingStatusName')) for x in rows]}") r = api('GET', '/api/customer/detail-head', params={'id': cid}) pc = (data_of(r) or {}).get('projectCount') step('⑤ N3 detail-head.projectCount 真值(port 真实现;字符串出参防 JS 精度丢失)', str(pc) == '1', f"projectCount={pc!r}") # ---------- 6. 文档示例对拍(docs = live truth) ---------- def doc_example_data(path): txt = open(path, encoding='utf-8').read() m = re.search(r'```json\s*(\{.*?\})\s*```', txt, re.S) return json.loads(m.group(1)).get('data') if m else None def keyset(obj, prefix=''): ks = set() if isinstance(obj, dict): for k, v in obj.items(): ks.add(prefix + k) ks |= keyset(v, prefix + k + '.') elif isinstance(obj, list) and obj: ks |= keyset(obj[0], prefix) return ks pairs = [ ('关联项目页签', DOCS + r'\客户详情\关联项目\关联项目页签.bru', ('/api/customer/project/page', {'id': cid, 'current': 1, 'size': 10})), ('图谱全量读', DOCS + r'\客户详情\联系人图谱\图谱全量读.bru', ('/api/customer/contact/graph/detail', {'customerId': cid})), ('详情公共头部', DOCS + r'\客户详情\详情公共头部.bru', ('/api/customer/detail-head', {'id': cid})), ] for title, path, (ep, prm) in pairs: try: doc_data = doc_example_data(path) except Exception as e: step(f'⑥ 对拍《{title}》解析文档示例', False, str(e)[:120]) continue r = api('GET', ep, params=prm) live = data_of(r) or {} if doc_data is None: step(f'⑥ 对拍《{title}》', False, '文档无 json 示例节') continue dk, lk = keyset(doc_data), keyset(live) missing = sorted(dk - lk) step(f'⑥ 对拍《{title}》:文档示例字段 ⊆ 实跑响应', not missing, f"文档{len(dk)}键 / 实跑{len(lk)}键" + (f" 缺={missing}" if missing else " 全覆盖")) # ---------- 7. 清理(项目夹具 DB 删 + 客户正门 archive) ---------- n = dbx('DELETE FROM crm_project WHERE id=%s', (PROJ_ID,)) step('⑦ 清理走查项目夹具行(DB)', n == 1, f"deleted={n}") r = api('POST', '/api/customer/archive', params={'id': cid}) step('⑦ 清理 POST /api/customer/archive(正门,走 ARCHIVE 日志)', code_of(r) == 0, f"code={code_of(r)}") r = api('GET', '/api/customer/oplog/page', params={'id': cid, 'current': 1, 'size': 20}) acts = [x.get('action') for x in (data_of(r) or {}).get('content') or []] step('⑦ 复核:归档走正门 ARCHIVE 日志(归档非删除,detail 仍可读为契约)', 'ARCHIVE' in acts, f"actions={acts[:8]}") print('\n==== 走查完毕 ====') with open('.scratch/customer-integration-ready/demo-walkthrough-log.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(LOG) + '\n') print('全量请求/响应 → demo-walkthrough-log.txt')