Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ace2c8aca | ||
|
|
fa559101b9 |
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
|
|||||||
"enabled": False,
|
"enabled": False,
|
||||||
"webhook_url": "",
|
"webhook_url": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
|
"at_mobiles": [],
|
||||||
|
"at_mobiles_app_store": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,22 +498,22 @@ 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 才发送安装页二维码通知。
|
# Ad Hoc 发安装页二维码通知,App Store 发 IPA 下载地址通知;
|
||||||
if task.build_type == "Ad_Hoc":
|
# 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
|
||||||
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
|
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
|
||||||
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
|
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
|
||||||
try:
|
try:
|
||||||
notified = await asyncio.to_thread(
|
notified = await asyncio.to_thread(
|
||||||
send_dingtalk_notification,
|
send_dingtalk_notification,
|
||||||
dingtalk_config,
|
dingtalk_config,
|
||||||
config_data,
|
config_data,
|
||||||
task.oss_url or oss_url,
|
task.oss_url or oss_url,
|
||||||
task.qr_code_path or qr_code_path,
|
task.qr_code_path or qr_code_path,
|
||||||
)
|
)
|
||||||
if notified:
|
if notified:
|
||||||
await log_streamer.emit(task_id, "钉钉通知发送成功")
|
await log_streamer.emit(task_id, "钉钉通知发送成功")
|
||||||
except NotificationError as exc:
|
except NotificationError as exc:
|
||||||
await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn")
|
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)
|
||||||
|
|||||||
@@ -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))}"
|
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 内容。"""
|
"""生成与 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 = (
|
details = (
|
||||||
f"**环境:** {config_data.get('SERVER', '')}\n\n"
|
f"**环境:** {config_data.get('SERVER', '')}\n\n"
|
||||||
f"**版本:** {config_data.get('VERSION', '')}\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"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
|
||||||
f"**App Guid:** {config_data.get('APPID', '')}"
|
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:
|
if qr_code_url:
|
||||||
text += f"\n"
|
text += f"\n"
|
||||||
|
# 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人,
|
||||||
|
# 仅靠 at.atMobiles 不生效。
|
||||||
|
if mobiles:
|
||||||
|
text += "\n\n" + " ".join(f"@{m}" for m in mobiles)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": {"title": "iOS应用下载", "text": text},
|
"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:
|
if not webhook_url:
|
||||||
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
|
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
|
||||||
|
|
||||||
payload = build_dingtalk_payload(config_data, download_url, qr_code_url)
|
# Ad_Hoc 与 App_Store 使用各自的 @ 名单。
|
||||||
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", [])
|
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(
|
response = httpx.post(
|
||||||
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
|
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
|
||||||
json=payload,
|
json=payload,
|
||||||
|
|||||||
@@ -36,18 +36,14 @@ def _update_plist(path: Path, config: dict):
|
|||||||
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
|
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
|
||||||
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
|
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()
|
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
|
||||||
values = {
|
values = {
|
||||||
"weixinlogin": config.get("weixinlogin", ""),
|
"weixinlogin": config.get("weixinlogin", ""),
|
||||||
"wechatpay": config.get("weixinpay", ""),
|
"wechatpay": config.get("weixinpay", ""),
|
||||||
"tencent": config.get("tencent", ""),
|
"tencent": config.get("tencent", ""),
|
||||||
|
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
|
||||||
|
# (CFBundleURLName == "wxwork"),未配置则整条移除。
|
||||||
|
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
|
||||||
"readoorUrlScheme": scheme,
|
"readoorUrlScheme": scheme,
|
||||||
}
|
}
|
||||||
url_types = []
|
url_types = []
|
||||||
|
|||||||
+3
-1
@@ -67,7 +67,9 @@
|
|||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"webhook_url": "",
|
"webhook_url": "",
|
||||||
"secret": ""
|
"secret": "",
|
||||||
|
"at_mobiles": [],
|
||||||
|
"at_mobiles_app_store": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
|
|||||||
@@ -268,6 +268,16 @@
|
|||||||
<label>加签密钥(可选)</label>
|
<label>加签密钥(可选)</label>
|
||||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -673,6 +683,21 @@ const onAppVerInput = (track) => {
|
|||||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||||
const jsonContent = ref('{}')
|
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 showServerModal = ref(false)
|
||||||
const editingServerName = ref(null)
|
const editingServerName = ref(null)
|
||||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: 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
|
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():
|
def test_dingtalk_notification_skips_when_disabled():
|
||||||
with patch("backend.services.notification.httpx.post") as post:
|
with patch("backend.services.notification.httpx.post") as post:
|
||||||
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
|
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"
|
"<string>applinks:old</string>\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
|
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 = tmp_path / "theme" / "AppIcon.appiconset"
|
||||||
theme.mkdir(parents=True)
|
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 (build_dir / "exportOptions.plist").exists()
|
||||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("rb") as f:
|
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):
|
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"
|
build_dir = tmp_path / "build"
|
||||||
plist_path = _make_minimal_project(build_dir)
|
plist_path = _make_minimal_project(build_dir)
|
||||||
with plist_path.open("wb") as f:
|
with plist_path.open("wb") as f:
|
||||||
plistlib.dump({"CFBundleURLTypes": [], "wxworkSchema": "stale-value"}, f)
|
plistlib.dump({"CFBundleURLTypes": [
|
||||||
|
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
|
||||||
|
]}, f)
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
|
"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)
|
apply_project_config(build_dir, config)
|
||||||
|
|
||||||
with plist_path.open("rb") as f:
|
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