8 Commits
Author SHA1 Message Date
shenleiandClaude Sonnet 5 dae14a8a10 fix: App_Store 钉钉通知因缺少关键词「打包」被拦截,标题改回含「打包」
钉钉机器人启用了自定义关键词校验。9ace2c8 将 App_Store 标题改为
「【iOS】App_Store 包信息」,丢掉了「打包」二字,导致消息被钉钉拒收
(errmsg: 关键词不匹配),任务日志出现「钉钉通知未发送」。

标题改回「【iOS】App_Store 打包信息」,重新包含关键词;测试同步更新。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FETBaqMgRqx3kdpzccKg1
2026-09-01 11:34:57 +09:00
shenleiandClaude Sonnet 5 9ace2c8aca feat: App_Store 打包也发钉钉通知,Ad_Hoc/App_Store 各自的 @ 名单
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZULZ8fkiaiJrxqf318xTn
2026-09-01 11:02:57 +09:00
shenleiandClaude Sonnet 5 fa559101b9 fix: 企业微信 Schema 未替换,改写 CFBundleURLTypes 中 wxwork 条目
之前把 wxworkSchema 当成 Info.plist 顶层键写入,但真实 readoor31.plist
里企业微信 Schema 位于 CFBundleURLTypes(CFBundleURLName == "wxwork",
占位值 "1"),顶层键无效、占位符从未被替换。

改为并入 _update_plist 已有的 CFBundleURLTypes 替换逻辑:填写则替换
CFBundleURLSchemes,未配置则整条移除,与 weixinlogin/wechatpay/tencent 一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZULZ8fkiaiJrxqf318xTn
2026-09-01 11:02:50 +09:00
shenleiandClaude Sonnet 5 e10afb180d feat: APP 配置新增企业微信 Schema,写入 Info.plist wxworkSchema 键
与微信登录 AppID 处理一致:填写则写入 Info.plist 的 wxworkSchema,
未配置则移除该键。前端「关联配置」区新增输入框,打包流程透传该字段。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wQh7H1Dn3TjQm87TyYgvf
2026-08-28 16:38:22 +09:00
shenleiandClaude Sonnet 5 082c487cf8 fix: dSYM/混淆映射下载缺少认证头导致下载失败,dSYM 远端下载改为签名链接
历史记录点击 dSYM 下载后停留在接口地址本身:window.open 无法带
Authorization 头,接口未认证直接 401,重定向逻辑根本没机会执行。
改为前端先用 authFetch 认证请求,再按返回类型跳转远端地址或保存二
进制文件;远端地址同时改造成与 IPA 一致的 OSS 签名短时链接,避免
未签名地址在私有 bucket 下被拒绝。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:36:36 +09:00
shenleiandClaude Sonnet 5 1466e24c83 feat: dSYM 按上传配置一并保存到远端,本地清理后回退到远端地址下载
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:02:03 +09:00
shenleiandClaude Sonnet 5 c17b7d5ad9 fix: 皮肤包删除后被自动迁移逻辑复活,IPA 下载连击刷屏操作日志
_migrate_old_themes 之前每次 load_config() 都会重跑,只要证书仍引用某皮肤
且旧版 themes 目录还在,删除后会被立即重新生成;现改为仅首次执行一次并
在 config.json 中打标记跳过后续调用。

