#!/usr/bin/env bash # scripts/prod-smoke.sh —— 生产四点冒烟(P5-prep Task 2;RUNBOOK.md §2.3 的判定口径)。 # # 用法: # scripts/prod-smoke.sh [选项] # BASE_CN/BASE_OV 两地域 API 基址(https://photo.example.cn 形态;末尾不带 /v1) # --skip-ov ov 未部署时跳过 ov 段(--ov 为其别名,同义) # --dry-run 只打印将执行的调用序列,不发任何请求(本地校验脚本逻辑用) # --wait-seconds N 公开可见轮询上限秒(默认 60;worker 处理 + cn→ov 推送 2s # 间隔通常几秒内完成,60s 覆盖慢盘/排队余量) # # 覆盖四点:①两地域 /v1/health ②登录(真实密码)③建时间戳相册 → presign 上传 # 真实 JPEG 字节(内置 800x600 测试图,刻意不用 1x1——避免变体流水线踩最小尺寸 # 边界)→ complete → ④公开读轮询至可见(cn)+ ov 侧同步后公开读确认。 # 结束打印 PASS/FAIL 摘要并清理(软删测试照片:公开端点即刻不可见;相册行保留 # 时间戳名供审计——相册无删除端点,照片软删后公开列表即空)。 # 依赖:bash + curl + openssl + base64(无 jq/node)。退出码:0=全过,1=有 FAIL,2=用法错。 set -u # ── 参数 ── BASE_CN="${1:-}"; BASE_OV="${2:-}"; ACCOUNT="${3:-}"; PASSWORD="${4:-}" [ "$#" -ge 4 ] && shift 4 SKIP_OV=0; DRY_RUN=0; WAIT_SECONDS=60; PREV_ARG="" for arg in "$@"; do case "$arg" in --skip-ov|--ov) SKIP_OV=1 ;; --dry-run) DRY_RUN=1 ;; --wait-seconds) : ;; *) [ "$PREV_ARG" = "--wait-seconds" ] && WAIT_SECONDS="$arg" ;; esac PREV_ARG="$arg" done # 内置测试图:800x600 JPEG(quality 55,3118 字节)——base64 内嵌,跨机器无依赖。 JPEG_B64='/9j/2wBDAA4KCw0LCQ4NDA0QDw4RFiQXFhQUFiwgIRokNC43NjMuMjI6QVNGOj1OPjIySGJJTlZYXV5dOEVmbWVabFNbXVn/2wBDAQ8QEBYTFioXFypZOzI7WVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVn/wAARCAJYAyADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCsAkKIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//2Q==' PASS=0; FAIL=0; FAILED_STEPS="" TOKEN=""; ALBUM_ID=""; PUBLIC_TOKEN=""; PHOTO_ID=""; UPLOAD_ID=""; CRED_URL="" HTTP_CODE="000"; BODY="" JPG_FILE="$(mktemp "${TMPDIR:-/tmp}/photo-smoke-XXXXXX.jpg")" trap cleanup EXIT step_pass() { PASS=$((PASS + 1)); echo " [PASS] $1"; } step_fail() { FAIL=$((FAIL + 1)); FAILED_STEPS="${FAILED_STEPS}${1}; "; echo " [FAIL] $1"; } # json_escape :处理反斜杠/双引号(密码与标题的常见特殊字符;控制字符不入参)。 json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } # extract_str :取扁平响应里 "key":"value" 的 value(本脚本响应均此形态)。 extract_str() { printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" | head -n 1; } # http [json-body] # 成功时置 HTTP_CODE/BODY 并返回 0。dry-run 下只打印调用并返回 0。 http() { local method="$1" url="$2" expect="$3" body="${4:-}" if [ "$DRY_RUN" -eq 1 ]; then echo " curl -sS -X $method '$url'${body:+ -d '$body'}${TOKEN:+ -H 'Authorization: Bearer '}" HTTP_CODE="dry"; BODY="" return 0 fi local tmp; tmp="$(mktemp "${TMPDIR:-/tmp}/photo-smoke-body-XXXXXX")" local -a args=(-sS -X "$method" "$url" -H 'Content-Type: application/json' -o "$tmp" -w '%{http_code}') [ -n "$TOKEN" ] && args+=(-H "Authorization: Bearer $TOKEN") if [ -n "$body" ]; then HTTP_CODE="$(curl "${args[@]}" -d "$body" 2>/dev/null || echo 000)" else HTTP_CODE="$(curl "${args[@]}" 2>/dev/null || echo 000)" fi BODY="$(cat "$tmp" 2>/dev/null || true)" rm -f "$tmp" case ",$expect," in *",$HTTP_CODE,"*) return 0 ;; *) return 1 ;; esac } cleanup() { if [ "$DRY_RUN" -eq 0 ] && [ -n "$PHOTO_ID" ] && [ -n "$TOKEN" ] && [ "$TOKEN" != "dry" ]; then echo "-- 清理:软删测试照片 $PHOTO_ID(公开列表即刻为空)" if http DELETE "$BASE_CN/v1/photos/$PHOTO_ID" 204; then step_pass "cleanup 软删照片"; else step_fail "cleanup 软删照片 (HTTP=$HTTP_CODE)"; fi fi rm -f "$JPG_FILE" } echo "== photo-live 生产冒烟 ==" echo " cn=$BASE_CN ov=$BASE_OV account=$ACCOUNT skip_ov=$SKIP_OV dry_run=$DRY_RUN" if [ -z "$BASE_CN" ] || [ -z "$ACCOUNT" ] || [ -z "$PASSWORD" ] || { [ "$SKIP_OV" -eq 0 ] && [ -z "$BASE_OV" ]; }; then echo "用法: $0 [--skip-ov|--ov] [--dry-run] [--wait-seconds N]" >&2 exit 2 fi printf '%s' "$JPEG_B64" | base64 -d >"$JPG_FILE" 2>/dev/null || { echo "base64 解码失败(需 GNU base64)" >&2; exit 2; } JPEG_BYTES=$(wc -c <"$JPG_FILE" | tr -d ' ') TS=$(date +%Y%m%d%H%M%S) echo "-- ① 健康检查(两地域)" if http GET "$BASE_CN/v1/health" 200; then step_pass "cn /v1/health"; else step_fail "cn /v1/health (HTTP=$HTTP_CODE)"; fi if [ "$SKIP_OV" -eq 0 ]; then if http GET "$BASE_OV/v1/health" 200; then step_pass "ov /v1/health"; else step_fail "ov /v1/health (HTTP=$HTTP_CODE)"; fi fi echo "-- ② 登录(真实密码,会话走 Bearer)" if [ "$DRY_RUN" -eq 1 ]; then http POST "$BASE_CN/v1/auth/password" 200 \ "{\"account\":\"$(json_escape "$ACCOUNT")\",\"password\":\"$(json_escape "$PASSWORD")\"}" TOKEN="" else if http POST "$BASE_CN/v1/auth/password" 200 \ "{\"account\":\"$(json_escape "$ACCOUNT")\",\"password\":\"$(json_escape "$PASSWORD")\"}"; then TOKEN=$(extract_str "$BODY" sessionToken) fi fi if [ -n "$TOKEN" ]; then step_pass "登录(sessionToken 获取)" else step_fail "登录 (HTTP=$HTTP_CODE BODY=$BODY)" echo "== 摘要:PASS=$PASS FAIL=$FAIL 失败项: $FAILED_STEPS =="; echo "RESULT: FAIL" exit 1 fi echo "-- ③ 建时间戳相册 → presign 上传 → complete" if http POST "$BASE_CN/v1/albums" 201 \ "{\"translations\":[{\"locale\":\"zh-CN\",\"title\":\"smoke-$TS\",\"description\":\"prod-smoke 自动冒烟\"}],\"defaultLocale\":\"zh-CN\",\"startAt\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}"; then ALBUM_ID=$(extract_str "$BODY" id); PUBLIC_TOKEN=$(extract_str "$BODY" publicToken) fi if [ -n "$ALBUM_ID" ] && [ -n "$PUBLIC_TOKEN" ]; then step_pass "建相册(publicToken=$PUBLIC_TOKEN)" elif [ "$DRY_RUN" -eq 1 ]; then ALBUM_ID=""; PUBLIC_TOKEN=""; step_pass "建相册(dry-run 占位)" else step_fail "建相册 (HTTP=$HTTP_CODE BODY=$BODY)" fi if http POST "$BASE_CN/v1/uploads" 201 \ "{\"albumId\":\"$ALBUM_ID\",\"clientTaskId\":\"smoke-$TS\",\"filename\":\"smoke.jpg\",\"bytes\":$JPEG_BYTES,\"contentType\":\"image/jpeg\"}"; then UPLOAD_ID=$(extract_str "$BODY" uploadId); PHOTO_ID=$(extract_str "$BODY" photoId); CRED_URL=$(extract_str "$BODY" url) fi if [ -n "$UPLOAD_ID" ] && [ -n "$CRED_URL" ]; then step_pass "presign 凭证(uploadId=$UPLOAD_ID)" elif [ "$DRY_RUN" -eq 1 ]; then UPLOAD_ID=""; PHOTO_ID=""; CRED_URL=""; step_pass "presign 凭证(dry-run 占位)" else step_fail "presign (HTTP=$HTTP_CODE BODY=$BODY)" fi if [ "$DRY_RUN" -eq 1 ]; then echo " curl -sS -X PUT '$CRED_URL' -H 'Content-Type: image/jpeg' --data-binary @$JPG_FILE ($JPEG_BYTES 字节)" echo " (complete → cn/ov 公开读轮询逻辑与真实流程一致,见脚本 --dry-run 外执行)" else PUT_CODE="$(curl -sS -X PUT "$CRED_URL" -H 'Content-Type: image/jpeg' --data-binary @"$JPG_FILE" -o /dev/null -w '%{http_code}' 2>/dev/null || echo 000)" case "$PUT_CODE" in 2*) step_pass "OSS presign PUT($JPEG_BYTES 字节真实上传)" ;; *) step_fail "OSS PUT (HTTP=$PUT_CODE)" ;; esac if http POST "$BASE_CN/v1/uploads/$UPLOAD_ID/complete" 200; then step_pass "complete(进入 worker 处理)"; else step_fail "complete (HTTP=$HTTP_CODE BODY=$BODY)"; fi fi echo "-- ④ 公开读轮询至可见(≤${WAIT_SECONDS}s;worker 变体流水线 + 发布事务为异步)" if [ "$DRY_RUN" -eq 1 ]; then echo " 轮询 GET $BASE_CN/v1/public/albums/$PUBLIC_TOKEN/photos(间隔 2s)直到响应含 \"photoId\":\"$PHOTO_ID\"" else SEEN_CN=0; WAITED=0 while [ "$WAITED" -le "$WAIT_SECONDS" ]; do http GET "$BASE_CN/v1/public/albums/$PUBLIC_TOKEN/photos" 200 || true if [ -n "$PHOTO_ID" ] && printf '%s' "$BODY" | grep -q "\"photoId\":\"$PHOTO_ID\""; then SEEN_CN=1; break; fi sleep 2; WAITED=$((WAITED + 2)) done if [ "$SEEN_CN" -eq 1 ]; then step_pass "cn 公开可见(${WAITED}s)"; else step_fail "cn 公开可见超时 ${WAIT_SECONDS}s(查 worker 日志)"; fi fi if [ "$SKIP_OV" -eq 0 ]; then echo "-- ④' ov 侧公开读确认(cn→ov 同步:推送 2s 间隔 + 对账 5min 兜底)" if [ "$DRY_RUN" -eq 1 ]; then echo " 轮询 GET $BASE_OV/v1/public/albums/$PUBLIC_TOKEN/photos(间隔 2s)直到响应含 \"photoId\":\"$PHOTO_ID\"" else SEEN_OV=0; WAITED=0 while [ "$WAITED" -le "$WAIT_SECONDS" ]; do http GET "$BASE_OV/v1/public/albums/$PUBLIC_TOKEN/photos" 200 || true if [ -n "$PHOTO_ID" ] && printf '%s' "$BODY" | grep -q "\"photoId\":\"$PHOTO_ID\""; then SEEN_OV=1; break; fi sleep 2; WAITED=$((WAITED + 2)) done if [ "$SEEN_OV" -eq 1 ]; then step_pass "ov 公开可见(同步链路端到端,${WAITED}s)"; else step_fail "ov 公开可见超时 ${WAIT_SECONDS}s(查 sync 日志 / RUNBOOK §5.1)"; fi fi fi echo "== 摘要:PASS=$PASS FAIL=$FAIL ${FAILED_STEPS:+失败项: $FAILED_STEPS}==" if [ "$FAIL" -eq 0 ]; then echo "RESULT: PASS"; else echo "RESULT: FAIL"; exit 1; fi