diff --git a/.dockerignore b/.dockerignore index 92f801f..cb9e15e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,11 +2,15 @@ .git/ .gitignore .vscode/ +.vs/ +.venv/ +.pytest_cache/ **/__pycache__/ *.pyc # 前端是独立仓库,后端镜像构建不需要 frontend/ +wagtailcms-frontend/ # 本地开发数据/产物,不应打进镜像(容器内由 entrypoint 重新生成或挂载卷提供) db.sqlite3 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7410d6d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## 项目指南 +- 本项目不需要生成或构建 Visual Studio 解决方案;本机已安装 Docker,可用 Docker 搭建本地测试环境,验证应采用项目原生测试及 Docker 环境。 \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..f583c6d --- /dev/null +++ b/docker-compose.local.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile index 038cf21..b810921 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/smoke_local.py b/docker/smoke_local.py new file mode 100644 index 0000000..80dfb97 --- /dev/null +++ b/docker/smoke_local.py @@ -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() diff --git a/documents/Docker本地测试.md b/documents/Docker本地测试.md new file mode 100644 index 0000000..3d43df8 --- /dev/null +++ b/documents/Docker本地测试.md @@ -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`;改变浏览器地址后需重建前端镜像。 diff --git a/wagtailcms/settings/docker_local.py b/wagtailcms/settings/docker_local.py new file mode 100644 index 0000000..566e4b6 --- /dev/null +++ b/wagtailcms/settings/docker_local.py @@ -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"}, +}