Compare commits
12
Commits
3e3e4123d0
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
624f534b0f | ||
|
|
13366ab3b4 | ||
|
|
44405e589d | ||
|
|
de33b3d4b2 | ||
|
|
8747792a4b | ||
|
|
89fb633b00 | ||
|
|
51d7374f2d | ||
|
|
9b77441ec7 | ||
|
|
8c0bce45a4 | ||
|
|
cf6cc1d21a | ||
|
|
cede7978e4 | ||
|
|
f0c6a2d9b6 |
+3
-2
@@ -105,6 +105,7 @@ COPY_ITEMS = [
|
||||
"readoor.xcodeproj",
|
||||
"readoorTests",
|
||||
"Vendor",
|
||||
"AutoPacking",
|
||||
"Podfile",
|
||||
"Pods",
|
||||
"Podfile.lock",
|
||||
@@ -157,8 +158,8 @@ def validate_production_security() -> None:
|
||||
or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32):
|
||||
errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串")
|
||||
if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-")
|
||||
or len(ADMIN_PASSWORD) < 12):
|
||||
errors.append("ADMIN_PASSWORD 必须设置为至少 12 位的强密码")
|
||||
or len(ADMIN_PASSWORD) < 11):
|
||||
errors.append("ADMIN_PASSWORD 必须设置为至少 11 位的强密码")
|
||||
if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS:
|
||||
errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *")
|
||||
if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS:
|
||||
|
||||
+115
-22
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
@@ -25,9 +26,10 @@ from ..deps import get_current_user, require_admin
|
||||
|
||||
|
||||
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
||||
"""普通用户仅可管理 Apps;其余配置及凭据只允许管理员访问。"""
|
||||
"""普通用户可管理 Apps 与版本号;其余配置及凭据只允许管理员访问。"""
|
||||
path = request.url.path.rstrip("/")
|
||||
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
|
||||
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
|
||||
or path == "/api/config/versions"):
|
||||
return user
|
||||
return require_admin(user)
|
||||
|
||||
@@ -65,22 +67,26 @@ DEFAULT_SERVERS = {
|
||||
"测试环境": {
|
||||
"api": "https://api3-dev.readoor.cn",
|
||||
"assDom": "applinks:dev-data1.readoor.cn",
|
||||
"universalLink": "https://dev-data1.readoor.cn"
|
||||
"universalLink": "https://dev-data1.readoor.cn",
|
||||
"app_id_prefix": 1,
|
||||
},
|
||||
"正式环境": {
|
||||
"api": "https://api3.readoor.cn",
|
||||
"assDom": "applinks:data1.readoor.cn",
|
||||
"universalLink": "https://data1.readoor.cn"
|
||||
"universalLink": "https://data1.readoor.cn",
|
||||
"app_id_prefix": 2,
|
||||
},
|
||||
"华师大环境": {
|
||||
"api": "https://api3.ecnupress.com.cn",
|
||||
"assDom": "applinks:data1.ecnupress.com.cn",
|
||||
"universalLink": "https://data1.ecnupress.com.cn"
|
||||
"universalLink": "https://data1.ecnupress.com.cn",
|
||||
"app_id_prefix": 3,
|
||||
},
|
||||
"外教环境": {
|
||||
"api": "https://weread-api3.sflep.com/api3",
|
||||
"assDom": "applinks:wereadossda.sflep.com",
|
||||
"universalLink": "https://wereadossda.sflep.com"
|
||||
"universalLink": "https://wereadossda.sflep.com",
|
||||
"app_id_prefix": 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +95,84 @@ DEFAULT_VERSIONS = {
|
||||
"build_ver": "2.180.0.0",
|
||||
}
|
||||
|
||||
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
|
||||
LEGACY_SERVER_PREFIXES = {
|
||||
"测试环境": 1,
|
||||
"正式环境": 2,
|
||||
"华师大环境": 3,
|
||||
"外教环境": 4,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_server_id_prefixes(config: dict) -> bool:
|
||||
"""为旧配置补齐环境 ID 前缀和下一个可分配前缀。"""
|
||||
changed = False
|
||||
servers = config.get("servers", {})
|
||||
used_prefixes = set()
|
||||
for name, server in servers.items():
|
||||
prefix = server.get("app_id_prefix")
|
||||
if prefix is None and name in LEGACY_SERVER_PREFIXES:
|
||||
prefix = LEGACY_SERVER_PREFIXES[name]
|
||||
server["app_id_prefix"] = prefix
|
||||
changed = True
|
||||
if isinstance(prefix, int) and prefix > 0:
|
||||
used_prefixes.add(prefix)
|
||||
|
||||
next_prefix = max(used_prefixes, default=0) + 1
|
||||
for server in servers.values():
|
||||
prefix = server.get("app_id_prefix")
|
||||
if not isinstance(prefix, int) or prefix <= 0:
|
||||
server["app_id_prefix"] = next_prefix
|
||||
used_prefixes.add(next_prefix)
|
||||
next_prefix += 1
|
||||
changed = True
|
||||
if not isinstance(config.get("next_app_id_prefix"), int) or config["next_app_id_prefix"] < next_prefix:
|
||||
config["next_app_id_prefix"] = next_prefix
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _ensure_special_app_prefix_overrides(config: dict) -> bool:
|
||||
"""将英汉大词典的历史 1xx 规则迁移为显式的 App 前缀覆盖。"""
|
||||
changed = False
|
||||
for app in config.get("apps", {}).values():
|
||||
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||||
app["app_id_prefix_override"] = 1
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _apply_special_app_prefix_override(app: dict) -> None:
|
||||
"""英汉大词典沿用历史 1xx 编号段。"""
|
||||
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||||
app["app_id_prefix_override"] = 1
|
||||
|
||||
|
||||
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
|
||||
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
|
||||
prefix = app.get("app_id_prefix_override")
|
||||
if prefix in (None, ""):
|
||||
server = servers.get(app.get("server", ""), {})
|
||||
prefix = server.get("app_id_prefix")
|
||||
try:
|
||||
prefix = int(prefix)
|
||||
except (TypeError, ValueError):
|
||||
prefix = 0
|
||||
if prefix <= 0:
|
||||
raise HTTPException(status_code=400, detail="请选择已配置 Apps ID 规则的服务器环境")
|
||||
|
||||
prefix_text = str(prefix)
|
||||
serials = [
|
||||
int(app_id[len(prefix_text):])
|
||||
for app_id in apps
|
||||
if (app_id.isdigit() and app_id.startswith(prefix_text)
|
||||
and len(app_id) == len(prefix_text) + 2)
|
||||
]
|
||||
next_serial = max(serials, default=-1) + 1
|
||||
if next_serial > 99:
|
||||
raise HTTPException(status_code=400, detail=f"Apps ID 前缀 {prefix} 的编号已用完")
|
||||
return f"{prefix}{next_serial:02d}"
|
||||
|
||||
|
||||
def _ensure_upload_keys(config: dict) -> bool:
|
||||
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
||||
@@ -150,7 +234,10 @@ def load_config() -> dict:
|
||||
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
|
||||
else:
|
||||
return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD}
|
||||
return {
|
||||
"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS,
|
||||
"next_app_id_prefix": 5, "branches": ["main"], "upload": DEFAULT_UPLOAD,
|
||||
}
|
||||
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# 确保必要字段存在
|
||||
@@ -162,8 +249,11 @@ def load_config() -> dict:
|
||||
config["upload"] = DEFAULT_UPLOAD
|
||||
if "versions" not in config:
|
||||
config["versions"] = DEFAULT_VERSIONS.copy()
|
||||
# 自动为缺少 upload_key 的 app 生成唯一标识
|
||||
if _ensure_upload_keys(config):
|
||||
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
|
||||
prefixes_changed = _ensure_server_id_prefixes(config)
|
||||
overrides_changed = _ensure_special_app_prefix_overrides(config)
|
||||
keys_changed = _ensure_upload_keys(config)
|
||||
if prefixes_changed or overrides_changed or keys_changed:
|
||||
save_config(config)
|
||||
# 迁移旧式目录皮肤为 ZIP
|
||||
if _migrate_old_themes(config):
|
||||
@@ -215,9 +305,9 @@ async def create_app(app: dict):
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
# 自动生成 ID
|
||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
|
||||
_apply_special_app_prefix_override(app)
|
||||
new_id = _next_app_id(apps, config.get("servers", {}), app)
|
||||
|
||||
# 自动生成 upload_key
|
||||
if not app.get("upload_key"):
|
||||
@@ -500,19 +590,18 @@ async def get_versions():
|
||||
|
||||
@router.put("/versions")
|
||||
async def update_versions(data: dict):
|
||||
"""更新 App_Ver 和 Build_Ver"""
|
||||
"""更新 App_Ver,并将 Build_Ver 重置为对应的 .0。"""
|
||||
app_ver = data.get("app_ver", "").strip()
|
||||
build_ver = data.get("build_ver", "").strip()
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
|
||||
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
|
||||
|
||||
if not app_ver or not build_ver:
|
||||
raise HTTPException(status_code=400, detail="版本号不能为空")
|
||||
|
||||
config = load_config()
|
||||
config["versions"] = {
|
||||
"app_ver": app_ver,
|
||||
"build_ver": build_ver,
|
||||
}
|
||||
build_ver = f"{app_ver}.0"
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
config["versions"] = {
|
||||
"app_ver": app_ver,
|
||||
"build_ver": build_ver,
|
||||
}
|
||||
save_config(config)
|
||||
|
||||
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver}
|
||||
@@ -557,12 +646,15 @@ async def create_server(server_data: dict):
|
||||
if name in servers:
|
||||
raise HTTPException(status_code=400, detail="环境名称已存在")
|
||||
|
||||
prefix = config.get("next_app_id_prefix", 1)
|
||||
servers[name] = {
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
"app_id_prefix": prefix,
|
||||
}
|
||||
config["servers"] = servers
|
||||
config["next_app_id_prefix"] = prefix + 1
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已创建"}
|
||||
|
||||
@@ -590,6 +682,7 @@ async def update_server(server_name: str, server_data: dict):
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
"app_id_prefix": servers[new_name].get("app_id_prefix"),
|
||||
}
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""任务 API"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
@@ -164,6 +165,29 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
||||
if task.status in ("running", "pending"):
|
||||
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
|
||||
|
||||
# 同一个 App / 版本 / 分支重复打包会覆盖并复用同一个远端文件;
|
||||
# 只要仍有其他历史记录引用该下载地址,就不能删除远端产物。
|
||||
has_shared_artifact = bool(
|
||||
task.oss_url and db.query(Task).filter(
|
||||
Task.id != task.id,
|
||||
Task.oss_url == task.oss_url,
|
||||
).first()
|
||||
)
|
||||
if task.oss_url and not has_shared_artifact:
|
||||
if not task.config_json:
|
||||
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端文件")
|
||||
try:
|
||||
build_config = json.loads(task.config_json)
|
||||
from .config import load_config
|
||||
from ..services.distribution import DistributionError, delete_published_artifacts
|
||||
delete_published_artifacts(
|
||||
build_config,
|
||||
load_config().get("upload", {}),
|
||||
task.oss_url,
|
||||
)
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
|
||||
|
||||
# 删除日志文件
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
@@ -178,7 +202,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
return {"message": "记录已删除"}
|
||||
message = "记录已删除"
|
||||
if task.oss_url:
|
||||
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.get("/{task_id}/log")
|
||||
|
||||
@@ -35,8 +35,8 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
|
||||
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
|
||||
if len(password) < 12:
|
||||
raise HTTPException(status_code=400, detail="密码至少 12 位")
|
||||
if len(password) < 11:
|
||||
raise HTTPException(status_code=400, detail="密码至少 11 位")
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
@@ -61,8 +61,8 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
new_password = data.get("password", "").strip()
|
||||
if not new_password or len(new_password) < 12:
|
||||
raise HTTPException(status_code=400, detail="密码至少 12 位")
|
||||
if not new_password or len(new_password) < 11:
|
||||
raise HTTPException(status_code=400, detail="密码至少 11 位")
|
||||
|
||||
target.password_hash = hash_password(new_password)
|
||||
db.commit()
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..config import (
|
||||
)
|
||||
from .log_streamer import log_streamer
|
||||
from .distribution import DistributionError, publish_ipa
|
||||
from .notification import NotificationError, send_dingtalk_notification
|
||||
from .project_patcher import ProjectPatchError, apply_project_config
|
||||
|
||||
|
||||
@@ -444,6 +445,23 @@ async def run_build_task(task_id: str):
|
||||
if task.oss_url:
|
||||
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
||||
|
||||
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。
|
||||
if task.build_type == "Ad_Hoc":
|
||||
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
|
||||
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
|
||||
try:
|
||||
notified = await asyncio.to_thread(
|
||||
send_dingtalk_notification,
|
||||
dingtalk_config,
|
||||
config_data,
|
||||
task.oss_url or oss_url,
|
||||
task.qr_code_path or qr_code_path,
|
||||
)
|
||||
if notified:
|
||||
await log_streamer.emit(task_id, "钉钉通知发送成功")
|
||||
except NotificationError as exc:
|
||||
await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn")
|
||||
|
||||
# 带超时执行打包
|
||||
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
|
||||
|
||||
@@ -787,7 +805,6 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
||||
"-archivePath", str(archive_path),
|
||||
"-derivedDataPath", str(export_path / "derived_data"),
|
||||
"-destination", "generic/platform=ios",
|
||||
"-quiet",
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import json
|
||||
import plistlib
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -15,7 +16,12 @@ class DistributionError(Exception):
|
||||
|
||||
def _artifact_stem(config: dict) -> str:
|
||||
version = config.get("VERSION", "0").replace(".", "_")
|
||||
return f"{config.get('APPID', 'app')}_{version}"
|
||||
# 历史任务快照没有 SOURCE_BRANCH,继续按旧文件名处理,确保删除旧记录时能清理到原产物。
|
||||
if "SOURCE_BRANCH" not in config:
|
||||
return f"{config.get('APPID', 'app')}_{version}"
|
||||
branch = re.sub(r"[^A-Za-z0-9._-]+", "_", config.get("SOURCE_BRANCH", "main")).strip("._-")
|
||||
build_type = "appstore" if config.get("BUILD_TYPE") == "App_Store" else "adhoc"
|
||||
return f"{config.get('APPID', 'app')}_{version}_{branch or 'main'}_{build_type}"
|
||||
|
||||
|
||||
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
|
||||
@@ -58,7 +64,7 @@ def _write_download_page(config: dict, html: Path, manifest_url: str):
|
||||
)
|
||||
|
||||
|
||||
def _remote_paths(config: dict) -> tuple[str, str, str]:
|
||||
def _remote_paths(config: dict) -> tuple[str, str, str, str]:
|
||||
stem = _artifact_stem(config)
|
||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||
root = posixpath.join(folder, "iOS")
|
||||
@@ -66,9 +72,101 @@ def _remote_paths(config: dict) -> tuple[str, str, str]:
|
||||
posixpath.join(root, f"{stem}.ipa"),
|
||||
posixpath.join(root, f"{stem}.plist"),
|
||||
posixpath.join(root, f"{stem}.html"),
|
||||
posixpath.join(root, f"{stem}.png"),
|
||||
)
|
||||
|
||||
|
||||
def _delete_webdav(config: dict, remote_paths: list[str]):
|
||||
webdav = config.get("webdav", {})
|
||||
server_url = webdav.get("server_url", "").rstrip("/")
|
||||
if not server_url or not webdav.get("username"):
|
||||
raise DistributionError("WebDAV 配置不完整")
|
||||
|
||||
base_path = webdav.get("base_path", "/ios-builds").strip("/")
|
||||
auth = (webdav.get("username", ""), webdav.get("password", ""))
|
||||
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
|
||||
for relative_path in remote_paths:
|
||||
remote_path = posixpath.join(base_path, relative_path)
|
||||
response = client.delete(f"{server_url}/{remote_path}")
|
||||
# 404 代表文件已不存在,可视为清理完成。
|
||||
if response.status_code not in (200, 202, 204, 404):
|
||||
raise DistributionError(f"WebDAV 删除失败: {relative_path} ({response.status_code})")
|
||||
|
||||
|
||||
def _delete_oss(config: dict, remote_paths: list[str]):
|
||||
oss = config.get("oss", {})
|
||||
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||
if any(not oss.get(key) for key in required):
|
||||
raise DistributionError("OSS 配置不完整")
|
||||
try:
|
||||
import oss2
|
||||
except ImportError as exc:
|
||||
raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc
|
||||
|
||||
bucket = oss2.Bucket(
|
||||
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||
oss["endpoint"],
|
||||
oss["bucket_name"],
|
||||
connect_timeout=30,
|
||||
)
|
||||
for relative_path in remote_paths:
|
||||
result = bucket.delete_object(relative_path)
|
||||
if result.status // 100 != 2:
|
||||
raise DistributionError(f"OSS 删除失败: {relative_path}")
|
||||
|
||||
|
||||
def _remote_paths_from_published_url(
|
||||
build_config: dict, upload_config: dict, published_url: str,
|
||||
) -> list[str] | None:
|
||||
"""从已保存的公开链接还原实际对象键,兼容历史命名规则。"""
|
||||
parsed = urlparse(published_url)
|
||||
if not parsed.path:
|
||||
return None
|
||||
|
||||
remote_path = unquote(parsed.path).lstrip("/")
|
||||
# base_url 允许带路径前缀;该前缀是公开地址的一部分,不属于 OSS 对象键。
|
||||
base_url = upload_config.get("oss", {}).get("base_url", "")
|
||||
base = urlparse(base_url)
|
||||
if base_url and base.netloc == parsed.netloc:
|
||||
base_path = unquote(base.path).strip("/")
|
||||
if base_path and remote_path.startswith(f"{base_path}/"):
|
||||
remote_path = remote_path[len(base_path) + 1:]
|
||||
|
||||
path = Path(remote_path)
|
||||
if path.suffix not in {".ipa", ".plist", ".html", ".png"}:
|
||||
return None
|
||||
if build_config.get("BUILD_TYPE") == "App_Store":
|
||||
return [str(path.with_suffix(".ipa"))]
|
||||
return [str(path.with_suffix(suffix)) for suffix in (".ipa", ".plist", ".html", ".png")]
|
||||
|
||||
|
||||
def delete_published_artifacts(
|
||||
build_config: dict, upload_config: dict, published_url: str = "",
|
||||
):
|
||||
"""删除任务对应的远端分发产物。
|
||||
|
||||
优先按任务保存的下载链接还原对象键。这样即使后续升级了文件命名
|
||||
规则,或历史快照含有新的分支字段,仍会删除当时实际上传的文件。
|
||||
"""
|
||||
mode = upload_config.get("mode", "")
|
||||
if mode not in {"oss", "webdav"}:
|
||||
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
|
||||
|
||||
remote_paths = None
|
||||
if mode == "oss" and published_url:
|
||||
remote_paths = _remote_paths_from_published_url(
|
||||
build_config, upload_config, published_url,
|
||||
)
|
||||
if not remote_paths:
|
||||
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config)
|
||||
remote_paths = [ipa_remote]
|
||||
if build_config.get("BUILD_TYPE") != "App_Store":
|
||||
remote_paths.extend([manifest_remote, html_remote, qr_remote])
|
||||
|
||||
deleter = _delete_oss if mode == "oss" else _delete_webdav
|
||||
deleter(upload_config, remote_paths)
|
||||
|
||||
|
||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||
path = ""
|
||||
for segment in remote_path.strip("/").split("/")[:-1]:
|
||||
@@ -127,7 +225,7 @@ def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
|
||||
|
||||
|
||||
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
||||
"""发布 IPA,返回下载 URL 与二维码本地路径。"""
|
||||
"""发布 IPA,返回下载 URL 与二维码公开 URL。"""
|
||||
upload = config.get("_upload_config", {})
|
||||
mode = upload.get("mode", "")
|
||||
if mode not in {"oss", "webdav"}:
|
||||
@@ -135,7 +233,7 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
||||
|
||||
output_dir = build_dir / "distribution"
|
||||
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
||||
ipa_remote, manifest_remote, html_remote = _remote_paths(config)
|
||||
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(config)
|
||||
|
||||
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
||||
|
||||
@@ -157,4 +255,5 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
||||
except ImportError as exc:
|
||||
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
||||
qrcode.make(urls[".html"]).save(qr_path)
|
||||
return urls[".html"], str(qr_path)
|
||||
urls.update(uploader(upload, [(qr_path, qr_remote)]))
|
||||
return urls[".html"], urls[".png"]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""打包完成后的钉钉通知。"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class NotificationError(Exception):
|
||||
"""通知发送失败。"""
|
||||
|
||||
|
||||
def _signed_webhook_url(webhook_url: str, secret: str) -> str:
|
||||
"""为启用了加签的钉钉机器人附加 timestamp 与 sign 参数。"""
|
||||
if not secret:
|
||||
return webhook_url
|
||||
|
||||
timestamp = str(round(time.time() * 1000))
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
f"{timestamp}\n{secret}".encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
separator = "&" if "?" in webhook_url else "?"
|
||||
return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}"
|
||||
|
||||
|
||||
def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict:
|
||||
"""生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。"""
|
||||
details = (
|
||||
f"**环境:** {config_data.get('SERVER', '')}\n\n"
|
||||
f"**版本:** {config_data.get('VERSION', '')}\n\n"
|
||||
f"**APP名称:** {config_data.get('APPID_NAME', '')}\n\n"
|
||||
f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
|
||||
f"**App Guid:** {config_data.get('APPID', '')}"
|
||||
)
|
||||
text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
|
||||
if qr_code_url:
|
||||
text += f"\n"
|
||||
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"title": "iOS应用下载", "text": text},
|
||||
"at": {"atMobiles": [], "isAtAll": False},
|
||||
}
|
||||
|
||||
|
||||
def send_dingtalk_notification(
|
||||
dingtalk_config: dict,
|
||||
config_data: dict,
|
||||
download_url: str,
|
||||
qr_code_url: str = "",
|
||||
) -> bool:
|
||||
"""发送钉钉通知;未启用通知时不发起网络请求。"""
|
||||
if not dingtalk_config.get("enabled"):
|
||||
return False
|
||||
|
||||
webhook_url = dingtalk_config.get("webhook_url", "").strip()
|
||||
if not webhook_url:
|
||||
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
|
||||
|
||||
payload = build_dingtalk_payload(config_data, download_url, qr_code_url)
|
||||
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", [])
|
||||
response = httpx.post(
|
||||
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
if response.status_code // 100 != 2:
|
||||
raise NotificationError(f"钉钉通知发送失败 ({response.status_code})")
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as exc:
|
||||
raise NotificationError("钉钉通知返回内容无效") from exc
|
||||
if result.get("errcode", 0) != 0:
|
||||
raise NotificationError(f"钉钉通知发送失败: {result.get('errmsg', '未知错误')}")
|
||||
return True
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
@@ -11,12 +12,25 @@ class MockWebSocket {
|
||||
}
|
||||
global.WebSocket = MockWebSocket
|
||||
|
||||
const mountBuildView = () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: BuildView }],
|
||||
})
|
||||
return mount(BuildView, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
provide: { getToken: () => '' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('BuildView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
@@ -28,7 +42,7 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
|
||||
it('显示打包配置面板', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('打包配置')
|
||||
@@ -39,15 +53,17 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
|
||||
it('加载 apps 和 schemes', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
const appOptions = wrapper.findAll('select')[0].findAll('option')
|
||||
wrapper.vm.selectedServer = '测试环境'
|
||||
await wrapper.vm.$nextTick()
|
||||
const appOptions = wrapper.findAll('select')[1].findAll('option')
|
||||
expect(appOptions.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('加载分支列表并显示下拉', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
const branchSelect = wrapper.findAll('select').find(s => {
|
||||
@@ -58,10 +74,10 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
|
||||
it('默认值正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.form.build_type).toBe('Ad_Hoc')
|
||||
expect(wrapper.vm.form.build_type).toBe('')
|
||||
expect(wrapper.vm.form.obfuscation).toBe(false)
|
||||
expect(wrapper.vm.form.branch).toBe('main')
|
||||
})
|
||||
@@ -75,7 +91,7 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
}
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
@@ -85,21 +101,28 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setData({ form: { ...wrapper.vm.form, app_id: '1' } })
|
||||
wrapper.vm.selectedServer = '测试环境'
|
||||
await wrapper.vm.$nextTick()
|
||||
wrapper.vm.form.app_id = '1'
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
expect(wrapper.text()).toContain('打包任务已创建,正在排队,请勿重复点击。')
|
||||
expect(wrapper.vm.form.app_id).toBe('')
|
||||
expect(wrapper.vm.form.build_type).toBe('')
|
||||
expect(wrapper.vm.form.scheme_id).toBe('')
|
||||
})
|
||||
|
||||
it('未选择 App 时提示', async () => {
|
||||
window.alert = vi.fn()
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
@@ -109,7 +132,7 @@ describe('BuildView.vue', () => {
|
||||
it('显示任务队列', async () => {
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [
|
||||
@@ -122,7 +145,7 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
const taskItems = wrapper.findAll('.task-item')
|
||||
@@ -130,7 +153,7 @@ describe('BuildView.vue', () => {
|
||||
})
|
||||
|
||||
it('状态文本正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
const wrapper = mountBuildView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
@@ -23,6 +24,7 @@ function mockFetch(url) {
|
||||
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
||||
'/api/config/branches': ['main', 'dev'],
|
||||
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
||||
'/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' },
|
||||
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
||||
}
|
||||
return Promise.resolve({
|
||||
@@ -30,6 +32,17 @@ function mockFetch(url) {
|
||||
})
|
||||
}
|
||||
|
||||
const mountConfigView = ({ loggedIn = true, admin = true } = {}) => mount(ConfigView, {
|
||||
global: {
|
||||
provide: {
|
||||
showLogin: ref(false),
|
||||
getToken: () => '',
|
||||
isLoggedIn: ref(loggedIn),
|
||||
isAdmin: ref(admin),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('ConfigView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -39,21 +52,19 @@ describe('ConfigView.vue', () => {
|
||||
})
|
||||
|
||||
it('未登录时显示权限提示', async () => {
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView({ loggedIn: false })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('需要管理员权限')
|
||||
expect(wrapper.text()).toContain('请先登录')
|
||||
})
|
||||
|
||||
it('已登录时显示管理页面', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('配置管理')
|
||||
})
|
||||
|
||||
it('显示侧边栏菜单', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
|
||||
const menuItems = wrapper.findAll('.sidebar-menu li')
|
||||
@@ -65,16 +76,14 @@ describe('ConfigView.vue', () => {
|
||||
expect(texts).toContain('打包设置')
|
||||
})
|
||||
|
||||
it('默认显示服务器环境 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
it('默认显示 Apps 配置 tab', async () => {
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('服务器环境配置')
|
||||
expect(wrapper.text()).toContain('Apps 配置')
|
||||
})
|
||||
|
||||
it('切换到分支管理 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
@@ -93,8 +102,7 @@ describe('ConfigView.vue', () => {
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
@@ -122,8 +130,7 @@ describe('ConfigView.vue', () => {
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
@@ -147,8 +154,7 @@ describe('ConfigView.vue', () => {
|
||||
})
|
||||
window.alert = vi.fn()
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
const wrapper = mountConfigView()
|
||||
await flushPromises()
|
||||
|
||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||
@@ -161,4 +167,20 @@ describe('ConfigView.vue', () => {
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
||||
})
|
||||
|
||||
it('普通账号可修改版本号但看不到打包参数', async () => {
|
||||
const wrapper = mountConfigView({ admin: false })
|
||||
await flushPromises()
|
||||
|
||||
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
|
||||
expect(menuTexts).toEqual(['Apps 配置', '打包设置'])
|
||||
|
||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||
await buildMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('应用版本号')
|
||||
expect(wrapper.text()).not.toContain('打包参数')
|
||||
expect(wrapper.findAll('input').length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
@@ -7,9 +8,9 @@ global.fetch = vi.fn()
|
||||
window.open = vi.fn()
|
||||
|
||||
const mockTasks = [
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败', error_category: 'compilation' },
|
||||
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' },
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', config_json: '{"VERSION":"2.196.0"}', has_log: true, dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', config_json: '{"VERSION":"2.195.0"}', error_message: '构建失败', error_category: 'compilation' },
|
||||
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00', config_json: '{"VERSION":"2.196.0"}' },
|
||||
]
|
||||
|
||||
function createMockRouter() {
|
||||
@@ -22,6 +23,15 @@ function createMockRouter() {
|
||||
})
|
||||
}
|
||||
|
||||
function mountHistory(admin = false) {
|
||||
return mount(HistoryView, {
|
||||
global: {
|
||||
plugins: [createMockRouter()],
|
||||
provide: { isAdmin: ref(admin) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('HistoryView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -78,7 +88,7 @@ describe('HistoryView.vue', () => {
|
||||
expect(wrapper.text()).toContain('等待中')
|
||||
})
|
||||
|
||||
it('按打包类型过滤', async () => {
|
||||
it('版本号筛选为下拉选项', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
@@ -86,7 +96,9 @@ describe('HistoryView.vue', () => {
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[0].setValue('Ad_Hoc')
|
||||
expect(selects).toHaveLength(3)
|
||||
expect(wrapper.text()).not.toContain('全部类型')
|
||||
await selects[1].setValue('2.196.0')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
@@ -101,27 +113,50 @@ describe('HistoryView.vue', () => {
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[1].setValue('failed')
|
||||
await selects[2].setValue('failed')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(1) // App2
|
||||
})
|
||||
|
||||
it('已完成任务显示下载按钮', async () => {
|
||||
it('按 App 名称和版本号下拉选项筛选', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[0].setValue('App1')
|
||||
await wrapper.find('.version-filter').setValue('2.196.0')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0].text()).toContain('App1')
|
||||
expect(rows[0].text()).toContain('2.196.0')
|
||||
})
|
||||
|
||||
it('已完成任务显示下载按钮', async () => {
|
||||
const wrapper = mountHistory(true)
|
||||
await flushPromises()
|
||||
|
||||
// 第一行(App1, completed)应该有 dSYM 和下载按钮
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||
const buttons = firstRow.findAll('.action-btn')
|
||||
const buttonTexts = buttons.map(b => b.text())
|
||||
expect(buttonTexts).toContain('dSYM')
|
||||
expect(buttonTexts).toContain('下载')
|
||||
expect(buttonTexts).toContain('二维码')
|
||||
expect(firstRow.find('.qr-thumb').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('普通用户不显示历史操作按钮', async () => {
|
||||
const wrapper = mountHistory(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.action-btns').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('删除')
|
||||
expect(wrapper.text()).not.toContain('日志')
|
||||
})
|
||||
|
||||
it('空列表显示提示', async () => {
|
||||
@@ -148,18 +183,27 @@ describe('HistoryView.vue', () => {
|
||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||
})
|
||||
|
||||
it('点击查看日志跳转', async () => {
|
||||
it('显示 App 版本号,Scheme 与状态单元格允许完整换行', async () => {
|
||||
const router = createMockRouter()
|
||||
router.push = vi.fn()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20')
|
||||
expect(wrapper.findAll('tbody tr')[0].text()).toContain('2.196.0')
|
||||
expect(wrapper.find('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName')
|
||||
expect(wrapper.find('.status-cell').text()).toContain('已完成')
|
||||
})
|
||||
|
||||
it('点击查看日志打开日志弹窗', async () => {
|
||||
const wrapper = mountHistory(true)
|
||||
await flushPromises()
|
||||
|
||||
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
|
||||
await logBtn.trigger('click')
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } })
|
||||
expect(wrapper.find('.log-modal').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('点击二维码弹出弹窗', async () => {
|
||||
@@ -169,11 +213,10 @@ describe('HistoryView.vue', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码')
|
||||
await qrBtn.trigger('click')
|
||||
await wrapper.find('.qr-thumb').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.qr-modal').exists()).toBe(true)
|
||||
expect(wrapper.find('.qr-preview-modal').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('下载二维码')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<label for="obfuscation">启用混淆</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="submitNotice" class="submit-notice" role="status" aria-live="polite">{{ submitNotice }}</p>
|
||||
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '开始打包' }}
|
||||
</button>
|
||||
@@ -126,7 +127,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<img :src="completedTask.qr_code_path" alt="下载二维码" class="qr-image">
|
||||
<p class="qr-hint">扫码下载安装</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,6 +194,7 @@ const form = ref({
|
||||
branch: 'main',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const submitNotice = ref('')
|
||||
const currentTaskId = ref(null)
|
||||
const completedTask = ref(null)
|
||||
const logs = ref([])
|
||||
@@ -294,7 +296,12 @@ watch(selectedServer, () => {
|
||||
form.value.app_id = ''
|
||||
})
|
||||
|
||||
watch(() => form.value.app_id, () => {
|
||||
watch(() => form.value.app_id, (appId) => {
|
||||
if (!appId) {
|
||||
form.value.scheme_id = ''
|
||||
form.value.build_type = ''
|
||||
return
|
||||
}
|
||||
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
|
||||
form.value.build_type = availableBuildTypes.value[0] || ''
|
||||
})
|
||||
@@ -347,6 +354,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const submitTask = async () => {
|
||||
if (submitting.value) return
|
||||
if (!form.value.app_id) {
|
||||
alert('请选择 App')
|
||||
return
|
||||
@@ -362,11 +370,20 @@ const submitTask = async () => {
|
||||
const task = await res.json()
|
||||
currentTaskId.value = task.id
|
||||
tasks.value.unshift(task)
|
||||
submitNotice.value = '打包任务已创建,正在排队,请勿重复点击。'
|
||||
selectedServer.value = ''
|
||||
form.value = {
|
||||
app_id: '',
|
||||
build_type: '',
|
||||
scheme_id: '',
|
||||
obfuscation: false,
|
||||
branch: branches.value[0] || 'main',
|
||||
}
|
||||
} else {
|
||||
alert('提交失败')
|
||||
submitNotice.value = '提交失败,请检查配置后重试。'
|
||||
}
|
||||
} catch (e) {
|
||||
alert('提交失败')
|
||||
submitNotice.value = '提交失败,请检查网络后重试。'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -443,6 +460,7 @@ const errorCategoryHint = (cat) => {
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.submit-notice { margin: 0 0 12px; padding: 9px 12px; border-radius: 6px; background: #e6f7ff; color: #096dd9; font-size: 13px; line-height: 1.5; }
|
||||
.btn-danger { background: #ff4d4f; color: white; }
|
||||
|
||||
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -66,6 +66,7 @@
|
||||
<tr>
|
||||
<th>环境名称</th>
|
||||
<th>API 地址</th>
|
||||
<th>App ID 前缀</th>
|
||||
<th>Associated Domains</th>
|
||||
<th>Universal Link</th>
|
||||
<th>操作</th>
|
||||
@@ -75,6 +76,7 @@
|
||||
<tr v-for="(server, name) in servers" :key="name">
|
||||
<td><strong>{{ name }}</strong></td>
|
||||
<td>{{ server.api }}</td>
|
||||
<td>{{ server.app_id_prefix }}</td>
|
||||
<td>{{ server.assDom }}</td>
|
||||
<td>{{ server.universalLink }}</td>
|
||||
<td class="action-btns">
|
||||
@@ -273,28 +275,25 @@
|
||||
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Build_Ver(构建版本)</label>
|
||||
<input type="text" v-model="versions.build_ver" placeholder="2.180.0.0">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号.构建号(App_Store 打包时构建号自动 +1)</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
|
||||
|
||||
<h4 class="section-title">打包参数</h4>
|
||||
<div class="form-group">
|
||||
<label>最大并行打包数</label>
|
||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包目录保留时间(小时)</label>
|
||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包基础目录</label>
|
||||
<input type="text" v-model="buildSettings.build_base_dir">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||
<template v-if="isAdmin">
|
||||
<h4 class="section-title">打包参数</h4>
|
||||
<div class="form-group">
|
||||
<label>最大并行打包数</label>
|
||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包目录保留时间(小时)</label>
|
||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包基础目录</label>
|
||||
<input type="text" v-model="buildSettings.build_base_dir">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,7 +324,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码 *</label>
|
||||
<input v-model="userForm.password" type="password" placeholder="至少 12 位">
|
||||
<input v-model="userForm.password" type="password" placeholder="至少 11 位">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
@@ -349,7 +348,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input v-model="newPassword" type="password" placeholder="至少 12 位">
|
||||
<input v-model="newPassword" type="password" placeholder="至少 11 位">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
|
||||
@@ -381,6 +380,11 @@
|
||||
<label>Universal Link</label>
|
||||
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App ID 前缀</label>
|
||||
<input :value="editingServerName ? serverForm.app_id_prefix : nextServerPrefix" type="text" disabled>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">新增环境自动分配,保存后不可修改。</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
||||
@@ -413,6 +417,10 @@
|
||||
</select>
|
||||
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
|
||||
</div>
|
||||
<div v-if="isAdmin" class="form-group">
|
||||
<label>App ID 前缀覆盖</label>
|
||||
<input v-model.number="appForm.app_id_prefix_override" type="number" min="1" placeholder="仅特殊 App 使用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@@ -558,7 +566,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import { computed, ref, onMounted, inject } from 'vue'
|
||||
|
||||
const showLogin = inject('showLogin')
|
||||
const getToken = inject('getToken')
|
||||
@@ -594,7 +602,13 @@ const jsonContent = ref('{}')
|
||||
|
||||
const showServerModal = ref(false)
|
||||
const editingServerName = ref(null)
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' })
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
|
||||
const nextServerPrefix = computed(() => {
|
||||
const prefixes = Object.values(servers.value)
|
||||
.map(server => Number(server.app_id_prefix))
|
||||
.filter(prefix => Number.isInteger(prefix) && prefix > 0)
|
||||
return Math.max(0, ...prefixes) + 1
|
||||
})
|
||||
|
||||
const showAppModal = ref(false)
|
||||
const editingAppId = ref(null)
|
||||
@@ -612,8 +626,12 @@ onMounted(async () => {
|
||||
|
||||
const loadData = async () => {
|
||||
if (!isAdmin.value) {
|
||||
const appsRes = await authFetch('/api/config/apps')
|
||||
const [appsRes, versionsRes] = await Promise.all([
|
||||
authFetch('/api/config/apps'),
|
||||
authFetch('/api/config/versions'),
|
||||
])
|
||||
if (appsRes.ok) apps.value = await appsRes.json()
|
||||
if (versionsRes.ok) versions.value = await versionsRes.json()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -650,8 +668,8 @@ const createUser = async () => {
|
||||
alert('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
if (userForm.value.password.length < 12) {
|
||||
alert('密码至少 12 位')
|
||||
if (userForm.value.password.length < 11) {
|
||||
alert('密码至少 11 位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -678,8 +696,8 @@ const openPasswordModal = (u) => {
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!newPassword.value || newPassword.value.length < 12) {
|
||||
alert('密码至少 12 位')
|
||||
if (!newPassword.value || newPassword.value.length < 11) {
|
||||
alert('密码至少 11 位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -727,7 +745,9 @@ const deleteUser = async (u) => {
|
||||
// 服务器环境管理
|
||||
const openServerModal = (name = null, server = null) => {
|
||||
editingServerName.value = name
|
||||
serverForm.value = server ? { ...server, name } : { name: '', api: '', assDom: '', universalLink: '' }
|
||||
serverForm.value = server
|
||||
? { ...server, name }
|
||||
: { name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
@@ -800,6 +820,7 @@ const openAppModal = (id = null, app = null) => {
|
||||
weixinpay: '',
|
||||
tencent: '',
|
||||
AlivcLicenseKey: '',
|
||||
app_id_prefix_override: '',
|
||||
certificates: {},
|
||||
}
|
||||
}
|
||||
@@ -1031,7 +1052,7 @@ const saveVersions = async () => {
|
||||
const res = await authFetch('/api/config/versions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(versions.value),
|
||||
body: JSON.stringify({ app_ver: versions.value.app_ver }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
<div class="header-row">
|
||||
<h2>打包历史</h2>
|
||||
<div class="filters">
|
||||
<select v-model="filterBuildType" class="filter-select">
|
||||
<option value="">全部类型</option>
|
||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
||||
<option value="App_Store">App_Store</option>
|
||||
<select v-model="filterAppName" class="filter-select">
|
||||
<option value="">全部 App</option>
|
||||
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
|
||||
</select>
|
||||
<select v-model="filterVersion" class="filter-select version-filter">
|
||||
<option value="">全部版本</option>
|
||||
<option v-for="version in appVersions" :key="version" :value="version">{{ version }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-select">
|
||||
<option value="">全部状态</option>
|
||||
@@ -22,30 +25,25 @@
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>App</th>
|
||||
<th>App 名称</th>
|
||||
<th>App 版本号</th>
|
||||
<th>打包类型</th>
|
||||
<th>Scheme</th>
|
||||
<th>状态</th>
|
||||
<th>下载地址</th>
|
||||
<th>操作</th>
|
||||
<th v-if="isAdmin">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.id">
|
||||
<td>
|
||||
{{ formatTime(task.created_at) }}
|
||||
<span v-if="task.status === 'completed' || task.status === 'failed'" class="duration-text">
|
||||
{{ formatDuration(task.started_at, task.completed_at) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.app_name }}</td>
|
||||
<td>{{ task.app_version }}</td>
|
||||
<td>
|
||||
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
||||
{{ task.build_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.scheme_name }}</td>
|
||||
<td class="scheme-cell">{{ task.scheme_name }}</td>
|
||||
<td class="status-cell">
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
|
||||
@@ -61,7 +59,7 @@
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="action-btns">
|
||||
<td v-if="isAdmin" class="action-btns">
|
||||
<button v-if="task.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||
<button v-if="task.status === 'completed' && task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
|
||||
<button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
|
||||
@@ -69,7 +67,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredTasks.length">
|
||||
<td colspan="7" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
<td :colspan="isAdmin ? 7 : 6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -162,7 +160,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
|
||||
|
||||
const getToken = inject('getToken')
|
||||
const getToken = inject('getToken', () => '')
|
||||
const isAdmin = inject('isAdmin', ref(false))
|
||||
const tasks = ref([])
|
||||
|
||||
const authFetch = (url, options = {}) => {
|
||||
@@ -172,8 +171,9 @@ const authFetch = (url, options = {}) => {
|
||||
}
|
||||
return fetch(url, options)
|
||||
}
|
||||
const filterBuildType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterAppName = ref('')
|
||||
const filterVersion = ref('')
|
||||
const showLogModal = ref(false)
|
||||
const logTask = ref(null)
|
||||
const logLines = ref([])
|
||||
@@ -182,6 +182,19 @@ const showVerboseLogs = ref(false)
|
||||
const qrPreview = ref(null)
|
||||
let logWs = null
|
||||
|
||||
const getAppVersion = (task) => {
|
||||
try {
|
||||
return JSON.parse(task.config_json || '{}').VERSION || '-'
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const appNames = computed(() => [...new Set(tasks.value.map(task => task.app_name).filter(Boolean))].sort())
|
||||
const appVersions = computed(() => [...new Set(
|
||||
tasks.value.map(task => task.app_version).filter(version => version && version !== '-')
|
||||
)].sort((a, b) => b.localeCompare(a, undefined, { numeric: true })))
|
||||
|
||||
const showQrPreview = (task) => {
|
||||
qrPreview.value = task
|
||||
}
|
||||
@@ -190,7 +203,8 @@ const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
// 默认隐藏已取消的任务
|
||||
if (!filterStatus.value && task.status === 'cancelled') return false
|
||||
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
|
||||
if (filterAppName.value && task.app_name !== filterAppName.value) return false
|
||||
if (filterVersion.value && task.app_version !== filterVersion.value) return false
|
||||
if (filterStatus.value && task.status !== filterStatus.value) return false
|
||||
return true
|
||||
})
|
||||
@@ -198,7 +212,8 @@ const filteredTasks = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await authFetch('/api/tasks?limit=100')
|
||||
tasks.value = await res.json()
|
||||
const data = await res.json()
|
||||
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
|
||||
})
|
||||
|
||||
const viewLogs = async (taskId) => {
|
||||
@@ -298,17 +313,24 @@ const downloadObfMaps = (taskId) => {
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
if (!confirm('确定要删除这条打包记录吗?')) return
|
||||
if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
|
||||
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
|
||||
} catch {}
|
||||
if (res.ok) {
|
||||
tasks.value = tasks.value.filter(t => t.id !== taskId)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
alert(data.detail || '删除失败,请稍后重试')
|
||||
}
|
||||
} catch {
|
||||
alert('删除失败,请检查网络后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z')
|
||||
return d.toLocaleString()
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
const formatDuration = (started, completed) => {
|
||||
@@ -362,12 +384,12 @@ const errorCategoryLabel = (cat) => {
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table th:nth-child(1) { width: 14%; }
|
||||
.config-table th:nth-child(2) { width: 14%; }
|
||||
.config-table th:nth-child(3) { width: 8%; }
|
||||
.config-table th:nth-child(4) { width: 10%; }
|
||||
.config-table th:nth-child(5) { width: 12%; }
|
||||
.config-table th:nth-child(6) { width: 16%; }
|
||||
.config-table th:nth-child(7) { width: 26%; }
|
||||
.config-table th:nth-child(2) { width: 12%; }
|
||||
.config-table th:nth-child(3) { width: 10%; }
|
||||
.config-table th:nth-child(4) { width: 18%; }
|
||||
.config-table th:nth-child(5) { width: 15%; }
|
||||
.config-table th:nth-child(6) { width: 12%; }
|
||||
.config-table th:nth-child(7) { width: 19%; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
|
||||
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
@@ -377,11 +399,12 @@ const errorCategoryLabel = (cat) => {
|
||||
.action-btns { white-space: normal; }
|
||||
.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
|
||||
.download-cell { white-space: normal; }
|
||||
.scheme-cell { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; overflow-wrap: anywhere; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
|
||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-cell { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; }
|
||||
.status-cell { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; overflow-wrap: anywhere; }
|
||||
.status-cell .task-status, .status-cell .error-cat-badge { display: inline-block; margin-bottom: 4px; }
|
||||
.status-pending { background: #f0f0f0; color: #666; }
|
||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||
@@ -433,9 +456,7 @@ const errorCategoryLabel = (cat) => {
|
||||
.meta-label { font-size: 12px; color: #999; }
|
||||
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
||||
|
||||
.duration-text {
|
||||
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
|
||||
}
|
||||
.version-filter { width: 150px; }
|
||||
|
||||
/* 日志操作栏 */
|
||||
.log-modal-actions {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ def tmp_config(tmp_path, monkeypatch):
|
||||
"1": {"name": "readoor31", "ossFloder": "test"}
|
||||
},
|
||||
"servers": {
|
||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"},
|
||||
"正式环境": {"api": "https://prod.api.com", "assDom": "applinks:prod.com", "universalLink": "https://prod.com"}
|
||||
},
|
||||
"branches": ["main", "dev"],
|
||||
}))
|
||||
|
||||
@@ -20,14 +20,32 @@ def test_get_apps(client, tmp_config):
|
||||
|
||||
|
||||
def test_create_app(client, tmp_config):
|
||||
resp = client.post("/api/config/apps", json={"name": "新App", "AppGuid": "new-guid"})
|
||||
resp = client.post("/api/config/apps", json={
|
||||
"name": "新App", "server": "测试环境", "AppGuid": "new-guid",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "2"
|
||||
assert resp.json()["id"] == "100"
|
||||
|
||||
# 验证已创建
|
||||
apps = client.get("/api/config/apps").json()
|
||||
assert "2" in apps
|
||||
assert apps["2"]["name"] == "新App"
|
||||
assert "100" in apps
|
||||
assert apps["100"]["name"] == "新App"
|
||||
|
||||
|
||||
def test_create_app_uses_environment_prefix_and_dictionary_exception(client, tmp_config):
|
||||
official = client.post("/api/config/apps", json={"name": "正式 App", "server": "正式环境"})
|
||||
dictionary = client.post("/api/config/apps", json={"name": "英汉大词典", "server": "正式环境"})
|
||||
|
||||
assert official.status_code == 200
|
||||
assert official.json()["id"] == "200"
|
||||
assert dictionary.status_code == 200
|
||||
assert dictionary.json()["id"] == "100"
|
||||
|
||||
|
||||
def test_create_app_rejects_unknown_environment_id_rule(client, tmp_config):
|
||||
resp = client.post("/api/config/apps", json={"name": "新 App", "server": "未知环境"})
|
||||
assert resp.status_code == 400
|
||||
assert "ID 规则" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_update_app(client, tmp_config):
|
||||
@@ -121,7 +139,13 @@ def test_create_server(client, tmp_config):
|
||||
"universalLink": "https://new.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "新环境" in client.get("/api/config/servers").json()
|
||||
servers = client.get("/api/config/servers").json()
|
||||
assert servers["新环境"]["app_id_prefix"] == 3
|
||||
|
||||
client.delete("/api/config/servers/新环境")
|
||||
second = client.post("/api/config/servers", json={"name": "第二环境", "api": "https://second.api.com"})
|
||||
assert second.status_code == 200
|
||||
assert client.get("/api/config/servers").json()["第二环境"]["app_id_prefix"] == 4
|
||||
|
||||
|
||||
def test_delete_server_in_use(client, tmp_config):
|
||||
@@ -144,3 +168,19 @@ def test_update_build_settings(client, tmp_config):
|
||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
|
||||
|
||||
|
||||
# ---- Versions ----
|
||||
|
||||
def test_update_versions_resets_build_number(client, tmp_config):
|
||||
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["app_ver"] == "2.196.0"
|
||||
assert resp.json()["build_ver"] == "2.196.0.0"
|
||||
assert client.get("/api/config/versions").json()["build_ver"] == "2.196.0.0"
|
||||
|
||||
|
||||
def test_update_versions_rejects_invalid_app_version(client, tmp_config):
|
||||
resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
|
||||
assert resp.status_code == 400
|
||||
assert "App_Ver" in resp.json()["detail"]
|
||||
|
||||
@@ -167,3 +167,50 @@ def test_cancel_task(mock_queue, client, tmp_config):
|
||||
# 验证状态已更新
|
||||
task = client.get(f"/api/tasks/{task_id}").json()
|
||||
assert task["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import Task
|
||||
|
||||
oss_url = "https://files.example.com/test/iOS/1_2_0_0_0_main.html"
|
||||
task = Task(
|
||||
id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc",
|
||||
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
|
||||
oss_url=oss_url,
|
||||
config_json=json.dumps({"APPID": "1", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "test"}),
|
||||
)
|
||||
db = SessionLocal()
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with patch("backend.services.distribution.delete_published_artifacts") as delete:
|
||||
resp = client.delete("/api/tasks/completed-task/delete")
|
||||
|
||||
assert resp.status_code == 200
|
||||
delete.assert_called_once()
|
||||
assert delete.call_args.args[2] == oss_url
|
||||
assert client.get("/api/tasks/completed-task").status_code == 404
|
||||
|
||||
|
||||
def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import Task
|
||||
|
||||
db = SessionLocal()
|
||||
for task_id in ("old-task", "new-task"):
|
||||
db.add(Task(
|
||||
id=task_id, app_id="1", app_name="测试App", build_type="Ad_Hoc",
|
||||
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
|
||||
oss_url="https://files.example.com/test/iOS/1_2_0_0_0_main.html",
|
||||
))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with patch("backend.services.distribution.delete_published_artifacts") as delete:
|
||||
resp = client.delete("/api/tasks/old-task/delete")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "仍被其他记录引用" in resp.json()["message"]
|
||||
delete.assert_not_called()
|
||||
|
||||
@@ -41,7 +41,9 @@ def tmp_dirs(tmp_path):
|
||||
(source_dir / "Pods").mkdir()
|
||||
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
||||
(source_dir / "readoor.xcworkspace").mkdir()
|
||||
(source_dir / "AutoPacking").mkdir()
|
||||
whitelist_script = source_dir / "AutoPacking" / "obfuscation" / "generate_image_whitelist.py"
|
||||
whitelist_script.parent.mkdir(parents=True)
|
||||
whitelist_script.write_text("# whitelist generator")
|
||||
vendor_dir = source_dir / "Vendor" / "RDEpubReaderView"
|
||||
vendor_dir.mkdir(parents=True)
|
||||
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
|
||||
@@ -161,7 +163,7 @@ async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||
assert (result / "Podfile.lock").exists()
|
||||
assert (result / "readoor.xcworkspace").exists()
|
||||
assert (result / "Vendor" / "RDEpubReaderView" / "RDEpubReaderView.podspec").exists()
|
||||
assert not (result / "AutoPacking").exists()
|
||||
assert (result / "AutoPacking" / "obfuscation" / "generate_image_whitelist.py").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_normalizes_lowercase_podfile(tmp_dirs, log_streamer):
|
||||
|
||||
@@ -3,6 +3,8 @@ import plistlib
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.services.distribution import (
|
||||
_artifact_stem,
|
||||
delete_published_artifacts,
|
||||
_write_distribution_files,
|
||||
_write_download_page,
|
||||
_write_manifest,
|
||||
@@ -10,6 +12,11 @@ from backend.services.distribution import (
|
||||
)
|
||||
|
||||
|
||||
def test_distribution_artifact_name_contains_sanitized_branch():
|
||||
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "feature/pay-v2", "BUILD_TYPE": "Ad_Hoc"}) == "100_2_0_0_0_feature_pay-v2_adhoc"
|
||||
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0"}) == "100_2_0_0_0"
|
||||
|
||||
|
||||
def test_distribution_files_use_current_service_config(tmp_path):
|
||||
ipa = tmp_path / "source.ipa"
|
||||
ipa.write_bytes(b"ipa")
|
||||
@@ -35,18 +42,80 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path):
|
||||
"APPID": "100",
|
||||
"VERSION": "2.0.0",
|
||||
"BUILD_TYPE": "App_Store",
|
||||
"SOURCE_BRANCH": "main",
|
||||
"OSS_FLODER": "readoor",
|
||||
"_upload_config": {"mode": "oss", "oss": {}},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.services.distribution._upload_oss",
|
||||
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0.ipa"},
|
||||
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0_main_appstore.ipa"},
|
||||
) as upload:
|
||||
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
|
||||
|
||||
uploaded_files = upload.call_args.args[1]
|
||||
assert len(uploaded_files) == 1
|
||||
assert uploaded_files[0][0].suffix == ".ipa"
|
||||
assert download_url.endswith("100_2_0_0.ipa")
|
||||
assert download_url.endswith("100_2_0_0_main_appstore.ipa")
|
||||
assert qr_path == ""
|
||||
|
||||
|
||||
def test_adhoc_distribution_uploads_qrcode(tmp_path):
|
||||
ipa = tmp_path / "source.ipa"
|
||||
ipa.write_bytes(b"ad-hoc-ipa")
|
||||
config = {
|
||||
"APPID": "100",
|
||||
"VERSION": "2.0.0",
|
||||
"BUILD_TYPE": "Ad_Hoc",
|
||||
"SOURCE_BRANCH": "dev",
|
||||
"OSS_FLODER": "readoor",
|
||||
"_upload_config": {"mode": "oss", "oss": {}},
|
||||
}
|
||||
|
||||
def upload_files(_upload_config, files):
|
||||
local_path, remote_path = files[0]
|
||||
return {local_path.suffix: f"https://files.example.com/{remote_path}"}
|
||||
|
||||
with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload:
|
||||
download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build")
|
||||
|
||||
assert download_url.endswith("100_2_0_0_dev_adhoc.html")
|
||||
assert qr_url.endswith("100_2_0_0_dev_adhoc.png")
|
||||
assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [
|
||||
".ipa", ".plist", ".html", ".png",
|
||||
]
|
||||
|
||||
|
||||
def test_delete_adhoc_artifacts_deletes_all_remote_files():
|
||||
config = {"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor"}
|
||||
with patch("backend.services.distribution._delete_oss") as delete:
|
||||
delete_published_artifacts(config, {"mode": "oss", "oss": {}})
|
||||
|
||||
assert delete.call_args.args[1] == [
|
||||
"readoor/iOS/100_2_0_0_0_main_adhoc.ipa",
|
||||
"readoor/iOS/100_2_0_0_0_main_adhoc.plist",
|
||||
"readoor/iOS/100_2_0_0_0_main_adhoc.html",
|
||||
"readoor/iOS/100_2_0_0_0_main_adhoc.png",
|
||||
]
|
||||
|
||||
|
||||
def test_delete_uses_saved_url_for_legacy_artifact_name():
|
||||
# 旧任务已有 SOURCE_BRANCH 快照,但上传时仍采用未带分支的旧命名。
|
||||
# 不能再根据当前命名规则推算,否则 OSS 会对不存在的键返回成功。
|
||||
config = {
|
||||
"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main",
|
||||
"BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor",
|
||||
}
|
||||
with patch("backend.services.distribution._delete_oss") as delete:
|
||||
delete_published_artifacts(
|
||||
config,
|
||||
{"mode": "oss", "oss": {"base_url": "https://files.example.com"}},
|
||||
"https://files.example.com/readoor/iOS/100_2_0_0_0.html",
|
||||
)
|
||||
|
||||
assert delete.call_args.args[1] == [
|
||||
"readoor/iOS/100_2_0_0_0.ipa",
|
||||
"readoor/iOS/100_2_0_0_0.plist",
|
||||
"readoor/iOS/100_2_0_0_0.html",
|
||||
"readoor/iOS/100_2_0_0_0.png",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""钉钉通知测试。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.notification import NotificationError, build_dingtalk_payload, send_dingtalk_notification
|
||||
|
||||
|
||||
def _build_config():
|
||||
return {
|
||||
"SERVER": "测试环境",
|
||||
"VERSION": "2.195.0",
|
||||
"APPID_NAME": "阅门户测试App",
|
||||
"BUNDLE_ID": "cn.touchv.a4YX061",
|
||||
"APPID": "647372741900537856",
|
||||
}
|
||||
|
||||
|
||||
def test_dingtalk_payload_matches_autopacking_content():
|
||||
payload = build_dingtalk_payload(
|
||||
_build_config(),
|
||||
"https://download.example.com/app.html",
|
||||
"https://download.example.com/app.png",
|
||||
)
|
||||
|
||||
text = payload["markdown"]["text"]
|
||||
assert payload["markdown"]["title"] == "iOS应用下载"
|
||||
assert "## 【iOS】打包信息" in text
|
||||
assert "**环境:** 测试环境" in text
|
||||
assert "**版本:** 2.195.0" in text
|
||||
assert "**APP名称:** 阅门户测试App" in text
|
||||
assert "**包名:** cn.touchv.a4YX061" in text
|
||||
assert "**App Guid:** 647372741900537856" in text
|
||||
assert "**iOS 下载链接:** https://download.example.com/app.html" in text
|
||||
assert "" in text
|
||||
|
||||
|
||||
def test_dingtalk_notification_skips_when_disabled():
|
||||
with patch("backend.services.notification.httpx.post") as post:
|
||||
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_dingtalk_notification_sends_markdown():
|
||||
response = MagicMock(status_code=200)
|
||||
response.json.return_value = {"errcode": 0}
|
||||
with patch("backend.services.notification.httpx.post", return_value=response) as post:
|
||||
assert send_dingtalk_notification(
|
||||
{"enabled": True, "webhook_url": "https://example.com/robot"},
|
||||
_build_config(),
|
||||
"https://download.example.com/app.html",
|
||||
"https://download.example.com/app.png",
|
||||
) is True
|
||||
|
||||
assert post.call_args.kwargs["json"]["markdown"]["title"] == "iOS应用下载"
|
||||
|
||||
|
||||
def test_dingtalk_notification_requires_webhook_when_enabled():
|
||||
with pytest.raises(NotificationError, match="Webhook"):
|
||||
send_dingtalk_notification({"enabled": True}, _build_config(), "https://download.example.com/app")
|
||||
+10
-3
@@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
|
||||
config.validate_production_security()
|
||||
|
||||
|
||||
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
|
||||
"""普通账号只能管理 Apps,不能读取或修改包含密钥的配置。"""
|
||||
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
|
||||
"""普通账号可管理 Apps 与版本号,不能读取或修改管理员配置。"""
|
||||
response = client.post("/api/users", json={
|
||||
"username": "builder",
|
||||
"password": "builder-password-123",
|
||||
@@ -54,7 +54,14 @@ def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
assert client.get("/api/config/apps", headers=headers).status_code == 200
|
||||
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200
|
||||
assert client.post("/api/config/apps", json={
|
||||
"name": "普通用户 App", "server": "测试环境",
|
||||
}, headers=headers).status_code == 200
|
||||
assert client.get("/api/config/versions", headers=headers).status_code == 200
|
||||
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
|
||||
assert versions.status_code == 200
|
||||
assert versions.json()["build_ver"] == "2.196.0.0"
|
||||
assert client.get("/api/config", headers=headers).status_code == 403
|
||||
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
||||
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403
|
||||
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user