diff --git a/app/services/api_executor.py b/app/services/api_executor.py index 9de43a5..2eba13f 100644 --- a/app/services/api_executor.py +++ b/app/services/api_executor.py @@ -1,4 +1,4 @@ -import json +import json import logging import time from datetime import datetime @@ -6,10 +6,9 @@ from typing import Any, Dict, Optional import requests from requests import Response - -from app.extensions import db from flask import current_app +from app.extensions import db from app.models import ApiCallLog, ApiConfig logger = logging.getLogger(__name__) @@ -27,6 +26,7 @@ def _parse_json_field(raw: Optional[str]) -> Optional[Dict[str, Any]]: def execute_api(api_config: ApiConfig) -> None: """ 根据配置调用 API,带重试,并将每次尝试都记录为日志(保留历史)。 + 支持流式响应:边接收边写入 response_body,便于前端实时查看。 """ request_time = datetime.utcnow() headers = _parse_json_field(api_config.headers) or {} @@ -46,9 +46,11 @@ def execute_api(api_config: ApiConfig) -> None: while attempt < total_attempts: last_response: Optional[Response] = None + log_entry: Optional[ApiCallLog] = None start_ts = time.time() attempt += 1 try: + # stream=True 便于流式响应实时写日志;非流式也兼容 last_response = requests.request( method=api_config.http_method, url=api_config.url, @@ -57,29 +59,74 @@ def execute_api(api_config: ApiConfig) -> None: data=None if json_body is not None else data, json=json_body, timeout=timeout, + stream=True, ) + + # 创建进行中的日志,前端可以立即看到“执行中” + log_entry = ApiCallLog( + api_id=api_config.id, + request_time=request_time, + success=None, # 进行中 + http_status_code=last_response.status_code, + response_body="", + ) + db.session.add(log_entry) + db.session.commit() + + max_body_length = 20000 # 避免日志过大 + last_flush = time.time() + encoding = last_response.encoding or "utf-8" + for chunk in last_response.iter_content(chunk_size=1024): + if not chunk: + continue + try: + text_part = chunk.decode(encoding, errors="ignore") + except Exception: + text_part = "" + if not text_part: + continue + + current_body = log_entry.response_body or "" + if len(current_body) < max_body_length: + log_entry.response_body = (current_body + text_part)[:max_body_length] + + now = time.time() + # 每秒刷新一次数据库,兼顾实时性与写入开销 + if now - last_flush >= 1: + db.session.commit() + last_flush = now + + # 流结束后,根据状态码判定是否成功 if 200 <= last_response.status_code < 300: last_error = None else: - body_snippet = (last_response.text or "")[:200] + body_snippet = (log_entry.response_body or "")[:200] last_error = f"Non-2xx status: {last_response.status_code}, body: {body_snippet}" except requests.RequestException as exc: last_error = str(exc) + except Exception as exc: + last_error = str(exc) - duration_ms = int((time.time() - start_ts) * 1000) + end_time = datetime.utcnow() + duration_ms = int((end_time - request_time).total_seconds() * 1000) success = last_error is None - log_entry = ApiCallLog( - api_id=api_config.id, - request_time=request_time, - response_time=datetime.utcnow(), - success=success, - http_status_code=last_response.status_code if last_response else None, - error_message=(f"[第{attempt}次尝试/{total_attempts}] {last_error}" if last_error else None), - response_body=(last_response.text[:2000] if last_response and last_response.text else None), - duration_ms=duration_ms, - ) - db.session.add(log_entry) + # 更新日志最终状态;如果之前没有创建(例如请求异常前失败),则创建一条 + if log_entry is None: + log_entry = ApiCallLog( + api_id=api_config.id, + request_time=request_time, + ) + db.session.add(log_entry) + + log_entry.response_time = end_time + log_entry.duration_ms = duration_ms + log_entry.success = success + if last_response: + log_entry.http_status_code = last_response.status_code + if last_error: + log_entry.error_message = f"[第{attempt}次尝试/{total_attempts}] {last_error}" + try: db.session.commit() except Exception: diff --git a/app/templates/logs/detail.html b/app/templates/logs/detail.html index 900002c..71bbb45 100644 --- a/app/templates/logs/detail.html +++ b/app/templates/logs/detail.html @@ -1,19 +1,73 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block title %}日志详情{% endblock %} {% block content %}

