# -*- coding: utf-8 -*- r"""票 06:A4 客户管理 Bruno 文档生成器(gen + inject)。 - 结构听源码:目录=源码 @Tag 原文;docs 首行=对账钥匙 METHOD 路径 - 示例值注入:specimens-core.json / specimens-heavy.json 实测真值(响应示例全真值, 请求示例值优先实测,缺失按语义兜底) - 输出 D:\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'd:\code\crm-backend-matt\.scratch\customer-e2e') from defs_a4 import DEFS, PATH_VARS, SEMANTIC DOCS = r'D:\code\crm-api-docs' BASE = os.path.join(DOCS, 'A4 客户管理') SP_DIR = r'd:\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()