打包历史下载 IPA 按钮此前无连击防护,前端加 in-flight 禁用态,后端对同一
用户同一任务的下载操作日志增加 5 秒去重,避免连击刷屏审计日志。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:19:00 +09:00
shenleiandClaude Opus 5 84a2d3db01 feat: 安装页改为移动端卡片样式并支持深色模式
- 抽出 DOWNLOAD_PAGE_TEMPLATE,卡片布局 + 大按钮,适配 iPhone 安全区
- 通过 prefers-color-scheme 切换配色,主色沿用 #50a0ff
- 展示构建版本/Bundle ID/分支,补充 Safari 打开与信任描述文件提示
- 动态字段统一 html.escape,修正 install_url 中裸 & 未转义的问题

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:50:22 +09:00
15 changed files with 588 additions and 68 deletions
+1
View File
@@ -25,6 +25,7 @@ def _migrate_db():
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"), ("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
+1
View File
@@ -32,6 +32,7 @@ class Task(Base):
ipa_path = Column(String) ipa_path = Column(String)
oss_url = Column(String) oss_url = Column(String)
dsym_path = Column(String) dsym_path = Column(String)
dsym_url = Column(String)
obfuscation_maps_path = Column(String) obfuscation_maps_path = Column(String)
qr_code_path = Column(String) qr_code_path = Column(String)
build_dir = Column(String) build_dir = Column(String)
+12 -4
View File
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
"enabled": False, "enabled": False,
"webhook_url": "", "webhook_url": "",
"secret": "", "secret": "",
"at_mobiles": [],
"at_mobiles_app_store": [],
}, },
} }
@@ -300,8 +302,14 @@ def _ensure_upload_keys(config: dict) -> bool:
def _migrate_old_themes(config: dict) -> bool: def _migrate_old_themes(config: dict) -> bool:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更""" """将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
changed = False
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes" themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items(): for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "") upload_key = app.get("upload_key", "")
@@ -336,8 +344,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins app["skins"] = skins
if theme != skin_name: if theme != skin_name:
cert["theme"] = skin_name cert["theme"] = skin_name
changed = True config["_legacy_themes_migrated"] = True
return changed return True
def load_config() -> dict: def load_config() -> dict:
+61 -8
View File
@@ -3,6 +3,7 @@ import os
import json import json
import shutil import shutil
import tempfile import tempfile
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
@@ -18,6 +19,10 @@ from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
_recent_download_logs: dict[str, float] = {}
@router.post("", response_model=TaskResponse) @router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)): async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -200,6 +205,25 @@ async def delete_task(task_id: str, request: Request, user: dict = Depends(get_c
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
# dSYM 压缩包的远端对象键与 IPA 独立,同一版本/分支重复打包会复用并覆盖同一个远端文件,
# 仍有其他历史记录引用时不能删除。
has_shared_dsym = bool(
task.dsym_url and db.query(Task).filter(
Task.id != task.id,
Task.dsym_url == task.dsym_url,
).first()
)
if task.dsym_url and not has_shared_dsym:
if not task.config_json:
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端 dSYM 文件")
try:
build_config = json.loads(task.config_json)
from .config import load_config
from ..services.distribution import DistributionError, delete_published_dsym
delete_published_dsym(build_config, load_config().get("upload", {}))
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"远端 dSYM 删除失败,记录未删除:{exc}") from exc
# 删除日志文件 # 删除日志文件
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@@ -276,6 +300,17 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
) )
except (json.JSONDecodeError, DistributionError) as exc: except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
debounce_key = f"{user.get('username')}:{task_id}"
now = time.monotonic()
last = _recent_download_logs.get(debounce_key)
if last is None or now - last > _DOWNLOAD_LOG_DEBOUNCE_SECONDS:
if len(_recent_download_logs) > 500:
cutoff = now - _DOWNLOAD_LOG_DEBOUNCE_SECONDS
for key, ts in list(_recent_download_logs.items()):
if ts < cutoff:
del _recent_download_logs[key]
_recent_download_logs[debounce_key] = now
from ..services.audit_log import record_operation from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task, record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url, resource_type="ipa", resource_name=task.oss_url,
@@ -286,7 +321,7 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
@router.get("/{task_id}/dsym") @router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)): async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载 dSYM 文件""" """下载 dSYM 文件:本地文件已被清理时回退到构建时上传保存的远端地址"""
import shutil import shutil
import zipfile import zipfile
import tempfile import tempfile
@@ -294,17 +329,35 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path dsym_path = task.dsym_path
if not os.path.exists(dsym_path): local_exists = bool(dsym_path) and os.path.exists(dsym_path)
if not local_exists and not task.dsym_url:
if not dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
raise HTTPException(status_code=404, detail="dSYM 文件已被清理") raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
from ..services.audit_log import record_operation
resource_name = os.path.basename(dsym_path) if dsym_path else os.path.basename(task.dsym_url)
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=resource_name)
db.commit()
if not local_exists:
# dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的
# 302 目标,因此改为返回 JSON 让前端自行发起下载。
try:
from .config import load_config
from ..services.distribution import DistributionError, get_published_dsym_url
download_url = get_published_dsym_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.dsym_url,
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
return {"url": download_url}
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
if os.path.isdir(dsym_path): if os.path.isdir(dsym_path):
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip") zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
+1
View File
@@ -31,6 +31,7 @@ class TaskResponse(BaseModel):
ipa_path: Optional[str] ipa_path: Optional[str]
oss_url: Optional[str] oss_url: Optional[str]
dsym_path: Optional[str] dsym_path: Optional[str]
dsym_url: Optional[str] = None
obfuscation_maps_path: Optional[str] obfuscation_maps_path: Optional[str]
qr_code_path: Optional[str] qr_code_path: Optional[str]
build_dir: Optional[str] build_dir: Optional[str]
+33 -9
View File
@@ -23,7 +23,7 @@ from ..config import (
mask_git_remote_url, mask_git_remote_url,
) )
from .log_streamer import log_streamer from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa from .distribution import DistributionError, publish_dsym, publish_ipa
from .notification import NotificationError, send_dingtalk_notification from .notification import NotificationError, send_dingtalk_notification
from .project_patcher import ProjectPatchError, apply_project_config from .project_patcher import ProjectPatchError, apply_project_config
@@ -455,7 +455,7 @@ async def run_build_task(task_id: str):
ipa_path = await build_project(task_id, task, config_data, build_dir) ipa_path = await build_project(task_id, task, config_data, build_dir)
# 查找 dSYM # 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir) dsym_path = await find_dsym(task_id, build_dir, config_data.get("SCHEME", ""))
# 查找混淆映射表 # 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps" obf_maps_path = build_dir / "obfuscation_maps"
@@ -477,6 +477,13 @@ async def run_build_task(task_id: str):
upload_fields["oss_url"] = oss_url upload_fields["oss_url"] = oss_url
if qr_code_path: if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path upload_fields["qr_code_path"] = qr_code_path
# dSYM 用于后续崩溃符号化,按上传配置一并保存到远端;失败不影响本次打包结果。
if dsym_path:
dsym_url = await upload_dsym(task_id, task, config_data, dsym_path, build_dir)
if dsym_url:
upload_fields["dsym_url"] = dsym_url
if upload_fields: if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields) await asyncio.to_thread(_db_update, db, task, **upload_fields)
@@ -491,8 +498,8 @@ 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:
@@ -715,7 +722,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 关联域名等配置 # 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]: for key in ["weixinlogin", "weixinpay", "wxworkSchema", "tencent", "UniversalLink", "AlivcLicenseKey"]:
if key in app: if key in app:
config_data[key] = app[key] config_data[key] = app[key]
@@ -914,16 +921,23 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
return ipa_path return ipa_path
async def find_dsym(task_id: str, build_dir: Path) -> Path: async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
"""查找 dSYM 文件""" """查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
derived_data = build_dir / "build" / "derived_data" derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists(): if not derived_data.exists():
return None return None
for dsym in derived_data.rglob("*.dSYM"): dsyms = list(derived_data.rglob("*.dSYM"))
return dsym if not dsyms:
return None return None
if scheme:
for dsym in dsyms:
if dsym.name == f"{scheme}.app.dSYM":
return dsym
return dsyms[0]
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple: async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA,返回 (download_url, qr_code_path)""" """上传 IPA,返回 (download_url, qr_code_path)"""
@@ -938,3 +952,13 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir) return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
except DistributionError as exc: except DistributionError as exc:
raise BuildError(str(exc), category="upload") from exc raise BuildError(str(exc), category="upload") from exc
async def upload_dsym(task_id: str, task, config_data: dict, dsym_path: Path, build_dir: Path) -> str:
"""上传 dSYM,返回下载 URL;上传失败仅记录警告,不影响已完成的打包结果"""
await log_streamer.emit_step(task_id, "上传 dSYM")
try:
return await asyncio.to_thread(publish_dsym, config_data, dsym_path, build_dir)
except DistributionError as exc:
await log_streamer.emit(task_id, f"dSYM 上传失败: {exc}", level="warn")
return ""
+194 -14
View File
@@ -4,6 +4,7 @@ import plistlib
import posixpath import posixpath
import re import re
import shutil import shutil
from html import escape
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
@@ -54,18 +55,149 @@ def _write_manifest(config: dict, manifest: Path, ipa_url: str):
plistlib.dump(data, f) plistlib.dump(data, f)
DOWNLOAD_PAGE_TEMPLATE = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<title>{title} - 安装</title>
<style>
:root {{
--bg: #f2f3f7;
--card: #ffffff;
--text: #1c1c1e;
--muted: #8a8a8e;
--line: rgba(60, 60, 67, .12);
--brand: #50a0ff;
}}
@media (prefers-color-scheme: dark) {{
:root {{
--bg: #000000;
--card: #1c1c1e;
--text: #f5f5f7;
--muted: #98989d;
--line: rgba(255, 255, 255, .12);
}}
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
padding: 10vh 20px calc(32px + env(safe-area-inset-bottom));
display: flex;
justify-content: center;
align-items: flex-start;
background: var(--bg);
color: var(--text);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
}}
.card {{
width: 100%;
max-width: 420px;
background: var(--card);
border-radius: 20px;
padding: 28px 24px 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .08);
text-align: center;
}}
.name {{
margin: 0;
font-size: 24px;
font-weight: 600;
word-break: break-word;
}}
.version {{
margin: 6px 0 22px;
color: var(--muted);
font-size: 15px;
}}
.install {{
display: block;
padding: 15px;
border-radius: 14px;
background: var(--brand);
color: #fff;
font-size: 17px;
font-weight: 600;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}}
.install:active {{ opacity: .75; }}
.meta {{
margin: 22px 0 0;
padding-top: 16px;
border-top: 1px solid var(--line);
text-align: left;
font-size: 14px;
}}
.meta div {{
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
}}
.meta dt {{ color: var(--muted); flex: none; }}
.meta dd {{
margin: 0;
text-align: right;
word-break: break-all;
font-variant-numeric: tabular-nums;
}}
.tips {{
margin: 18px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
text-align: left;
}}
</style>
</head>
<body>
<div class="card">
<h1 class="name">{title}</h1>
<p class="version">版本 {version}</p>
<a class="install" href="{install_url}">安装 App</a>
<dl class="meta">{meta}</dl>
<p class="tips">请使用 iPhone 自带的 Safari 浏览器打开本页面安装。<br>
安装后如提示“未受信任的开发者”,请前往「设置 → 通用 → VPN 与设备管理」信任该描述文件。</p>
</div>
</body>
</html>
"""
def _write_download_page(config: dict, html: Path, manifest_url: str): def _write_download_page(config: dict, html: Path, manifest_url: str):
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&') }" install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}"
title = config.get("APPID_NAME", "iOS App") title = config.get("APPID_NAME") or "iOS App"
meta_items = [
("构建版本", config.get("BUILD_VERSION", "")),
("Bundle ID", config.get("BUNDLE_ID", "")),
("分支", config.get("SOURCE_BRANCH", "")),
]
meta = "".join(
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
for label, value in meta_items
if value
)
html.write_text( html.write_text(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">" DOWNLOAD_PAGE_TEMPLATE.format(
f"<title>{title}</title><body><h2>{title}</h2>" title=escape(title),
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>" version=escape(str(config.get("VERSION", ""))),
"</body></html>", install_url=escape(install_url, quote=True),
meta=meta,
),
encoding="utf-8", encoding="utf-8",
) )
def _dsym_remote_path(config: dict) -> str:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip")
def _remote_paths(config: dict) -> tuple[str, 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"
@@ -169,14 +301,21 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths) deleter(upload_config, remote_paths)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str: def delete_published_dsym(build_config: dict, upload_config: dict):
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。""" """删除任务对应的远端 dSYM 压缩包。
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url) dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
if not remote_paths: 无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接") """
mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
deleter = _delete_oss if mode == "oss" else _delete_webdav
deleter(upload_config, [_dsym_remote_path(build_config)])
def _sign_oss_url(upload_config: dict, remote_path: str) -> str:
oss = upload_config.get("oss", {}) oss = upload_config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"] required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required): if any(not oss.get(key) for key in required):
@@ -190,7 +329,29 @@ def get_published_download_url(build_config: dict, upload_config: dict, publishe
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]), oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30, oss["endpoint"], oss["bucket_name"], connect_timeout=30,
) )
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True) return bucket.sign_url("GET", remote_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
return _sign_oss_url(upload_config, remote_paths[0])
def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str:
"""返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。
dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可,
不需要像 IPA 那样从已保存的下载链接反推。
"""
if upload_config.get("mode") != "oss":
return dsym_url
return _sign_oss_url(upload_config, _dsym_remote_path(build_config))
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
@@ -283,3 +444,22 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
qrcode.make(urls[".html"]).save(qr_path) qrcode.make(urls[".html"]).save(qr_path)
urls.update(uploader(upload, [(qr_path, qr_remote)])) urls.update(uploader(upload, [(qr_path, qr_remote)]))
return urls[".html"], urls[".png"] return urls[".html"], urls[".png"]
def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str:
"""压缩并上传主 target 的 dSYM,返回下载 URL。"""
upload = config.get("_upload_config", {})
mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
output_dir = build_dir / "distribution"
output_dir.mkdir(parents=True, exist_ok=True)
zip_base = output_dir / f"{_artifact_stem(config)}.dSYM"
zip_path = Path(shutil.make_archive(
str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name,
))
uploader = _upload_oss if mode == "oss" else _upload_webdav
urls = uploader(upload, [(zip_path, _dsym_remote_path(config))])
return urls[".zip"]
+37 -5
View File
@@ -27,8 +27,33 @@ 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 +61,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![image]({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 { 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 +90,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,
+4
View File
@@ -35,11 +35,15 @@ 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 ""
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 = []
+4 -1
View File
@@ -11,6 +11,7 @@
"tencent": "", "tencent": "",
"weixinlogin": "", "weixinlogin": "",
"weixinpay": "", "weixinpay": "",
"wxworkSchema": "",
"skins": ["default.zip"], "skins": ["default.zip"],
"certificates": { "certificates": {
"Ad_Hoc": { "Ad_Hoc": {
@@ -66,7 +67,9 @@
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
"webhook_url": "", "webhook_url": "",
"secret": "" "secret": "",
"at_mobiles": [],
"at_mobiles_app_store": []
} }
}, },
"versions": { "versions": {
+32
View File
@@ -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>
@@ -488,6 +498,12 @@
<input v-model="appForm.weixinpay" type="text" placeholder="wx..."> <input v-model="appForm.weixinpay" type="text" placeholder="wx...">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>企业微信 Schema</label>
<input v-model="appForm.wxworkSchema" type="text" placeholder="不填则不写入 Info.plist(与微信登录 AppID 相同)">
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>腾讯 AppID</label> <label>腾讯 AppID</label>
@@ -667,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 })
@@ -883,6 +914,7 @@ const openAppModal = (id = null, app = null) => {
UniversalLink: '', UniversalLink: '',
weixinlogin: '', weixinlogin: '',
weixinpay: '', weixinpay: '',
wxworkSchema: '',
tencent: '', tencent: '',
AlivcLicenseKey: '', AlivcLicenseKey: '',
app_id_prefix_override: '', app_id_prefix_override: '',
+47 -6
View File
@@ -56,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path" <img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb" :src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)"> @click="showQrPreview(task)">
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button> <button v-if="task.build_type === 'App_Store'" class="download-link" :disabled="!!downloadingIds[task.id]" @click="downloadIpa(task.id)">{{ downloadingIds[task.id] ? '下载中...' : '下载 IPA' }}</button>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
@@ -159,7 +159,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, reactive, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken', () => '') const getToken = inject('getToken', () => '')
const isAdmin = inject('isAdmin', ref(false)) const isAdmin = inject('isAdmin', ref(false))
@@ -182,6 +182,7 @@ const logContainer = ref(null)
const showVerboseLogs = ref(false) const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
const showSuperseded = ref(false) const showSuperseded = ref(false)
const downloadingIds = reactive({})
let logWs = null let logWs = null
const getAppVersion = (task) => { const getAppVersion = (task) => {
@@ -308,11 +309,41 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null } if (logWs) { logWs.close(); logWs = null }
}) })
const downloadDsym = (taskId) => { const triggerBlobDownload = (blob, filename) => {
window.open(`/api/tasks/${taskId}/dsym`) const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename || ''
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const filenameFromDisposition = (res, fallback) => {
const cd = res.headers.get('content-disposition') || ''
const match = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
return match ? decodeURIComponent(match[1]) : fallback
}
const downloadDsym = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/dsym`)
if (!res.ok) throw new Error()
if ((res.headers.get('content-type') || '').includes('application/json')) {
const { url } = await res.json()
window.open(url, '_blank')
} else {
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'dsym.zip'))
}
} catch {
alert('下载失败,请稍后重试')
}
} }
const downloadIpa = async (taskId) => { const downloadIpa = async (taskId) => {
if (downloadingIds[taskId]) return
downloadingIds[taskId] = true
try { try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`) const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error() if (!res.ok) throw new Error()
@@ -320,11 +351,19 @@ const downloadIpa = async (taskId) => {
window.open(url, '_blank') window.open(url, '_blank')
} catch { } catch {
alert('下载失败,请稍后重试') alert('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
} }
} }
const downloadObfMaps = (taskId) => { const downloadObfMaps = async (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) try {
const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`)
if (!res.ok) throw new Error()
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip'))
} catch {
alert('下载失败,请稍后重试')
}
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
@@ -524,6 +563,8 @@ const errorCategoryLabel = (cat) => {
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; } .download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
.download-link:hover { text-decoration: underline; } .download-link:hover { text-decoration: underline; }
.download-link:disabled { color: #999; cursor: default; }
.download-link:disabled:hover { text-decoration: none; }
.text-muted { color: #999; font-size: 13px; } .text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.qr-thumb:hover { border-color: #1890ff; } .qr-thumb:hover { border-color: #1890ff; }
+14
View File
@@ -66,6 +66,20 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
assert resp.status_code == 404 assert resp.status_code == 404
def test_download_dsym_remote_fallback(client, tmp_config):
"""本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
task_id = _create_task_with_files(
client, tmp_config,
dsym_path="/tmp/nonexistent.dSYM",
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip",
)
with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
resp = client.get(f"/api/tasks/{task_id}/dsym")
assert resp.status_code == 200
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
# ---- 混淆映射表下载 ---- # ---- 混淆映射表下载 ----
def test_download_obfuscation_maps_not_exist(client, tmp_config): def test_download_obfuscation_maps_not_exist(client, tmp_config):
+73
View File
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
assert "![image](https://download.example.com/app.png)" in text 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 下载链接:** 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
+54 -1
View File
@@ -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)
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
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",
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test", "API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
"wxworkSchema": "wx1234567890abcdef",
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile", "BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc", "ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution", "CERTIFICATE": "Apple Distribution",
@@ -58,3 +61,53 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
assert "iPhone Developer" not in project_content assert "iPhone Developer" not in project_content
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:
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):
"""构造 apply_project_config 所需的最小工程骨架,返回关键文件路径。"""
project = build_dir / "readoor.xcodeproj"
swift = build_dir / "readoor" / "3.0" / "AppConfig"
entitlements_dir = build_dir / "readoor" / "3.0"
for path in [project, swift, entitlements_dir]:
path.mkdir(parents=True, exist_ok=True)
(project / "project.pbxproj").write_text(
"MARKETING_VERSION = 1.0;\nCURRENT_PROJECT_VERSION = 1;\n"
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
encoding="utf-8",
)
(swift / "RDAppConfiguration.swift").write_text(
'let RD_APP_GUID: String = "old"\nlet RD_API_DOMAIN: String = "old"\n'
'let RD_WECHAT_PAY_ID: String = "old"\nlet RD_SOURCE_VERSION: String = "old"\n',
encoding="utf-8",
)
(entitlements_dir / "readoor31.entitlements").write_text(
"<string>applinks:old</string>\n", encoding="utf-8"
)
return entitlements_dir / "readoor31.plist"
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": [
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
]}, f)
config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "APPID_NAME": "Test",
"ASSOCIATED_DOMAINS": "applinks:example.com", "BUILD_TYPE": "Ad_Hoc",
"CERTIFICATE": "Apple Distribution",
}
apply_project_config(build_dir, config)
with plist_path.open("rb") as f:
url_types = plistlib.load(f)["CFBundleURLTypes"]
assert all(i.get("CFBundleURLName") != "wxwork" for i in url_types)