From 9ace2c8aca5747ee7b6a46432d4cafcf3887a7ff Mon Sep 17 00:00:00 2001 From: shenlei Date: Tue, 1 Sep 2026 11:02:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20App=5FStore=20=E6=89=93=E5=8C=85?= =?UTF-8?q?=E4=B9=9F=E5=8F=91=E9=92=89=E9=92=89=E9=80=9A=E7=9F=A5=EF=BC=8C?= =?UTF-8?q?Ad=5FHoc/App=5FStore=20=E5=90=84=E8=87=AA=E7=9A=84=20@=20?= =?UTF-8?q?=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App_Store 完成后同样发送钉钉通知:标题改为「【iOS】App_Store 包信息」, 下载链接为 IPA 地址,不带二维码。 - 钉钉 markdown 消息需在正文出现 @手机号 才会真正 @ 到人,故除 at.atMobiles 外同时在正文追加。 - 新增 upload.dingtalk.at_mobiles(Ad_Hoc)与 at_mobiles_app_store(App_Store) 两份名单,按 BUILD_TYPE 选用;配置页「钉钉通知」区新增两个输入框。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YZULZ8fkiaiJrxqf318xTn --- backend/routers/config.py | 2 + backend/services/build_service.py | 32 +++++++------- backend/services/notification.py | 41 ++++++++++++++--- config.example.json | 4 +- frontend/src/views/ConfigView.vue | 25 +++++++++++ tests/test_notification.py | 73 +++++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/backend/routers/config.py b/backend/routers/config.py index 5abc917..e8d90a2 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -58,6 +58,8 @@ DEFAULT_UPLOAD = { "enabled": False, "webhook_url": "", "secret": "", + "at_mobiles": [], + "at_mobiles_app_store": [], }, } diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 952cadf..b7888bc 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -498,22 +498,22 @@ 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") + # Ad Hoc 发安装页二维码通知,App Store 发 IPA 下载地址通知; + # 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。 + # 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。 + 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/notification.py b/backend/services/notification.py index b89e40d..5ebbee6 100644 --- a/backend/services/notification.py +++ b/backend/services/notification.py @@ -27,8 +27,32 @@ def _signed_webhook_url(webhook_url: str, secret: str) -> str: 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: +def _normalize_mobiles(at_mobiles) -> list: + """把手机号配置统一成去重、去空的字符串列表,兼容列表或逗号/换行分隔的字符串。""" + if not at_mobiles: + return [] + if isinstance(at_mobiles, str): + raw = at_mobiles.replace("\n", ",").replace(",", ",").replace(" ", ",").split(",") + else: + raw = at_mobiles + seen = [] + for item in raw: + mobile = str(item).strip() + if mobile and mobile not in seen: + seen.append(mobile) + return seen + + +def build_dingtalk_payload( + config_data: dict, + download_url: str, + qr_code_url: str = "", + at_mobiles=None, +) -> dict: """生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。""" + mobiles = _normalize_mobiles(at_mobiles) + # App_Store 包与 Ad Hoc 用不同标题,App_Store 只有 IPA 下载地址、无二维码。 + heading = "【iOS】App_Store 包信息" if config_data.get("BUILD_TYPE") == "App_Store" else "【iOS】打包信息" details = ( f"**环境:** {config_data.get('SERVER', '')}\n\n" f"**版本:** {config_data.get('VERSION', '')}\n\n" @@ -36,14 +60,18 @@ def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: st 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" + text = f"## {heading}\n\n{details}\n\n**iOS 下载链接:** {download_url}\n" if qr_code_url: text += f"\n![image]({qr_code_url})" + # 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人, + # 仅靠 at.atMobiles 不生效。 + if mobiles: + text += "\n\n" + " ".join(f"@{m}" for m in mobiles) return { "msgtype": "markdown", "markdown": {"title": "iOS应用下载", "text": text}, - "at": {"atMobiles": [], "isAtAll": False}, + "at": {"atMobiles": mobiles, "isAtAll": False}, } @@ -61,8 +89,11 @@ def send_dingtalk_notification( 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", []) + # Ad_Hoc 与 App_Store 使用各自的 @ 名单。 + mobiles_key = "at_mobiles_app_store" if config_data.get("BUILD_TYPE") == "App_Store" else "at_mobiles" + payload = build_dingtalk_payload( + config_data, download_url, qr_code_url, dingtalk_config.get(mobiles_key, []) + ) response = httpx.post( _signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")), json=payload, diff --git a/config.example.json b/config.example.json index 4df4711..b8e2b38 100644 --- a/config.example.json +++ b/config.example.json @@ -67,7 +67,9 @@ "dingtalk": { "enabled": false, "webhook_url": "", - "secret": "" + "secret": "", + "at_mobiles": [], + "at_mobiles_app_store": [] } }, "versions": { diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index 4976769..7b1b426 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -268,6 +268,16 @@ +
+ + +
须是钉钉账号绑定的手机号,Ad_Hoc 打包完成的通知会 @ 这些人。
+
+
+ + +
App_Store 打包完成的通知会 @ 这些人。
+
@@ -673,6 +683,21 @@ const onAppVerInput = (track) => { const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} }) const jsonContent = ref('{}') +// 钉钉 @ 手机号:配置里存字符串数组,输入框按逗号/换行分隔编辑。 +// Ad_Hoc 与 App_Store 各一份名单。 +const dingtalkAtMobilesModel = (key) => computed({ + get: () => (uploadConfig.value.dingtalk?.[key] || []).join('\n'), + set: (val) => { + if (!uploadConfig.value.dingtalk) uploadConfig.value.dingtalk = {} + uploadConfig.value.dingtalk[key] = val + .split(/[\s,,]+/) + .map(s => s.trim()) + .filter(Boolean) + }, +}) +const dingtalkAtMobiles = dingtalkAtMobilesModel('at_mobiles') +const dingtalkAtMobilesAppStore = dingtalkAtMobilesModel('at_mobiles_app_store') + const showServerModal = ref(false) const editingServerName = ref(null) const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }) diff --git a/tests/test_notification.py b/tests/test_notification.py index d4c3b1b..c75cb25 100644 --- a/tests/test_notification.py +++ b/tests/test_notification.py @@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content(): assert "![image](https://download.example.com/app.png)" in text +def test_dingtalk_payload_at_mobiles_appended_to_text_and_at_field(): + payload = build_dingtalk_payload( + _build_config(), + "https://download.example.com/app.html", + at_mobiles=["13800000000", " 13900000001 ", "13800000000", ""], + ) + + # 去重去空后写入 at.atMobiles,并在正文追加 @手机号(钉钉 markdown 必需) + assert payload["at"]["atMobiles"] == ["13800000000", "13900000001"] + assert "@13800000000" in payload["markdown"]["text"] + assert "@13900000001" in payload["markdown"]["text"] + + +def test_dingtalk_payload_without_at_mobiles_has_empty_at(): + payload = build_dingtalk_payload(_build_config(), "https://download.example.com/app.html") + assert payload["at"]["atMobiles"] == [] + assert "@" not in payload["markdown"]["text"] + + +def test_dingtalk_notification_forwards_at_mobiles(): + response = MagicMock(status_code=200) + response.json.return_value = {"errcode": 0} + with patch("backend.services.notification.httpx.post", return_value=response) as post: + send_dingtalk_notification( + { + "enabled": True, + "webhook_url": "https://example.com/robot", + "at_mobiles": ["13800000000", "13900000001"], + }, + _build_config(), + "https://download.example.com/app.html", + ) + + sent = post.call_args.kwargs["json"] + assert sent["at"]["atMobiles"] == ["13800000000", "13900000001"] + assert "@13800000000" in sent["markdown"]["text"] + + +def test_dingtalk_payload_app_store_heading_and_no_qr(): + config = {**_build_config(), "BUILD_TYPE": "App_Store"} + # App_Store 流程不传二维码地址(publish_ipa 返回空串) + payload = build_dingtalk_payload( + config, "https://oss.example.com/readoor/iOS/app_2_197_1.ipa" + ) + text = payload["markdown"]["text"] + assert "## 【iOS】App_Store 包信息" in text + assert "## 【iOS】打包信息" not in text + assert "**iOS 下载链接:** https://oss.example.com/readoor/iOS/app_2_197_1.ipa" in text + assert "![image]" not in text + + +def test_dingtalk_notification_app_store_uses_own_at_list(): + response = MagicMock(status_code=200) + response.json.return_value = {"errcode": 0} + dingtalk_config = { + "enabled": True, + "webhook_url": "https://example.com/robot", + "at_mobiles": ["13800000000"], + "at_mobiles_app_store": ["13911111111", "13922222222"], + } + with patch("backend.services.notification.httpx.post", return_value=response) as post: + send_dingtalk_notification( + dingtalk_config, + {**_build_config(), "BUILD_TYPE": "App_Store"}, + "https://oss.example.com/app.ipa", + ) + + sent = post.call_args.kwargs["json"] + assert sent["at"]["atMobiles"] == ["13911111111", "13922222222"] + assert "## 【iOS】App_Store 包信息" in sent["markdown"]["text"] + assert "@13800000000" not in sent["markdown"]["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