From 9b77441ec705039058e64e68d7a70672705ebece Mon Sep 17 00:00:00 2001 From: shenlei Date: Mon, 20 Jul 2026 15:22:44 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E5=8F=91=E9=80=81=20Ad=20Hoc=20=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/build_service.py | 18 +++++++ backend/services/distribution.py | 10 ++-- backend/services/notification.py | 80 +++++++++++++++++++++++++++++++ frontend/src/views/BuildView.vue | 2 +- tests/test_distribution.py | 25 ++++++++++ tests/test_notification.py | 60 +++++++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 backend/services/notification.py create mode 100644 tests/test_notification.py diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 8b51ff1..7397c2a 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -24,6 +24,7 @@ from ..config import ( ) from .log_streamer import log_streamer from .distribution import DistributionError, publish_ipa +from .notification import NotificationError, send_dingtalk_notification from .project_patcher import ProjectPatchError, apply_project_config @@ -444,6 +445,23 @@ async def run_build_task(task_id: str): if task.oss_url: await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") + # App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。 + if task.build_type == "Ad_Hoc": + # 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。 + dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {}) + try: + notified = await asyncio.to_thread( + send_dingtalk_notification, + dingtalk_config, + config_data, + task.oss_url or oss_url, + task.qr_code_path or qr_code_path, + ) + if notified: + await log_streamer.emit(task_id, "钉钉通知发送成功") + except NotificationError as exc: + await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn") + # 带超时执行打包 await asyncio.wait_for(_do_build(), timeout=timeout_seconds) diff --git a/backend/services/distribution.py b/backend/services/distribution.py index 4f25459..da13376 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -58,7 +58,7 @@ def _write_download_page(config: dict, html: Path, manifest_url: str): ) -def _remote_paths(config: dict) -> tuple[str, str, str]: +def _remote_paths(config: dict) -> tuple[str, str, str, str]: stem = _artifact_stem(config) folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" root = posixpath.join(folder, "iOS") @@ -66,6 +66,7 @@ def _remote_paths(config: dict) -> tuple[str, str, str]: posixpath.join(root, f"{stem}.ipa"), posixpath.join(root, f"{stem}.plist"), posixpath.join(root, f"{stem}.html"), + posixpath.join(root, f"{stem}.png"), ) @@ -127,7 +128,7 @@ def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]: def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]: - """发布 IPA,返回下载 URL 与二维码本地路径。""" + """发布 IPA,返回下载 URL 与二维码公开 URL。""" upload = config.get("_upload_config", {}) mode = upload.get("mode", "") if mode not in {"oss", "webdav"}: @@ -135,7 +136,7 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str output_dir = build_dir / "distribution" ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir) - ipa_remote, manifest_remote, html_remote = _remote_paths(config) + ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(config) uploader = _upload_oss if mode == "oss" else _upload_webdav @@ -157,4 +158,5 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str except ImportError as exc: raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc qrcode.make(urls[".html"]).save(qr_path) - return urls[".html"], str(qr_path) + urls.update(uploader(upload, [(qr_path, qr_remote)])) + return urls[".html"], urls[".png"] diff --git a/backend/services/notification.py b/backend/services/notification.py new file mode 100644 index 0000000..b89e40d --- /dev/null +++ b/backend/services/notification.py @@ -0,0 +1,80 @@ +"""打包完成后的钉钉通知。""" +import base64 +import hashlib +import hmac +import time +from urllib.parse import quote + +import httpx + + +class NotificationError(Exception): + """通知发送失败。""" + + +def _signed_webhook_url(webhook_url: str, secret: str) -> str: + """为启用了加签的钉钉机器人附加 timestamp 与 sign 参数。""" + if not secret: + return webhook_url + + timestamp = str(round(time.time() * 1000)) + signature = hmac.new( + secret.encode("utf-8"), + f"{timestamp}\n{secret}".encode("utf-8"), + hashlib.sha256, + ).digest() + separator = "&" if "?" in webhook_url else "?" + return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}" + + +def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict: + """生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。""" + details = ( + f"**环境:** {config_data.get('SERVER', '')}\n\n" + f"**版本:** {config_data.get('VERSION', '')}\n\n" + f"**APP名称:** {config_data.get('APPID_NAME', '')}\n\n" + f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n" + f"**App Guid:** {config_data.get('APPID', '')}" + ) + text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n" + if qr_code_url: + text += f"\n![image]({qr_code_url})" + + return { + "msgtype": "markdown", + "markdown": {"title": "iOS应用下载", "text": text}, + "at": {"atMobiles": [], "isAtAll": False}, + } + + +def send_dingtalk_notification( + dingtalk_config: dict, + config_data: dict, + download_url: str, + qr_code_url: str = "", +) -> bool: + """发送钉钉通知;未启用通知时不发起网络请求。""" + if not dingtalk_config.get("enabled"): + return False + + webhook_url = dingtalk_config.get("webhook_url", "").strip() + if not webhook_url: + raise NotificationError("钉钉通知已启用,但未配置 Webhook URL") + + payload = build_dingtalk_payload(config_data, download_url, qr_code_url) + payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", []) + response = httpx.post( + _signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")), + json=payload, + timeout=15, + ) + if response.status_code // 100 != 2: + raise NotificationError(f"钉钉通知发送失败 ({response.status_code})") + + try: + result = response.json() + except ValueError as exc: + raise NotificationError("钉钉通知返回内容无效") from exc + if result.get("errcode", 0) != 0: + raise NotificationError(f"钉钉通知发送失败: {result.get('errmsg', '未知错误')}") + return True diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue index 1f2c6d1..36fa86f 100644 --- a/frontend/src/views/BuildView.vue +++ b/frontend/src/views/BuildView.vue @@ -126,7 +126,7 @@
- 下载二维码 + 下载二维码

