动机:MinIO 的 GitHub 仓库已于 2026-02 归档,不再产出安全补丁,已累积多个 CVE 且不修;本项目存身份证 / 实名材料 / 用户照片,无人修 CVE 才是真正驱动因素 (Docker Hub 下架只是表象,quay.io 仍在提供)。 变更: - 三套 compose(dev / staging / prod)的 minio 服务替换为 seaweedfs 4.47: 覆盖 entrypoint 渲染 -s3.config 注入 identity(S3 网关默认不鉴权,必须显式开启), -volume.max=100 解除第二个 bucket 写入时的 InternalError。 - 新增一次性 storage-init 容器,经 filer 接口预建 public / private 桶 (SeaweedFS PutObject 到不存在的 bucket 直接失败;全仓库原无任何 createBucket)。 - S3Config 显式开启 path-style:AWS SDK v1 默认 virtual-host 会把 bucket 拼进 主机名,容器名在 DNS 中不存在必然失败(本机有 http_proxy 时易误判为 502 拒绝)。 - ci.yml:镜像校验与起栈步骤改为 seaweedfs + storage-init。 - 运维脚本 health-check.sh / run-uat.sh 的 MinIO 探针改为 SeaweedFS (S3 网关开启鉴权后匿名根请求返回 403,200/403 均算存活)。 注:path-style 与建桶属换存储的必要项;presigned URL 的 host 须对外可达这一 既有缺陷(原 minio:9000 直发客户端不可达)留待下一轮「改缺陷」处理。
291 lines
11 KiB
Bash
291 lines
11 KiB
Bash
#!/bin/bash
|
||
# ============================================
|
||
# 丹阳公益婚恋服务平台 — UAT 自动化检查脚本
|
||
# 此脚本在 UAT 环境启动后运行,自动化验证各角色核心流程
|
||
# ============================================
|
||
set -euo pipefail
|
||
|
||
BASE_URL="${UAT_BASE_URL:-http://localhost:8080}"
|
||
# 管理端 UAT 入口。
|
||
# 13521 = staging admin 容器发布端口(infrastructure/docker-compose.staging.yml),
|
||
# 按「生产 admin 端口 + 1」错开;/admin/ 是管理端产物的部署子路径
|
||
# (base 由 admin-web/vite.config.ts 决定)——根路径不提供服务,访问根会 301 到 /admin/。
|
||
# 此默认值由 ui-tests/ui-document-baseline-contracts.mjs 钉住,改端口时须两处同步。
|
||
ADMIN_URL="${UAT_ADMIN_URL:-http://localhost:13521/admin/}"
|
||
PLATFORM_URL="${UAT_PLATFORM_URL:-https://match.danyang.gov.cn}"
|
||
TOKEN_FILE="/tmp/uat_token.txt"
|
||
PASS=0
|
||
FAIL=0
|
||
SKIP=0
|
||
|
||
log_result() {
|
||
local test_name="$1"
|
||
local status="$2"
|
||
local detail="${3:-}"
|
||
if [ "$status" = "PASS" ]; then
|
||
PASS=$((PASS + 1))
|
||
echo " ✅ $test_name"
|
||
elif [ "$status" = "FAIL" ]; then
|
||
FAIL=$((FAIL + 1))
|
||
echo " ❌ $test_name — $detail"
|
||
else
|
||
SKIP=$((SKIP + 1))
|
||
echo " ⏭ $test_name (跳过)"
|
||
fi
|
||
}
|
||
|
||
echo "============================================"
|
||
echo " 丹阳公益婚恋平台 — UAT 自动化检查"
|
||
echo " $(date '+%Y-%m-%d %H:%M:%S')"
|
||
echo "============================================"
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 1. 基础设施检查
|
||
# ========================================
|
||
echo "【1. 基础设施检查】"
|
||
|
||
# 健康检查
|
||
if curl -sf "${BASE_URL}/api/v1/health/live" > /dev/null 2>&1; then
|
||
log_result "后端健康检查" "PASS"
|
||
else
|
||
log_result "后端健康检查" "FAIL" "后端服务不可达"
|
||
fi
|
||
|
||
# 版本信息
|
||
VERSION=$(curl -sf "${BASE_URL}/version" 2>/dev/null || echo "unknown")
|
||
log_result "版本信息接口" "PASS" "version=${VERSION}"
|
||
|
||
# PostgreSQL
|
||
if docker exec danyang-match-postgres pg_isready -U danyang -d danyang_match 2>/dev/null; then
|
||
log_result "PostgreSQL 连接" "PASS"
|
||
else
|
||
log_result "PostgreSQL 连接" "FAIL" "数据库不可用"
|
||
fi
|
||
|
||
# Redis
|
||
if docker exec danyang-match-redis redis-cli -a "${REDIS_PASSWORD:-staging}" ping 2>/dev/null | grep -q PONG; then
|
||
log_result "Redis 连接" "PASS"
|
||
else
|
||
log_result "Redis 连接" "FAIL" "Redis 不可用"
|
||
fi
|
||
|
||
# SeaweedFS(S3 网关)。开启鉴权后匿名根请求返回 403,403 同样说明网关正在服务,
|
||
# 故 200/403 都算存活。宿主端口:本地 8333 / staging 8334,可用 UAT_STORAGE_S3_PORT 覆盖。
|
||
S3_PORT="${UAT_STORAGE_S3_PORT:-8333}"
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${S3_PORT}/" 2>/dev/null || echo 000)
|
||
if [ "${code}" = "200" ] || [ "${code}" = "403" ]; then
|
||
log_result "SeaweedFS 连接" "PASS"
|
||
else
|
||
log_result "SeaweedFS 连接" "FAIL" "对象存储不可用"
|
||
fi
|
||
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 2. 认证流程
|
||
# ========================================
|
||
echo "【2. 认证流程】"
|
||
|
||
# Mock WeChat登录获取Token
|
||
LOGIN_RESP=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/wechat/login" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"code":"uat-test-code"}' 2>/dev/null || echo '{}')
|
||
|
||
if echo "$LOGIN_RESP" | grep -q "accessToken"; then
|
||
ACCESS_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('accessToken',''))" 2>/dev/null || echo "")
|
||
if [ -n "$ACCESS_TOKEN" ]; then
|
||
echo "$ACCESS_TOKEN" > "$TOKEN_FILE"
|
||
log_result "Mock微信登录" "PASS"
|
||
else
|
||
log_result "Mock微信登录" "FAIL" "未能获取Token"
|
||
fi
|
||
else
|
||
log_result "Mock微信登录" "SKIP" "Mock Provider 不可用(生产环境需真实WeChat)"
|
||
fi
|
||
|
||
# Token过期检查
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
EXPIRE_RESP=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/refresh" \
|
||
-H "Content-Type: application/json" \
|
||
-d "{\"refreshToken\":\"$(cat $TOKEN_FILE)\"}" 2>/dev/null || echo '{}')
|
||
if echo "$EXPIRE_RESP" | grep -q "accessToken"; then
|
||
log_result "Token刷新" "PASS"
|
||
else
|
||
log_result "Token刷新" "FAIL" "刷新失败"
|
||
fi
|
||
fi
|
||
|
||
# 无效Token拒绝
|
||
INVALID_RESP=$(curl -s -o /dev/null -w "%{http_code}" \
|
||
"${BASE_URL}/api/v1/users/me" \
|
||
-H "Authorization: Bearer invalid-token-here" 2>/dev/null || echo "000")
|
||
if [ "$INVALID_RESP" = "401" ]; then
|
||
log_result "无效Token拒绝" "PASS"
|
||
else
|
||
log_result "无效Token拒绝" "FAIL" "HTTP ${INVALID_RESP}"
|
||
fi
|
||
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 3. 核心业务流程
|
||
# ========================================
|
||
echo "【3. 核心业务流程】"
|
||
|
||
# 3.1 用户资料创建
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
CREATE_PROFILE=$(curl -sf -X POST "${BASE_URL}/api/v1/profile" \
|
||
-H "Authorization: Bearer $(cat $TOKEN_FILE)" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"displayName":"UAT测试用户","gender":"male","birthDate":"1990-01-01",
|
||
"regionCode":"321100","regionName":"丹阳市","maritalStatus":"single",
|
||
"childrenStatus":"none","educationCode":"bachelor","occupationCode":"other"}' \
|
||
2>/dev/null || echo '{}')
|
||
if echo "$CREATE_PROFILE" | grep -q "success\|profileId"; then
|
||
log_result "用户资料创建" "PASS"
|
||
else
|
||
log_result "用户资料创建" "FAIL" "资料创建失败"
|
||
fi
|
||
fi
|
||
|
||
# 3.2 个人资料完整性
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
GET_PROFILE=$(curl -sf "${BASE_URL}/api/v1/profile" \
|
||
-H "Authorization: Bearer $(cat $TOKEN_FILE)" 2>/dev/null || echo '{}')
|
||
if echo "$GET_PROFILE" | grep -q "completion"; then
|
||
COMPLETION=$(echo "$GET_PROFILE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('completion',0))" 2>/dev/null || echo "0")
|
||
if [ "$COMPLETION" -ge 60 ]; then
|
||
log_result "资料完整度≥60%" "PASS" "completion=${COMPLETION}%"
|
||
else
|
||
log_result "资料完整度≥60%" "FAIL" "completion=${COMPLETION}%"
|
||
fi
|
||
else
|
||
log_result "资料完整度≥60%" "SKIP" "无法获取资料"
|
||
fi
|
||
fi
|
||
|
||
# 3.3 推荐列表获取
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
REC_RESP=$(curl -sf "${BASE_URL}/api/v1/matches/recommendations?limit=6" \
|
||
-H "Authorization: Bearer $(cat $TOKEN_FILE)" 2>/dev/null || echo '{}')
|
||
if echo "$REC_RESP" | grep -q "list\|items"; then
|
||
log_result "推荐列表获取" "PASS"
|
||
else
|
||
log_result "推荐列表获取" "FAIL" "推荐接口异常"
|
||
fi
|
||
fi
|
||
|
||
# 3.4 喜欢操作幂等
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
LIKE_RESP=$(curl -sf -X POST "${BASE_URL}/api/v1/matches/like/test-user-123" \
|
||
-H "Authorization: Bearer $(cat $TOKEN_FILE)" 2>/dev/null || echo '{}')
|
||
# 第二次 Like 应返回 ALREADY_LIKED
|
||
log_result "Like幂等性" "SKIP" "需真实Match数据验证"
|
||
fi
|
||
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 4. 安全测试
|
||
# ========================================
|
||
echo "【4. 安全测试】"
|
||
|
||
# 4.1 SQL注入防护(参数化查询验证)
|
||
INJECTION_TEST=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/wechat/login" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"code":"'$(echo "'; DROP TABLE users; --")'"}' \
|
||
-w "%{http_code}" 2>/dev/null || echo "000")
|
||
if [ "$INJECTION_TEST" = "400" ] || [ "$INJECTION_TEST" = "401" ]; then
|
||
log_result "SQL注入防护" "PASS"
|
||
else
|
||
log_result "SQL注入防护" "FAIL" "HTTP ${INJECTION_TEST}"
|
||
fi
|
||
|
||
# 4.2 敏感信息脱敏
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
USER_RESP=$(curl -sf "${BASE_URL}/api/v1/users/me" \
|
||
-H "Authorization: Bearer $(cat $TOKEN_FILE)" 2>/dev/null || echo '{}')
|
||
PHONE=$(echo "$USER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('phone',''))" 2>/dev/null || echo "")
|
||
if echo "$PHONE" | grep -q '\*'; then
|
||
log_result "手机号脱敏" "PASS"
|
||
elif [ -z "$PHONE" ]; then
|
||
log_result "手机号脱敏" "PASS" "接口不返回phone字段(已脱敏)"
|
||
else
|
||
log_result "手机号脱敏" "FAIL" "手机号明文暴露: ${PHONE}"
|
||
fi
|
||
fi
|
||
|
||
# 4.3 HTTPS强制
|
||
HTTPS_CHECK=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost/" 2>/dev/null || echo "000")
|
||
if [ "$HTTPS_CHECK" = "301" ] || [ "$HTTPS_CHECK" = "308" ] || [ "$HTTPS_CHECK" = "200" ]; then
|
||
log_result "HTTPS重定向" "PASS" "HTTP→HTTPS 301"
|
||
else
|
||
log_result "HTTPS重定向" "SKIP" "HTTP直接访问未配置"
|
||
fi
|
||
|
||
# 4.4 Rate Limit 登录接口
|
||
for i in $(seq 1 15); do
|
||
curl -sf -X POST "${BASE_URL}/api/v1/auth/wechat/login" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"code":"spam-test"}' > /dev/null 2>&1
|
||
done
|
||
RATE_LIMIT_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||
-X POST "${BASE_URL}/api/v1/auth/wechat/login" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"code":"spam-test"}' 2>/dev/null || echo "200")
|
||
if [ "$RATE_LIMIT_CODE" = "429" ]; then
|
||
log_result "登录限流" "PASS"
|
||
else
|
||
log_result "登录限流" "SKIP" "开发环境未启用严格限流(生产已配置)"
|
||
fi
|
||
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 5. 部署验证
|
||
# ========================================
|
||
echo "【5. 部署验证】"
|
||
|
||
# 5.1 容器状态
|
||
if docker compose -f infrastructure/docker-compose.prod.yml ps 2>/dev/null | grep -q "healthy"; then
|
||
log_result "所有容器健康" "PASS"
|
||
else
|
||
log_result "所有容器健康" "SKIP" "Docker Compose 未运行(使用其他方式部署)"
|
||
fi
|
||
|
||
# 5.2 备份文件存在
|
||
# 备份由 infrastructure/scripts/backup.sh 产出(容器内 /app/backups,宿主机为 infrastructure/backups)。
|
||
if ls infrastructure/backups/*.sql.gz > /dev/null 2>&1; then
|
||
BACKUP_COUNT=$(ls -1 infrastructure/backups/*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
||
log_result "数据库备份文件" "PASS" "发现 ${BACKUP_COUNT} 个备份"
|
||
else
|
||
log_result "数据库备份文件" "SKIP" "未发现备份文件(首次部署前正常)"
|
||
fi
|
||
|
||
# 5.3 管理端页面可达
|
||
# 2026-09-18 前 ${ADMIN_URL} 是个定义了却无人消费的常量 —— 端口/子路径改了、
|
||
# 脚本没同步,也不会有任何检查变红。此处把它接到实测上:
|
||
# 期望 200,且期望的是 `/admin/` 子路径(管理端产物 base 为 /admin/,访问根路径会 301)。
|
||
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${ADMIN_URL}" 2>/dev/null || echo "000")
|
||
if [ "$ADMIN_CODE" = "200" ]; then
|
||
log_result "管理端页面可达" "PASS" "${ADMIN_URL}"
|
||
else
|
||
log_result "管理端页面可达" "FAIL" "${ADMIN_URL} → HTTP ${ADMIN_CODE}"
|
||
fi
|
||
|
||
echo ""
|
||
|
||
# ========================================
|
||
# 6. 结果汇总
|
||
# ========================================
|
||
echo "============================================"
|
||
echo " UAT 检查结果:PASS=${PASS} FAIL=${FAIL} SKIP=${SKIP}"
|
||
echo "============================================"
|
||
|
||
# 有 FAIL 时以非零退出:让 CI/人工调用方靠退出码就能发现,而不是必须滚屏找 ❌。
|
||
if [ "$FAIL" -gt 0 ]; then
|
||
exit 1
|
||
fi
|
||
exit 0
|