diff --git a/rdgenerator/app_settings.py b/rdgenerator/app_settings.py index efbad24..e832d9a 100644 --- a/rdgenerator/app_settings.py +++ b/rdgenerator/app_settings.py @@ -169,24 +169,6 @@ SETTING_DEFS = [ 'sensitive': True, 'help': '在 Gitea 个人设置中生成的 API 令牌,需对构建仓库授予 Actions 读写权限。页面仅显示保存状态,不会回显明文。', }, - { - 'key': 'GITEA_WEB_USERNAME', - 'env': 'GITEA_WEB_USERNAME', - 'default': '', - 'label': 'Gitea 网页登录账号(可选)', - 'group': 'gitea', - 'sensitive': False, - 'help': '仅 Gitea 1.27 及更早版本需要:这些版本没有取消运行的 API,填账号后本平台可模拟网页登录一键停止构建。留空则停止时给出运行页链接手动 Cancel;升级到 Gitea 1.28+ 后无需填写。建议使用专用账号并授予构建仓库写权限,该账号不能启用两步验证。', - }, - { - 'key': 'GITEA_WEB_PASSWORD', - 'env': 'GITEA_WEB_PASSWORD', - 'default': '', - 'label': 'Gitea 网页登录密码(可选)', - 'group': 'gitea', - 'sensitive': True, - 'help': '与上面的网页登录账号配套,仅用于 Gitea 1.27 及更早版本的一键停止。页面仅显示保存状态,不会回显明文。', - }, { 'key': 'GITEA_PROXY', 'env': 'GITEA_PROXY', diff --git a/rdgenerator/build_backends/base.py b/rdgenerator/build_backends/base.py index 48ca2c7..68aaa92 100644 --- a/rdgenerator/build_backends/base.py +++ b/rdgenerator/build_backends/base.py @@ -18,13 +18,6 @@ class DispatchResult: html_url: str = '' # 平台上的运行详情页地址 -@dataclass -class RunInfo: - finished: bool # 是否已进入终态 - status: str = '' # 终态结论:success/failure/cancelled/skipped;未结束为进行中状态 - html_url: str = '' - - # rdgen 目标平台 -> 工作流文件名(两平台同名) WORKFLOW_FILES = { 'windows': 'generator-windows.yml', @@ -57,22 +50,6 @@ class BuildBackend: """触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。""" raise NotImplementedError - def get_run(self, run_id, *, timeout=12): - """查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。""" - raise NotImplementedError - - def get_runs_batch(self, run_ids, *, timeout=12): - """批量查询多个运行,返回 {str(run_id): RunInfo}。 - - 列表页一次刷新多个在途任务时使用;平台不支持或查询失败返回 None, - 调用方退化为逐个 get_run。返回字典中缺失的 id 由调用方逐个兜底。 - """ - return None - - def cancel_run(self, run_id, *, timeout=15): - """请求平台取消一次运行;成功无返回,已结束时抛 BackendError(kind='finished')。""" - raise NotImplementedError - def log_url(self, run_id) -> str: raise NotImplementedError diff --git a/rdgenerator/build_backends/gitea.py b/rdgenerator/build_backends/gitea.py index ae56ae6..3ae48e7 100644 --- a/rdgenerator/build_backends/gitea.py +++ b/rdgenerator/build_backends/gitea.py @@ -4,31 +4,13 @@ API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实) - 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches ?return_run_details=true,body {ref, inputs},200 返回 {workflow_run_id, html_url, run_url},不传该参数为 204; -- 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id}, - status/conclusion 字段仿 GitHub; - 鉴权:Authorization: token 。 + +注意:本平台不主动查询/取消 Gitea 上的运行。构建状态完全以工作流结束时的 +回调(update_github_run)为准,停止/删除只作用于本平台本地记录。 """ -import re - from .. import app_settings, ghnet -from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend - -# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner) -_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending', 'running'} -# Gitea 原生终态(部分版本 run.status 直接返回结论,不走 completed+conclusion) -_GITEA_TERMINAL = {'success', 'failure', 'cancelled', 'skipped', 'timed_out', 'stopped', 'dead'} -# Gitea 结论 -> rdgen 内部状态 -_CONCLUSION_MAP = { - 'success': 'success', - 'failure': 'failure', - 'cancelled': 'cancelled', - 'skipped': 'skipped', - 'timed_out': 'timed_out', - 'stopped': 'cancelled', - 'dead': 'failure', -} -# 作业出现这些结论时,整个运行最终必然失败(cleanup 等收尾作业除外) -_JOB_BAD_CONCLUSIONS = {'failure', 'cancelled', 'timed_out', 'dead'} +from .base import BackendError, DispatchResult, WORKFLOW_FILES, BuildBackend class GiteaBackend(BuildBackend): @@ -148,289 +130,6 @@ class GiteaBackend(BuildBackend): print(f'查询 Gitea 最新运行失败:{e}') return None - # ---- 状态查询 ---- - def get_run(self, run_id, *, timeout=12): - url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}') - token = app_settings.get_value('GITEA_TOKEN') - proxy = self._proxy() - try: - resp = ghnet.get(url, token=token, timeout=timeout, flavor='gitea', proxy=proxy) - except Exception as e: - print(f'查询 Gitea 状态出错:{e}') - return None - if resp.status_code != 200: - return None - data = resp.json() - html_url = data.get('html_url') or self.log_url(run_id) - status = (data.get('status') or '').lower() - conclusion = (data.get('conclusion') or '').lower() - if status == 'completed': - return RunInfo( - finished=True, - status=_CONCLUSION_MAP.get(conclusion, 'failure'), - html_url=html_url, - ) - # 部分 Gitea 版本直接在 status 返回终态(无 completed 包裹) - if status in _GITEA_TERMINAL: - return RunInfo( - finished=True, - status=_CONCLUSION_MAP.get(status, 'failure'), - html_url=html_url, - ) - # 仅在途运行才需要 jobs:识别「前置作业失败但父运行长期挂起」的 Gitea 已知行为 - jobs_url = self._api( - f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs' - ) - try: - jobs_resp = ghnet.get( - jobs_url, token=token, timeout=timeout, flavor='gitea', proxy=proxy) - except Exception as e: - print(f'查询 Gitea 作业状态出错:{e}') - jobs_resp = None - if jobs_resp is not None: - bad = self._bad_job_conclusion(jobs_resp) - if bad: - return RunInfo(finished=True, status=bad, html_url=html_url) - # blocked(等待审批)等也算进行中 - return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url) - - @staticmethod - def _bad_job_conclusion(resp): - """从 jobs 响应中判定终态:有非 cleanup 作业失败/取消/超时时返回对应状态,否则 None。""" - try: - if resp.status_code != 200: - return None - data = resp.json() - jobs = data.get('jobs', data if isinstance(data, list) else []) - for job in jobs: - name = (job.get('name') or '').lower() - if name.startswith('cleanup'): - continue - conclusion = (job.get('conclusion') or '').lower() - if conclusion in _JOB_BAD_CONCLUSIONS: - return _CONCLUSION_MAP.get(conclusion, 'failure') - except Exception as e: - print(f'解析 Gitea 作业状态出错:{e}') - return None - - # ---- 状态批量查询(列表页用,一次请求取回全部运行) ---- - def get_runs_batch(self, run_ids, *, timeout=12): - """一次 runs 列表请求批量同步状态,避免每个在途任务各发两个请求。 - - 返回 {str(run_id): RunInfo};列表拿不到时返回 None 让调用方退化逐个查询。 - 列表中仍在途的运行,再查 jobs 以识别「前置作业失败但父运行挂起」的僵死; - 列表中不存在的 id(已删除或超出最近窗口)不放进返回字典。 - """ - want = {str(i) for i in run_ids if i} - if not want: - return {} - url = self._api( - f'/repos/{self._owner()}/{self._repo()}/actions/runs?limit=50' - ) - try: - resp = ghnet.get( - url, token=app_settings.get_value('GITEA_TOKEN'), - timeout=timeout, flavor='gitea', proxy=self._proxy(), - ) - except Exception as e: - print(f'批量查询 Gitea 状态出错:{e}') - return None - if resp.status_code != 200: - return None - try: - data = resp.json() - items = data.get('workflow_runs') if isinstance(data, dict) else data - except ValueError: - return None - if not items: - return {} - - result = {} - active = [] # (id, html_url) - for item in items: - rid = item.get('id') - if rid is None or str(rid) not in want: - continue - html_url = item.get('html_url') or self.log_url(rid) - status = (item.get('status') or '').lower() - conclusion = (item.get('conclusion') or '').lower() - if status == 'completed' or status in _GITEA_TERMINAL: - result[str(rid)] = RunInfo( - finished=True, - status=_CONCLUSION_MAP.get(conclusion or status, 'failure'), - html_url=html_url, - ) - else: - active.append((rid, html_url)) - - # 在途运行查 jobs 识别 Gitea 的作业级失败挂起(通常在途任务很少) - if active: - token = app_settings.get_value('GITEA_TOKEN') - proxy = self._proxy() - owner_repo = f'{self._owner()}/{self._repo()}' - - def _probe(rid_html): - rid, html_url = rid_html - jobs_url = self._api( - f'/repos/{owner_repo}/actions/runs/{rid}/jobs' - ) - try: - jobs_resp = ghnet.get( - jobs_url, token=token, timeout=timeout, - flavor='gitea', proxy=proxy, - ) - except Exception: - return str(rid), RunInfo(False, 'in_progress', html_url) - bad = self._bad_job_conclusion(jobs_resp) - if bad: - return str(rid), RunInfo(True, bad, html_url) - return str(rid), RunInfo(False, 'in_progress', html_url) - - from concurrent.futures import ThreadPoolExecutor - with ThreadPoolExecutor(max_workers=min(8, len(active))) as pool: - for key, info in pool.map(_probe, active): - result[key] = info - return result - - # ---- 取消运行 ---- - def cancel_run(self, run_id, *, timeout=15): - """请求平台取消运行。 - - - 2xx:已接受取消; - - 409:运行已结束(调用方应改为拉取真实状态); - - 运行在平台上已不存在(如已被手工删除):kind='missing'; - - 旧版 Gitea(≤1.27)无取消 API:配置了 Web 账密则模拟网页登录取消, - 否则 kind='unsupported',引导用户去运行页手动 Cancel。 - """ - url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/cancel') - try: - resp = ghnet.post( - url, token=app_settings.get_value('GITEA_TOKEN'), - timeout=timeout, flavor='gitea', proxy=self._proxy(), - ) - except Exception as e: - raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network') - if 200 <= resp.status_code < 300: - return - if resp.status_code == 409: - raise BackendError('该构建运行已经结束,无法取消。', kind='finished') - if resp.status_code == 404: - exists = self._run_exists(run_id, timeout=timeout) - if exists is False: - raise BackendError( - '该运行在 Gitea 上已不存在(可能已被删除)。', kind='missing') - # 运行存在但 cancel 端点 404:Gitea ≤ 1.27 没有取消 API - if self._cancel_via_web(run_id, timeout=timeout): - return - raise BackendError( - '当前 Gitea 版本(1.27 及更早)未开放取消运行的 API。' - '可在后台「Gitea 连接」填写 Gitea 网页登录账号以启用一键停止,' - '或在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可直接一键停止。', - kind='unsupported', - ) - detail = '' - try: - detail = (resp.text or '')[:200] - except Exception: - pass - raise BackendError(f'Gitea 拒绝了取消请求(HTTP {resp.status_code}):{detail}') - - def _run_exists(self, run_id, *, timeout=10): - """GET run:存在 True / 404 False / 无法确定 None。""" - url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}') - try: - resp = ghnet.get( - url, token=app_settings.get_value('GITEA_TOKEN'), - timeout=timeout, flavor='gitea', proxy=self._proxy(), - ) - except Exception: - return None - if resp.status_code == 200: - return True - if resp.status_code == 404: - return False - return None - - def _cancel_via_web(self, run_id, *, timeout=20): - """Gitea ≤1.27 无取消 API 时,用网页会话方式取消(需后台配置 Web 账密)。 - - 模拟浏览器:登录拿会话 → 打开运行页拿 CSRF → POST 网页取消路由。 - 注意:部分 1.27 实例的登录页本身不输出 CSRF(登录 POST 豁免), - 但取消操作需要登录后页面里的令牌;未配置账密、登录失败 - (含开启两步验证)时返回 False。 - """ - import requests - - username = app_settings.get_value('GITEA_WEB_USERNAME').strip() - password = app_settings.get_value('GITEA_WEB_PASSWORD') - if not username or not password: - return False - server = self._server() - owner, repo = self._owner(), self._repo() - proxies = ghnet.proxies(self._proxy()) - sess = requests.Session() - sess.proxies.update(proxies) - ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 rdgen' - try: - # 1. 登录页(建立会话;CSRF 若页面有则带上,没有也无妨——该实例登录豁免) - r = sess.get(f'{server}/user/login', timeout=timeout, - headers={'User-Agent': ua}, allow_redirects=True) - csrf = self._extract_csrf(r.text) - login_data = {'user_name': username, 'password': password} - login_headers = {'User-Agent': ua, 'Referer': f'{server}/user/login'} - if csrf: - login_data['_csrf'] = csrf - login_headers['X-Csrf-Token'] = csrf - # 2. 提交登录(Gitea 用 user_name 字段;成功后 302 离开 /user/login) - r = sess.post( - f'{server}/user/login', data=login_data, - headers=login_headers, timeout=timeout, allow_redirects=True, - ) - if '/user/login' in r.url or 'name="password"' in r.text: - print('Gitea 网页取消:登录失败(账号密码错误或启用了两步验证)') - return False - # 3. 打开运行页取 CSRF(登录后页面才输出令牌) - run_page = f'{server}/{owner}/{repo}/actions/runs/{run_id}' - r = sess.get(run_page, timeout=timeout, headers={'User-Agent': ua}) - if r.status_code != 200: - print(f'Gitea 网页取消:运行页 HTTP {r.status_code}(账号可能无该仓库访问权限)') - return False - csrf = self._extract_csrf(r.text) - if not csrf: - print('Gitea 网页取消:登录后运行页仍未找到 CSRF 令牌(账号可能无该仓库写权限)') - return False - # 4. POST 网页取消路由(302/303 回运行页即成功) - r = sess.post( - f'{run_page}/cancel', - data={'_csrf': csrf}, - headers={'User-Agent': ua, 'Referer': run_page, - 'X-Csrf-Token': csrf}, - timeout=timeout, allow_redirects=False, - ) - if r.status_code in (200, 302, 303): - return True - print(f'Gitea 网页取消:取消请求被拒绝 HTTP {r.status_code}') - return False - except Exception as e: - print(f'Gitea 网页取消出错:{e}') - return False - - @staticmethod - def _extract_csrf(html): - """从 Gitea 页面提取 CSRF:优先 meta,其次隐藏域,最后前端 JSON 配置。""" - if not html: - return None - for pattern in ( - r'name="_csrf"\s+content="([^"]+)"', - r'content="([^"]+)"\s+name="_csrf"', - r'name="_csrf"\s+value="([^"]+)"', - r'"(?:csrfToken|_csrf)"\s*:\s*"([^"]+)"', - ): - m = re.search(pattern, html) - if m: - return m.group(1) - return None - # ---- 日志链接 ---- def log_url(self, run_id): if not run_id: diff --git a/rdgenerator/build_backends/github.py b/rdgenerator/build_backends/github.py index a9632f3..e6492b5 100644 --- a/rdgenerator/build_backends/github.py +++ b/rdgenerator/build_backends/github.py @@ -1,12 +1,13 @@ -"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。""" +"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。 + +注意:本平台不主动查询/取消 GitHub 上的运行。构建状态完全以工作流结束时的 +回调(update_github_run)为准,停止/删除只作用于本平台本地记录。 +""" from .. import app_settings, ghnet -from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend +from .base import BackendError, DispatchResult, WORKFLOW_FILES, BuildBackend API = 'https://api.github.com' -# 未进入 completed 的状态一律视为进行中 -_GITHUB_ACTIVE = {'queued', 'in_progress', 'waiting', 'requested', 'pending'} - class GitHubBackend(BuildBackend): name = 'github' @@ -56,40 +57,6 @@ class GitHubBackend(BuildBackend): pass return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '') - # ---- 状态查询 ---- - def get_run(self, run_id, *, timeout=12): - user = app_settings.get_value('GHUSER') - repo = app_settings.get_value('REPONAME') - url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}' - try: - resp = ghnet.get(url, token=app_settings.get_value('GHBEARER'), timeout=timeout) - except Exception as e: - print(f'查询 GitHub 状态出错:{e}') - return None - if resp.status_code != 200: - return None - data = resp.json() - if data.get('status') == 'completed': - return RunInfo(finished=True, status=data.get('conclusion') or 'failure', - html_url=data.get('html_url') or self.log_url(run_id)) - return RunInfo(finished=False, status=data.get('status') or 'in_progress', - html_url=data.get('html_url') or self.log_url(run_id)) - - # ---- 取消运行 ---- - def cancel_run(self, run_id, *, timeout=15): - user = app_settings.get_value('GHUSER') - repo = app_settings.get_value('REPONAME') - url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}/cancel' - try: - resp = ghnet.post(url, token=app_settings.get_value('GHBEARER'), timeout=timeout) - except Exception as e: - raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network') - if 200 <= resp.status_code < 300: - return - if resp.status_code == 409: - raise BackendError('该构建运行已经结束,无法取消。', kind='finished') - raise BackendError(f'GitHub 拒绝了取消请求(HTTP {resp.status_code})。') - # ---- 日志链接 ---- def log_url(self, run_id): if not run_id: diff --git a/rdgenerator/forms.py b/rdgenerator/forms.py index 70c413f..b7ed4d6 100644 --- a/rdgenerator/forms.py +++ b/rdgenerator/forms.py @@ -307,18 +307,6 @@ class GitHubSettingsForm(forms.Form): required=False, ) clear_gitea_token = forms.BooleanField(label="清除后台保存的令牌", required=False) - gitea_web_username = forms.CharField( - label="Gitea 网页登录账号(可选)", - max_length=100, - required=False, - widget=forms.TextInput(attrs={'placeholder': '仅 Gitea 1.27 及更早版本一键停止时需要'}), - ) - gitea_web_password = forms.CharField( - label="Gitea 网页登录密码(可选)", - widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}), - required=False, - ) - clear_gitea_web_password = forms.BooleanField(label="清除后台保存的网页登录密码", required=False) gitea_proxy = forms.CharField( label="Gitea 访问代理", widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '内网直连通常留空,如 http://host:port'}), diff --git a/rdgenerator/views.py b/rdgenerator/views.py index 97718f0..7809576 100644 --- a/rdgenerator/views.py +++ b/rdgenerator/views.py @@ -2,7 +2,6 @@ import io import json import os import re -import time import uuid from datetime import timedelta from pathlib import Path @@ -33,114 +32,35 @@ from .models import GithubRun, STATUS_LABELS, STATUS_BADGE # 终态状态集合 TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped') -# 正常构建约 30~45 分钟;超过该时长平台仍显示进行中的,视为 runner 离线/作业僵死 +# 正常构建约 30~45 分钟;超过该时长仍在进行中的,视为 runner 离线/作业僵死。 +# 状态以构建脚本结束回调为准,页面不再向构建平台主动查询;超时兜底只做本地判断。 STALE_BUILD_HOURS = 6 -# 列表页状态同步节流:同一在途任务两次主动查询的最小间隔,避免每次刷页面都串行请求平台 -REFRESH_THROTTLE_SECONDS = 20 -# 列表/仪表盘同步用短超时,平台抖动时不能拖垮页面 -REFRESH_TIMEOUT = 8 -_refresh_stamps = {} -def _refresh_due(run_pk): - now = time.monotonic() - ts = _refresh_stamps.get(run_pk, 0) - if now - ts >= REFRESH_THROTTLE_SECONDS: - # 发起前先占位,避免并发请求堆积 - _refresh_stamps[run_pk] = now - return True - return False +def mark_stale_builds(gh_runs=None): + """纯本地兜底:在途任务超过 STALE_BUILD_HOURS 未收到结束回调的,标记为超时。 - -def refresh_active_run(gh_run, *, force=False, timeout=REFRESH_TIMEOUT): - """向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。 - - 同时兜底两种平台侧的僵死: - - Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起; - - 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。 - - force=False 时按 REFRESH_THROTTLE_SECONDS 节流(列表/仪表盘用); - force=True 跳过节流立即查询(详情页、停止操作后用),但同样刷新节流戳, - 使紧接着的列表打开不再重复请求。 + 不产生任何网络请求。传入任务列表时只处理这些任务;不传则扫描全表。 """ - if gh_run.is_finished() or not gh_run.github_run_id: - return False - if not force and not _refresh_due(gh_run.pk): - return False - _refresh_stamps[gh_run.pk] = time.monotonic() - backend = build_backends.get_backend(gh_run.backend or 'github') - try: - info = backend.get_run(gh_run.github_run_id, timeout=timeout) - except Exception as e: - print(f"查询构建状态出错:{e}") - return False - if info is None: - return False - return _apply_run_info(gh_run, info) + cutoff = timezone.now() - timedelta(hours=STALE_BUILD_HOURS) + qs = GithubRun.objects.exclude(status__in=TERMINAL_STATUSES) + if gh_runs is not None: + ids = [r.pk for r in gh_runs if not r.is_finished()] + qs = qs.filter(pk__in=ids) + qs.filter(created_at__lt=cutoff).update( + status='timed_out', updated_at=timezone.now()) -def _apply_run_info(gh_run, info): - """把平台返回的状态落库;终态与 6 小时僵死兜底均在此。返回是否有变化。""" - new_status = info.status if info.finished else None - if new_status is None and timezone.now() - gh_run.created_at > timedelta(hours=STALE_BUILD_HOURS): - new_status = 'timed_out' - if new_status and new_status != gh_run.status: - gh_run.status = new_status +def mark_run_stale_if_needed(gh_run): + """单个任务的纯本地超时兜底(详情/状态接口用)。""" + if not gh_run.is_finished() and gh_run.created_at < \ + timezone.now() - timedelta(hours=STALE_BUILD_HOURS): + gh_run.status = 'timed_out' gh_run.save(update_fields=['status', 'updated_at']) return True return False -def refresh_active_runs(gh_runs, *, force=False): - """并发同步一批在途任务(列表页/仪表盘),整页只承受一次平台往返的耗时。 - - Gitea 优先用 runs 列表接口一次取回全部状态(在途任务再补 jobs 探测); - 其他后端或批量接口不可用时退化为并发逐个查询。 - """ - due = [] - for run in gh_runs: - if run.is_finished() or not run.github_run_id: - continue - if _refresh_due(run.pk) or force: - _refresh_stamps[run.pk] = time.monotonic() - due.append(run) - if not due: - return - - # 按后端分组批量 - groups = {} - for run in due: - groups.setdefault(run.backend or 'github', []).append(run) - for backend_name, runs in groups.items(): - backend = build_backends.get_backend(backend_name) - get_runs_batch = getattr(backend, 'get_runs_batch', None) - infos = None - if get_runs_batch is not None: - try: - infos = get_runs_batch( - [r.github_run_id for r in runs], timeout=REFRESH_TIMEOUT) - except Exception as e: - print(f"批量查询构建状态出错:{e}") - infos = None - if infos is None: - from concurrent.futures import ThreadPoolExecutor - workers = min(8, len(runs)) - with ThreadPoolExecutor(max_workers=workers) as pool: - list(pool.map(lambda r: refresh_active_run(r, force=force), runs)) - continue - missing = [run for run in runs if infos.get(str(run.github_run_id)) is None] - if missing: - # 列表窗口里没有的运行(已删除或超出最近 50 条):并发逐个查询兜底 - from concurrent.futures import ThreadPoolExecutor - workers = min(8, len(missing)) - with ThreadPoolExecutor(max_workers=workers) as pool: - list(pool.map(lambda r: refresh_active_run(r, force=True), missing)) - for run in runs: - info = infos.get(str(run.github_run_id)) - if info is not None: - _apply_run_info(run, info) - - def _webhook_denied(request): """配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。""" secret = app_settings.get_value('WEBHOOK_SECRET') @@ -502,7 +422,8 @@ def _get_run_status(uuid_val): backend = build_backends.get_backend(gh_run.backend or 'github') github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else '' - refresh_active_run(gh_run, force=True, timeout=12) + # 状态以构建脚本结束回调为准;这里只做纯本地的 6 小时超时兜底,不请求构建平台 + mark_run_stale_if_needed(gh_run) return { "found": True, diff --git a/rdgenerator/web_views.py b/rdgenerator/web_views.py index fa32dba..c37c7ab 100644 --- a/rdgenerator/web_views.py +++ b/rdgenerator/web_views.py @@ -32,9 +32,9 @@ def staff_required(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) + # 仅做纯本地的 6 小时超时兜底,不向构建平台发起任何请求,页面秒开 + from .views import mark_stale_builds + mark_stale_builds(builds) return render(request, 'my_builds.html', {'builds': builds}) @@ -62,48 +62,24 @@ def delete_my_build(request, run_id): @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, "该任务已经结束,无需停止。") - 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: + 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: - 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()}」。") + tip += "(仅本地标记)" + messages.success(request, tip) return redirect('my_builds') @@ -170,9 +146,9 @@ def delete_token(request, key): @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) + # 仅做纯本地的 6 小时超时兜底,不向构建平台发起任何请求,页面秒开 + from .views import mark_stale_builds + mark_stale_builds(builds) stats = { 'total': GithubRun.objects.count(), 'in_progress': GithubRun.objects.exclude( @@ -318,7 +294,6 @@ _PLAIN_FIELDS = [ ('GITEA_OWNER', 'gitea_owner'), ('GITEA_REPO', 'gitea_repo'), ('GITEA_BRANCH', 'gitea_branch'), - ('GITEA_WEB_USERNAME', 'gitea_web_username'), ('GHUSER', 'ghuser'), ('REPONAME', 'reponame'), ('GHBRANCH', 'ghbranch'), @@ -328,7 +303,6 @@ _PLAIN_FIELDS = [ # 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名) _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')),