From 2ddae8b2d60545a8a4f2aeb6f1a88f13d3f8e51a Mon Sep 17 00:00:00 2001 From: sans Date: Mon, 20 Jul 2026 10:57:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E9=85=8D=E7=BD=AE=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/admin.py | 52 +++++++++++++++++++++++++++++----- frontend/src/views/Domains.vue | 2 ++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/backend/routers/admin.py b/backend/routers/admin.py index ac834ba..4428b8f 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -299,6 +299,17 @@ async def create_domain(data: DomainCreate, db: AsyncSession = Depends(get_db), dump = data.model_dump() if not dump.get("cert_dir"): dump["cert_dir"] = dump["domain"].lstrip("*.") + + # 校验:同域名 + 同服务器 + 同 ACME 配置不允许重复 + conditions = [Domain.domain == dump["domain"], Domain.server_id == dump["server_id"]] + if dump.get("acme_config_id"): + conditions.append(Domain.acme_config_id == dump["acme_config_id"]) + else: + conditions.append(Domain.acme_config_id.is_(None)) + existing = (await db.execute(select(Domain).where(*conditions))).scalars().first() + if existing: + raise HTTPException(409, "该域名在此服务器下已存在相同 ACME 配置的记录") + domain = Domain(**dump) db.add(domain) await db.flush() @@ -312,7 +323,27 @@ async def update_domain(domain_id: int, data: DomainUpdate, db: AsyncSession = D domain = result.scalar_one_or_none() if not domain: raise HTTPException(404, "Domain not found") - for key, value in data.model_dump(exclude_unset=True).items(): + + update_fields = data.model_dump(exclude_unset=True) + + # 如果改了 server_id 或 acme_config_id,校验是否与已有记录冲突 + new_server_id = update_fields.get("server_id", domain.server_id) + new_acme_config_id = update_fields.get("acme_config_id", domain.acme_config_id) + if "server_id" in update_fields or "acme_config_id" in update_fields: + conditions = [ + Domain.domain == domain.domain, + Domain.server_id == new_server_id, + Domain.id != domain_id, + ] + if new_acme_config_id: + conditions.append(Domain.acme_config_id == new_acme_config_id) + else: + conditions.append(Domain.acme_config_id.is_(None)) + dup = (await db.execute(select(Domain).where(*conditions))).scalars().first() + if dup: + raise HTTPException(409, "该域名在此服务器下已存在相同 ACME 配置的记录") + + for key, value in update_fields.items(): setattr(domain, key, value) return {"id": domain.id} @@ -606,7 +637,7 @@ async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(g # ──────────────── 部署脚本 ──────────────── -async def _render_script(domain: str, server_name: str, os: str, db: AsyncSession) -> tuple[str, Server, Domain]: +async def _render_script(domain: str, server_name: str, os: str, db: AsyncSession, acme_config_id: int | None = None) -> tuple[str, Server, Domain]: """渲染部署脚本,返回 (script, server, domain_obj)""" from jinja2 import Environment, FileSystemLoader @@ -618,9 +649,11 @@ async def _render_script(domain: str, server_name: str, os: str, db: AsyncSessio if not server: raise HTTPException(404, "Server not found") - result = await db.execute( - select(Domain).where(Domain.domain == domain, Domain.server_id == server.id) - ) + # 同一域名+服务器可能有多条记录(测试/正式不同 ACME 配置),用 acme_config_id 精确匹配 + conditions = [Domain.domain == domain, Domain.server_id == server.id] + if acme_config_id is not None: + conditions.append(Domain.acme_config_id == acme_config_id) + result = await db.execute(select(Domain).where(*conditions)) d = result.scalars().first() if not d: raise HTTPException(404, "Domain not found for this server") @@ -644,11 +677,12 @@ async def admin_generate_script( domain: str, server_name: str, os: str = "linux", + acme_config_id: int | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth), ): """管理端生成部署脚本(JSON)""" - script, server, d = await _render_script(domain, server_name, os, db) + script, server, d = await _render_script(domain, server_name, os, db, acme_config_id) return { "script": script, "token": server.token, @@ -663,11 +697,12 @@ async def admin_download_script( domain: str, server_name: str, os: str = "linux", + acme_config_id: int | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth), ): """下载脚本文件(纯文本,供 curl 使用)""" - script, _, _ = await _render_script(domain, server_name, os, db) + script, _, _ = await _render_script(domain, server_name, os, db, acme_config_id) ext = "ps1" if os == "windows" else "sh" filename = f"deploy-{domain.replace('*', '_')}.{ext}" return PlainTextResponse( @@ -683,6 +718,7 @@ async def admin_setup_script( server_name: str, os: str = "linux", token: str = "", + acme_config_id: int | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth), ): @@ -697,6 +733,8 @@ async def admin_setup_script( base_url = settings.base_url.rstrip("/") ext = "ps1" if os == "windows" else "sh" download_url = f"{base_url}/admin/api/script/download?domain={quote(domain)}&server_name={quote(server_name)}&os={os}" + if acme_config_id is not None: + download_url += f"&acme_config_id={acme_config_id}" template_name = "setup-cert.ps1.j2" if os == "windows" else "setup-cert.sh.j2" tpl = jinja_env.get_template(template_name) diff --git a/frontend/src/views/Domains.vue b/frontend/src/views/Domains.vue index 3a6f370..6c4f5a0 100644 --- a/frontend/src/views/Domains.vue +++ b/frontend/src/views/Domains.vue @@ -263,6 +263,7 @@ const loadScript = async () => { domain: scriptDomain.value.domain, server_name: scriptDomain.value.server_name, os: scriptOs.value, + acme_config_id: scriptDomain.value.acme_config_id || undefined, }, }) scriptContent.value = resp.data.script @@ -301,6 +302,7 @@ const downloadSetupScript = async () => { server_name: scriptDomain.value.server_name, os: scriptOs.value, token: localStorage.getItem('admin_token') || '', + acme_config_id: scriptDomain.value.acme_config_id || undefined, }, responseType: 'blob', })