Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ace2c8aca | ||
|
|
fa559101b9 |
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
|
||||
"enabled": False,
|
||||
"webhook_url": "",
|
||||
"secret": "",
|
||||
"at_mobiles": [],
|
||||
"at_mobiles_app_store": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
# 钉钉 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,
|
||||
|
||||
@@ -36,18 +36,14 @@ def _update_plist(path: Path, config: dict):
|
||||
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
|
||||
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
|
||||
|
||||
# 企业微信 Schema:与微信登录 AppID 处理方式一致,未配置则移除该键。
|
||||
wxwork_schema = (config.get("wxworkSchema", "") or "").strip()
|
||||
if wxwork_schema:
|
||||
plist["wxworkSchema"] = wxwork_schema
|
||||
else:
|
||||
plist.pop("wxworkSchema", None)
|
||||
|
||||
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
|
||||
values = {
|
||||
"weixinlogin": config.get("weixinlogin", ""),
|
||||
"wechatpay": config.get("weixinpay", ""),
|
||||
"tencent": config.get("tencent", ""),
|
||||
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
|
||||
# (CFBundleURLName == "wxwork"),未配置则整条移除。
|
||||
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
|
||||
"readoorUrlScheme": scheme,
|
||||
}
|
||||
url_types = []
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
"webhook_url": "",
|
||||
"secret": ""
|
||||
"secret": "",
|
||||
"at_mobiles": [],
|
||||
"at_mobiles_app_store": []
|
||||
}
|
||||
},
|
||||
"versions": {
|
||||
|
||||
@@ -268,6 +268,16 @@
|
||||
<label>加签密钥(可选)</label>
|
||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ad_Hoc @ 开发人员手机号(可选)</label>
|
||||
<textarea v-model="dingtalkAtMobiles" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">须是钉钉账号绑定的手机号,Ad_Hoc 打包完成的通知会 @ 这些人。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App_Store @ 开发人员手机号(可选)</label>
|
||||
<textarea v-model="dingtalkAtMobilesAppStore" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">App_Store 打包完成的通知会 @ 这些人。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
|
||||
assert "" 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
|
||||
|
||||
@@ -33,7 +33,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
"<string>applinks:old</string>\n", encoding="utf-8"
|
||||
)
|
||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
|
||||
plistlib.dump({"CFBundleURLTypes": []}, f)
|
||||
plistlib.dump({"CFBundleURLTypes": [
|
||||
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
|
||||
]}, f)
|
||||
|
||||
theme = tmp_path / "theme" / "AppIcon.appiconset"
|
||||
theme.mkdir(parents=True)
|
||||
@@ -60,7 +62,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
assert (build_dir / "exportOptions.plist").exists()
|
||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("rb") as f:
|
||||
assert plistlib.load(f)["wxworkSchema"] == "wx1234567890abcdef"
|
||||
url_types = plistlib.load(f)["CFBundleURLTypes"]
|
||||
wxwork = next(i for i in url_types if i["CFBundleURLName"] == "wxwork")
|
||||
assert wxwork["CFBundleURLSchemes"] == ["wx1234567890abcdef"]
|
||||
|
||||
|
||||
def _make_minimal_project(build_dir):
|
||||
@@ -91,7 +95,9 @@ def test_wxwork_schema_removed_when_not_configured(tmp_path):
|
||||
build_dir = tmp_path / "build"
|
||||
plist_path = _make_minimal_project(build_dir)
|
||||
with plist_path.open("wb") as f:
|
||||
plistlib.dump({"CFBundleURLTypes": [], "wxworkSchema": "stale-value"}, f)
|
||||
plistlib.dump({"CFBundleURLTypes": [
|
||||
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
|
||||
]}, f)
|
||||
|
||||
config = {
|
||||
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
|
||||
@@ -103,4 +109,5 @@ def test_wxwork_schema_removed_when_not_configured(tmp_path):
|
||||
apply_project_config(build_dir, config)
|
||||
|
||||
with plist_path.open("rb") as f:
|
||||
assert "wxworkSchema" not in plistlib.load(f)
|
||||
url_types = plistlib.load(f)["CFBundleURLTypes"]
|
||||
assert all(i.get("CFBundleURLName") != "wxwork" for i in url_types)
|
||||
|
||||
Reference in New Issue
Block a user