feat: add isolated Docker local testing and HTTP smoke validation
This commit is contained in:
@@ -2,11 +2,15 @@
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
.vs/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 前端是独立仓库,后端镜像构建不需要
|
||||
frontend/
|
||||
wagtailcms-frontend/
|
||||
|
||||
# 本地开发数据/产物,不应打进镜像(容器内由 entrypoint 重新生成或挂载卷提供)
|
||||
db.sqlite3
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copilot Instructions
|
||||
|
||||
## 项目指南
|
||||
- 本项目不需要生成或构建 Visual Studio 解决方案;本机已安装 Docker,可用 Docker 搭建本地测试环境,验证应采用项目原生测试及 Docker 环境。
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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`;改变浏览器地址后需重建前端镜像。
|
||||
@@ -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"},
|
||||
}
|
||||
Reference in New Issue
Block a user