feat: add isolated Docker local testing and HTTP smoke validation

This commit is contained in:
2026-11-03 19:21:58 +08:00
parent e28364e061
commit 1d08bdb6a5
7 changed files with 324 additions and 2 deletions
+4
View File
@@ -2,11 +2,15 @@
.git/
.gitignore
.vscode/
.vs/
.venv/
.pytest_cache/
**/__pycache__/
*.pyc
# 前端是独立仓库,后端镜像构建不需要
frontend/
wagtailcms-frontend/
# 本地开发数据/产物,不应打进镜像(容器内由 entrypoint 重新生成或挂载卷提供)
db.sqlite3
+4
View File
@@ -0,0 +1,4 @@
# Copilot Instructions
## 项目指南
- 本项目不需要生成或构建 Visual Studio 解决方案;本机已安装 Docker,可用 Docker 搭建本地测试环境,验证应采用项目原生测试及 Docker 环境。
+80
View File
@@ -0,0 +1,80 @@
# 仅本机测试。不要用于公网或生产环境;不引用生产 .env。
name: wagtailcms-local
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: cms_local
POSTGRES_USER: cms_local
POSTGRES_PASSWORD: local-database-only
volumes:
- local_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cms_local -d cms_local"]
interval: 5s
timeout: 5s
retries: 20
mailpit:
image: axllent/mailpit:v1.29
ports:
- "127.0.0.1:18025:8025"
backend:
build:
context: .
dockerfile: docker/Dockerfile
target: local-test
environment:
DJANGO_SETTINGS_MODULE: wagtailcms.settings.docker_local
SECRET_KEY: docker-local-only-not-for-production-6dc8049e
DATABASE_URL: postgres://cms_local:local-database-only@postgres:5432/cms_local
WAGTAILADMIN_BASE_URL: http://localhost:18000
FRONTEND_BASE_URL: http://localhost:13000
FRONTEND_REVALIDATE_URL: http://frontend:3000/api/revalidate
REVALIDATE_SECRET: docker-local-revalidate-only
DJANGO_SUPERUSER_USERNAME: localadmin
DJANGO_SUPERUSER_EMAIL: admin@example.test
DJANGO_SUPERUSER_PASSWORD: ${CMS_LOCAL_ADMIN_PASSWORD:-Local-CMS-Only-ChangeMe!}
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
ports:
- "127.0.0.1:18000:8000"
volumes:
- local_media:/app/media
command: ["gunicorn", "wagtailcms.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "1", "--timeout", "120"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/admin/login/', timeout=5)"]
interval: 10s
timeout: 10s
start_period: 60s
retries: 20
frontend:
build:
context: ./wagtailcms-frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost:18000
environment:
API_INTERNAL_URL: http://backend:8000
REVALIDATE_SECRET: docker-local-revalidate-only
depends_on:
backend:
condition: service_healthy
ports:
- "127.0.0.1:13000:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 10s
start_period: 30s
retries: 20
volumes:
local_postgres:
local_media:
+10 -2
View File
@@ -27,7 +27,7 @@ COPY requirements/ requirements/
RUN pip install --user --no-warn-script-location -r requirements/production.txt
FROM python:${PYTHON_VERSION} AS runtime
FROM python:${PYTHON_VERSION} AS runtime-base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -49,7 +49,8 @@ COPY --from=builder /root/.local /home/django/.local
COPY . .
COPY docker/entrypoint.sh /app/docker/entrypoint.sh
RUN chmod +x /app/docker/entrypoint.sh \
RUN sed -i 's/\r$//' /app/docker/entrypoint.sh \
&& chmod +x /app/docker/entrypoint.sh \
&& mkdir -p /app/staticfiles /app/media \
&& chown -R django:django /app /home/django/.local
@@ -59,3 +60,10 @@ EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["gunicorn", "wagtailcms.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
FROM runtime-base AS local-test
RUN pip install --user --no-warn-script-location --no-cache-dir -r requirements/dev.txt
FROM runtime-base AS runtime
+137
View File
@@ -0,0 +1,137 @@
"""仅在 Docker 本地测试容器中执行的真实 HTTP 冒烟检查。"""
import os
import sys
import time
import uuid
from pathlib import Path
import django
import requests
from bs4 import BeautifulSoup
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
if os.environ.get("DJANGO_SETTINGS_MODULE") != "wagtailcms.settings.docker_local":
raise SystemExit("拒绝执行:此脚本只允许用于 docker_local 隔离环境。")
django.setup()
from wagtail.models import Page
from apps.core.models import ContactPage, SiteSettings
from apps.forms.models import Lead
BACKEND = "http://localhost:8000"
FRONTEND = "http://frontend:3000"
COMPANY = "Docker 联调测试公司"
def require(condition, message):
if not condition:
raise AssertionError(message)
def csrf(response):
response.raise_for_status()
field = BeautifulSoup(response.text, "html.parser").find("input", {"name": "csrfmiddlewaretoken"})
require(field is not None, "响应没有 CSRF 表单令牌")
return field["value"]
def main():
session = requests.Session()
login_url = f"{BACKEND}/admin/login/"
token = csrf(session.get(login_url, timeout=30))
response = session.post(login_url, data={
"username": os.environ["DJANGO_SUPERUSER_USERNAME"],
"password": os.environ["DJANGO_SUPERUSER_PASSWORD"],
"csrfmiddlewaretoken": token,
}, headers={"Referer": login_url}, timeout=30)
response.raise_for_status()
require("sessionid" in session.cookies, "后台登录失败;已有管理员密码不会自动重置")
setup_url = f"{BACKEND}/admin/company-setup/"
payload = {
"company_name": COMPANY,
"contact_phone": "010-12345678",
"contact_email": "leads@example.test",
"address": "仅用于 Docker 本地联调",
"icp_number": "",
"create_demo": "on",
}
for attempt in range(2):
token = csrf(session.get(setup_url, timeout=30))
response = session.post(setup_url, data={**payload, "csrfmiddlewaretoken": token},
headers={"Referer": setup_url}, timeout=120)
response.raise_for_status()
require("企业资料已保存" in response.text, "开站向导保存失败")
require(SiteSettings.objects.filter(company_name=COMPANY).exists(), "企业资料未持久化")
if attempt == 0:
count = Page.objects.count()
page_state = list(Page.objects.order_by("pk").values_list(
"pk", "title", "live", "has_unpublished_changes", "latest_revision_id",
))
else:
require(Page.objects.count() == count, "重复开站产生重复页面")
require(list(Page.objects.order_by("pk").values_list(
"pk", "title", "live", "has_unpublished_changes", "latest_revision_id",
)) == page_state, "重复开站改变已有页面状态")
print("PASS: HTTP 登录、CSRF 开站表单及重复执行")
response = requests.post(f"{FRONTEND}/api/revalidate", json={"tags": ["wagtail-pages"]}, timeout=30)
require(response.status_code == 401, "缓存刷新接口未拒绝匿名请求")
response = requests.post(f"{FRONTEND}/api/revalidate", json={"tags": ["wagtail-pages", "site-settings"]},
headers={"Authorization": f"Bearer {os.environ['REVALIDATE_SECRET']}"}, timeout=30)
require(response.status_code == 200, "授权缓存刷新失败")
print("PASS: 前端缓存刷新鉴权和内部网络连接")
for path, text in [
("/", COMPANY), ("/products", "企业内容平台"),
("/products/content-platform", "企业内容平台"),
("/solutions/digital-workplace", "企业数字化协同"),
("/cases/demo-content-project", "虚构"),
("/blog/website-content-checklist", "上线内容清单"),
("/about", COMPANY), ("/contact", "需求描述"),
]:
for attempt in range(24):
response = requests.get(f"{FRONTEND}{path}", timeout=30)
if response.status_code == 200 and text in response.text:
break
time.sleep(5)
require(response.status_code == 200 and text in response.text, f"前端页面未显示真实内容:{path}")
print(f"PASS: 前端 {path}")
contact = ContactPage.objects.get(slug="contact")
form = next(block.value["form"] for block in contact.body if block.block_type == "form")
form.notification_email = "leads@example.test"
form.save(update_fields=["notification_email"])
marker = f"docker-smoke-{uuid.uuid4().hex}"
cors = requests.options(f"{BACKEND}/api/v1/custom/leads/", headers={
"Origin": "http://localhost:13000", "Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
}, timeout=30)
require(cors.headers.get("Access-Control-Allow-Origin") == "http://localhost:13000", "浏览器跨域预检未通过")
response = requests.post(f"{BACKEND}/api/v1/custom/leads/", json={
"form_id": form.pk, "data": {"name": "Docker 测试", "company": COMPANY,
"phone": "010-12345678", "email": "visitor@example.test", "message": marker},
"source_url": "http://localhost:13000/contact", "website": "",
}, headers={"Origin": "http://localhost:13000"}, timeout=30)
require(response.status_code == 201 and response.json().get("success"), "咨询提交失败")
require(Lead.objects.filter(form=form, data__message=marker).exists(), "咨询未入库")
found = False
for attempt in range(15):
messages = requests.get("http://mailpit:8025/api/v1/messages", timeout=10).json().get("messages", [])
for message in messages:
detail = requests.get(f"http://mailpit:8025/api/v1/message/{message['ID']}", timeout=10).json()
if marker in detail.get("Text", ""):
found = True
break
if found:
break
time.sleep(2)
require(found, "通知邮件未被 Mailpit 捕获")
print("PASS: 跨域预检、真实表单 API、PostgreSQL 线索入库及 SMTP 邮件捕获")
print("ALL LOCAL HTTP SMOKE CHECKS PASSED")
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
# Docker 本地测试环境
此环境只用于本机联调,不生成或构建 Visual Studio 解决方案,也不使用生产 `.env`。依赖 Docker Desktop 的 Linux 容器模式。
## 启动
在仓库根目录执行 `docker.exe compose -f docker-compose.local.yml up -d --build --wait`。
若系统 PATH 中的 `docker` 同名程序无输出,PowerShell 使用 `& 'C:\Program Files\Docker\Docker\resources\bin\docker.exe'` 代替 `docker.exe`。
- 前端:http://localhost:13000
- 后台:http://localhost:18000/admin/
- 开站向导:http://localhost:18000/admin/company-setup/
- 测试邮件:http://localhost:18025
- 初始管理员:`localadmin`;初始密码:`Local-CMS-Only-ChangeMe!`
可在首次启动前设置 PowerShell 环境变量 `$env:CMS_LOCAL_ADMIN_PASSWORD` 自定义密码。已有账号不会因重启自动重置密码。
所有公开端口仅绑定 `127.0.0.1`。不要将此编排、固定测试密钥或测试密码用于公网部署。PostgreSQL 和 SMTP 不向主机公开端口。项目名 `wagtailcms-local` 及专用数据卷与其他本地容器隔离。
## 联调
1. 登录后台,打开企业开站向导,填写企业资料,按需勾选创建示例站。
2. 新建页面会直接发布,现有首页、联系页和隐私页保留原样。示例案例为虚构,不要对外展示为真实业绩。
3. 打开前端的产品、方案、案例、博客、关于和联系页面,核对导航及企业名称。
4. 在后台的“线索表单”中检查联系页所选表单,并将通知邮箱设为 `leads@example.test`。旧联系表单不会被向导覆盖。
5. 从前端提交一条咨询,检查后台线索记录与 Mailpit。Mailpit 捕获所有邮件,不会发送给外部真实邮箱。
6. 编辑一个页面,使用预览检查未发布内容;发布后刷新前端检查更新。
前端镜像构建时后端尚未可访问,预渲染可能使用既有空数据回退。服务启动后首次访问可能需要等约 60 秒缓存更新;这不是完成联调的依据,应确认实际内容已出现。
## 自动检查
- 后端:`docker.exe compose -f docker-compose.local.yml exec -T backend python -m pytest --ds=wagtailcms.settings.docker_local --create-db -q`
- Django:`docker.exe compose -f docker-compose.local.yml exec -T backend python manage.py check`
- 迁移:`docker.exe compose -f docker-compose.local.yml exec -T backend python manage.py makemigrations --check --dry-run`
- 前端:镜像构建阶段执行全部 Node 测试及 Next.js 生产构建。
- HTTP 冒烟:`docker.exe compose -f docker-compose.local.yml exec -T backend python docker/smoke_local.py`
HTTP 冒烟会通过真实登录与 CSRF 表单访问向导,创建示例内容并测试重复执行;刷新 `wagtail-pages` 和 `site-settings` 两类缓存,为本地“联系我们”表单配置测试通知邮箱,提交一条唯一标记的测试线索并核对数据库与捕获邮件。仅允许在 `docker_local` 设置下执行,不修改本机数据库。
## 已完成的本机验收
- 前后端镜像构建成功,四个服务健康检查通过。
- PostgreSQL 环境后端测试:88 项通过,12 条既有警告。
- 前端 Node 测试:16 项通过;Next.js 生产构建通过。
- Django 系统检查、迁移一致性检查及 `pip check` 通过。
- 真实 HTTP 登录、CSRF 向导、重复执行保留页面状态及缓存刷新鉴权通过。
- 首页、产品列表及详情、方案详情、案例详情、博客详情、关于和联系页验证通过。
- CORS 预检、咨询 API、PostgreSQL 线索入库及 Mailpit SMTP 捕获通过。
验收为 HTTP 及容器测试,未覆盖真实浏览器交互、视觉布局或后台预览自动化。企业配置使用独立缓存标签,页面发布刷新不等同于企业配置立即刷新。默认启动入口会调用既有种子命令更新联系页和隐私页;向导自身不覆盖已有内容,但不要将本地测试库用于正式内容维护。
## 常用运维
- 状态:`docker.exe compose -f docker-compose.local.yml ps`
- 日志:`docker.exe compose -f docker-compose.local.yml logs --tail 100 backend frontend`
- 停止但保留数据:`docker.exe compose -f docker-compose.local.yml down`
- 只有确认不再需要本地数据时,才使用 `down -v` 删除本编排数据库和媒体卷。
后端使用 `docker_local` 专用设置允许本机 HTTP,生产 HTTPS 配置未改动;仅本地测试镜像安装 pytest 等开发依赖。前端服务端使用 `API_INTERNAL_URL=http://backend:8000`,浏览器使用构建参数 `NEXT_PUBLIC_API_URL=http://localhost:18000`;改变浏览器地址后需重建前端镜像。
+28
View File
@@ -0,0 +1,28 @@
"""仅用于绑定本机回环端口的 Docker 测试环境,不得用于公网部署。"""
from .production import * # noqa: F401,F403
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "backend", "testserver", "host.docker.internal"]
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SECURE_HSTS_SECONDS = 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
SECURE_PROXY_SSL_HEADER = None
CSRF_TRUSTED_ORIGINS = ["http://localhost:18000", "http://localhost:13000"]
CORS_ALLOWED_ORIGINS = ["http://localhost:13000"]
EMAIL_HOST = "mailpit"
EMAIL_PORT = 1025
EMAIL_HOST_USER = ""
EMAIL_HOST_PASSWORD = ""
EMAIL_USE_TLS = False
DEFAULT_FROM_EMAIL = "cms-local@example.test"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}