Compare commits

..

2 Commits

Author SHA1 Message Date
shenlei
44405e589d feat: 优化打包产物与历史筛选 2026-07-20 16:55:17 +09:00
shenlei
de33b3d4b2 feat: 按环境前缀生成 Apps ID 2026-07-20 16:24:13 +09:00
11 changed files with 385 additions and 61 deletions

View File

@ -67,22 +67,26 @@ DEFAULT_SERVERS = {
"测试环境": { "测试环境": {
"api": "https://api3-dev.readoor.cn", "api": "https://api3-dev.readoor.cn",
"assDom": "applinks:dev-data1.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", "api": "https://api3.readoor.cn",
"assDom": "applinks:data1.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", "api": "https://api3.ecnupress.com.cn",
"assDom": "applinks:data1.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", "api": "https://weread-api3.sflep.com/api3",
"assDom": "applinks:wereadossda.sflep.com", "assDom": "applinks:wereadossda.sflep.com",
"universalLink": "https://wereadossda.sflep.com" "universalLink": "https://wereadossda.sflep.com",
"app_id_prefix": 4,
} }
} }
@ -91,6 +95,84 @@ DEFAULT_VERSIONS = {
"build_ver": "2.180.0.0", "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: def _ensure_upload_keys(config: dict) -> bool:
"""为没有 upload_key 的 app 自动生成,返回是否有变更""" """为没有 upload_key 的 app 自动生成,返回是否有变更"""
@ -152,7 +234,10 @@ def load_config() -> dict:
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH) shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
else: 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: with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
# 确保必要字段存在 # 确保必要字段存在
@ -164,8 +249,11 @@ def load_config() -> dict:
config["upload"] = DEFAULT_UPLOAD config["upload"] = DEFAULT_UPLOAD
if "versions" not in config: if "versions" not in config:
config["versions"] = DEFAULT_VERSIONS.copy() config["versions"] = DEFAULT_VERSIONS.copy()
# 自动为缺少 upload_key 的 app 生成唯一标识 # 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
if _ensure_upload_keys(config): 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) save_config(config)
# 迁移旧式目录皮肤为 ZIP # 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config): if _migrate_old_themes(config):
@ -217,9 +305,9 @@ async def create_app(app: dict):
config = load_config() config = load_config()
apps = config.get("apps", {}) apps = config.get("apps", {})
# 自动生成 ID # 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()] _apply_special_app_prefix_override(app)
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key # 自动生成 upload_key
if not app.get("upload_key"): if not app.get("upload_key"):
@ -558,12 +646,15 @@ async def create_server(server_data: dict):
if name in servers: if name in servers:
raise HTTPException(status_code=400, detail="环境名称已存在") raise HTTPException(status_code=400, detail="环境名称已存在")
prefix = config.get("next_app_id_prefix", 1)
servers[name] = { servers[name] = {
"api": server_data.get("api", ""), "api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""), "assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""), "universalLink": server_data.get("universalLink", ""),
"app_id_prefix": prefix,
} }
config["servers"] = servers config["servers"] = servers
config["next_app_id_prefix"] = prefix + 1
save_config(config) save_config(config)
return {"message": "服务器环境已创建"} return {"message": "服务器环境已创建"}
@ -591,6 +682,7 @@ async def update_server(server_name: str, server_data: dict):
"api": server_data.get("api", ""), "api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""), "assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""), "universalLink": server_data.get("universalLink", ""),
"app_id_prefix": servers[new_name].get("app_id_prefix"),
} }
config["servers"] = servers config["servers"] = servers
save_config(config) save_config(config)

View File

@ -1,5 +1,6 @@
"""任务 API""" """任务 API"""
import os import os
import json
import shutil import shutil
import tempfile import tempfile
import uuid import uuid
@ -164,6 +165,25 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if task.status in ("running", "pending"): if task.status in ("running", "pending"):
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除") 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", {}))
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" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@ -178,7 +198,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
db.delete(task) db.delete(task)
db.commit() db.commit()
return {"message": "记录已删除"} message = "记录已删除"
if task.oss_url:
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
return {"message": message}
@router.get("/{task_id}/log") @router.get("/{task_id}/log")

View File

@ -2,6 +2,7 @@
import json import json
import plistlib import plistlib
import posixpath import posixpath
import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -15,7 +16,12 @@ class DistributionError(Exception):
def _artifact_stem(config: dict) -> str: def _artifact_stem(config: dict) -> str:
version = config.get("VERSION", "0").replace(".", "_") 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]: def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
@ -70,6 +76,60 @@ def _remote_paths(config: dict) -> tuple[str, str, str, str]:
) )
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 delete_published_artifacts(build_config: dict, upload_config: dict):
"""删除任务对应的远端分发产物。"""
mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
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): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = "" path = ""
for segment in remote_path.strip("/").split("/")[:-1]: for segment in remote_path.strip("/").split("/")[:-1]:

