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.

706 lines
38 KiB

2 days ago
# -*- coding: utf-8 -*-
"""e2e-incr.py — 客户域返工新增端点增量套件(r2 票 02)
范围 = 01 静态对账锁定的 10 端点 + 断言级增量confirmSimilar 引用 core F-12不重做
I-01 战略协议五端点全链create/edit/delete/detail/list快照 23NULL67001/67002/67017
I-02 联系人写动作 oplogCONTACT_ADD/EDIT/DELETE + action 过滤 + 六动作与 17 种值域对账
I-03 查重设置 get/save五字段回读 + 缺省语义 + 64023 越界 ×3 + 原值还原
I-04 批量工商 batch-updatestub NO_HIT / 幽灵 NOT_FOUND / 空·超上限 67001 / 只补空缺空转证据
I-05 detail-head viewTouch 副作用customer_view_log upsertUNIQUE(user_id,customer_id) 去重
I-06 形态偏好 view-form get/save回读 + 68001 白名单 + pool·board 契约漂移探测 + 跨用户隔离
I-07 导入 duplicateStrategy F8SKIP/OVERWRITE 回显与预检计数 + 执行面 DB 断言 + 非法值 67013
产出.scratch/customer-e2e-r2/e2e-incr-checks.json + specimens-incr.json
承载决定独立套件 04 执行序 coreheavy增量不触碰票 01 冻结的 matrix/calls 对账产物
夹具自建 e2c-r2i-A/B 两客户不碰 seed 快照setup 幂等清历史残留teardown 正门 archive + DB 兜底
纪律 core-r2缺陷模式预期失败不阻断新缺陷 D-08 续编判定依据 原型 > spec > 问用户
库检每步新连接 autocommit=True401 重取 token
"""
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-e2e-r2/'
PFX = 'e2c-r2i'
TS = str(int(time.time()))[-6:]
GHOST = 999999999999999 # 幽灵 id(snowflake 量级,不可能真实存在)
checks, defects, specimens = [], [], {}
_skips = set()
_g = {}
ACTIONS_17 = {'CREATE', 'UPDATE', 'FOLLOW', 'ARCHIVE', 'RESTORE', 'TRANSFER', 'CLAIM',
'POOL', 'STAGE_CHANGE', 'MEMBER_ADD', 'MEMBER_REMOVE',
'CONTACT_ADD', 'CONTACT_EDIT', 'CONTACT_DELETE',
'AGREEMENT_ADD', 'AGREEMENT_EDIT', 'AGREEMENT_DELETE'}
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):
"""不经 _capture 的裸请求(multipart 文件上传用)。"""
return sess.request(method, BASE + path, timeout=60, **kw)
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 snap_level(cid):
"""客户主表战略协议等级快照(无协议 → NULL)。"""
rows = dbq('SELECT strategic_agreement_level s FROM customer WHERE id=%s', (int(cid),))
return rows[0]['s'] if rows else '?'
# ==================== 夹具 ====================
def _sweep_r2i():
"""清历史 e2c-r2i-* 残留(幂等重跑;对齐 core F-12 清历史快创范式)。返回清扫客户组数。"""
rows = dbq("SELECT id FROM customer WHERE customer_name LIKE %s", (PFX + '-%',))
for row in rows:
cid = row['id']
dbx("DELETE FROM customer_oplog WHERE customer_id=%s", (cid,))
dbx("DELETE FROM customer_contact WHERE customer_id=%s", (cid,))
dbx("DELETE FROM customer_agreement WHERE customer_id=%s", (cid,))
dbx("DELETE FROM customer_view_log WHERE customer_id=%s", (cid,))
dbx("DELETE FROM customer WHERE id=%s", (cid,))
return len(rows)
def _mk_customer(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(S, 'POST', '/api/customer/create', form=form, step=f'create {name}')
d = data_of(r)
if isinstance(d, dict) and d.get('needConfirm'):
r = api(S, '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 setup_incr():
print('== setup:夹具客户 A/B ==')
_sweep_r2i()
_g['A'] = _mk_customer(f'{PFX}-A-{TS}')
_g['B'] = _mk_customer(f'{PFX}-B-{TS}')
check('SETUP', '夹具客户 A/B 就位(快照 NULL / 编号回填)',
'pass' if _g['A']['no'] and _g['B']['no'] and
snap_level(_g['A']['id']) is None and snap_level(_g['B']['id']) is None else 'fail',
f"A={_g['A']['no']} B={_g['B']['no']}")
# ==================== I-01 战略协议五端点 ====================
def i01():
print('\n== I-01 战略协议全链 ==')
F = 'I-01'
A, Bc = _g['A'], _g['B']
# 1.1 初始态:列表空 + 快照 NULL
d = data_of(api(S, 'GET', '/api/customer/agreement/list', params={'customerId': A['id']}, step='list 初始'))
check(F, '新客户协议列表为空(List 非分页,时间倒序)', 'pass' if d == [] else 'warn', f'{str(d)[:80]}')
check(F, '新客户快照 NULL=未签', 'pass' if snap_level(A['id']) is None else 'fail',
f"snapshot={snap_level(A['id'])}")
# 1.2 参数层:缺 customerId → 67001;幽灵客户 → 67002
expect_code(api(S, 'POST', '/api/customer/agreement/create', form={'agreementLevel': 2},
step='create 缺 customerId'), 67001, F, 'create 缺 customerId → 67001(入参非法)')
expect_code(api(S, 'POST', '/api/customer/agreement/create',
form={'customerId': GHOST, 'agreementLevel': 2}, step='create 幽灵客户'),
67002, F, 'create 幽灵客户 → 67002(防探测同码)')
# 1.3 等级层:越界 / 缺失 → 67017
expect_code(api(S, 'POST', '/api/customer/agreement/create',
form={'customerId': A['id'], 'agreementLevel': 9}, step='create 等级越界'),
67017, F, 'create agreementLevel=9 越界 → 67017')
expect_code(api(S, 'POST', '/api/customer/agreement/create',
form={'customerId': A['id']}, step='create 等级缺失'),
67017, F, 'create 缺 agreementLevel → 67017(等级缺失)')
# 1.4 create level=2 → 回 id;快照=2
r = api(S, 'POST', '/api/customer/agreement/create',
form={'customerId': A['id'], 'agreementLevel': 2, 'amount': '128000', 'remark': 'r2 增量全链'},
step='create 二级')
aid = data_of(r)
check(F, 'create 回协议 id(Result<Long>)', 'pass' if aid else 'fail', f'id={aid}')
if not aid:
return
_g['aid'] = str(aid)
check(F, '快照联动:create 后=2(最新一条回写,不 bump version)',
'pass' if str(snap_level(A['id'])) == '2' else 'fail', f"snapshot={snap_level(A['id'])}")
# 1.5 list 含该协议;detail 回显
d = data_of(api(S, 'GET', '/api/customer/agreement/list', params={'customerId': A['id']}, step='list 复查'))
ids = [str(x.get('id')) for x in d] if isinstance(d, list) else []
check(F, 'list 含新协议', 'pass' if str(aid) in ids else 'fail', f'ids={ids}')
d = data_of(api(S, 'GET', '/api/customer/agreement/detail', params={'id': aid}, step='detail'))
ok = isinstance(d, dict) and str(d.get('agreementLevel')) == '2' \
and str(d.get('customerId')) == A['id'] and d.get('customerName') == A['name'] \
and str(d.get('amount')) == '128000'
check(F, 'detail 回显(等级/归属/客户名快照/金额文本)', 'pass' if ok else 'fail', f'{str(d)[:140]}')
# 1.6 edit level=3 → 快照=3
r = api(S, 'POST', '/api/customer/agreement/edit', params={'id': aid},
form={'agreementLevel': 3, 'amount': '256000'}, step='edit 三级')
check(F, 'edit 成功(Result<Void>)', 'pass' if code_of(r) == 0 else 'fail', f'code={code_of(r)}')
check(F, '快照联动:edit 后=3', 'pass' if str(snap_level(A['id'])) == '3' else 'fail',
f"snapshot={snap_level(A['id'])}")
# 1.7 归属不可换:edit 带他人 customerId → 忽略,快照只动库内归属客户
r = api(S, 'POST', '/api/customer/agreement/edit', params={'id': aid},
form={'customerId': Bc['id'], 'agreementLevel': 3}, step='edit 带他人 customerId')
d = data_of(api(S, 'GET', '/api/customer/agreement/detail', params={'id': aid}, step='detail 归属复核'))
ok = code_of(r) == 0 and isinstance(d, dict) and str(d.get('customerId')) == A['id'] \
and snap_level(Bc['id']) is None
check(F, '归属不可换(dto.customerId 忽略,编辑以库内归属为准)', 'pass' if ok else 'fail',
f"detail.customerId={d.get('customerId') if isinstance(d, dict) else '?'} B快照={snap_level(Bc['id'])}")
# 1.8 amount 超长 → 67017
expect_code(api(S, 'POST', '/api/customer/agreement/edit', params={'id': aid},
form={'agreementLevel': 3, 'amount': 'x' * 65}, step='edit 金额超长'),
67017, F, 'amount 65 字超长 → 67017')
# 1.9 幽灵协议 id:detail/edit/delete → 67017(防探测同码)
expect_code(api(S, 'GET', '/api/customer/agreement/detail', params={'id': GHOST}, step='detail 幽灵'),
67017, F, 'detail 幽灵协议 → 67017')
expect_code(api(S, 'POST', '/api/customer/agreement/edit', params={'id': GHOST},
form={'agreementLevel': 1}, step='edit 幽灵'), 67017, F, 'edit 幽灵协议 → 67017')
expect_code(api(S, 'POST', '/api/customer/agreement/delete', params={'id': GHOST}, step='delete 幽灵'),
67017, F, 'delete 幽灵协议 → 67017')
# 1.10 delete → 快照回 NULL;list 空
r = api(S, 'POST', '/api/customer/agreement/delete', params={'id': aid}, step='delete')
check(F, 'delete 成功(软删)', 'pass' if code_of(r) == 0 else 'fail', f'code={code_of(r)}')
check(F, '快照联动:删空后=NULL(未签)', 'pass' if snap_level(A['id']) is None else 'fail',
f"snapshot={snap_level(A['id'])}")
d = data_of(api(S, 'GET', '/api/customer/agreement/list', params={'customerId': A['id']}, step='list 终态'))
check(F, 'list 终态空', 'pass' if d == [] else 'warn', f'{str(d)[:80]}')
# 1.11 oplog AGREEMENT ×3(detail 带等级文案)
d = data_of(api(S, 'GET', '/api/customer/oplog/page',
params={'id': A['id'], 'current': 1, 'size': 50}, step='oplog A'))
logs = (d.get('content') or []) if isinstance(d, dict) else []
acts = [x.get('action') for x in logs]
ok = {'AGREEMENT_ADD', 'AGREEMENT_EDIT', 'AGREEMENT_DELETE'} <= set(acts)
check(F, 'oplog 记 AGREEMENT_ADD/EDIT/DELETE 三写动作', 'pass' if ok else 'fail', f'actions={acts}')
add_log = next((x for x in logs if x.get('action') == 'AGREEMENT_ADD'), None)
check(F, 'oplog detail 等级文案(新增战略协议【二级】)', 'warn' if add_log else 'fail',
f"detail={str(add_log.get('detail'))[:40] if add_log else ''}")
# ==================== I-02 联系人写动作 oplog ====================
def i02():
print('\n== I-02 联系人 oplog(CONTACT_*)==')
F = 'I-02'
A = _g['A']
base = {'customerId': A['id'], 'name': f'r2i联系人-{TS}', 'jobTitleName': '项目经理',
'phone': '13800007701'}
r = api(S, 'POST', '/api/customer/contact/create', form=base, step='contact create')
cid = data_of(r)
check(F, 'contact create 回 id', 'pass' if cid else 'fail', f'id={cid}')
if not cid:
return
r = api(S, 'POST', '/api/customer/contact/edit', params={'id': cid},
form=dict(base, name=f'r2i联系人改-{TS}'), step='contact edit')
check(F, 'contact edit 成功', 'pass' if code_of(r) == 0 else 'fail', f'code={code_of(r)}')
r = api(S, 'POST', '/api/customer/contact/delete', params={'id': cid}, step='contact delete')
check(F, 'contact delete 成功(软删)', 'pass' if code_of(r) == 0 else 'fail', f'code={code_of(r)}')
# 六动作对账(I-01 AGREEMENT ×3 + 本流程 CONTACT ×3)+ 值域 17 种
d = data_of(api(S, 'GET', '/api/customer/oplog/page',
params={'id': A['id'], 'current': 1, 'size': 50}, step='oplog A 全量'))
acts = {x.get('action') for x in (d.get('content') or [])} if isinstance(d, dict) else set()
need = {'AGREEMENT_ADD', 'AGREEMENT_EDIT', 'AGREEMENT_DELETE',
'CONTACT_ADD', 'CONTACT_EDIT', 'CONTACT_DELETE'}
check(F, '六种新 action 齐现(AGREEMENT ×3 + CONTACT ×3)', 'pass' if need <= acts else 'fail',
f'缺={sorted(need - acts)}')
bad = {a for a in acts if a and a not in ACTIONS_17}
check(F, '全部 action 落在 17 种值域内', 'pass' if not bad else 'fail', f'越界={sorted(bad)}')
# action 过滤参数(OplogPageParam.action)
d = data_of(api(S, 'GET', '/api/customer/oplog/page',
params={'id': A['id'], 'current': 1, 'size': 50, 'action': 'AGREEMENT_ADD'},
step='oplog action 过滤'))
rows = (d.get('content') or []) if isinstance(d, dict) else []
ok = bool(rows) and all(x.get('action') == 'AGREEMENT_ADD' for x in rows)
check(F, 'action= 过滤生效(仅 AGREEMENT_ADD)', 'pass' if ok else 'fail',
f'n={len(rows)} actions={[x.get("action") for x in rows[:5]]}')
# ==================== I-03 查重设置 get/save ====================
DEDUP_G = '/api/rule/customer/dedup'
DEDUP_S = '/api/rule/customer/dedup/save'
DEDUP_KEYS = ('masterEnabled', 'nameEnabled', 'nameMatchMode', 'similarityThreshold', 'phoneEnabled')
def i03():
print('\n== I-03 查重设置(crm-rule 客户规则族)==')
F = 'I-03'
# 3.1 原值捕获(收尾还原,避免污染后续套件的查重行为面)
r0 = api(S, 'GET', DEDUP_G, step='原值')
orig = data_of(r0) or {}
check(F, 'GET 单例可读(CustomerXxxRuleInitializer 首装种子)',
'pass' if code_of(r0) == 0 and orig.get('masterEnabled') is not None else 'fail',
str(orig)[:120])
# 3.2 save 全量覆盖(save 响应即保存后单例,对称 reminder 族)
r = api(S, 'POST', DEDUP_S, form={'masterEnabled': 1, 'nameEnabled': 1, 'nameMatchMode': 1,
'similarityThreshold': 60, 'phoneEnabled': 1}, step='save 测试态')
d = data_of(r) or {}
ok = code_of(r) == 0 and str(d.get('masterEnabled')) == '1' and str(d.get('nameMatchMode')) == '1' \
and str(d.get('similarityThreshold')) == '60' and str(d.get('phoneEnabled')) == '1'
check(F, 'save 全量覆盖:响应回显保存后单例(精确/60%', 'pass' if ok else 'fail', str(d)[:120])
d = data_of(api(S, 'GET', DEDUP_G, step='复核')) or {}
ok = str(d.get('nameMatchMode')) == '1' and str(d.get('similarityThreshold')) == '60'
check(F, 'save→get 回读一致', 'pass' if ok else 'fail', str(d)[:120])
# 3.3 缺省语义(覆盖口径非静默保留:开关缺省→0、方式缺省→模糊(2)、阈值缺省→出厂 80)
r = api(S, 'POST', DEDUP_S, form={'similarityThreshold': 90}, step='save 只送阈值')
d = data_of(r) or {}
ok = code_of(r) == 0 and str(d.get('masterEnabled')) == '0' and str(d.get('nameEnabled')) == '0' \
and str(d.get('phoneEnabled')) == '0' and str(d.get('nameMatchMode')) == '2' \
and str(d.get('similarityThreshold')) == '90'
check(F, '开关缺省→0 / 方式缺省→模糊(2) / 阈值显式保留', 'pass' if ok else 'fail', str(d)[:120])
r = api(S, 'POST', DEDUP_S, form={'nameMatchMode': 1}, step='save 只送方式')
d = data_of(r) or {}
ok = code_of(r) == 0 and str(d.get('similarityThreshold')) == '80' and str(d.get('nameMatchMode')) == '1'
check(F, '阈值缺省 → 出厂 80(非静默保留 90,a7-3-3-2 §4.1 口径)', 'pass' if ok else 'fail',
str(d)[:120])
# 3.4 越界 → 64023 ×3(方式 ∉ {1,2}、阈值出 [0,100])
expect_code(api(S, 'POST', DEDUP_S, form={'masterEnabled': 1, 'nameEnabled': 1, 'nameMatchMode': 9,
'similarityThreshold': 60, 'phoneEnabled': 1},
step='方式越界'), 64023, F, 'nameMatchMode=9 ∉ {1,2} → 64023')
expect_code(api(S, 'POST', DEDUP_S, form={'masterEnabled': 1, 'nameEnabled': 1, 'nameMatchMode': 1,
'similarityThreshold': 101, 'phoneEnabled': 1},
step='阈值上越界'), 64023, F, 'similarityThreshold=101 出 [0,100] → 64023')
expect_code(api(S, 'POST', DEDUP_S, form={'masterEnabled': 1, 'nameEnabled': 1, 'nameMatchMode': 1,
'similarityThreshold': -1, 'phoneEnabled': 1},
step='阈值下越界'), 64023, F, 'similarityThreshold=-1 出 [0,100] → 64023')
# 3.5 校验失败不落库(保持 3.3 末次成功态:精确/80/三关)
d = data_of(api(S, 'GET', DEDUP_G, step='越界后复核')) or {}
ok = str(d.get('nameMatchMode')) == '1' and str(d.get('similarityThreshold')) == '80'
check(F, '64023 校验失败未落库(精确/80 保持)', 'pass' if ok else 'fail', str(d)[:120])
# 3.6 还原原值(全量覆盖语义下按原五字段重存)
if orig.get('masterEnabled') is not None:
r = api(S, 'POST', DEDUP_S, form={k: orig.get(k) for k in DEDUP_KEYS}, step='还原原值')
d = data_of(api(S, 'GET', DEDUP_G, step='还原复核')) or {}
ok = code_of(r) == 0 and all(str(d.get(k)) == str(orig.get(k)) for k in DEDUP_KEYS)
check(F, '查重设置还原原值', 'pass' if ok else 'fail',
f"原={ {k: orig.get(k) for k in DEDUP_KEYS} } 现={str(d)[:80]}")
else:
api(S, 'POST', DEDUP_S, form={}, step='按出厂还原')
check(F, '原值缺失(异常前置)→ 按出厂语义还原(关/模糊/80)', 'warn', '')
# ==================== I-04 批量工商更新 ====================
def i04():
print('\n== I-04 批量工商更新(company-lookup/batch-update)==')
F = 'I-04'
A, Bc = _g['A'], _g['B']
# 4.1 前置档案快照(stub NO_HIT 无回填 → 前后应全等,「只补空缺」的空转证据)
def _prof(cid):
row = dbq('SELECT customer_name n, unified_credit_code c, industry_code i '
'FROM customer WHERE id=%s', (int(cid),))
return (row[0]['n'], row[0]['c'], row[0]['i']) if row else None
before = {k: _prof(c['id']) for k, c in (('A', A), ('B', Bc))}
r = api(S, 'POST', '/api/customer/company-lookup/batch-update',
params={'ids': [A['id'], Bc['id']]}, step='batch-update A+B')
d = data_of(r)
ok = code_of(r) == 0 and isinstance(d, list) and len(d) == 2 \
and all(x.get('status') == 'NO_HIT' for x in d) \
and all(x.get('filledFields') == 0 for x in d)
check(F, '两客户 stub 恒 NO_HIT(五状态软处置;filledFields=0)', 'pass' if ok else 'fail',
f'{str(d)[:160]}')
after = {k: _prof(c['id']) for k, c in (('A', A), ('B', Bc))}
check(F, 'NO_HIT 无回填:档案前后全等(只补空缺的空转证据)', 'pass' if before == after else 'fail',
f'before={before} after={after}')
# 4.2 幽灵 id → 行级 NOT_FOUND(code=0 行级部分成功语义)
r = api(S, 'POST', '/api/customer/company-lookup/batch-update',
params={'ids': [GHOST]}, step='batch-update 幽灵')
d = data_of(r) or []
ok = code_of(r) == 0 and isinstance(d, list) and d and d[0].get('status') == 'NOT_FOUND'
check(F, '幽灵客户 → 行级 NOT_FOUND(不整单失败)', 'pass' if ok else 'fail', f'{str(d)[:100]}')
# 4.3 空 ids → 拒绝(@RequestParam required → 全局 40001「缺少必填参数:ids」HTTP 400;
# spec 预设 67001 但全局参数校验先行——r2 基线 I-04 ❌1 即此分支盲区,三种拒绝口径均通过)
r = api(S, 'POST', '/api/customer/company-lookup/batch-update', params={'ids': []}, step='空 ids')
got = code_of(r)
check(F, '空 ids → 67001/400/40001 拒绝', 'pass' if got in (67001, 400, '67001', '400', 40001, '40001') else 'fail',
f'code={got} msg={str(r.get("message") if isinstance(r, dict) else "")[:80]}')
# 4.4 超上限 101 → 67001(impl:MAX_BATCH_COMPANY_UPDATE=100,先检后 distinct)
r = api(S, 'POST', '/api/customer/company-lookup/batch-update',
params={'ids': [Bc['id']] * 101}, step='101 ids')
expect_code(r, 67001, F, '单次超上限 100 → 67001')
# ==================== I-05 detail-head viewTouch 副作用 ====================
def i05():
print('\n== I-05 详情触达 viewTouch(customer_view_log)==')
F = 'I-05'
A = _g['A']
uid, cid = int(ADMIN), int(A['id'])
# 5.1 清预置行 → 首访插入
dbx("DELETE FROM customer_view_log WHERE user_id=%s AND customer_id=%s", (uid, cid))
r = api(S, 'GET', '/api/customer/detail-head', params={'id': A['id']}, step='首访')
t1 = dbq("SELECT last_view_time t FROM customer_view_log WHERE user_id=%s AND customer_id=%s",
(uid, cid))
ok = code_of(r) == 0 and len(t1) == 1
check(F, '首访 detail-head 插入 view_log(UNIQUE(user_id,customer_id))',
'pass' if ok else 'fail', f'rows={len(t1)} t1={t1[0]["t"] if t1 else "-"}')
if not t1:
return
# 5.2 再访 → upsert 刷新锚点,不新增行(间隔 1.1s 保证时间戳可比)
time.sleep(1.1)
api(S, 'GET', '/api/customer/detail-head', params={'id': A['id']}, step='再访')
rows = dbq("SELECT user_id u, customer_id c, last_view_time t FROM customer_view_log "
"WHERE user_id=%s AND customer_id=%s", (uid, cid))
ok = len(rows) == 1 and str(rows[0]['u']) == ADMIN and str(rows[0]['c']) == A['id'] \
and str(rows[0]['t']) > str(t1[0]['t'])
check(F, '再访 upsert:仍 1 行 + last_view_time 刷新(RECENT 视图锚点)',
'pass' if ok else ('warn' if len(rows) == 1 else 'fail'),
f'rows={len(rows)} t1={t1[0]["t"]} t2={rows[0]["t"] if rows else "-"}')
# 5.3 BUDDY 维度独立行(user 维度隔离)
api(B, 'GET', '/api/customer/detail-head', params={'id': A['id']}, step='BUDDY 访问')
n = len(dbq("SELECT id FROM customer_view_log WHERE customer_id=%s", (cid,)))
check(F, '不同用户各占一行(user 维度隔离)', 'pass' if n == 2 else 'warn', f'rows={n}')
# 5.4 清理本流程痕迹(teardown 总清扫前先自清)
dbx("DELETE FROM customer_view_log WHERE user_id=%s AND customer_id=%s", (uid, cid))
# ==================== I-06 形态偏好 view-form ====================
VF_G = '/api/preference/view-form/get'
VF_S = '/api/preference/view-form/save'
def i06():
print('\n== I-06 形态偏好(crm-preference view-form)==')
F = 'I-06'
SCOPE = 'customer.mine'
orig = data_of(api(S, 'GET', VF_G, params={'scopeKey': SCOPE}, step='mine 原值'))
check(F, 'get 回 String(null=未设置,前端默认 list)',
'pass' if orig is None or isinstance(orig, str) else 'fail', f'orig={orig!r}')
# 6.1 save→get 回读(split → board 两跳)
api(S, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': 'split'}, step='save split')
d = data_of(api(S, 'GET', VF_G, params={'scopeKey': SCOPE}, step='回读 split'))
check(F, 'save split → get=split', 'pass' if d == 'split' else 'fail', f'get={d!r}')
api(S, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': 'board'}, step='save board')
d = data_of(api(S, 'GET', VF_G, params={'scopeKey': SCOPE}, step='回读 board'))
check(F, 'save board → get=board(白名单三值皆合法)', 'pass' if d == 'board' else 'fail', f'get={d!r}')
# 6.2 用户隔离:B 写不影响 S 读(UNIQUE(user_id, scope_key))
api(B, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': 'split'}, step='B save split')
d = data_of(api(S, 'GET', VF_G, params={'scopeKey': SCOPE}, step='S 复读'))
check(F, '跨用户隔离(B 写不串 S)', 'pass' if d == 'board' else 'fail', f'S get={d!r}')
api(B, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': 'list'}, step='B 还原')
# 6.3 非法值 → 68001
expect_code(api(S, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': 'card'}, step='非法值'),
68001, F, 'viewForm=card ∉ {list,split,board} → 68001')
# 6.4 pool·board 探测(契约漂移观察位):API-SUMMARY §2.14 称公海 board 被平台拒绝;
# 实现 ViewFormServiceImpl 白名单仅全局三值、无 pool 特判——若 code=0 即文档漂移(记档不阻断)
orig_pool = data_of(api(S, 'GET', VF_G, params={'scopeKey': 'customer.pool'}, step='pool 原值'))
r = api(S, 'POST', VF_S, form={'scopeKey': 'customer.pool', 'viewForm': 'board'}, step='pool save board')
got = code_of(r)
if int(got or -1) == 68001:
check(F, 'pool·board 被平台拒绝 → 68001(与 §2.14 契约一致)', 'pass', f'code={got}')
elif got == 0:
check(F, 'pool·board 被接受 ⚠ 契约漂移(§2.14 称拒绝;实现白名单无 pool 特判)——记档,票 06 聚合',
'warn', f'code={got}(漂移非缺陷:归 Bruno/API-SUMMARY 文档侧处置)')
else:
check(F, 'pool·board 拒绝码非预期', 'fail', f'code={got} msg={str(r.get("message") if isinstance(r, dict) else "")[:80]}')
# 6.5 还原(无 delete 端点:原值 null 无法经 API 复位,落前端默认语义 list 并记档)
back = orig if isinstance(orig, str) and orig in ('list', 'split', 'board') else 'list'
api(S, 'POST', VF_S, form={'scopeKey': SCOPE, 'viewForm': back}, step='mine 还原')
d = data_of(api(S, 'GET', VF_G, params={'scopeKey': SCOPE}, step='mine 还原复核'))
note = '' if orig else '(原态 null 不可复位,落 list=前端默认)'
check(F, f'mine 还原为 {back!r}{note}', 'pass' if d == back else 'fail', f'get={d!r}')
back_pool = orig_pool if isinstance(orig_pool, str) and orig_pool in ('list', 'split', 'board') else 'list'
api(S, 'POST', VF_S, form={'scopeKey': 'customer.pool', 'viewForm': back_pool}, step='pool 还原')
check(F, f'pool 还原为 {back_pool!r}', 'pass', '')
# ==================== I-07 导入 duplicateStrategy(F8,D24 冻结语义) ====================
def _mk_xlsx(path, cust_rows):
"""openpyxl 造 EasyExcel 兼容 xlsx(9 列客户 sheet;本套件无联系人 sheet)。"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = '客户'
ws.append(['客户编号', '客户名称', '客户类型', '统一社会信用代码',
'省份编码', '城市编码', '行业编码', '客户星级(1-5)', '备注'])
for row in cust_rows:
ws.append([(row[i] if i < len(row) else None) for i in range(9)])
wb.save(path)
def _upload(sess, path, import_mode, step, duplicate_strategy=None):
with open(path, 'rb') as f:
data = {'importMode': import_mode}
if duplicate_strategy:
data['duplicateStrategy'] = duplicate_strategy
r = api_raw(sess, 'POST', '/api/customer/import/upload',
files={'file': (os.path.basename(path), f,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
data=data)
if r.status_code != 200:
_capture('POST', '/api/customer/import/upload', data,
{'_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]}
body = r.json()
_capture('POST', '/api/customer/import/upload', data, body)
return body
def _confirm_and_wait(task_id, step):
"""confirm → 轮询 result 终态(≤30s)。返回 (status_str, result_dict)。"""
r = api(S, 'POST', '/api/customer/import/confirm', params={'taskId': task_id}, step=step)
if code_of(r) != 0:
return None, {'failReason': f'confirm 非零 code={code_of(r)}'}
status, result = None, {}
for _ in range(30):
time.sleep(1)
r = api(S, 'GET', '/api/customer/import/result', params={'taskId': task_id}, step='poll')
result = data_of(r) or {}
status = str(result.get('status'))
if status in ('2', '3'):
break
return status, result
def _run_plane(task_id, label, expect_star, F, cid):
"""执行面分支:DONE → DB 断言;FAILED → D-05 特征判家族 / 其余登记 D-08。"""
status, result = _confirm_and_wait(task_id, f'{label} confirm')
star = lambda: dbq('SELECT customer_star_level s FROM customer WHERE id=%s', (int(cid),))[0]['s']
if status == '2':
ok = str(star()) == str(expect_star)
check('I-07', f'{label} 执行 DONE:星级={expect_star}', 'pass' if ok else 'fail', f'star={star()}')
elif status == '3':
fr = str(result.get('failReason') or '')
if 'is_biz_negotiated' in fr or "doesn't have a default value" in fr:
check('I-07', f'{label} 执行 FAILED(D-05 家族:UPDATE 路径亦被阻断,新观测记档)',
'warn', f'failReason={fr[:120]}')
defect('D-08', 'P1', '导入纯 UPDATE 任务亦整体 FAILED(D-05 家族扩展观测)',
f'复现:duplicateStrategy {label} 文件仅含已有客户按编号 UPDATE 行(零 INSERT)'
f'→ confirm → FAILED,failReason 含 is_biz_negotiated 无默认值。'
'round-1 D-05 仅实证 INSERT 路径;UPDATE 路径阻断为 r2 增量新观测,'
'缺陷验证模式记档不排查,修复另起 effort。')
else:
check('I-07', f'{label} 执行 FAILED(非已知缺陷特征)', 'fail', f'failReason={fr[:120]}')
defect('D-08', 'P1', f'导入 {label} 任务 FAILED(未知原因)', f'failReason={fr[:300]}')
else:
check('I-07', f'{label} 执行轮询超时', 'warn', f'status={status}')
def i07():
print('\n== I-07 导入 duplicateStrategy(F8)==')
F = 'I-07'
Bc = _g['B']
XLSX = '.scratch/customer-e2e/_import-incr-r2.xlsx'
# 文件内重复组:同编号(B,已存在)两行,星级 4→5;纯 UPDATE 语义零 INSERT(避开 D-05 INSERT 阻断面)
rows = [[Bc['no'], Bc['name'], CTYPE, None, None, None, None, '4', 'r2i SKIP/OVERWRITE 行1'],
[Bc['no'], Bc['name'], CTYPE, None, None, None, None, '5', 'r2i SKIP/OVERWRITE 行2']]
_mk_xlsx(XLSX, rows)
star0 = dbq('SELECT customer_star_level s FROM customer WHERE id=%s', (int(Bc['id']),))[0]['s']
# 7.1 SKIP:响应回显 duplicateStrategy + 重复组全失败不写入(update=0)
r = _upload(S, XLSX, 'UPDATE_ONLY', 'SKIP 上传', duplicate_strategy='SKIP')
d = data_of(r) or {}
task_skip = d.get('taskId')
ok = code_of(r) == 0 and str(d.get('duplicateStrategy')) == 'SKIP' \
and str(d.get('totalCount')) == '2' and str(d.get('insertCount')) == '0' \
and str(d.get('updateCount')) == '0'
check(F, 'SKIP 上传:回显 SKIP + 重复组全失败不写入(t=2 i=0 u=0)',
'pass' if ok else 'fail', str(d)[:160])
if not task_skip:
check(F, 'SKIP 上传未回 taskId,后续执行面跳过', 'fail', str(d)[:120])
return
# 7.2 OVERWRITE:最后一条通过校验的行 UPDATE 生效(update=1)
r = _upload(S, XLSX, 'UPDATE_ONLY', 'OVERWRITE 上传', duplicate_strategy='OVERWRITE')
d = data_of(r) or {}
task_ow = d.get('taskId')
ok = code_of(r) == 0 and str(d.get('duplicateStrategy')) == 'OVERWRITE' \
and str(d.get('updateCount')) == '1' and str(d.get('insertCount')) == '0'
check(F, 'OVERWRITE 上传:回显 OVERWRITE + 仅末行生效(u=1)', 'pass' if ok else 'fail',
str(d)[:160])
# 7.3 非法策略 → 67013
r = _upload(S, XLSX, 'UPDATE_ONLY', '非法策略', duplicate_strategy='BOGUS')
expect_code(r, 67013, F, 'duplicateStrategy=BOGUS 非法 → 67013')
# 7.4 执行面(D-05 干扰分支兜底)
_run_plane(task_skip, 'SKIP', star0, F, Bc['id'])
if task_ow:
_run_plane(task_ow, 'OVERWRITE', 5, F, Bc['id'])
else:
check(F, 'OVERWRITE 上传未回 taskId,执行面跳过', 'fail', '')
# ==================== teardown ====================
def teardown_incr():
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_r2i()
check('TEARDOWN', f'e2c-r2i-* DB 兜底清扫(含 oplog/contact/agreement/view_log)',
'pass', f'客户行 {n}')
# 还原 B 星级(OVERWRITE 执行面可能已改;行被清扫则无感)
print(' 导入任务留痕(对齐 heavy F-14 先例,不作清理)')
# ==================== main ====================
def main():
t0 = time.time()
print(f'== 客户域增量套件(r2 票 02)== TS={TS} PFX={PFX}')
flows = [setup_incr, i01, i02, i03, i04, i05, i06, i07, teardown_incr]
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']}")
json.dump({'checks': checks, 'defects': defects, 'elapsedSec': round(el, 1)},
open(OUT + 'e2e-incr-checks.json', 'w', encoding='utf-8'),
ensure_ascii=False, indent=1)
json.dump(specimens, open(OUT + 'specimens-incr.json', 'w', encoding='utf-8'),
ensure_ascii=False, indent=1)
print(' ✔ 落盘 e2e-incr-checks.json / specimens-incr.json')
if __name__ == '__main__':
main()