"""登录后的业务页面:我的构建、接口令牌、管理仪表盘、用户管理。""" 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)) # 列表打开时并发同步在途任务的真实状态(带短节流,避免频繁刷页面拖慢) from .views import refresh_active_runs refresh_active_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): """停止自己的在途构建:调用构建平台取消运行,并把任务标记为已取消。""" run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user) if run.is_finished(): messages.info(request, "该任务已经结束,无需停止。") return redirect('my_builds') if not run.github_run_id: messages.error(request, "该任务尚未获得构建平台的运行编号,暂时无法停止,请稍后重试。") return redirect('my_builds') from . import build_backends from .build_backends.base import BackendError backend = build_backends.get_backend(run.backend or 'github') try: backend.cancel_run(run.github_run_id) except BackendError as e: if e.kind == 'finished': # 平台侧已结束:立即拉一次真实状态,避免本地仍显示构建中 from .views import refresh_active_run refresh_active_run(run, force=True) if run.is_finished(): messages.info(request, f"构建平台上该任务实际已结束({run.status_label()}),列表状态已同步。") else: messages.error(request, str(e)) elif e.kind == 'missing': # 平台上运行已被删除:本地直接标记取消,避免永远卡在构建中 run.status = 'cancelled' run.save(update_fields=['status', 'updated_at']) messages.info(request, "Gitea 上该运行已不存在(可能已被删除),已在本平台标记为已取消。") elif e.kind == 'unsupported': # 平台版本不支持远程取消(如 Gitea ≤ 1.27):给出运行页,本地状态保持构建中 log_link = backend.log_url(run.github_run_id) messages.error( request, f"无法远程停止:{e.message} 运行页面:{log_link}", ) else: messages.error(request, f"停止失败:{e.message}") return redirect('my_builds') run.status = 'cancelled' run.save(update_fields=['status', 'updated_at']) messages.success(request, f"已请求{run.backend_label}停止构建任务「{run.filename or run.short_uuid()}」。") 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 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]) # 打开仪表盘时并发同步在途任务的真实状态(带短节流) from .views import refresh_active_runs refresh_active_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'), ('GITEA_WEB_USERNAME', 'gitea_web_username'), ('GHUSER', 'ghuser'), ('REPONAME', 'reponame'), ('GHBRANCH', 'ghbranch'), ('GENURL', 'genurl'), ('PROTOCOL', 'protocol'), ] # 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名) _SECRET_FIELDS = [ ('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')), ('GITEA_WEB_PASSWORD', ('gitea_web_password', 'clear_gitea_web_password')), ('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"})