View File

@ -7,9 +7,9 @@ global.fetch = vi.fn()
window.open = vi.fn() window.open = vi.fn()
const mockTasks = [ const mockTasks = [
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', 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: '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', error_message: '构建失败', error_category: 'compilation' }, { 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' }, { 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() { function createMockRouter() {
@ -86,7 +86,7 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[0].setValue('Ad_Hoc') await selects[2].setValue('Ad_Hoc')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
@ -101,13 +101,31 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[1].setValue('failed') await selects[3].setValue('failed')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1) // App2 expect(rows.length).toBe(1) // App2
}) })
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')
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 () => { it('已完成任务显示下载按钮', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
@ -120,8 +138,7 @@ describe('HistoryView.vue', () => {
const buttons = firstRow.findAll('.action-btn') const buttons = firstRow.findAll('.action-btn')
const buttonTexts = buttons.map(b => b.text()) const buttonTexts = buttons.map(b => b.text())
expect(buttonTexts).toContain('dSYM') expect(buttonTexts).toContain('dSYM')
expect(buttonTexts).toContain('下载') expect(firstRow.find('.qr-thumb').exists()).toBe(true)
expect(buttonTexts).toContain('二维码')
}) })
it('空列表显示提示', async () => { it('空列表显示提示', async () => {
@ -148,7 +165,7 @@ describe('HistoryView.vue', () => {
expect(wrapper.vm.statusText('failed')).toBe('失败') expect(wrapper.vm.statusText('failed')).toBe('失败')
}) })
it('时间仅显示月和日Scheme 与状态单元格允许完整换行', async () => { it('显示 App 版本号Scheme 与状态单元格允许完整换行', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
@ -156,13 +173,13 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20') 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('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName')
expect(wrapper.find('.status-cell').text()).toContain('已完成') expect(wrapper.find('.status-cell').text()).toContain('已完成')
}) })
it('点击查看日志跳转', async () => { it('点击查看日志打开日志弹窗', async () => {
const router = createMockRouter() const router = createMockRouter()
router.push = vi.fn()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
}) })
@ -171,7 +188,7 @@ describe('HistoryView.vue', () => {
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志') const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
await logBtn.trigger('click') await logBtn.trigger('click')
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } }) expect(wrapper.find('.log-modal').exists()).toBe(true)
}) })
it('点击二维码弹出弹窗', async () => { it('点击二维码弹出弹窗', async () => {
@ -181,11 +198,10 @@ describe('HistoryView.vue', () => {
}) })
await flushPromises() await flushPromises()
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码') await wrapper.find('.qr-thumb').trigger('click')
await qrBtn.trigger('click')
await wrapper.vm.$nextTick() 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('下载二维码') expect(wrapper.text()).toContain('下载二维码')
}) })
}) })

View File

@ -66,6 +66,7 @@
<tr> <tr>
<th>环境名称</th> <th>环境名称</th>
<th>API 地址</th> <th>API 地址</th>
<th>App ID 前缀</th>
<th>Associated Domains</th> <th>Associated Domains</th>
<th>Universal Link</th> <th>Universal Link</th>
<th>操作</th> <th>操作</th>
@ -75,6 +76,7 @@
<tr v-for="(server, name) in servers" :key="name"> <tr v-for="(server, name) in servers" :key="name">
<td><strong>{{ name }}</strong></td> <td><strong>{{ name }}</strong></td>
<td>{{ server.api }}</td> <td>{{ server.api }}</td>
<td>{{ server.app_id_prefix }}</td>
<td>{{ server.assDom }}</td> <td>{{ server.assDom }}</td>
<td>{{ server.universalLink }}</td> <td>{{ server.universalLink }}</td>
<td class="action-btns"> <td class="action-btns">
@ -273,7 +275,6 @@
<input type="text" v-model="versions.app_ver" placeholder="2.180.0"> <input type="text" v-model="versions.app_ver" placeholder="2.180.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div>
</div> </div>
<div style="font-size: 12px; color: #999; margin: -8px 0 16px;">保存后 Build_Ver 将自动重置为 App_Ver.0App Store 打包时构建号自动 +1</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
<template v-if="isAdmin"> <template v-if="isAdmin">
@ -379,6 +380,11 @@
<label>Universal Link</label> <label>Universal Link</label>
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn"> <input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
</div> </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;"> <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="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button> <button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
@ -411,6 +417,10 @@
</select> </select>
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境"> <input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
</div> </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>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -556,7 +566,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, inject } from 'vue' import { computed, ref, onMounted, inject } from 'vue'
const showLogin = inject('showLogin') const showLogin = inject('showLogin')
const getToken = inject('getToken') const getToken = inject('getToken')
@ -592,7 +602,13 @@ const jsonContent = ref('{}')
const showServerModal = ref(false) const showServerModal = ref(false)
const editingServerName = ref(null) 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 showAppModal = ref(false)
const editingAppId = ref(null) const editingAppId = ref(null)
@ -729,7 +745,9 @@ const deleteUser = async (u) => {
// //
const openServerModal = (name = null, server = null) => { const openServerModal = (name = null, server = null) => {
editingServerName.value = name 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 showServerModal.value = true
} }
@ -802,6 +820,7 @@ const openAppModal = (id = null, app = null) => {
weixinpay: '', weixinpay: '',
tencent: '', tencent: '',
AlivcLicenseKey: '', AlivcLicenseKey: '',
app_id_prefix_override: '',
certificates: {}, certificates: {},
} }
} }

