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" + + 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 @@
扫码下载安装