Files
rdgen/rdgenerator/web_views.py
T
naeeo d78c071818 构建状态增加平台终态只读核对,修复回调丢失后任务长期卡在构建中
工作流在脚本收尾前失败(拉取/编译/runner 异常)或 GENURL 不可达时,
结束回调会永久丢失,而等待页 30 秒轮询与列表页此前只读本地库,
任务只能等 6 小时本地超时,平台侧已失败也无法及时反映。

- GitHub/Gitea 后端新增只读 get_run(),仅在平台报告终态时返回结论,
  在途、非 200、网络异常、坏 JSON 一律返回 None;不取消、不写平台
- 等待页 30 秒轮询同步核对一次真实状态:20 秒节流、6 秒超时、异常静默
- 我的构建/后台仪表盘改为后台守护线程核对,页面渲染零等待
- 仅做非终态到终态的条件更新,回调抢先到达时不会被覆盖
- 停止/删除纯本地语义、6 小时本地超时兜底等现有逻辑保持不变
2026-09-30 12:46:05 +08:00

547 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""登录后的业务页面:我的构建、接口令牌、管理仪表盘、用户管理。"""
import json
import os
import re
import shutil
from urllib.parse import quote
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.db.models import Count
from django.http import (
JsonResponse, HttpResponseRedirect, HttpResponse, Http404, FileResponse,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from . import app_settings, build_backends, maintenance
from .forms import (
AdminUserCreateForm, AdminUserEditForm, ApiTokenForm, GenerateForm, GitHubSettingsForm,
MaintenanceSettingsForm,
)
from .models import ApiToken, GithubRun, SavedConfig
def staff_required(view_func):
"""仅管理员(is_staff)可访问。"""
return user_passes_test(lambda u: u.is_authenticated and u.is_staff)(view_func)
@login_required
def my_builds(request):
builds = list(GithubRun.objects.filter(created_by=request.user))
# 回调为第一状态来源;本地 6 小时超时兜底 + 后台线程只读核对平台终态(不阻塞页面)
from .views import mark_stale_builds, schedule_refresh_for_runs
mark_stale_builds(builds)
schedule_refresh_for_runs(builds)
return render(request, 'my_builds.html', {'builds': builds})
def _remove_run_artifacts(run):
"""删除任务对应的本地安装包与图片目录(忽略不存在的情况)。"""
if run.uuid:
shutil.rmtree(os.path.join('exe', run.uuid), ignore_errors=True)
shutil.rmtree(os.path.join('png', run.uuid), ignore_errors=True)
@login_required
@require_POST
def delete_my_build(request, run_id):
"""用户删除自己的已结束任务;进行中的任务不允许删除(避免与 GitHub 回调竞态)。"""
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
if not run.is_finished():
messages.error(request, "该任务仍在进行中,请先点「停止」取消构建(或等待结束)后再删除。")
else:
_remove_run_artifacts(run)
run.delete()
messages.success(request, f"构建任务「{run.filename or run.short_uuid()}」及本地产物已删除。")
return redirect('my_builds')
@login_required
@require_POST
def stop_build(request, run_id):
"""停止构建:仅在本平台把任务标记为已取消,不调用 GitHub/Gitea 的远程取消接口。
构建平台上的流水线可能仍在继续运行,需要真正停止时请到对应平台的运行页手动 Cancel。
"""
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
if run.is_finished():
messages.info(request, "该任务已经结束,无需停止。")
else:
run.status = 'cancelled'
run.save(update_fields=['status', 'updated_at'])
tip = "已在本平台停止构建任务"
if run.github_run_id:
from . import build_backends
backend = build_backends.get_backend(run.backend or 'github')
tip += f"(仅本地标记;{run.backend_label}上的流水线若仍在运行,请前往运行页手动 Cancel:{backend.log_url(run.github_run_id)})"
else:
tip += "(仅本地标记)"
messages.success(request, tip)
return redirect('my_builds')
@login_required
@require_POST
def retry_build(request, run_id):
"""用任务提交时保存的配置快照重新派发一个新构建(原任务保留作历史)。"""
qs = GithubRun.objects.all() if request.user.is_staff else GithubRun.objects.filter(created_by=request.user)
run = get_object_or_404(qs, pk=run_id)
if not run.is_finished():
messages.error(request, "该任务尚未结束,无需重试。")
return redirect('my_builds')
if run.status == 'success':
messages.info(request, "该任务已构建成功,请直接下载产物;如需重新生成请重新提交配置。")
return redirect('my_builds')
if not run.config_data:
messages.error(request, "该任务提交时未保存配置快照(较早的历史任务),无法一键重试,请重新填写配置后提交。")
return redirect('my_builds')
# 局部导入避免与 views 模块在导入期形成耦合
from .views import generate_custom_client
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
# 新任务归属原提交者(管理员代重试时也保持原归属);匿名历史任务归当前操作者
owner = run.created_by or request.user
result = generate_custom_client(dict(run.config_data), full_url, user=owner)
if result.get('success'):
messages.success(
request,
f"已用原配置重新提交构建(新任务:{result.get('filename', '')}),可在下方查看进度。",
)
else:
messages.error(request, f"重试失败:{result.get('error', '未知错误')}")
return redirect('my_builds')
@login_required
def api_tokens(request):
new_plain_key = None
if request.method == 'POST':
form = ApiTokenForm(request.POST)
if form.is_valid():
token = ApiToken.objects.create(user=request.user, name=form.cleaned_data['name'])
new_plain_key = token.key
messages.success(request, "接口令牌创建成功,请立即复制保存(离开本页后将不再完整显示)。")
form = ApiTokenForm()
else:
form = ApiTokenForm()
tokens = ApiToken.objects.filter(user=request.user)
return render(request, 'tokens.html', {
'form': form,
'tokens': tokens,
'new_plain_key': new_plain_key,
})
@login_required
@require_POST
def delete_token(request, key):
token = get_object_or_404(ApiToken, key=key, user=request.user)
token.delete()
messages.success(request, "接口令牌已删除。")
return redirect('api_tokens')
@staff_required
def dashboard(request):
builds = list(GithubRun.objects.select_related('created_by').all()[:100])
# 回调为第一状态来源;本地 6 小时超时兜底 + 后台线程只读核对平台终态(不阻塞页面)
from .views import mark_stale_builds, schedule_refresh_for_runs
mark_stale_builds(builds)
schedule_refresh_for_runs(builds)
stats = {
'total': GithubRun.objects.count(),
'in_progress': GithubRun.objects.exclude(
status__in=['success', 'failure', 'cancelled', 'timed_out', 'skipped']
).count(),
'success': GithubRun.objects.filter(status='success').count(),
'failure': GithubRun.objects.filter(status__in=['failure', 'timed_out', 'cancelled']).count(),
'users': User.objects.count(),
'tokens': ApiToken.objects.filter(is_active=True).count(),
}
return render(request, 'dashboard.html', {'builds': builds, 'stats': stats})
@staff_required
@require_POST
def delete_build(request, run_id):
run = get_object_or_404(GithubRun, pk=run_id)
_remove_run_artifacts(run)
run.delete()
messages.success(request, "构建任务及本地产物已删除。")
return redirect('dashboard')
# ===== 维护设置(定时清理 + 数据库备份) =====
@staff_required
def maintenance_settings(request):
if request.method == 'POST':
form = MaintenanceSettingsForm(request.POST)
if form.is_valid():
app_settings.set_value(
'BUILD_RETENTION_DAYS', str(form.cleaned_data['build_retention_days'])
)
app_settings.set_value(
'DB_BACKUP_ENABLED', '1' if form.cleaned_data['db_backup_enabled'] else '0'
)
app_settings.set_value(
'DB_BACKUP_KEEP', str(form.cleaned_data['db_backup_keep'])
)
messages.success(request, "维护设置已保存,定时任务下一轮检查时自动生效。")
return redirect('maintenance_settings')
else:
form = MaintenanceSettingsForm(initial={
'build_retention_days': maintenance.retention_days(),
'db_backup_enabled': maintenance.backup_enabled(),
'db_backup_keep': maintenance.backup_keep(),
})
return render(request, 'maintenance_settings.html', {
'form': form,
'status': maintenance.maintenance_status(),
})
@staff_required
@require_POST
def maintenance_run_now(request):
"""手动立即执行一轮维护(清理过期任务 + 立即备份数据库)。"""
result = maintenance.run_maintenance(force_backup=True)
if result['backup_created']:
backup_name = os.path.basename(result['backup_created'])
messages.success(
request,
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
f"临时文件 {result['deleted_temp_files']} 个,并已创建新备份 {backup_name}。",
)
else:
messages.success(
request,
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
f"临时文件 {result['deleted_temp_files']} 个;本轮未创建数据库备份。",
)
return redirect('maintenance_settings')
@staff_required
def download_backup(request, filename):
"""下载指定的数据库备份文件(文件名严格白名单,防路径穿越)。"""
if not re.fullmatch(r'db-\d{8}-\d{6}\.sqlite3', filename):
raise Http404("非法的备份文件名")
file_path = maintenance.backup_dir() / filename
if not file_path.is_file():
raise Http404("备份文件不存在")
return FileResponse(
open(file_path, 'rb'),
content_type='application/octet-stream',
as_attachment=True,
filename=filename,
)
@staff_required
def user_list(request):
if request.method == 'POST':
form = AdminUserCreateForm(request.POST)
if form.is_valid():
User.objects.create_user(
username=form.cleaned_data['username'],
email=form.cleaned_data.get('email', ''),
password=form.cleaned_data['password'],
is_staff=form.cleaned_data.get('is_staff', False),
)
messages.success(request, f"用户「{form.cleaned_data['username']}」创建成功。")
return redirect('user_list')
else:
form = AdminUserCreateForm()
users = User.objects.annotate(build_count=Count('builds')).order_by('-date_joined')
return render(request, 'user_list.html', {'users': users, 'form': form})
@staff_required
def user_edit(request, user_id):
target = get_object_or_404(User, pk=user_id)
if request.method == 'POST':
form = AdminUserEditForm(request.POST)
if form.is_valid():
target.email = form.cleaned_data.get('email', '')
target.is_active = form.cleaned_data.get('is_active', False)
target.is_staff = form.cleaned_data.get('is_staff', False)
new_password = form.cleaned_data.get('new_password')
if new_password:
target.set_password(new_password)
# 管理员修改自己的密码后保持登录状态
if request.user.pk == target.pk:
update_session_auth_hash(request, target)
target.save()
messages.success(request, f"用户「{target.username}」已更新。")
return redirect('user_list')
else:
form = AdminUserEditForm(initial={
'email': target.email,
'is_active': target.is_active,
'is_staff': target.is_staff,
})
return render(request, 'user_form.html', {'form': form, 'target': target})
# 非密钥配置项:设置键 -> 表单字段名
_PLAIN_FIELDS = [
('BUILD_PLATFORM', 'build_platform'),
('GITEA_SERVER_URL', 'gitea_server_url'),
('GITEA_OWNER', 'gitea_owner'),
('GITEA_REPO', 'gitea_repo'),
('GITEA_BRANCH', 'gitea_branch'),
('GHUSER', 'ghuser'),
('REPONAME', 'reponame'),
('GHBRANCH', 'ghbranch'),
('GENURL', 'genurl'),
('PROTOCOL', 'protocol'),
]
# 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名)
_SECRET_FIELDS = [
('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')),
('GITEA_PROXY', ('gitea_proxy', 'clear_gitea_proxy')),
('GHBEARER', ('ghbearer', 'clear_ghbearer')),
('ZIP_PASSWORD', ('zip_password', 'clear_zip_password')),
('SH_SECRET', ('sh_secret', 'clear_sh_secret')),
('WEBHOOK_SECRET', ('webhook_secret', 'clear_webhook_secret')),
('GH_PROXY', ('gh_proxy', 'clear_gh_proxy')),
]
def _settings_rows(form):
"""把配置定义与绑定后的表单字段组合成渲染行。"""
rows = []
for item in app_settings.all_settings():
field_name = item['key'].lower()
# 仅渲染本表单包含的设置组(maintenance 组在独立维护页处理)
if field_name not in form.fields:
continue
row = dict(item)
row['field'] = form[field_name]
row['source_label'] = app_settings.SOURCE_LABELS[row['source']]
if item['sensitive']:
row['clear_field'] = form[f'clear_{field_name}']
rows.append(row)
return rows
@staff_required
def github_settings(request):
"""后台 GitHub 构建配置:DB 覆盖优先,环境变量继续作为回退。"""
if request.method == 'POST':
form = GitHubSettingsForm(request.POST)
if form.is_valid():
cleaned = form.cleaned_data
# 非密钥项:提交值与环境变量(或默认值)一致时不写覆盖,保持来源可追溯
for key, field_name in _PLAIN_FIELDS:
value = cleaned.get(field_name, '').strip()
env_value = os.environ.get(app_settings.DEFS_BY_KEY[key]['env'])
if env_value is not None:
fallback = env_value.strip()
else:
fallback = app_settings.DEFS_BY_KEY[key]['default']
if value == fallback:
app_settings.delete_value(key)
else:
app_settings.set_value(key, value)
# 密钥项:勾选清除则回退;填写新值则覆盖;留空保持不变
for key, (field_name, clear_name) in _SECRET_FIELDS:
if cleaned.get(clear_name):
app_settings.delete_value(key)
elif cleaned.get(field_name):
app_settings.set_value(key, cleaned[field_name])
messages.success(request, "构建平台配置已保存。")
return redirect('github_settings')
else:
# 仅回填非密钥项;密钥绝不回显明文
initial = {
item['key'].lower(): item['value']
for item in app_settings.all_settings()
if not item['sensitive']
}
form = GitHubSettingsForm(initial=initial)
rows = _settings_rows(form)
groups = [
('platform', '构建平台', '选择实际执行构建任务的平台;切换后仅影响新提交的任务,历史任务仍在原平台查询。'),
('gitea', 'Gitea 连接', '自建 Gitea(建议 1.27 或更高版本)的服务器、令牌与仓库信息;构建机(Linux/Windows/macOS runner)需自行准备。'),
('basic', 'GitHub 基础连接', 'GitHub 仓库与访问令牌,用于触发 Actions 构建。'),
('callback', '回调与加密', '构建产物回传地址与配置包加密,两个平台共用,需与仓库 Secret 保持一致。'),
('advanced', '高级 / 安全', '回调签名校验两平台共用;自托管 Runner 密钥仅 GitHub 自托管构建需要,均按需配置。'),
('network', 'GitHub 网络代理', '服务器访问 GitHub(触发构建、查询状态、拉取版本列表)的网络出口;能直连则无需配置。'),
]
return render(request, 'site_settings.html', {
'rows': rows,
'groups': groups,
'source_labels': app_settings.SOURCE_LABELS,
'build_platform': build_backends.current_platform(),
'github_ready': app_settings.is_github_configured(),
'warnings': app_settings.config_warnings(),
'gh_user': app_settings.get_value('GHUSER'),
'gh_repo': app_settings.get_value('REPONAME'),
'gitea_server': app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/'),
'gitea_owner': app_settings.get_value('GITEA_OWNER'),
'gitea_repo': app_settings.get_value('GITEA_REPO'),
})
@staff_required
@require_POST
def test_build(request):
"""测试当前所选(或表单中刚选的)构建平台的连接。"""
platform = (request.POST.get('platform') or '').strip().lower()
if platform not in build_backends.BACKENDS:
platform = build_backends.current_platform()
result = build_backends.get_backend(platform).test_connection()
return JsonResponse(result)
@staff_required
@require_POST
def test_github(request):
"""兼容旧路由:直接测试 GitHub 连接。"""
return JsonResponse(build_backends.get_backend('github').test_connection())
# ===== 构建配置方案(与账号绑定,可载入/导出/导入) =====
# 允许进入方案 JSON 的字段(即 GenerateForm 全部字段;图片以 data URL 字符串存储)
_PROFILE_KEYS = set(GenerateForm.base_fields.keys())
_PROFILE_MAX_BYTES = 4 * 1024 * 1024 # 含三张 base64 图片,上限 4MB
def _sanitize_profile_data(raw):
"""校验并清洗方案 JSON:仅保留表单已知字段的原始值。"""
if not isinstance(raw, dict):
raise ValueError("配置内容必须是 JSON 对象。")
cleaned = {}
for key, value in raw.items():
if key not in _PROFILE_KEYS:
continue
if isinstance(value, bool) or value is None:
if value is not None:
cleaned[key] = value
elif isinstance(value, (str, int, float)):
cleaned[key] = value
if len(json.dumps(cleaned, ensure_ascii=False).encode('utf-8')) > _PROFILE_MAX_BYTES:
raise ValueError("配置文件过大(含图片上限 4MB)。")
return cleaned
def _parse_profile_upload(upload):
try:
raw_text = upload.read().decode('utf-8')
except (UnicodeDecodeError, AttributeError):
raise ValueError("配置文件编码无效,请上传本平台导出的 UTF-8 JSON 文件。")
if len(raw_text.encode('utf-8')) > _PROFILE_MAX_BYTES:
raise ValueError("配置文件过大(上限 4MB)。")
try:
raw = json.loads(raw_text)
except (json.JSONDecodeError, ValueError):
raise ValueError("配置文件不是有效的 JSON。")
return _sanitize_profile_data(raw)
@login_required
def saved_configs(request):
"""配置方案管理页:列表、删除、导出、导入。"""
if request.method == 'POST':
upload = request.FILES.get('config_file')
name = request.POST.get('name', '').strip()
if not upload:
messages.error(request, "请选择要导入的 .json 配置文件。")
return redirect('saved_configs')
if not name:
name = os.path.splitext(upload.name)[0].strip()
name = name[:100]
if not name:
messages.error(request, "请填写方案名称。")
return redirect('saved_configs')
if SavedConfig.objects.filter(owner=request.user, name=name).exists():
messages.error(request, f"已存在同名方案「{name}」,请改名后再导入,或先删除原方案。")
return redirect('saved_configs')
try:
data = _parse_profile_upload(upload)
except ValueError as e:
messages.error(request, str(e))
return redirect('saved_configs')
SavedConfig.objects.create(owner=request.user, name=name, data=data)
messages.success(request, f"配置方案「{name}」导入成功,可在客户端定制页直接载入。")
return redirect('saved_configs')
configs = request.user.saved_configs.all()
return render(request, 'saved_configs.html', {'configs': configs})
@login_required
@require_POST
def save_config(request):
"""定制页「另存为方案」:AJAX 提交 {name, data, overwrite?}。"""
try:
payload = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'ok': False, 'message': '请求内容不是有效的 JSON。'}, status=400)
name = (payload.get('name') or '').strip()[:100]
if not name:
return JsonResponse({'ok': False, 'message': '请填写方案名称。'}, status=400)
try:
data = _sanitize_profile_data(payload.get('data'))
except ValueError as e:
return JsonResponse({'ok': False, 'message': str(e)}, status=400)
existing = SavedConfig.objects.filter(owner=request.user, name=name).first()
if existing and not payload.get('overwrite'):
return JsonResponse({'ok': False, 'message': f'已存在同名方案「{name}」,是否覆盖?', 'duplicate': True}, status=409)
if existing:
existing.data = data
existing.save(update_fields=['data', 'updated_at'])
return JsonResponse({'ok': True, 'id': existing.id, 'overwritten': True})
cfg = SavedConfig.objects.create(owner=request.user, name=name, data=data)
return JsonResponse({'ok': True, 'id': cfg.id, 'overwritten': False})
@login_required
def config_data(request, pk):
"""供定制页下拉载入:返回方案完整 JSON。"""
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
return JsonResponse({'name': cfg.name, 'data': cfg.data})
@login_required
def export_config(request, pk):
"""把方案导出为 .json 文件(格式与定制页本地导出一致,可互导/供 cli 使用)。"""
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
body = json.dumps(cfg.data, ensure_ascii=False, indent=2)
response = HttpResponse(body, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(cfg.name)}.json"
return response
@login_required
@require_POST
def delete_config(request, pk):
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
name = cfg.name
cfg.delete()
messages.success(request, f"配置方案「{name}」已删除。")
return redirect('saved_configs')
def healthz(request):
"""容器健康检查端点,无需登录。"""
return JsonResponse({"status": "ok"})