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.
261 lines
13 KiB
261 lines
13 KiB
|
2 days ago
|
# -*- 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')
|