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.
 
 
 
 
 

326 lines
13 KiB

# -*- coding: utf-8 -*-
"""票 06:A4 客户管理 Bruno 文档生成器(gen + inject)。
- 结构听源码:目录=源码 @Tag 原文;docs 首行=对账钥匙 METHOD 路径
- 示例值注入:specimens-core.json / specimens-heavy.json 实测真值(响应示例全真值,
请求示例值优先实测,缺失按语义兜底)
- 输出 E:\code\crm-api-docs(python 直写,Write 工具不可出工作区)
- 幂等:重跑整目录重建(A4 为全新生成,无「人的手改」需保护)
"""
import io, sys, os, re, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, r'e:\code\crm-backend-matt\.scratch\customer-e2e')
from defs_a4 import DEFS, PATH_VARS, SEMANTIC
DOCS = r'E:\code\crm-api-docs'
BASE = os.path.join(DOCS, 'A4 客户管理')
SP_DIR = r'e:\code\crm-backend-matt\.scratch\customer-e2e'
spec = {}
for f in ('specimens-core.json', 'specimens-heavy.json'):
spec.update(json.load(open(os.path.join(SP_DIR, f), encoding='utf-8')))
def norm_key(path):
"""specimens 钥匙归一:15+ 位数字段与 taskId 段 → {id};workspace 字面值展开。"""
p = re.sub(r'/\d{15,}', '/{id}', path)
p = re.sub(r'/\d{6,14}', '/{id}', p) if '/import/' in path else p
return p
def spec_lookup(method, path_tpl):
"""候选钥匙:workspace 三字面值 + 全占位归一 {id}"""
cands = []
ws = '{workspace}' in path_tpl
bases = [path_tpl.replace('{workspace}', w) for w in ('mine', 'overview', 'pool')] if ws else [path_tpl]
for b in bases:
cands.append((method, b))
cands.append((method, re.sub(r'\{(id|customerId|memberUserId|taskId)\}', '{id}', b)))
for m, p in cands:
if f'{m} {p}' in spec:
return spec[f'{m} {p}']
return None
def url_of(path_tpl):
for k, v in PATH_VARS.items():
path_tpl = path_tpl.replace(k, v)
return path_tpl.replace('{taskId}', str(_task_id()))
_TASKID = None
def _task_id():
global _TASKID
if _TASKID is None:
d = (spec.get('POST /api/customer/import/upload') or {}).get('response', {}).get('data') or {}
_TASKID = d.get('taskId') or '750854700000000001'
return _TASKID
def jtype(v):
if v is None: return 'string'
if isinstance(v, bool): return 'boolean'
if isinstance(v, (int, float)): return 'number'
if isinstance(v, list): return 'array'
return 'string'
def sample(v, maxlen=48):
s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v
s = s if len(s) <= maxlen else s[:maxlen] + ''
return s
def resp_struct_table(data, indent=''):
"""从实测 data 推导字段表(字段/类型/示例值);分页 content 展开一层。"""
rows = []
if isinstance(data, dict):
for k, v in data.items():
if k == 'content' and isinstance(v, list) and v and isinstance(v[0], dict):
rows.append(f'| content[] | array | 分页元素,实测共 {len(v)} 条,结构如下 |')
for k2, v2 in v[0].items():
rows.append(f'| content[].{k2} | {jtype(v2)} | {sample(v2)} |')
else:
rows.append(f'| {k} | {jtype(v)} | {sample(v)} |')
elif isinstance(data, list) and data and isinstance(data[0], dict):
for k, v in data[0].items():
rows.append(f'| {indent}[].{k} | {jtype(v)} | {sample(v)} |')
elif isinstance(data, list):
rows.append(f'| {indent}[] | {jtype(data[0] if data else None)} | {sample(data[0] if data else "", 24)} |')
return rows
def trim_spec_resp(d):
"""响应示例裁剪:content 取 1 条,其余原样。"""
if not isinstance(d, dict):
return d, ''
note = ''
dd = json.loads(json.dumps(d)) # deep copy
data = dd.get('data')
if isinstance(data, dict) and isinstance(data.get('content'), list) and len(data['content']) > 1:
note = f"(实测 total={data.get('total', '?')},content 截取 1 条)"
data['content'] = data['content'][:1]
elif isinstance(data, list) and len(data) > 3:
note = f'(实测 {len(data)} 条,截取前 3 条)'
dd['data'] = data[:3]
return dd, note
def gen_bru(d, seq):
method = d['method'].lower()
key = f"{d['method']} {re.sub(chr(92)+'{(id|customerId|memberUserId|taskId)}', '{id}', d['path'])}"
sp = spec_lookup(d['method'], d['path'])
url = url_of(d['path'])
L = []
L.append('meta {')
L.append(f' name: {d["name"]}')
L.append(' type: http')
L.append(f' seq: {seq}')
L.append(' tags: [')
L.append(' generated')
L.append(' ]')
L.append('}')
L.append('')
body_kind = {'form': 'formUrlEncoded', 'none': 'none', 'json': 'json', 'multipart': 'multipartFormData'}[d['body']]
L.append(f'{method} {{')
L.append(f' url: {{{{baseUrl}}}}{url}')
L.append(f' body: {body_kind}')
L.append(' auth: inherit')
L.append('}')
L.append('')
# ---- 参数块 ----
qp = d['params'] if d['body'] == 'none' else []
fp = d['params'] if d['body'] in ('form', 'multipart') else []
if qp:
L.append('params:query {')
L.append('')
for n, t, r, _ in qp:
L.append(f' {n}: {SEMANTIC.get(n, "") if SEMANTIC.get(n, "") != "" else "1"}')
L.append('}')
L.append('')
if d['body'] == 'form' and fp:
L.append('body:form-urlencoded {')
L.append('')
spb = ((sp or {}).get('request') or {}).get('body')
for n, t, r, _ in fp:
v = (spb or {}).get(n) if isinstance(spb, dict) else None
if v is None or v == '':
sv = SEMANTIC.get(n, '')
if sv == '' and r:
sv = '1'
v = sv
L.append(f' {n}: {v}')
L.append('}')
L.append('')
if d['body'] == 'multipart':
L.append('body:multipartFormData {')
L.append('')
L.append(' file: @客户导入模板.xlsx')
L.append(f" importMode: {SEMANTIC['importMode']}")
L.append('}')
L.append('')
if d['body'] == 'json':
L.append('body:json {')
L.append('')
spb = ((sp or {}).get('request') or {}).get('body')
body_json = spb if isinstance(spb, dict) and spb else {n: int(SEMANTIC.get(n, 0) or 0) for n, t, r, _ in fp}
for ln in json.dumps(body_json, ensure_ascii=False, indent=2).split('\n'):
L.append(' ' + ln)
L.append('}')
L.append('')
# ---- docs ----
L.append('docs {')
L.append(f' # {d["name"]}')
L.append(f' `{d["method"]} {d["path"]}`(本行兼作对账钥匙,勿手改)')
for para in d['desc'].split('') if False else [d['desc']]:
L.append(f' {para}')
L.append('')
L.append(' ## 请求参数')
L.append('')
if d['params']:
L.append(' | 参数 | 类型 | 必填 | 说明 |')
L.append(' | --- | --- | --- | --- |')
for n, t, r, desc in d['params']:
L.append(f' | {n} | {t} | {"" if r else ""} | {desc} |')
else:
L.append(' 无(路径变量外无业务参数)')
L.append('')
L.append(' ## 请求示例')
L.append('')
if d['body'] == 'none':
q = '&'.join(f'{n}={SEMANTIC.get(n, "1")}' for n, t, r, _ in d['params'] if SEMANTIC.get(n, '1') != '')
shown = d['path']
for k, v in PATH_VARS.items():
shown = shown.replace(k, v)
shown = shown.replace('{taskId}', str(_task_id()))
L.append(' 请求头:Authorization: Bearer {{token}}')
L.append('')
L.append(f' {d["method"]} {shown}' + (f'?{q}' if q else ''))
elif d['body'] == 'form':
L.append(' 请求头:Authorization: Bearer {{token}}')
L.append(' Content-Type: application/x-www-form-urlencoded')
L.append('')
shown = d['path']
for k, v in PATH_VARS.items():
shown = shown.replace(k, v)
L.append(f' {d["method"]} {shown}')
pairs = []
spb = ((sp or {}).get('request') or {}).get('body')
for n, t, r, _ in d['params']:
v = (spb or {}).get(n) if isinstance(spb, dict) else None
if v is None or v == '':
v = SEMANTIC.get(n, '')
if v == '':
continue
pairs.append(f'{n}={v}')
L.append(' // 示例数据(E2E 实测值注入),非真实返回' if sp else ' // 示例数据,非真实返回')
L.append(' ' + '&'.join(pairs))
elif d['body'] == 'multipart':
L.append(' 请求头:Authorization: Bearer {{token}}')
L.append(' Content-Type: multipart/form-data')
L.append('')
L.append(f' {d["method"]} {d["path"]}')
L.append(' file=@客户导入模板.xlsx')
L.append(f" importMode={SEMANTIC['importMode']}")
elif d['body'] == 'json':
L.append(' 请求头:Authorization: Bearer {{token}}')
L.append(' Content-Type: application/json')
L.append('')
L.append(f' {d["method"]} {d["path"]}')
L.append(' ```json')
spb = ((sp or {}).get('request') or {}).get('body')
body_json = spb if isinstance(spb, dict) and spb else {n: int(SEMANTIC.get(n, 0) or 0) for n, t, r, _ in d['params']}
for ln in json.dumps(body_json, ensure_ascii=False, indent=2).split('\n'):
L.append(' ' + ln)
L.append(' ```')
L.append('')
L.append(' ## 响应 data 结构')
L.append('')
rdata = ((sp or {}).get('response') or {}).get('data') if sp else None
if rdata is not None:
rows = resp_struct_table(rdata)
if rows:
L.append(' | 字段 | 类型 | 示例值(E2E 实测) |')
L.append(' | --- | --- | --- |')
for r in rows:
L.append(' ' + r)
else:
L.append(f' {d["resp"]}')
else:
L.append(f' {d["resp"]}')
if d['errs']:
L.append('')
L.append(' ## 错误码')
L.append('')
for e in d['errs']:
L.append(f' - {e}')
L.append('')
L.append(' ## 响应示例')
L.append('')
if sp and (sp.get('response') or {}).get('data', 'MISS') != 'MISS':
trimmed, note = trim_spec_resp(sp['response'])
L.append(' // 示例数据(E2E 实测真值),字段结构以上表为准' + note)
L.append(' ```json')
for ln in json.dumps(trimmed, ensure_ascii=False, indent=2).split('\n'):
L.append(' ' + ln)
L.append(' ```')
elif d['body'] == 'none' and rdata is None and not d['resp'].startswith('非信封') and '文字' not in d['resp']:
L.append(' // 示例数据,字段结构以上表为准,非真实返回')
L.append(' ```json')
for ln in json.dumps({'code': 0, 'success': True, 'message': 'success', 'data': None}, indent=2).split('\n'):
L.append(' ' + ln)
L.append(' ```')
else:
L.append(f' {d["resp"]}')
L.append('}')
return '\n'.join(L) + '\n'
def folder_bru(name, seq):
return f'meta {{\n name: {name}\n seq: {seq}\n}}\n'
def main():
# 按 dir 分组;dir='A4 客户管理/xxx' 相对 BASE(剥首段),其余相对 collection 根
groups = {}
roots = {}
for d in DEFS:
g = d['dir']
if g.startswith('A4 客户管理/'):
roots[g] = BASE
rel = g[len('A4 客户管理/'):]
else:
roots[g] = DOCS
rel = g
groups.setdefault((roots[g], rel), []).append(d)
written = []
for (root, rel), items in groups.items():
gdir = os.path.join(root, *rel.split('/'))
os.makedirs(gdir, exist_ok=True)
short = rel.split('/')[-1]
open(os.path.join(gdir, 'folder.bru'), 'w', encoding='utf-8', newline='').write(folder_bru(short, 1))
# 中间层级 folder.bru(如 根级 客户管理/)
parent = gdir
while os.path.relpath(parent, root) not in ('.', ''):
parent = os.path.dirname(parent)
for missing in [gdir]:
pass
if root == DOCS: # 根级目录需要逐级 folder.bru
acc = DOCS
for seg in rel.split('/')[:-1]:
acc = os.path.join(acc, seg)
fp = os.path.join(acc, 'folder.bru')
if not os.path.exists(fp):
open(fp, 'w', encoding='utf-8', newline='').write(folder_bru(seg, 12))
for i, d in enumerate(items, 1):
p = os.path.join(gdir, d['name'] + '.bru')
open(p, 'w', encoding='utf-8', newline='').write(gen_bru(d, i))
written.append(os.path.relpath(p, DOCS))
print(f'generated {len(written)} .bru in {len(groups)} dirs')
for w in written:
print(' ', w)
if __name__ == '__main__':
main()