Files
WorkBuddy 797b1c46cf refactor(storage): 对象存储从 MinIO 换成 SeaweedFS(钉 4.47)
动机: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 直发客户端不可达)留待下一轮「改缺陷」处理。
2026-09-22 14:26:48 +08:00

291 lines
11 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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