提交 4cff19b6 authored 作者: 陈泽健's avatar 陈泽健

feat(deploy): V2部署架构升级 — 合一容器+MySQL

完成服务器部署架构升级(V1三容器→V2双容器),解决代码更新需重建镜像、前端无法热更新、SQLite并发锁等问题。

变更内容:
- 新建合一Dockerfile(仅装环境,代码volume挂载)
- 重写docker-compose.yml(2服务:app+mysql)
- 改造main.py(StaticFiles挂载前端+SPA路由回退)
- 改造database.py(区分SQLite/MySQL连接参数)
- 7个model ID字段String(32)→String(64)(适配MySQL长度校验)
- 新增aiomysql+cryptography依赖
- 编写migrate_sqlite_to_mysql.py(断点续传+外键处理)
- 新增init.sql、deploy.sh、.env.example
- 删除旧Dockerfile.backend/frontend
- 新增PRD需求文档、执行计划、部署方案、HANDOFF_V2文档
- CLAUDE.md新增MySQL踩坑记录#10-#13
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 c3417d53
......@@ -147,8 +147,12 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**
| 7 | 前端页面空白 | `useRoute()` 在 computed 内调用 | 移到 setup 顶层 |
| 8 | `NOT NULL constraint failed: error_message` | 字段 nullable=False 但成功时为 None | 改为 nullable=True |
| 9 | Playwright Sync API inside asyncio loop | `run_in_executor` 线程中 Playwright 检测到 asyncio 循环 | `asyncio.set_event_loop(None)` 在工作线程中 |
| 10 | **MySQL 连接 `Can't connect on '2026@mysql'`** | DATABASE_URL 密码含 `@`,被 URL 解析为主机分隔符 | MySQL 密码不要含 `@` 等 URL 特殊字符(已用 `PlatApp2026`) |
| 11 | **MySQL `Data too long for column 'id'`** | 模型 `String(32)` 容不下带前缀 ID(如 `module_xxx` 39 字符),SQLite 不校验长度但 MySQL 校验 | ID 类字段统一用 `String(64)` |
| 12 | **MySQL 迁移 `foreign key constraint fails`** | 自引用外键(如 `modules.parent_id`)插入顺序问题,SQLite 不强制外键但 MySQL 强制 | 迁移脚本先 `SET FOREIGN_KEY_CHECKS=0`,完成后再开启 |
| 13 | **MySQL `ping() missing 'reconnect'` → API 500** | aiomysql 的 `ping()` 与 SQLAlchemy `pool_pre_ping=True` 不兼容 | MySQL 连接禁用 `pool_pre_ping`,改用 `pool_recycle=3600`(见 `database.py`) |
详见 `HANDOFF.md` 第五节。
详见 `HANDOFF.md` 第五节,V2 部署踩坑详见 `HANDOFF_V2部署升级.md`
---
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
......@@ -25,12 +25,17 @@ from app.config import settings
logger = logging.getLogger(__name__)
# 创建异步引擎(SQLite 需要特殊配置避免锁定)
# 注意:aiomysql 的 ping() 方法与 SQLAlchemy 的 pool_pre_ping 不兼容
# (AsyncAdapt_aiomysql_connection.ping() 缺少 reconnect 参数),
# 因此 MySQL 改用 pool_recycle 定期回收连接,SQLite 仍用 pool_pre_ping。
_is_sqlite = "sqlite" in settings.DATABASE_URL
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
pool_pre_ping=True,
connect_args={"check_same_thread": False} if _is_sqlite else {},
pool_pre_ping=_is_sqlite,
pool_recycle=3600 if not _is_sqlite else -1,
)
# 创建异步会话工厂
......
......@@ -215,3 +215,27 @@ async def serve_file(file_path: str):
status_code=404,
detail=f"文件不存在: {file_path}"
)
# ==================== 前端静态文件服务(容器化部署用) ====================
FRONTEND_DIST = Path(__file__).parent.parent / "frontend" / "dist"
if FRONTEND_DIST.exists():
logger.info(f"挂载前端静态文件: {FRONTEND_DIST}")
# 挂载静态资源目录(JS/CSS/图片等)
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
# 前端 SPA 路由:所有非 API 路径回退到 index.html
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""
前端 SPA 入口,所有非 API 路径返回 index.html
注意:此路由必须注册在所有 API 路由之后,避免覆盖 API 路由。
"""
file_path = FRONTEND_DIST / full_path
if file_path.exists() and file_path.is_file():
return FileResponse(str(file_path))
return FileResponse(str(FRONTEND_DIST / "index.html"))
\ No newline at end of file
......@@ -43,16 +43,16 @@ class CaseDependency(Base):
__tablename__ = "case_dependencies"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="依赖关系ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="依赖关系ID")
case_id: Mapped[str] = mapped_column(
String(32),
String(64),
ForeignKey("test_cases.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="用例ID(依赖方)"
)
depends_on_case_id: Mapped[str] = mapped_column(
String(32),
String(64),
ForeignKey("test_cases.id", ondelete="CASCADE"),
nullable=False,
index=True,
......
......@@ -48,15 +48,15 @@ class CaseResult(Base):
__tablename__ = "case_results"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="结果ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="结果ID")
execution_id: Mapped[str] = mapped_column(
String(32),
String(64),
ForeignKey("executions.id", ondelete="CASCADE"),
nullable=False,
comment="执行记录ID"
)
case_id: Mapped[str] = mapped_column(
String(32),
String(64),
ForeignKey("test_cases.id", ondelete="SET NULL"),
nullable=True,
comment="用例ID"
......
......@@ -51,7 +51,7 @@ class Execution(Base):
__tablename__ = "executions"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="执行ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="执行ID")
name: Mapped[str] = mapped_column(String(200), default="", comment="执行名称")
trigger_type: Mapped[str] = mapped_column(
String(20),
......
......@@ -42,7 +42,7 @@ class Module(Base):
__tablename__ = "modules"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="模块ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="模块ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模块名称")
description: Mapped[str] = mapped_column(Text, default="", comment="模块描述")
icon: Mapped[str] = mapped_column(String(50), default="folder", comment="模块图标")
......@@ -66,7 +66,7 @@ class Module(Base):
# 父模块ID(支持层级结构)
parent_id: Mapped[Optional[str]] = mapped_column(
String(32),
String(64),
ForeignKey("modules.id"),
nullable=True,
default=None,
......
......@@ -39,7 +39,7 @@ class SecurityConfig(Base):
__tablename__ = "security_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="配置ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="配置ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="配置名称")
target_url: Mapped[str] = mapped_column(String(500), nullable=False, comment="目标服务器地址")
server_ip: Mapped[Optional[str]] = mapped_column(String(50), default="", comment="服务器IP")
......
......@@ -50,9 +50,9 @@ class TestCase(Base):
__tablename__ = "test_cases"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="用例ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="用例ID")
module_id: Mapped[str] = mapped_column(
String(32),
String(64),
ForeignKey("modules.id", ondelete="CASCADE"),
nullable=False,
comment="所属模块ID"
......
......@@ -51,12 +51,12 @@ class VulnerabilityResult(Base):
LEVEL_LOW = "low"
LEVEL_INFO = "info"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="结果ID")
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="结果ID")
execution_id: Mapped[str] = mapped_column(
String(32), ForeignKey("executions.id", ondelete="CASCADE"),
String(64), ForeignKey("executions.id", ondelete="CASCADE"),
nullable=False, comment="关联执行ID"
)
case_id: Mapped[str] = mapped_column(String(32), default="", comment="关联用例ID")
case_id: Mapped[str] = mapped_column(String(64), default="", comment="关联用例ID")
# 漏洞信息
test_id: Mapped[str] = mapped_column(String(20), default="", comment="OWASP测试编号(如2.1.1)")
......
......@@ -2,6 +2,8 @@ fastapi==0.110.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
aiosqlite==0.19.0
aiomysql==0.2.0
cryptography==42.0.0
pydantic==2.5.3
python-multipart==0.0.6
playwright==1.40.0
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:migrate_sqlite_to_mysql.py
模块描述:SQLite → MySQL 数据迁移脚本
将本地 SQLite 数据库(backend/data/test_platform.db)的数据迁移到远程 MySQL。
支持断点续传:已存在的记录自动跳过(ON DUPLICATE KEY UPDATE)。
作者:czj
创建日期:2026-07-28
"""
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# ==================== 数据库连接配置 ====================
# SQLite(本地数据源)
SQLITE_PATH = os.getenv("SQLITE_PATH", "sqlite+aiosqlite:///./data/test_platform.db")
# MySQL(目标数据库)
MYSQL_HOST = os.getenv("MYSQL_HOST", "192.168.5.60")
MYSQL_PORT = os.getenv("MYSQL_PORT", "3307")
MYSQL_USER = os.getenv("MYSQL_USER", "platapp")
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "PlatApp2026")
MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "plat_auto_test")
MYSQL_URL = (
f"mysql+aiomysql://{MYSQL_USER}:{MYSQL_PASSWORD}"
f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
)
# ==================== 表迁移顺序(按外键依赖排序) ====================
TABLES = [
# (表名, 主键字段, 是否需要处理 JSON)
("modules", "id", False),
("test_cases", "id", True), # 含 JSON 字段(steps, selectors 等)
("executions", "id", True), # 含 JSON 字段
("case_results", "id", False), # 依赖 execution.id
("case_dependencies", "id", False), # 依赖 test_case.id
("security_configs", "id", False),
("vulnerability_results", "id", False), # 依赖 security_configs.id
]
async def get_sqlite_data(sqlite_engine, table: str) -> list:
"""从 SQLite 读取表数据"""
async with sqlite_engine.connect() as conn:
result = await conn.execute(text(f"SELECT * FROM {table}"))
columns = result.keys()
rows = []
for row in result.fetchall():
rows.append(dict(zip(columns, row)))
logger.info(f" SQLite [{table}]: {len(rows)} 行")
return rows
def serialize_value(value, is_json: bool = False):
"""序列化值,处理特殊类型"""
if value is None:
return None
if is_json and not isinstance(value, (str, int, float, bool)):
# JSON 字段:如果是 dict/list 则序列化,否则保持原样
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
# datetime 类型转为字符串
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
# bytes 类型转为字符串
if isinstance(value, bytes):
return value.decode("utf-8")
return value
async def migrate_table(mysql_engine, table: str, pk_field: str, rows: list, is_json_table: bool) -> int:
"""将数据写入 MySQL"""
if not rows:
return 0
# 获取列名(从第一行数据)
columns = list(rows[0].keys())
# 构建 INSERT ... ON DUPLICATE KEY UPDATE 语句
col_list = ", ".join([f"`{c}`" for c in columns])
placeholders = ", ".join([f":{c}" for c in columns])
update_parts = ", ".join([f"`{c}`=VALUES(`{c}`)" for c in columns if c != pk_field])
sql = f"""
INSERT INTO `{table}` ({col_list})
VALUES ({placeholders})
ON DUPLICATE KEY UPDATE {update_parts}
"""
# 序列化数据
serialized_rows = []
for row in rows:
serialized = {}
for col, val in row.items():
# 判断是否是 JSON 字段 - 通过表名 + 列名判断
is_json_col = is_json_table and col in ("steps", "selectors", "params", "result", "config", "tags", "metadata", "extra")
serialized[col] = serialize_value(val, is_json_col)
serialized_rows.append(serialized)
async with mysql_engine.connect() as conn:
for row in serialized_rows:
await conn.execute(text(sql), row)
await conn.commit()
logger.info(f" MySQL [{table}]: {len(rows)} 行写入成功")
return len(rows)
async def verify_migration(mysql_engine):
"""验证迁移后的数据"""
logger.info("")
logger.info("=" * 50)
logger.info("验证迁移结果")
logger.info("=" * 50)
async with mysql_engine.connect() as conn:
for table, pk_field, _ in TABLES:
result = await conn.execute(text(f"SELECT COUNT(*) FROM `{table}`"))
count = result.scalar()
logger.info(f" {table}: {count} 行")
# 验证用例完整性
result = await conn.execute(text("SELECT COUNT(*) FROM test_cases"))
case_count = result.scalar()
result = await conn.execute(text("SELECT COUNT(*) FROM modules"))
module_count = result.scalar()
logger.info("")
logger.info(f"✅ 迁移完成: {case_count} 个用例, {module_count} 个模块")
async def main():
"""主迁移流程"""
start_time = datetime.now()
logger.info("=" * 50)
logger.info("SQLite → MySQL 数据迁移")
logger.info(f"来源: {SQLITE_PATH}")
logger.info(f"目标: {MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}")
logger.info("=" * 50)
# 创建引擎
sqlite_engine = create_async_engine(SQLITE_PATH)
mysql_engine = create_async_engine(MYSQL_URL)
# 迁移前:关闭外键检查(避免自引用外键如 modules.parent_id 的顺序问题)
async with mysql_engine.connect() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
await conn.commit()
logger.info("已关闭 MySQL 外键检查(迁移期间)")
total_rows = 0
try:
# 逐表迁移
for table, pk_field, is_json in TABLES:
logger.info("")
logger.info(f"📦 迁移表: {table}")
# 1. 从 SQLite 读取
rows = await get_sqlite_data(sqlite_engine, table)
# 2. 写入 MySQL
count = await migrate_table(mysql_engine, table, pk_field, rows, is_json)
total_rows += count
# 重新开启外键检查
async with mysql_engine.connect() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
await conn.commit()
logger.info("已重新开启 MySQL 外键检查")
# 3. 验证
await verify_migration(mysql_engine)
except Exception as e:
logger.error(f"❌ 迁移失败: {str(e)}")
raise
finally:
await sqlite_engine.dispose()
await mysql_engine.dispose()
elapsed = (datetime.now() - start_time).total_seconds()
logger.info("")
logger.info(f"⏱ 总耗时: {elapsed:.1f} 秒")
logger.info(f"📊 总行数: {total_rows}")
return total_rows
if __name__ == "__main__":
asyncio.run(main())
\ No newline at end of file
# 环境变量配置
# 复制此文件为 .env 并根据需要修改
# 注意:.env 文件不要提交到 git
# 数据库连接
DATABASE_URL=sqlite+aiosqlite:///./data/test_platform.db
# ==================== MySQL 配置 ====================
MYSQL_ROOT_PASSWORD=Ubains@13579
MYSQL_DATABASE=plat_auto_test
MYSQL_USER=platapp
MYSQL_PASSWORD=PlatApp2026
# Playwright 配置
# ==================== 应用配置 ====================
# DATABASE_URL 由 docker-compose 根据 MYSQL_* 变量自动拼接,无需手动设置
PLAYWRIGHT_HEADLESS=true
PLAYWRIGHT_TIMEOUT=30000
# 日志级别
LOG_LEVEL=INFO
\ No newline at end of file
FROM python:3.10-slim
WORKDIR /app
# 安装系统依赖(Playwright 需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget gnupg curl \
libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 \
libcups2t64 libdrm2 libdbus-1-3 libxkbcommon0 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2t64 \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖(使用国内镜像源加速)
COPY requirements.txt .
RUN pip install --no-cache-dir \
-i https://pypi.tuna.tsinghua.edu.cn/simple \
--trusted-host pypi.tuna.tsinghua.edu.cn \
--retries 5 --timeout 120 -r requirements.txt
# 安装 Playwright Chromium(Linux 版本)
RUN playwright install chromium
# 创建必要目录
RUN mkdir -p /app/data/screenshots /app/data/reports \
/app/frontend/dist /app/logs
# 注意:以下内容通过 volume 挂载,不 COPY 进镜像
# - backend/ 源码 → /app
# - frontend/dist/ → /app/frontend/dist
# - data/ → /app/data
# - logs/ → /app/logs
EXPOSE 80
# 启动 uvicorn,监听 80 端口(同时提供 API + 前端静态文件)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
\ No newline at end of file
FROM python:3.10-slim
WORKDIR /app
# 安装 Playwright 系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
gnupg \
curl \
libnss3 \
libnspr4 \
libatk1.0-0t64 \
libatk-bridge2.0-0t64 \
libcups2t64 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2t64 \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖(使用国内镜像源加速,支持重试)
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple --trusted-host pypi.tuna.tsinghua.edu.cn --retries 5 --timeout 120 -r requirements.txt
# 安装 Playwright Chromium(Linux 版本)
RUN playwright install chromium
# 复制后端代码
COPY backend/ .
# 创建数据目录
RUN mkdir -p /app/data/screenshots /app/data/reports
# 暴露端口
EXPOSE 8001
# 启动命令(单 worker,SQLite 不支持多 worker 并发)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
\ No newline at end of file
# ---- 构建阶段 ----
FROM node:20-alpine AS build
WORKDIR /app
# 安装依赖(使用国内镜像)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm config set registry https://registry.npmmirror.com && npm ci
# 复制源码并构建
COPY frontend/ .
RUN npm run build
# ---- 运行阶段 ----
FROM nginx:alpine
# 复制构建产物
COPY --from=build /app/dist /usr/share/nginx/html
# 复制 Nginx 配置
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
\ No newline at end of file
......@@ -2,15 +2,14 @@
set -e
echo "============================================"
echo " 平台自动化测试系统 - Docker 部署脚本"
echo " 平台自动化测试系统 - V2 Docker 部署脚本"
echo " 架构: 合一应用容器 + MySQL"
echo "============================================"
echo ""
# 检查 Docker
if ! command -v docker &> /dev/null; then
echo "❌ 错误: Docker 未安装"
echo " 请先安装 Docker: sudo apt-get install -y docker.io docker-compose-v2"
echo " 或参考: https://docs.docker.com/engine/install/"
exit 1
fi
......@@ -22,16 +21,39 @@ fi
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_DIR="/data/third_party/plat-auto-test"
echo "📁 项目目录: $PROJECT_DIR"
echo "📁 部署目录: $PROJECT_DIR"
echo ""
# 创建数据目录
echo "📂 创建数据目录..."
mkdir -p "$PROJECT_DIR/data/screenshots" "$PROJECT_DIR/data/reports"
mkdir -p "$PROJECT_DIR/mysql/data"
mkdir -p "$PROJECT_DIR/logs"
echo " ✅ 数据目录已就绪"
# 检查 .env 文件
if [ ! -f "$SCRIPT_DIR/.env" ]; then
echo "⚠️ .env 文件不存在,从 .env.example 复制..."
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
echo " ⚠️ 请检查 .env 中的数据库密码配置"
fi
# 检查后端代码是否存在
if [ ! -d "$PROJECT_DIR/backend/app" ]; then
echo "⚠️ 后端代码不存在,请先上传后端代码到 $PROJECT_DIR/backend/"
exit 1
fi
# 检查前端构建产物是否存在
if [ ! -f "$PROJECT_DIR/frontend/dist/index.html" ]; then
echo "⚠️ 前端构建产物不存在,请先上传 dist/ 到 $PROJECT_DIR/frontend/dist/"
echo " 本地构建: cd frontend && npm run build"
echo " 上传: scp -r dist/* ubains@192.168.5.60:$PROJECT_DIR/frontend/dist/"
exit 1
fi
# 构建镜像
echo ""
echo "📦 构建 Docker 镜像..."
......@@ -48,28 +70,28 @@ echo " ✅ 服务已启动"
# 等待服务就绪
echo ""
echo "⏳ 等待服务就绪..."
sleep 10
sleep 15
# 健康检查
echo ""
echo "🔍 执行健康检查..."
MAX_RETRIES=6
MAX_RETRIES=12
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/health 2>/dev/null || echo "000")
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health 2>/dev/null || echo "000")
if [ "$HEALTH" = "200" ]; then
echo " ✅ 后端健康检查通过 (HTTP 200)"
echo " ✅ 应用健康检查通过 (HTTP 200)"
break
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo " ⏳ 等待后端就绪... ($RETRY_COUNT/$MAX_RETRIES)"
echo " ⏳ 等待应用就绪... ($RETRY_COUNT/$MAX_RETRIES)"
sleep 5
fi
done
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo " ⚠️ 后端健康检查超时,请检查日志: docker compose logs backend"
echo " ⚠️ 应用健康检查超时,请检查日志: docker compose logs app"
fi
# 检查前端
......@@ -80,9 +102,17 @@ else
echo " ⚠️ 前端响应码: $FRONTEND_CODE"
fi
# 检查 MySQL
MYSQL_OK=$(docker exec plat-auto-test-mysql mysqladmin ping -h localhost -uroot -p"${MYSQL_ROOT_PASSWORD:-Ubains@13579}" 2>/dev/null || echo "failed")
if [ "$MYSQL_OK" != "failed" ]; then
echo " ✅ MySQL 运行正常"
else
echo " ⚠️ MySQL 状态异常,请检查: docker compose logs mysql"
fi
# 检查已有服务是否受影响
TROUBLESHOOT_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8088/ 2>/dev/null || echo "000")
echo " ℹ️ 已有故障排查平台 (8088): HTTP $TROUBLESHOOT_CODE"
echo " ℹ️ 已有维护平台 (8088): HTTP $TROUBLESHOOT_CODE"
# 输出访问信息
echo ""
......@@ -90,14 +120,14 @@ echo "============================================"
echo " ✅ 部署完成!"
echo "============================================"
echo ""
echo " 前端: http://$(curl -s ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost')"
echo " API 文档: http://localhost:8001/docs"
echo " 前端: http://192.168.5.60"
echo " API 文档: http://192.168.5.60/docs"
echo ""
echo " 已有故障排查平台: http://localhost:8088(不受影响)"
echo " 已有维护平台: http://192.168.5.60:8088(不受影响)"
echo ""
echo " 📋 查看日志: docker compose logs -f"
echo " 🛑 停止服务: docker compose down"
echo " 🔄 重启服务: docker compose restart"
echo " 🔄 重启应用: docker compose restart app"
echo ""
# 检查容器状态
......
version: "3.8"
services:
backend:
build:
context: ..
dockerfile: deploy/Dockerfile.backend
container_name: plat-auto-test-backend
ports:
- "8001:8001"
# ==================== MySQL 数据库 ====================
mysql:
image: mysql:8.0
container_name: plat-auto-test-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-Ubains@13579}
MYSQL_DATABASE: ${MYSQL_DATABASE:-plat_auto_test}
MYSQL_USER: ${MYSQL_USER:-platapp}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-PlatApp2026}
TZ: Asia/Shanghai
volumes:
- ../data:/app/data
# MySQL 数据持久化
- /data/third_party/plat-auto-test/mysql/data:/var/lib/mysql
# 初始化脚本(仅首次启动执行)
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "3307:3306" # 映射到宿主机 3307,避免与本地 MySQL 冲突
networks:
- plat-auto-test-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-Ubains@13579}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
- --max_connections=200
# ==================== 应用容器(前后端合一) ====================
app:
build:
context: ../backend
dockerfile: ../deploy/Dockerfile
container_name: plat-auto-test-app
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/test_platform.db
- DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-platapp}:${MYSQL_PASSWORD:-PlatApp2026}@mysql:3306/${MYSQL_DATABASE:-plat_auto_test}?charset=utf8mb4
- PLAYWRIGHT_HEADLESS=true
- PLAYWRIGHT_TIMEOUT=30000
- LOG_LEVEL=INFO
restart: unless-stopped
- TZ=Asia/Shanghai
volumes:
# 后端源码(更新后端只需重启容器,无需重建镜像)
- /data/third_party/plat-auto-test/backend:/app
# 前端构建产物(更新前端无需重启,替换文件即热生效)
- /data/third_party/plat-auto-test/frontend/dist:/app/frontend/dist
# 应用数据(截图、报告)
- /data/third_party/plat-auto-test/data:/app/data
# 日志
- /data/third_party/plat-auto-test/logs:/app/logs
ports:
- "80:80"
networks:
- plat-auto-test-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
start_period: 40s
frontend:
build:
context: ..
dockerfile: deploy/Dockerfile.frontend
container_name: plat-auto-test-frontend
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
\ No newline at end of file
networks:
plat-auto-test-net:
driver: bridge
\ No newline at end of file
-- 平台自动化测试系统 - MySQL 初始化脚本
-- 注意:MYSQL_DATABASE 环境变量会自动创建数据库,此脚本用于补充设置
-- 确保字符集正确
ALTER DATABASE plat_auto_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 授予 platapp 用户完整权限
GRANT ALL PRIVILEGES ON plat_auto_test.* TO 'platapp'@'%';
FLUSH PRIVILEGES;
-- 时区设置(避免 SQLAlchemy 时间字段时区问题)
SET GLOBAL time_zone = '+08:00';
SET SESSION time_zone = '+08:00';
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论