fix: 上传二维码并发送 Ad Hoc 通知
This commit is contained in:
parent
8c0bce45a4
commit
9b77441ec7
@ -24,6 +24,7 @@ from ..config import (
|
|||||||
)
|
)
|
||||||
from .log_streamer import log_streamer
|
from .log_streamer import log_streamer
|
||||||
from .distribution import DistributionError, publish_ipa
|
from .distribution import DistributionError, publish_ipa
|
||||||
|
from .notification import NotificationError, send_dingtalk_notification
|
||||||
from .project_patcher import ProjectPatchError, apply_project_config
|
from .project_patcher import ProjectPatchError, apply_project_config
|
||||||
|
|
||||||
|
|
||||||
@ -444,6 +445,23 @@ async def run_build_task(task_id: str):
|
|||||||
if task.oss_url:
|
if task.oss_url:
|
||||||
await log_streamer.emit(task_id, f"下载链接: {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)
|
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
stem = _artifact_stem(config)
|
||||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||||
root = posixpath.join(folder, "iOS")
|
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}.ipa"),
|
||||||
posixpath.join(root, f"{stem}.plist"),
|
posixpath.join(root, f"{stem}.plist"),
|
||||||
posixpath.join(root, f"{stem}.html"),
|
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]:
|
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
||||||
"""发布 IPA,返回下载 URL 与二维码本地路径。"""
|
"""发布 IPA,返回下载 URL 与二维码公开 URL。"""
|
||||||
upload = config.get("_upload_config", {})
|
upload = config.get("_upload_config", {})
|
||||||
mode = upload.get("mode", "")
|
mode = upload.get("mode", "")
|
||||||
if mode not in {"oss", "webdav"}:
|
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"
|
output_dir = build_dir / "distribution"
|
||||||
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
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
|
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:
|
except ImportError as exc:
|
||||||
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
||||||
qrcode.make(urls[".html"]).save(qr_path)
|
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"]
|
||||||
|
|||||||
80
backend/services/notification.py
Normal file
80
backend/services/notification.py
Normal file
@ -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
|
||||||
@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
||||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
<img :src="completedTask.qr_code_path" alt="下载二维码" class="qr-image">
|
||||||
<p class="qr-hint">扫码下载安装</p>
|
<p class="qr-hint">扫码下载安装</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,3 +50,28 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path):
|
|||||||
assert uploaded_files[0][0].suffix == ".ipa"
|
assert uploaded_files[0][0].suffix == ".ipa"
|
||||||
assert download_url.endswith("100_2_0_0.ipa")
|
assert download_url.endswith("100_2_0_0.ipa")
|
||||||
assert qr_path == ""
|
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",
|
||||||
|
]
|
||||||
|
|||||||
60
tests/test_notification.py
Normal file
60
tests/test_notification.py
Normal file
@ -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 "" 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")
|
||||||
Loading…
Reference in New Issue
Block a user