| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- #!/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
- # ---------------------------------------------------------------------------
- # 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})" \
- >>"$LOG_DIR/interface_cron.log" 2>&1 && log "已提交测试结果"
- 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 "===== 本轮结束 ====="
|