7 Commits
Author SHA1 Message Date
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 587 additions and 68 deletions
+1
View File
@@ -25,6 +25,7 @@ def _migrate_db():
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"),
]
with engine.connect() as conn:
for table, column, col_type in migrations:
+1
View File
@@ -32,6 +32,7 @@ class Task(Base):
ipa_path = Column(String)
oss_url = Column(String)
dsym_path = Column(String)
dsym_url = Column(String)
obfuscation_maps_path = Column(String)
qr_code_path = Column(String)
build_dir = Column(String)
+12 -4
View File
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
"enabled": False,
"webhook_url": "",
"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:
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更"""
changed = False
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
"""
if config.get("_legacy_themes_migrated"):
return False
themes_dir = AUTOMATION_DIR / "themes"
for app_id, app in config.get("apps", {}).items():
upload_key = app.get("upload_key", "")
@@ -336,8 +344,8 @@ def _migrate_old_themes(config: dict) -> bool:
app["skins"] = skins
if theme != skin_name:
cert["theme"] = skin_name
changed = True
return changed
config["_legacy_themes_migrated"] = True
return True
def load_config() -> dict:
+61 -8
View File
@@ -3,6 +3,7 @@ import os
import json
import shutil
import tempfile
import time
import uuid
from datetime import datetime
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)])
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
_recent_download_logs: dict[str, float] = {}
@router.post("", response_model=TaskResponse)
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:
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"
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:
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
record_operation(db, user, "ipa_download_requested", request=request, task=task,
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")
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 zipfile
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()
if not task:
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
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 文件已被清理")
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 再下载
if os.path.isdir(dsym_path):
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]
oss_url: Optional[str]
dsym_path: Optional[str]
dsym_url: Optional[str] = None
obfuscation_maps_path: Optional[str]
qr_code_path: Optional[str]
build_dir: Optional[str]
+33 -9
View File
@@ -23,7 +23,7 @@ from ..config import (
mask_git_remote_url,
)
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 .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)
# 查找 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"
@@ -477,6 +477,13 @@ async def run_build_task(task_id: str):
upload_fields["oss_url"] = oss_url
if 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:
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:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。
if task.build_type == "Ad_Hoc":
# Ad Hoc 发安装页二维码通知,App Store IPA 下载地址通知;
# 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
try:
@@ -715,7 +722,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 关联域名等配置
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:
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
async def find_dsym(task_id: str, build_dir: Path) -> Path:
"""查找 dSYM 文件"""
async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
"""查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists():
return None
for dsym in derived_data.rglob("*.dSYM"):
return dsym
dsyms = list(derived_data.rglob("*.dSYM"))
if not dsyms:
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:
"""上传 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)
except DistributionError as 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 ""
+193 -13
View File
@@ -4,6 +4,7 @@ import plistlib
import posixpath
import re
import shutil
from html import escape
from pathlib import Path
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)
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):
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(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">"
f"<title>{title}</title><body><h2>{title}</h2>"
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>"
"</body></html>",
DOWNLOAD_PAGE_TEMPLATE.format(
title=escape(title),
version=escape(str(config.get("VERSION", ""))),
install_url=escape(install_url, quote=True),
meta=meta,
),
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]:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
@@ -169,14 +301,21 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths)
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
def delete_published_dsym(build_config: dict, upload_config: dict):
"""删除任务对应的远端 dSYM 压缩包。
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
"""
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", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
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"]),
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):
@@ -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)
urls.update(uploader(upload, [(qr_path, qr_remote)]))
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"]
+36 -5
View File
@@ -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,
+4
View File
@@ -35,11 +35,15 @@ def _update_plist(path: Path, config: dict):
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
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 = []
+4 -1
View File
@@ -11,6 +11,7 @@
"tencent": "",
"weixinlogin": "",
"weixinpay": "",
"wxworkSchema": "",
"skins": ["default.zip"],
"certificates": {
"Ad_Hoc": {
@@ -66,7 +67,9 @@
"dingtalk": {
"enabled": false,
"webhook_url": "",
"secret": ""
"secret": "",
"at_mobiles": [],
"at_mobiles_app_store": []
}
},
"versions": {
+32
View File
@@ -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>
@@ -488,6 +498,12 @@
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
</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-group">
<label>腾讯 AppID</label>
@@ -667,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 })
@@ -883,6 +914,7 @@ const openAppModal = (id = null, app = null) => {
UniversalLink: '',
weixinlogin: '',
weixinpay: '',
wxworkSchema: '',
tencent: '',
AlivcLicenseKey: '',
app_id_prefix_override: '',
+47 -6
View File
@@ -56,7 +56,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb"
@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>
<span v-else class="text-muted">-</span>
</td>
@@ -159,7 +159,7 @@
</template>
<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 isAdmin = inject('isAdmin', ref(false))
@@ -182,6 +182,7 @@ const logContainer = ref(null)
const showVerboseLogs = ref(false)
const qrPreview = ref(null)
const showSuperseded = ref(false)
const downloadingIds = reactive({})
let logWs = null
const getAppVersion = (task) => {
@@ -308,11 +309,41 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null }
})
const downloadDsym = (taskId) => {
window.open(`/api/tasks/${taskId}/dsym`)
const triggerBlobDownload = (blob, filename) => {
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) => {
if (downloadingIds[taskId]) return
downloadingIds[taskId] = true
try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error()
@@ -320,11 +351,19 @@ const downloadIpa = async (taskId) => {
window.open(url, '_blank')
} catch {
alert('下载失败,请稍后重试')
} finally {
delete downloadingIds[taskId]
}
}
const downloadObfMaps = (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
const downloadObfMaps = async (taskId) => {
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) => {
@@ -524,6 +563,8 @@ const errorCategoryLabel = (cat) => {
.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: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; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
.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
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):
+73
View File
@@ -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
+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"
)
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)
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
config = {
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
"wxworkSchema": "wx1234567890abcdef",
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
"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 (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:
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)