扫码下载安装

diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 8e2c4b1..5f3e1ee 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -50,3 +50,28 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path): assert uploaded_files[0][0].suffix == ".ipa" assert download_url.endswith("100_2_0_0.ipa") assert qr_path == "" + + +def test_adhoc_distribution_uploads_qrcode(tmp_path): + ipa = tmp_path / "source.ipa" + ipa.write_bytes(b"ad-hoc-ipa") + config = { + "APPID": "100", + "VERSION": "2.0.0", + "BUILD_TYPE": "Ad_Hoc", + "OSS_FLODER": "readoor", + "_upload_config": {"mode": "oss", "oss": {}}, + } + + def upload_files(_upload_config, files): + local_path, remote_path = files[0] + return {local_path.suffix: f"https://files.example.com/{remote_path}"} + + with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload: + download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build") + + assert download_url.endswith("100_2_0_0.html") + assert qr_url.endswith("100_2_0_0.png") + assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [ + ".ipa", ".plist", ".html", ".png", + ] diff --git a/tests/test_notification.py b/tests/test_notification.py new file mode 100644 index 0000000..d4c3b1b --- /dev/null +++ b/tests/test_notification.py @@ -0,0 +1,60 @@ +"""钉钉通知测试。""" +from unittest.mock import MagicMock, patch + +import pytest + +from backend.services.notification import NotificationError, build_dingtalk_payload, send_dingtalk_notification + + +def _build_config(): + return { + "SERVER": "测试环境", + "VERSION": "2.195.0", + "APPID_NAME": "阅门户测试App", + "BUNDLE_ID": "cn.touchv.a4YX061", + "APPID": "647372741900537856", + } + + +def test_dingtalk_payload_matches_autopacking_content(): + payload = build_dingtalk_payload( + _build_config(), + "https://download.example.com/app.html", + "https://download.example.com/app.png", + ) + + text = payload["markdown"]["text"] + assert payload["markdown"]["title"] == "iOS应用下载" + assert "## 【iOS】打包信息" in text + assert "**环境:** 测试环境" in text + assert "**版本:** 2.195.0" in text + assert "**APP名称:** 阅门户测试App" in text + assert "**包名:** cn.touchv.a4YX061" in text + assert "**App Guid:** 647372741900537856" in text + assert "**iOS 下载链接:** https://download.example.com/app.html" in text + assert "![image](https://download.example.com/app.png)" in text + + +def test_dingtalk_notification_skips_when_disabled(): + with patch("backend.services.notification.httpx.post") as post: + assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False + post.assert_not_called() + + +def test_dingtalk_notification_sends_markdown(): + response = MagicMock(status_code=200) + response.json.return_value = {"errcode": 0} + with patch("backend.services.notification.httpx.post", return_value=response) as post: + assert send_dingtalk_notification( + {"enabled": True, "webhook_url": "https://example.com/robot"}, + _build_config(), + "https://download.example.com/app.html", + "https://download.example.com/app.png", + ) is True + + assert post.call_args.kwargs["json"]["markdown"]["title"] == "iOS应用下载" + + +def test_dingtalk_notification_requires_webhook_when_enabled(): + with pytest.raises(NotificationError, match="Webhook"): + send_dingtalk_notification({"enabled": True}, _build_config(), "https://download.example.com/app")