View File

@ -4,6 +4,11 @@
<div class="header-row"> <div class="header-row">
<h2>打包历史</h2> <h2>打包历史</h2>
<div class="filters"> <div class="filters">
<select v-model="filterAppName" class="filter-select">
<option value="">全部 App</option>
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
</select>
<input v-model.trim="filterVersion" class="filter-select version-filter" type="search" placeholder="筛选 App 版本号">
<select v-model="filterBuildType" class="filter-select"> <select v-model="filterBuildType" class="filter-select">
<option value="">全部类型</option> <option value="">全部类型</option>
<option value="Ad_Hoc">Ad_Hoc</option> <option value="Ad_Hoc">Ad_Hoc</option>
@ -22,8 +27,8 @@
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>App 名称</th>
<th>App</th> <th>App 版本号</th>
<th>打包类型</th> <th>打包类型</th>
<th>Scheme</th> <th>Scheme</th>
<th>状态</th> <th>状态</th>
@ -33,13 +38,8 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="task in filteredTasks" :key="task.id"> <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_name }}</td>
<td>{{ task.app_version }}</td>
<td> <td>
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']"> <span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
{{ task.build_type }} {{ task.build_type }}
@ -162,7 +162,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken') const getToken = inject('getToken', () => '')
const tasks = ref([]) const tasks = ref([])
const authFetch = (url, options = {}) => { const authFetch = (url, options = {}) => {
@ -174,6 +174,8 @@ const authFetch = (url, options = {}) => {
} }
const filterBuildType = ref('') const filterBuildType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterAppName = ref('')
const filterVersion = ref('')
const showLogModal = ref(false) const showLogModal = ref(false)
const logTask = ref(null) const logTask = ref(null)
const logLines = ref([]) const logLines = ref([])
@ -182,6 +184,16 @@ const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
let logWs = 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 showQrPreview = (task) => { const showQrPreview = (task) => {
qrPreview.value = task qrPreview.value = task
} }
@ -190,6 +202,8 @@ const filteredTasks = computed(() => {
return tasks.value.filter(task => { return tasks.value.filter(task => {
// //
if (!filterStatus.value && task.status === 'cancelled') return false if (!filterStatus.value && task.status === 'cancelled') return false
if (filterAppName.value && task.app_name !== filterAppName.value) return false
if (filterVersion.value && !task.app_version.toLowerCase().includes(filterVersion.value.toLowerCase())) return false
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
if (filterStatus.value && task.status !== filterStatus.value) return false if (filterStatus.value && task.status !== filterStatus.value) return false
return true return true
@ -198,7 +212,8 @@ const filteredTasks = computed(() => {
onMounted(async () => { onMounted(async () => {
const res = await authFetch('/api/tasks?limit=100') 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) => { const viewLogs = async (taskId) => {
@ -298,11 +313,18 @@ const downloadObfMaps = (taskId) => {
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
if (!confirm('确定要删除这条打包记录吗?')) return if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
try { try {
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' }) const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId) if (res.ok) {
} catch {} tasks.value = tasks.value.filter(t => t.id !== taskId)
} else {
const data = await res.json().catch(() => ({}))
alert(data.detail || '删除失败,请稍后重试')
}
} catch {
alert('删除失败,请检查网络后重试')
}
} }
const formatTime = (t) => { const formatTime = (t) => {
@ -361,13 +383,13 @@ const errorCategoryLabel = (cat) => {
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; } .config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; } .config-table td { font-size: 14px; }
.config-table th:nth-child(1) { width: 10%; } .config-table th:nth-child(1) { width: 14%; }
.config-table th:nth-child(2) { width: 12%; } .config-table th:nth-child(2) { width: 12%; }
.config-table th:nth-child(3) { width: 9%; } .config-table th:nth-child(3) { width: 10%; }
.config-table th:nth-child(4) { width: 18%; } .config-table th:nth-child(4) { width: 18%; }
.config-table th:nth-child(5) { width: 17%; } .config-table th:nth-child(5) { width: 15%; }
.config-table th:nth-child(6) { width: 12%; } .config-table th:nth-child(6) { width: 12%; }
.config-table th:nth-child(7) { width: 22%; } .config-table th:nth-child(7) { width: 19%; }
.config-table tr:hover { background: #fafafa; } .config-table tr:hover { background: #fafafa; }
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } .build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
@ -434,9 +456,7 @@ const errorCategoryLabel = (cat) => {
.meta-label { font-size: 12px; color: #999; } .meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 13px; color: #333; font-weight: 500; } .meta-value { font-size: 13px; color: #333; font-weight: 500; }
.duration-text { .version-filter { width: 150px; }
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
}
/* 日志操作栏 */ /* 日志操作栏 */
.log-modal-actions { .log-modal-actions {

View File

@ -54,7 +54,8 @@ def tmp_config(tmp_path, monkeypatch):
"1": {"name": "readoor31", "ossFloder": "test"} "1": {"name": "readoor31", "ossFloder": "test"}
}, },
"servers": { "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"], "branches": ["main", "dev"],
})) }))

View File

@ -20,14 +20,32 @@ def test_get_apps(client, tmp_config):
def test_create_app(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.status_code == 200
assert resp.json()["id"] == "2" assert resp.json()["id"] == "100"
# 验证已创建 # 验证已创建
apps = client.get("/api/config/apps").json() apps = client.get("/api/config/apps").json()
assert "2" in apps assert "100" in apps
assert apps["2"]["name"] == "新App" 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): def test_update_app(client, tmp_config):
@ -121,7 +139,13 @@ def test_create_server(client, tmp_config):
"universalLink": "https://new.com", "universalLink": "https://new.com",
}) })
assert resp.status_code == 200 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): def test_delete_server_in_use(client, tmp_config):

View File

@ -167,3 +167,48 @@ def test_cancel_task(mock_queue, client, tmp_config):
# 验证状态已更新 # 验证状态已更新
task = client.get(f"/api/tasks/{task_id}").json() task = client.get(f"/api/tasks/{task_id}").json()
assert task["status"] == "cancelled" 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
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="https://files.example.com/test/iOS/1_2_0_0_0_main.html",
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 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()

View File

@ -3,6 +3,8 @@ import plistlib
from unittest.mock import patch from unittest.mock import patch
from backend.services.distribution import ( from backend.services.distribution import (
_artifact_stem,
delete_published_artifacts,
_write_distribution_files, _write_distribution_files,
_write_download_page, _write_download_page,
_write_manifest, _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): def test_distribution_files_use_current_service_config(tmp_path):
ipa = tmp_path / "source.ipa" ipa = tmp_path / "source.ipa"
ipa.write_bytes(b"ipa") ipa.write_bytes(b"ipa")
@ -35,20 +42,21 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path):
"APPID": "100", "APPID": "100",
"VERSION": "2.0.0", "VERSION": "2.0.0",
"BUILD_TYPE": "App_Store", "BUILD_TYPE": "App_Store",
"SOURCE_BRANCH": "main",
"OSS_FLODER": "readoor", "OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}}, "_upload_config": {"mode": "oss", "oss": {}},
} }
with patch( with patch(
"backend.services.distribution._upload_oss", "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: ) as upload:
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build") download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
uploaded_files = upload.call_args.args[1] uploaded_files = upload.call_args.args[1]
assert len(uploaded_files) == 1 assert len(uploaded_files) == 1
assert uploaded_files[0][0].suffix == ".ipa" 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 == "" assert qr_path == ""
@ -59,6 +67,7 @@ def test_adhoc_distribution_uploads_qrcode(tmp_path):
"APPID": "100", "APPID": "100",
"VERSION": "2.0.0", "VERSION": "2.0.0",
"BUILD_TYPE": "Ad_Hoc", "BUILD_TYPE": "Ad_Hoc",
"SOURCE_BRANCH": "dev",
"OSS_FLODER": "readoor", "OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}}, "_upload_config": {"mode": "oss", "oss": {}},
} }
@ -70,8 +79,21 @@ def test_adhoc_distribution_uploads_qrcode(tmp_path):
with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload: with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload:
download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build") download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build")
assert download_url.endswith("100_2_0_0.html") assert download_url.endswith("100_2_0_0_dev_adhoc.html")
assert qr_url.endswith("100_2_0_0.png") 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] == [ assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [
".ipa", ".plist", ".html", ".png", ".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",
]

View File

@ -54,7 +54,9 @@ def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_conf
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
assert client.get("/api/config/apps", headers=headers).status_code == 200 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 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) versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
assert versions.status_code == 200 assert versions.status_code == 200