#!/usr/bin/env bash # ============================================================================= # CFC 接口测试闭环(定时任务主体) # 流程:git pull 最新 -> 检测新增/变更需求 -> 跑冒烟(含回归对比) -> # 为新增接口补全用例并跑新测试 -> 输出测试用例完成情况(总数/已完成/未开始原因) -> # 提交测试结果 -> push 到远端(与开发团队闭环) # # 网络说明:原默认目标 192.168.16.251 已不可用(上游失效)。 # 当前默认测试目标为 cfc.iwintrue.com(公网可达、后端在线)。 # 本脚本设计为在宿主机用 Windows 任务计划程序每 30 分钟调用, # 不要在受限沙箱后台运行(后台网络被拦截)。 # ============================================================================= set -uo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$REPO_DIR" || exit 1 LOG_DIR="$REPO_DIR/logs" mkdir -p "$LOG_DIR" # 防重入:若上轮仍在 25 分钟内,跳过本轮(避免 30 分钟间隔下长耗时测试重叠) LOCK="$LOG_DIR/.loop.lock" NOW=$(date +%s) if [ -f "$LOCK" ]; then LAST=$(cat "$LOCK" 2>/dev/null || echo 0) if [ $((NOW - LAST)) -lt 1500 ]; then echo "[$(date +%H:%M:%S)] 上轮仍在运行(锁 $(( (NOW - LAST) / 60 )) 分钟前),跳过本轮" exit 0 fi fi echo "$NOW" > "$LOCK" trap 'rm -f "$LOCK"' EXIT BRANCH="${CFC_BRANCH:-cfclub}" BASE_URL="${CFC_BASE:-http://cfc.iwintrue.com}" HOST_HDR="${CFC_HOST:-cfc.iwintrue.com}" TIMEOUT="${CFC_TIMEOUT:-8}" TEST_SCRIPT="ops_interface_smoke.py" SCANNER="scan_frontend_endpoints.py" TS="$(date +%Y%m%d_%H%M%S)" RUN_DIR="$LOG_DIR/run_$TS" mkdir -p "$RUN_DIR" CUR_TSV="$RUN_DIR/results_$TS.tsv" ENDPOINTS_CUR="$RUN_DIR/endpoints_$TS.txt" EXTRA_CASES="$REPO_DIR/interface_test_extra_cases.tsv" BASELINE_EP="$REPO_DIR/interface_test_baseline_endpoints.txt" PREV_TSV="$REPO_DIR/interface_test_prev.tsv" TREND="$REPO_DIR/interface_test_trend.log" MANAGED="INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md ops_interface_smoke.py" log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_DIR/interface_cron.log"; } log "===== CFC 接口测试闭环 开始 $TS =====" log "REPO=$REPO_DIR BRANCH=$BRANCH BASE=$BASE_URL HOST=$HOST_HDR" # --------------------------------------------------------------------------- # 1) 拉取远端最新(含开发团队的修复/新需求) # 仅丢弃我们生成的报告类文件本地改动(每次都会重新生成),其余未提交改动用 # --autostash 处理,避免破坏用户其他在途工作。 # --------------------------------------------------------------------------- git checkout -- INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md 2>/dev/null if git pull --rebase --autostash origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then log "git pull 成功" else log "git pull 失败(可能本地有冲突或凭证问题),用当前代码继续" fi # --------------------------------------------------------------------------- # 2) 检测新增/变更需求(前端接口面变化) # --------------------------------------------------------------------------- python "$SCANNER" --out "$ENDPOINTS_CUR" 2>/dev/null NEWEP=""; RMVEP="" if [ -f "$BASELINE_EP" ]; then NEWEP=$(comm -13 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR")) RMVEP=$(comm -23 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR")) n_new=$(echo "$NEWEP" | grep -c . || true) n_rmv=$(echo "$RMVEP" | grep -c . || true) log "接口面变化:新增 $n_new 个,移除 $n_rmv 个" if [ "$n_new" -gt 0 ]; then : > "$EXTRA_CASES" while IFS= read -r line; do [ -z "$line" ] && continue m=$(echo "$line" | awk '{print $1}') p=$(echo "$line" | awk '{print $2}') printf 'auto\tauto\t%s\t%s\tfalse\tfalse\n' "$m" "$p" >> "$EXTRA_CASES" done <<< "$NEWEP" log "已为新增接口生成补全用例 -> $EXTRA_CASES" fi else log "首次运行:建立接口基线(不触发补全)" fi cp "$ENDPOINTS_CUR" "$BASELINE_EP" # --------------------------------------------------------------------------- # 3) 跑冒烟测试(前台调用,含登录态;新增接口通过 --extra 一并测试) # --------------------------------------------------------------------------- EXTRA_FLAG="" [ -s "$EXTRA_CASES" ] && EXTRA_FLAG="--extra $EXTRA_CASES" log "开始执行冒烟测试 $EXTRA_FLAG" python "$TEST_SCRIPT" --base "$BASE_URL" --host "$HOST_HDR" \ --timeout "$TIMEOUT" --auto-login --authed $EXTRA_FLAG \ --report "$CUR_TSV" > "$RUN_DIR/smoke_stdout.txt" 2>&1 log "冒烟测试完成,结果 -> $CUR_TSV" # --------------------------------------------------------------------------- # 4) 汇总 + 回归对比 + 生成报告 # --------------------------------------------------------------------------- python - "$CUR_TSV" "$PREV_TSV" "INTERFACE_TEST_REPORT.md" "$TS" "$TREND" <<'PY' import sys, collections, os cur_tsv, prev_tsv, rpt, ts, trend = sys.argv[1:6] def load(path): d = {} try: with open(path, encoding='utf-8') as f: next(f, None) for line in f: c = line.rstrip('\n').split('\t') if len(c) < 7: continue page, fn, method, path, http, biz, cat = c d[(method, path)] = (page, fn, http, biz, cat) except FileNotFoundError: pass return d cur = load(cur_tsv) prev = load(prev_tsv) if prev_tsv and os.path.exists(prev_tsv) else {} cats = collections.Counter(v[4] for v in cur.values()) total = len(cur) ok = cats.get('BIZ_OK', 0) rate = ok * 100 // total if total else 0 # 回归对比 fixed = [] # 之前非 OK -> 现在 OK regressed = [] # 之前 OK -> 现在非 OK for k, v in cur.items(): cat = v[4] if k in prev: pcat = prev[k][4] if pcat != 'BIZ_OK' and cat == 'BIZ_OK': fixed.append((k, pcat, cat)) elif pcat == 'BIZ_OK' and cat != 'BIZ_OK': regressed.append((k, pcat, cat)) def top(cat, n=20): return [(k[0], k[1], v[3], v[4]) for k, v in cur.items() if v[4] == cat][:n] lines = [] lines.append(f"# CFC 接口冒烟测试报告 ({ts})\n") lines.append(f"- 目标: {os.environ.get('CFC_HOST', 'cfc.iwintrue.com')} 用例总数: **{total}**") lines.append(f"- 业务成功率(BIZ_OK): **{ok}/{total} = {rate}%**\n") lines.append("## 分类汇总") for k in ('BIZ_OK','BIZ_FAIL','AUTH_FAIL','NOT_FOUND','SERVER_ERR','NON_JSON','CONN_ERR'): if cats.get(k): lines.append(f"- {k}: {cats[k]}") lines.append("") if fixed: lines.append(f"## ✅ 回归修复({len(fixed)} 个,之前失败现已通过)") for (m,p),pc,_ in fixed[:30]: lines.append(f"- `{m} {p}` (原 {pc})") lines.append("") if regressed: lines.append(f"## ⚠️ 回归劣化({len(regressed)} 个,之前通过现失败)") for (m,p),_,c in regressed[:30]: lines.append(f"- `{m} {p}` (现 {c})") lines.append("") lines.append(f"## 需开发团队关注的契约缺口 (NOT_FOUND={cats.get('NOT_FOUND',0)})") for m,p,b,c in top('NOT_FOUND'): lines.append(f"- `{m} {p}` (biz={b}, {c})") lines.append("") lines.append(f"## 健壮性风险 (SERVER_ERR={cats.get('SERVER_ERR',0)})") for m,p,b,c in top('SERVER_ERR'): lines.append(f"- `{m} {p}` (http={b}, {c})") lines.append("") lines.append("> 说明:冒烟为空体请求,部分 500/400 属预期(缺必填参数)。NOT_FOUND 与回归劣化才需优先处理。") # ───────────────────────── 守卫:识别无效轮 ───────────────────────── # 上游宕机 / 网关抖动 / 登录失败 / 部分轮 不应被当作真实回归提交,亦不可污染 # 回归基线(PREV_TSV)。命中则标 INVALID,由 bash 侧跳过 commit/push/基线更新。 non_json = cats.get('NON_JSON', 0) server_err = cats.get('SERVER_ERR', 0) conn_err = cats.get('CONN_ERR', 0) http_502 = sum(1 for v in cur.values() if v[2] == '502') # v[2]=http 列 nz_rate = (non_json * 100 // total) if total else 0 NON_JSON_THRESHOLD = 20 # NON_JSON 占比 > 20% 视为上游风暴 HTTP502_THRESHOLD = 10 # HTTP 502 数 > 10 视为网关抖动 MIN_TOTAL = 400 # 用例数 < 400 视为部分轮(如登录失败跳过 auth 用例) login_failed = False _smoke_out = os.path.join(os.path.dirname(cur_tsv), 'smoke_stdout.txt') try: with open(_smoke_out, encoding='utf-8', errors='ignore') as _f: _so = _f.read() if ('父/师 token 均未取得' in _so) or ('未取到 token' in _so) or ('登录态接口将被跳过' in _so): login_failed = True except FileNotFoundError: pass reasons = [] if login_failed: reasons.append('登录失败(auto-login 未取得 token,auth 用例被跳过)') if nz_rate > NON_JSON_THRESHOLD: reasons.append(f'NON_JSON 风暴({nz_rate}%)') if http_502 > HTTP502_THRESHOLD: reasons.append(f'HTTP502 网关抖动({http_502})') if total < MIN_TOTAL: reasons.append(f'用例数异常({total}<{MIN_TOTAL},疑似部分轮/中断)') INVALID = len(reasons) > 0 if INVALID: banner = ("\n> ⚠️ **本轮判定 INVALID:结果不提交、不推送、不更新回归基线。**\n" f"> 原因:{';'.join(reasons)}\n" "> 处置:保留上次有效基线不变,待上游稳定后重跑即可;勿将此轮数值当作真实回归。\n") lines.append(banner) report = "\n".join(lines) + "\n" # 报告恒写入本轮运行目录(供排查);仅当有效时同步到仓库根(避免污染已提交产物) _run_report = os.path.join(os.path.dirname(cur_tsv), 'INTERFACE_TEST_REPORT.md') with open(_run_report, 'w', encoding='utf-8') as f: f.write(report) if not INVALID: with open(rpt, 'w', encoding='utf-8') as f: f.write(report) # 趋势日志(本地,不随提交推送;INVALID 也记录以留痕) with open(trend, 'a', encoding='utf-8') as f: f.write(f"{ts}\ttotal={total}\tBIZ_OK={ok}\trate={rate}%\t" f"fixed={len(fixed)}\tregressed={len(regressed)}\t" f"NOT_FOUND={cats.get('NOT_FOUND',0)}\tSERVER_ERR={cats.get('SERVER_ERR',0)}\t" f"NON_JSON={non_json}\tHTTP502={http_502}\tINVALID={1 if INVALID else 0}" + (f"({ '|'.join(reasons) })" if INVALID else "") + "\n") # 导出真实指标供 bash 判定(修复 rate/fixed/regressed 在子进程不可见导致 # commit 失败 + 假通过率 ?%;bash 侧 set -u 下 unbound variable 亦会导致整条命令展开失败) _metrics_path = os.path.join(os.path.dirname(trend), 'interface_test_metrics.env') with open(_metrics_path, 'w', encoding='utf-8') as _mf: _mf.write(f"RATE={rate}\nFIXED_COUNT={len(fixed)}\nREGRESSED_COUNT={len(regressed)}\n" f"TOTAL={total}\nOK_COUNT={ok}\nNON_JSON_COUNT={non_json}\n" f"SERVER_ERR_COUNT={server_err}\nCONN_ERR_COUNT={conn_err}\n" f"HTTP_502_COUNT={http_502}\nINVALID={1 if INVALID else 0}\n" f"INVALID_REASON={'|'.join(reasons)}\n") print(report) print(f"\n[趋势] 已写入 {trend}") if INVALID: print(f"[守卫] INVALID 触发:{';'.join(reasons)} --> 跳过提交/推送/基线更新") PY # --------------------------------------------------------------------------- # 4.5) 测试用例完成情况检查(总数 / 已完成 / 未完成原因) # 统计自动化用例(test()) 与文档化用例(TC-xxxx),并标注未开始执行的原因, # 追加到报告与趋势日志,供人工与闭环跟踪。 # --------------------------------------------------------------------------- python - "$CUR_TSV" "$RUN_DIR" "$REPO_DIR" "$TREND" <<'PY' import sys, os, re, glob, json cur_tsv, run_dir, repo_dir, trend = sys.argv[1:5] # —— 读取 step4 导出的指标(接口冒烟)—— metrics = {} _mp = os.path.join(repo_dir, 'interface_test_metrics.env') try: for line in open(_mp, encoding='utf-8', errors='ignore'): line = line.strip() if '=' in line and not line.startswith('#'): k, v = line.split('=', 1) metrics[k.strip()] = v.strip() except FileNotFoundError: pass INVALID = metrics.get('INVALID', '0') == '1' OK_COUNT = int(metrics.get('OK_COUNT', '0') or 0) tests_dir = os.path.join(repo_dir, 'tests') # 1) 文档化用例 TC-xxxx doc_tcs = set() for f in glob.glob(os.path.join(tests_dir, 'test-cases', '*.md')): try: t = open(f, encoding='utf-8', errors='ignore').read() except Exception: continue for m in re.findall(r'TC-[A-Z]?-?\d+', t): doc_tcs.add(m) TOTAL_DOC = len(doc_tcs) # 2) 自动化用例 test()(按层级分类) specs = glob.glob(os.path.join(tests_dir, 'e2e', '**', '*.spec.js'), recursive=True) e2e_total = dom = roles = api = skip = 0 for f in specs: try: src = open(f, encoding='utf-8', errors='ignore').read() except Exception: continue n = len(re.findall(r'^\s*test\s*\(', src, re.M)) e2e_total += n s = (len(re.findall(r'test\s*\.\s*(?:skip|fixme)\s*\(', src)) + len(re.findall(r'test\s*\.\s*describe\s*\.\s*skip\s*\(', src))) skip += s p = f.replace('\\', '/') if '/e2e/api/' in p: api += n elif '/e2e/roles/' in p: roles += n else: dom += n completed_runnable = e2e_total - skip # 3) API e2e 末次实跑(.last-run.json) api_pass = api_fixme = 0 lr = os.path.join(tests_dir, 'test-results', '.last-run.json') if os.path.exists(lr): try: j = json.load(open(lr, encoding='utf-8')) if isinstance(j, dict): api_pass = int(j.get('passed', j.get('stats', {}).get('expected', 0) or 0)) api_fixme = int(j.get('fixme', j.get('stats', {}).get('skipped', 0) or 0)) except Exception: pass # 4) NOT_FOUND 端点数(从本轮 CUR_TSV 直接统计,避免依赖 env 未导出字段) nf = 0 try: for line in open(cur_tsv, encoding='utf-8', errors='ignore'): c = line.rstrip('\n').split('\t') if len(c) >= 7 and c[6].strip() == 'NOT_FOUND': nf += 1 except Exception: pass def pct(a, b): return f"{a * 100 // b}%" if b else "0%" reasons = [] if dom > 0: reasons.append((dom, f"DOM 型 E2E 无法执行(CFC-ENV-001):{dom} 个用例已编写,但远程 cfc.iwintrue.com 为纯 Spring Boot API 后端、未部署 uni-app H5 / cfc-web 前端,浏览器型用例无法运行,未开始执行。")) if skip > 0: reasons.append((skip, f"已知缺陷/待上线导致跳过:{skip} 个用例被 test.skip/test.fixme 标记(能量发放 500-CFC-EN-001、昵称未持久化-CFC-FM-001 等),待后端修复后启用。")) if nf > 0: reasons.append((nf, f"新功能待部署后验证:接口冒烟发现 {nf} 个 NOT_FOUND 端点,对应前端页面尚未部署到测试环境,相关 E2E 暂挂起。")) if TOTAL_DOC > 0: reasons.append((TOTAL_DOC, f"文档化用例未全量自动化:{TOTAL_DOC} 条设计用例(TC-xxxx),核心主链路已落地 e2e,边缘/异常流仍以文档形式待补自动化。")) section = [] section.append("\n## 测试用例完成情况") section.append(f"- 自动化用例总数(可执行 `test()`):**{e2e_total}**(api 层 {api} / 角色分包 {roles} / DOM 层 {dom})") section.append(f"- 文档化用例(设计层 TC-xxxx):**{TOTAL_DOC}**") section.append(f"- 已完成(自动化且未被跳过):**{completed_runnable}/{e2e_total} = {pct(completed_runnable, e2e_total)}**") section.append(f"- 已实跑通过:接口冒烟 BIZ_OK **{OK_COUNT}** 端点 + API e2e 末次 **{api_pass}** 通过 / {api_fixme} fixme") section.append("") section.append(f"### 未完成 / 未开始的原因(共 {len(reasons)} 类)") if reasons: for i, (c, t) in enumerate(reasons, 1): section.append(f"{i}. [{c} 项] {t}") else: section.append("- 无(全部用例均已落地并可执行)") section.append("") section.append("> 说明:自动化完成率按 `test()` 未被 skip/fixme 计;DOM 层受 CFC-ENV-001 限制需前端部署后执行,属环境阻塞而非用例缺失。") report_txt = "\n".join(section) # 追加到本轮运行目录报告(始终) _run_rpt = os.path.join(run_dir, 'INTERFACE_TEST_REPORT.md') try: _b = open(_run_rpt, encoding='utf-8', errors='ignore').read() open(_run_rpt, 'w', encoding='utf-8').write(_b.rstrip() + "\n\n" + report_txt) except FileNotFoundError: open(_run_rpt, 'w', encoding='utf-8').write(report_txt) # 仅有效轮才写回仓库根报告 if not INVALID: _repo_rpt = os.path.join(repo_dir, 'INTERFACE_TEST_REPORT.md') try: _b = open(_repo_rpt, encoding='utf-8', errors='ignore').read() open(_repo_rpt, 'w', encoding='utf-8').write(_b.rstrip() + "\n\n" + report_txt) except FileNotFoundError: pass # JSON 供工具读取 js = dict(e2e_total=e2e_total, api=api, roles=roles, dom=dom, skip=skip, completed_runnable=completed_runnable, doc_total=TOTAL_DOC, smoke_ok=OK_COUNT, api_pass=api_pass, api_fixme=api_fixme, reasons=[{"count": c, "reason": t} for c, t in reasons], invalid=INVALID) json.dump(js, open(os.path.join(run_dir, 'test_case_completion.json'), 'w', encoding='utf-8'), ensure_ascii=False, indent=2) if not INVALID: json.dump(js, open(os.path.join(repo_dir, 'test_case_completion.json'), 'w', encoding='utf-8'), ensure_ascii=False, indent=2) # 趋势日志 with open(trend, 'a', encoding='utf-8') as f: f.write(f"\tTC_E2E={e2e_total}\tTC_DONE={completed_runnable}\tTC_DOC={TOTAL_DOC}\tTC_SKIP={skip}\tTC_DOM={dom}\n") # 导出指标给 bash(追加,供 commit 信息使用) with open(os.path.join(repo_dir, 'interface_test_metrics.env'), 'a', encoding='utf-8') as f: f.write(f"TC_E2E_TOTAL={e2e_total}\nTC_DONE={completed_runnable}\nTC_DOC={TOTAL_DOC}\nTC_SKIP={skip}\n") print(f"[测试用例] 总数(自动化)={e2e_total} 已完成(可运行)={completed_runnable} 文档化={TOTAL_DOC} 跳过={skip} | 原因 {len(reasons)} 类") PY # --------------------------------------------------------------------------- # 5) 守卫判定:无效轮(宕机/NON_JSON 风暴/502 抖动/登录失败/部分轮) # 不提交、不推送、不更新回归基线,避免污染趋势与开发侧闭环 # --------------------------------------------------------------------------- INVALID=0; INVALID_REASON="" RATE="?"; FIXED_COUNT=0; REGRESSED_COUNT=0 METRICS_ENV="$REPO_DIR/interface_test_metrics.env" [ -f "$METRICS_ENV" ] && . "$METRICS_ENV" if [ "${INVALID:-0}" -eq 1 ]; then log "⚠️ 守卫触发 INVALID:本轮结果不提交 / 不推送 / 不更新回归基线" log " 原因: ${INVALID_REASON}" log " 处置: 保留 PREV_TSV 基线不变,待上游稳定后重跑即可" log " 排查: 本轮明细见 $RUN_DIR/results_$TS.tsv 与 $RUN_DIR/smoke_stdout.txt" log "===== 本轮结束(INVALID,已跳过推送)=====" exit 0 fi # --------------------------------------------------------------------------- # 6) 提交测试结果 + 补全用例(仅有效轮) # --------------------------------------------------------------------------- git add -f ops_interface_smoke.py # 强制纳入报告产物(防止未来 .gitignore 规则或路径规范导致漏提); # EXTRA_CASES 仅在存在且有内容时才加入,避免缺失路径使整个 git add 失败(旧逻辑缺陷) git add -f INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md [ -s "$EXTRA_CASES" ] && git add -f "$EXTRA_CASES" if git diff --cached --quiet; then log "无测试结果变更,跳过提交" else git commit -m "test(interface): 接口冒烟闭环 $(date '+%Y-%m-%d %H:%M') — 通过率 ${RATE}% (fix=${FIXED_COUNT} reg=${REGRESSED_COUNT}); 测试用例 ${TC_DONE:-?}/${TC_E2E_TOTAL:-?} 完成(文档化${TC_DOC:-?})" \ >>"$LOG_DIR/interface_cron.log" 2>&1 && log "已提交测试结果(测试用例完成 ${TC_DONE:-?}/${TC_E2E_TOTAL:-?})" fi # --------------------------------------------------------------------------- # 7) 推送远端,与开发团队形成闭环 # 增强:远端领先(非快进)时自动 rebase 后重试,避免测试结果不落远端。 # 凭证/网络类错误不盲目重试,直接失败并留待下次运行。 # --------------------------------------------------------------------------- PUSH_LOG="$RUN_DIR/push.log" MAX_PUSH_RETRY=2 push_ok=0 attempt=0 while [ "$attempt" -lt $((MAX_PUSH_RETRY + 1)) ]; do attempt=$((attempt + 1)) if git push origin "$BRANCH" >"$PUSH_LOG" 2>&1; then push_ok=1 log "git push 成功 — 测试闭环已同步远端(第 ${attempt} 次尝试)" break fi # 仅在「远端领先/非快进」时自动 rebase 后重试 if grep -qiE "non-fast-forward|fetch first|Updates were rejected|rejected" "$PUSH_LOG"; then log "git push 被拒(远端领先/非快进)。rebase 后重试(${attempt}/${MAX_PUSH_RETRY})" # rebase 期间用 --autostash 保护未提交改动(如事后刷新的 interface_test_report.tsv) if git pull --rebase --autostash origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then log "rebase 完成,重新尝试 push" else log "rebase 失败(可能存在冲突),中止 push 重试" break fi else # 凭证/网络等其他错误:不盲目重试,避免空耗与误报 log "git push 失败(疑似凭证/网络,非非快进类): $(grep -iE 'error|fatal|timed out|could not|permission denied' "$PUSH_LOG" | head -3 | tr '\n' ' ')" break fi done if [ "$push_ok" -ne 1 ]; then log "git push 最终失败,本轮结果未同步远端(下次运行将重试)" fi # 更新回归基线(仅有效轮才覆盖,避免无效轮污染下一轮回归对比) cp "$CUR_TSV" "$PREV_TSV" # 同步仓库根目录的全量结果 TSV(供人工/工具读取,避免与报告一起变陈旧) cp "$CUR_TSV" "interface_test_report.tsv" log "已同步全量结果 -> interface_test_report.tsv" log "===== 本轮结束 ====="