日志详情

{{ log.api.name }}
-

请求时间: {{ log.request_time|to_cst }}

-

响应时间: {{ log.response_time|to_cst }}

-

是否成功: {{ log.success }}

-

HTTP 状态码: {{ log.http_status_code or '-' }}

-

耗时 (ms): {{ log.duration_ms or '-' }}

-

错误信息: {{ log.error_message or '-' }}

+

请求时间: {{ log.request_time|to_cst }}

+

响应时间: {{ log.response_time|to_cst or '-' }}

+

状态: + {% if log.success is none %} + 进行中 + {% elif log.success %} + 成功 + {% else %} + 失败 + {% endif %} +

+

HTTP 状态码: {{ log.http_status_code if log.http_status_code is not none else '-' }}

+

耗时 (ms): {{ log.duration_ms if log.duration_ms is not none else '-' }}

+

错误信息: {{ log.error_message or '-' }}

响应内容:

-
{{ log.response_body or '-' }}
+
{{ log.response_body or '-' }}
返回 + + {% endblock %} diff --git a/app/templates/logs/list.html b/app/templates/logs/list.html index b67509c..fd15fb9 100644 --- a/app/templates/logs/list.html +++ b/app/templates/logs/list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block title %}调用日志{% endblock %} {% block content %}

API 调用日志

@@ -41,19 +41,21 @@ {{ log.api.name }} {{ log.request_time|to_cst }} - {% if log.success %} + {% if log.success is none %} + 进行中 + {% elif log.success %} 成功 {% else %} 失败 {% endif %} - {{ log.http_status_code or '-' }} - {{ log.duration_ms or '-' }} + {{ log.http_status_code if log.http_status_code is not none else '-' }} + {{ log.duration_ms if log.duration_ms is not none else '-' }} {{ log.error_message or '' }} 详情 {% else %} - 暂无日志。 + 暂无日志 {% endfor %} diff --git a/app/views/logs.py b/app/views/logs.py index 7e4ccba..3ade9bd 100644 --- a/app/views/logs.py +++ b/app/views/logs.py @@ -1,9 +1,8 @@ -from datetime import datetime +from datetime import datetime -from flask import Blueprint, render_template, request +from flask import Blueprint, render_template, request, jsonify, current_app from flask_login import login_required -from app.extensions import db from app.forms import LogFilterForm from app.models import ApiCallLog, ApiConfig @@ -44,3 +43,32 @@ def list_logs(): def log_detail(log_id: int): log_entry = ApiCallLog.query.get_or_404(log_id) return render_template("logs/detail.html", log=log_entry) + + +@logs_bp.route("//stream", methods=["GET"]) +@login_required +def log_stream(log_id: int): + log_entry = ApiCallLog.query.get_or_404(log_id) + to_cst = current_app.jinja_env.filters.get("to_cst") + + def fmt(dt): + return to_cst(dt) if to_cst else (dt.isoformat() if dt else "") + + duration_ms = log_entry.duration_ms + if duration_ms is None and log_entry.request_time: + duration_ms = int((datetime.utcnow() - log_entry.request_time).total_seconds() * 1000) + + return jsonify( + { + "id": log_entry.id, + "api_name": log_entry.api.name if log_entry.api else "", + "success": log_entry.success, + "http_status_code": log_entry.http_status_code, + "error_message": log_entry.error_message, + "response_body": log_entry.response_body or "", + "request_time": fmt(log_entry.request_time), + "response_time": fmt(log_entry.response_time) or "", + "duration_ms": duration_ms, + "finished": log_entry.success is not None, + } + )