diff --git a/.env b/.env new file mode 100644 index 0000000..37730a4 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +FLASK_ENV=development +SECRET_KEY=change-this-secret +DATABASE_URL=mysql+pymysql://api_schedule:22CKSd22NFekncDc@dify.pinnovatecloud.com:3306/api_schedule +APP_TIMEZONE=UTC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68f3d0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +app/__pycache__/*.pyc +*.pyc diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..04a1a23 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,108 @@ +import logging +import os +from flask import Flask, redirect, url_for +from flask_migrate import Migrate + +from app.config import DevelopmentConfig, ProductionConfig +from app.extensions import db, login_manager, scheduler +from app.services.scheduler import SchedulerService + + +def create_app() -> Flask: + """ + Application factory creating the Flask app, loading config, registering blueprints, + initializing extensions, and booting the scheduler. + """ + app = Flask(__name__) + + config_name = os.getenv("FLASK_ENV", "development").lower() + if config_name == "production": + app_config = ProductionConfig() + else: + app_config = DevelopmentConfig() + app.config.from_object(app_config) + + configure_logging(app) + register_extensions(app) + register_blueprints(app) + register_template_filters(app) + enable_scheduler = app.config.get("ENABLE_SCHEDULER", True) and os.getenv("FLASK_SKIP_SCHEDULER") != "1" + if enable_scheduler: + init_scheduler(app) + else: + app.logger.info("Scheduler not started (ENABLE_SCHEDULER=%s, FLASK_SKIP_SCHEDULER=%s)", + app.config.get("ENABLE_SCHEDULER", True), os.getenv("FLASK_SKIP_SCHEDULER")) + + @app.route("/") + def index(): + return redirect(url_for("apis.list_apis")) + + return app + + +def configure_logging(app: Flask) -> None: + log_level = logging.DEBUG if app.debug else logging.INFO + logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") + + +def register_extensions(app: Flask) -> None: + db.init_app(app) + login_manager.init_app(app) + Migrate(app, db) + scheduler.init_app(app) + + +def register_blueprints(app: Flask) -> None: + from app.views.auth import auth_bp + from app.views.apis import apis_bp + from app.views.logs import logs_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(apis_bp) + app.register_blueprint(logs_bp) + + +def register_template_filters(app: Flask) -> None: + @app.template_filter("cron_human") + def cron_human(expr: str) -> str: + """ + 将常见的 5 字段 cron 表达式转换为简单中文描述,不能完全覆盖所有情况。 + """ + parts = expr.strip().split() + if len(parts) != 5: + return expr + minute, hour, day, month, dow = parts + + # 每 N 分钟 + if minute.startswith("*/") and hour == "*" and day == "*" and month == "*" and dow == "*": + return f"每 {minute[2:]} 分钟" + + # 整点或固定时间 + if minute.isdigit() and hour.isdigit() and day == "*" and month == "*" and dow in ("*", "?"): + return f"每天 {hour.zfill(2)}:{minute.zfill(2)}" + + # 每 N 小时的整点 + if minute == "0" and hour.startswith("*/") and day == "*" and month == "*" and dow in ("*", "?"): + return f"每 {hour[2:]} 小时整点" + + # 每月某日 + if minute.isdigit() and hour.isdigit() and day.isdigit() and month == "*" and dow in ("*", "?"): + return f"每月 {day} 日 {hour.zfill(2)}:{minute.zfill(2)}" + + # 每周某天 + weekday_map = {"0": "周日", "1": "周一", "2": "周二", "3": "周三", "4": "周四", "5": "周五", "6": "周六", "7": "周日"} + if minute.isdigit() and hour.isdigit() and day in ("*", "?") and month == "*" and dow not in ("*", "?"): + label = weekday_map.get(dow, f"周{dow}") + return f"每{label} {hour.zfill(2)}:{minute.zfill(2)}" + + return expr + + +def init_scheduler(app: Flask) -> None: + """ + Start APScheduler and load enabled jobs from database. + """ + scheduler.start() + with app.app_context(): + service = SchedulerService(scheduler) + service.load_enabled_jobs() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..47f9e6c --- /dev/null +++ b/app/config.py @@ -0,0 +1,30 @@ +import os +from datetime import timedelta + + +class BaseConfig: + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-me") + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", + "mysql+pymysql://user:password@localhost:3306/api_scheduler", + ) + SQLALCHEMY_ENGINE_OPTIONS = { + # 防止长时间空闲导致连接断开 + "pool_pre_ping": True, + "pool_recycle": 300, + "pool_timeout": 30, + } + REMEMBER_COOKIE_DURATION = timedelta(days=7) + SCHEDULER_API_ENABLED = False + # 默认采用中国标准时间,可通过环境变量 APP_TIMEZONE 覆盖 + SCHEDULER_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + ENABLE_SCHEDULER = True + + +class DevelopmentConfig(BaseConfig): + DEBUG = True + + +class ProductionConfig(BaseConfig): + DEBUG = False diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..32f87be --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,11 @@ +from flask_apscheduler import APScheduler +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = "auth.login" +login_manager.login_message = "请先登录后再访问该页面" + +# 通过 Flask-APScheduler 集成 APScheduler,方便在应用上下文中管理后台任务 +scheduler = APScheduler() diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..c5979c0 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,65 @@ +from datetime import datetime +from typing import Optional + +from flask_wtf import FlaskForm +from wtforms import ( + BooleanField, + IntegerField, + PasswordField, + SelectField, + StringField, + SubmitField, + TextAreaField, +) +from wtforms.validators import DataRequired, Length, NumberRange, Optional as OptionalValidator + + +class LoginForm(FlaskForm): + username = StringField("用户名", validators=[DataRequired(), Length(max=64)]) + password = PasswordField("密码", validators=[DataRequired()]) + submit = SubmitField("登录") + + +class ApiConfigForm(FlaskForm): + name = StringField("名称", validators=[DataRequired(), Length(max=128)]) + description = TextAreaField("描述", validators=[OptionalValidator()]) + url = StringField("URL 地址", validators=[DataRequired(), Length(max=512)]) + http_method = SelectField( + "HTTP 方法", + choices=[("GET", "GET"), ("POST", "POST"), ("PUT", "PUT"), ("DELETE", "DELETE")], + validators=[DataRequired()], + ) + headers = TextAreaField("请求头(JSON)", validators=[OptionalValidator()]) + query_params = TextAreaField("查询参数(JSON)", validators=[OptionalValidator()]) + body = TextAreaField("请求体(JSON 或文本)", validators=[OptionalValidator()]) + schedule_type = SelectField( + "调度类型", + choices=[("cron", "cron"), ("interval", "interval"), ("daily", "daily")], + validators=[DataRequired()], + ) + schedule_expression = StringField("调度表达式", validators=[DataRequired(), Length(max=128)]) + timeout_seconds = IntegerField("超时时间(秒)", validators=[DataRequired(), NumberRange(min=1, max=600)]) + retry_times = IntegerField("重试次数", validators=[DataRequired(), NumberRange(min=0, max=10)]) + retry_interval_seconds = IntegerField( + "重试间隔(秒)", validators=[DataRequired(), NumberRange(min=1, max=300)] + ) + enabled = BooleanField("启用", default=True) + submit = SubmitField("保存") + + +class LogFilterForm(FlaskForm): + api_id = SelectField("API", coerce=int, validators=[OptionalValidator()]) + success = SelectField( + "成功状态", choices=[("", "全部"), ("1", "成功"), ("0", "失败")], validators=[OptionalValidator()] + ) + start_date = StringField("开始日期(YYYY-MM-DD)", validators=[OptionalValidator()]) + end_date = StringField("结束日期(YYYY-MM-DD)", validators=[OptionalValidator()]) + submit = SubmitField("筛选") + + def parse_date(self, value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return None diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1c3bc9b --- /dev/null +++ b/app/models.py @@ -0,0 +1,82 @@ +from datetime import datetime +from typing import Any + +from flask_login import UserMixin +from werkzeug.security import check_password_hash, generate_password_hash + +from app.extensions import db, login_manager + + +class TimestampMixin: + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class User(UserMixin, TimestampMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + email = db.Column(db.String(128)) + role = db.Column(db.String(32)) + is_active = db.Column(db.Boolean, default=True) + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + @property + def is_authenticated(self) -> bool: # type: ignore[override] + return True + + def get_id(self) -> str: # type: ignore[override] + return str(self.id) + + +@login_manager.user_loader +def load_user(user_id: str) -> Any: + return User.query.get(int(user_id)) + + +class ApiConfig(TimestampMixin, db.Model): + __tablename__ = "api_configs" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False) + description = db.Column(db.Text) + url = db.Column(db.String(512), nullable=False) + http_method = db.Column(db.String(10), nullable=False, default="GET") + headers = db.Column(db.Text) + query_params = db.Column(db.Text) + body = db.Column(db.Text) + enabled = db.Column(db.Boolean, default=True) + schedule_type = db.Column(db.String(32), nullable=False, default="cron") + schedule_expression = db.Column(db.String(128), nullable=False, default="0 10 * * *") + timeout_seconds = db.Column(db.Integer, default=30) + retry_times = db.Column(db.Integer, default=0) + retry_interval_seconds = db.Column(db.Integer, default=3) + created_by = db.Column(db.Integer, db.ForeignKey("users.id")) + + creator = db.relationship("User", backref="api_configs") + + def job_id(self) -> str: + return f"api_job_{self.id}" + + +class ApiCallLog(TimestampMixin, db.Model): + __tablename__ = "api_call_logs" + + id = db.Column(db.Integer, primary_key=True) + api_id = db.Column(db.Integer, db.ForeignKey("api_configs.id"), nullable=False) + request_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + response_time = db.Column(db.DateTime) + success = db.Column(db.Boolean, default=False) + http_status_code = db.Column(db.Integer) + error_message = db.Column(db.Text) + response_body = db.Column(db.Text) + duration_ms = db.Column(db.Integer) + + api = db.relationship("ApiConfig", backref="call_logs") diff --git a/app/services/api_executor.py b/app/services/api_executor.py new file mode 100644 index 0000000..9c62b5b --- /dev/null +++ b/app/services/api_executor.py @@ -0,0 +1,105 @@ +import json +import logging +import time +from datetime import datetime +from typing import Any, Dict, Optional + +import requests +from requests import Response + +from app.extensions import db +from flask import current_app + +from app.models import ApiCallLog, ApiConfig + +logger = logging.getLogger(__name__) + + +def _parse_json_field(raw: Optional[str]) -> Optional[Dict[str, Any]]: + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def execute_api(api_config: ApiConfig) -> None: + """ + Execute an API call based on ApiConfig, apply retry logic, and persist ApiCallLog. + Only the final attempt is logged to avoid noise. + """ + request_time = datetime.utcnow() + headers = _parse_json_field(api_config.headers) or {} + params = _parse_json_field(api_config.query_params) or {} + data = api_config.body + try: + json_body = json.loads(api_config.body) if api_config.body else None + except json.JSONDecodeError: + json_body = None + + retries = max(api_config.retry_times, 0) + delay = max(api_config.retry_interval_seconds, 1) + timeout = max(api_config.timeout_seconds, 1) + attempt = 0 + last_response: Optional[Response] = None + last_error: Optional[str] = None + start_ts = time.time() + + while attempt <= retries: + try: + attempt += 1 + last_response = requests.request( + method=api_config.http_method, + url=api_config.url, + headers=headers, + params=params, + data=None if json_body is not None else data, + json=json_body, + timeout=timeout, + ) + if 200 <= last_response.status_code < 300: + last_error = None + break + last_error = f"Non-2xx status: {last_response.status_code}" + except requests.RequestException as exc: + last_response = None + last_error = str(exc) + if attempt <= retries: + time.sleep(delay) + + duration_ms = int((time.time() - start_ts) * 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=last_error, + response_body=(last_response.text[:2000] if last_response and last_response.text else None), + duration_ms=duration_ms, + ) + db.session.add(log_entry) + try: + db.session.commit() + except Exception: + logger.exception("Failed to persist ApiCallLog for api_id=%s", api_config.id) + db.session.rollback() + + if not success: + logger.warning("API call failed for api_id=%s error=%s", api_config.id, last_error) + + +def execute_api_by_id(api_id: int, app=None) -> None: + """ + Load ApiConfig by id and execute within app context (for scheduler threads). + """ + app_obj = app or current_app._get_current_object() + with app_obj.app_context(): + config = ApiConfig.query.get(api_id) + if not config: + logger.error("ApiConfig not found for id=%s", api_id) + return + execute_api(config) diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..1b590d3 --- /dev/null +++ b/app/services/scheduler.py @@ -0,0 +1,94 @@ +import logging +from typing import Optional + +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from flask import current_app +from flask_apscheduler import APScheduler + +from app.models import ApiConfig +from flask import current_app + +from app.services.api_executor import execute_api_by_id + +logger = logging.getLogger(__name__) + + +class SchedulerService: + """ + Thin wrapper around APScheduler for adding/removing API jobs. + """ + + def __init__(self, scheduler: APScheduler) -> None: + self.scheduler = scheduler + + def load_enabled_jobs(self) -> None: + try: + enabled_configs = ApiConfig.query.filter_by(enabled=True).all() + except Exception: + logger.warning("Could not load jobs; database might not be initialized yet.") + return + for config in enabled_configs: + self.add_job_for_api(config) + logger.info("Scheduler loaded %s enabled jobs", len(enabled_configs)) + + def add_job_for_api(self, api_config: ApiConfig) -> None: + job_id = api_config.job_id() + trigger = self._build_trigger(api_config) + if not trigger: + logger.error("Unsupported schedule type for api_id=%s", api_config.id) + return + self.remove_job_for_api(api_config.id) + app_obj = current_app._get_current_object() + self.scheduler.add_job( + func=execute_api_by_id, + trigger=trigger, + args=[api_config.id, app_obj], + id=job_id, + replace_existing=True, + max_instances=1, + misfire_grace_time=current_app.config.get("SCHEDULER_MISFIRE_GRACE_TIME", 60), + ) + logger.info("Registered job %s for api_id=%s", job_id, api_config.id) + + def remove_job_for_api(self, api_id: int) -> None: + job_id = f"api_job_{api_id}" + try: + self.scheduler.remove_job(job_id) + logger.info("Removed job %s", job_id) + except Exception: + # 如果任务不存在则静默忽略 + pass + + def reschedule_job_for_api(self, api_config: ApiConfig) -> None: + self.add_job_for_api(api_config) + + def _build_trigger(self, api_config: ApiConfig): + schedule_type = api_config.schedule_type + expr = api_config.schedule_expression + if schedule_type == "cron": + try: + fields = expr.split() + if len(fields) == 5: + minute, hour, day, month, day_of_week = fields + return CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week) + return CronTrigger.from_crontab(expr) + except Exception as exc: + logger.error("Invalid cron expression for api_id=%s error=%s", api_config.id, exc) + return None + if schedule_type == "interval": + try: + seconds = int(expr) + return IntervalTrigger(seconds=seconds) + except ValueError: + logger.error("Invalid interval seconds for api_id=%s", api_config.id) + return None + if schedule_type == "daily": + # 期待格式为 "HH:MM" + try: + hour, minute = expr.split(":") + return CronTrigger(hour=int(hour), minute=int(minute)) + except Exception as exc: + logger.error("Invalid daily expression for api_id=%s error=%s", api_config.id, exc) + return None + return None diff --git a/app/templates/apis/form.html b/app/templates/apis/form.html new file mode 100644 index 0000000..a353859 --- /dev/null +++ b/app/templates/apis/form.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if api else '新建' }} API{% endblock %} +{% block content %} +

{{ '编辑' if api else '新建' }} API

+
+ {{ form.hidden_tag() }} +
+
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} +
+
+ {{ form.url.label(class="form-label") }} + {{ form.url(class="form-control") }} +
+
+
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows="2") }} +
+
+
+ {{ form.http_method.label(class="form-label") }} + {{ form.http_method(class="form-select") }} +
+
+ {{ form.schedule_type.label(class="form-label") }} + {{ form.schedule_type(class="form-select") }} +
+
+ {{ form.schedule_expression.label(class="form-label") }} + {{ form.schedule_expression(class="form-control") }} + Cron: "0 10 * * *",Interval:秒数,Daily:"HH:MM" +
+
+
+
+ {{ form.timeout_seconds.label(class="form-label") }} + {{ form.timeout_seconds(class="form-control") }} +
+
+ {{ form.retry_times.label(class="form-label") }} + {{ form.retry_times(class="form-control") }} +
+
+ {{ form.retry_interval_seconds.label(class="form-label") }} + {{ form.retry_interval_seconds(class="form-control") }} +
+
+
+ {{ form.headers.label(class="form-label") }} + {{ form.headers(class="form-control", rows="2") }} +
+
+ {{ form.query_params.label(class="form-label") }} + {{ form.query_params(class="form-control", rows="2") }} +
+
+ {{ form.body.label(class="form-label") }} + {{ form.body(class="form-control", rows="4") }} +
+
+ {{ form.enabled(class="form-check-input") }} + {{ form.enabled.label(class="form-check-label") }} +
+ {{ form.submit(class="btn btn-primary") }} + 取消 +
+{% endblock %} diff --git a/app/templates/apis/list.html b/app/templates/apis/list.html new file mode 100644 index 0000000..cf3ae2d --- /dev/null +++ b/app/templates/apis/list.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}API 列表{% endblock %} +{% block content %} +
+

API 配置列表

+ 新建 API +
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + {% for api in apis %} + + + + + + + + {% else %} + + {% endfor %} + +
名称URL调度启用操作
{{ api.name }}{{ api.url }} + {{ api.schedule_type }}: {{ api.schedule_expression }} + {% if api.schedule_type == 'cron' %} +
{{ api.schedule_expression|cron_human }}
+ {% endif %} +
+ {% if api.enabled %} + 已启用 + {% else %} + 已停用 + {% endif %} + + 编辑 +
+ +
+
+ +
+
+ +
+
暂无 API 配置。
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..cbd09ef --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}登录{% endblock %} +{% block content %} +
+
+

登录

+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} +
+ {{ form.submit(class="btn btn-primary w-100") }} +
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d0a8286 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}API 定时平台{% endblock %} + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/logs/detail.html b/app/templates/logs/detail.html new file mode 100644 index 0000000..0969778 --- /dev/null +++ b/app/templates/logs/detail.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}日志详情{% endblock %} +{% block content %} +

日志详情

+
+
+
{{ log.api.name }}
+

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

+

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

+

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

+

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

+

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

+

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

+

响应内容:

+
{{ log.response_body or '-' }}
+
+
+返回 +{% endblock %} diff --git a/app/templates/logs/list.html b/app/templates/logs/list.html new file mode 100644 index 0000000..7383efc --- /dev/null +++ b/app/templates/logs/list.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}调用日志{% endblock %} +{% block content %} +

API 调用日志

+
+
+ {{ form.api_id.label(class="form-label") }} + {{ form.api_id(class="form-select") }} +
+
+ {{ form.success.label(class="form-label") }} + {{ form.success(class="form-select") }} +
+
+ {{ form.start_date.label(class="form-label") }} + {{ form.start_date(class="form-control", placeholder="YYYY-MM-DD") }} +
+
+ {{ form.end_date.label(class="form-label") }} + {{ form.end_date(class="form-control", placeholder="YYYY-MM-DD") }} +
+
+ +
+
+ + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + {% else %} + + {% endfor %} + +
API请求时间状态HTTP耗时(ms)错误
{{ log.api.name }}{{ log.request_time }} + {% if log.success %} + 成功 + {% else %} + 失败 + {% endif %} + {{ log.http_status_code or '-' }}{{ log.duration_ms or '-' }}{{ log.error_message or '' }}详情
暂无日志。
+{% if pagination.pages > 1 %} + +{% endif %} +{% endblock %} diff --git a/app/views/apis.py b/app/views/apis.py new file mode 100644 index 0000000..9c04c07 --- /dev/null +++ b/app/views/apis.py @@ -0,0 +1,115 @@ +import threading + +from flask import Blueprint, flash, redirect, render_template, request, url_for, current_app +from flask_login import current_user, login_required + +from app.extensions import db, scheduler +from app.forms import ApiConfigForm +from app.models import ApiConfig +from app.services.api_executor import execute_api_by_id +from app.services.scheduler import SchedulerService + +apis_bp = Blueprint("apis", __name__, url_prefix="/apis") + + +def _scheduler_service() -> SchedulerService: + return SchedulerService(scheduler) + + +@apis_bp.route("", methods=["GET"]) +@login_required +def list_apis(): + name = request.args.get("name") + enabled = request.args.get("enabled") + query = ApiConfig.query + if name: + query = query.filter(ApiConfig.name.ilike(f"%{name}%")) + if enabled in {"0", "1"}: + query = query.filter(ApiConfig.enabled == (enabled == "1")) + apis = query.order_by(ApiConfig.updated_at.desc()).all() + return render_template("apis/list.html", apis=apis) + + +@apis_bp.route("/new", methods=["GET", "POST"]) +@login_required +def create_api(): + form = ApiConfigForm() + if form.validate_on_submit(): + api = ApiConfig( + name=form.name.data, + description=form.description.data, + url=form.url.data, + http_method=form.http_method.data, + headers=form.headers.data, + query_params=form.query_params.data, + body=form.body.data, + schedule_type=form.schedule_type.data, + schedule_expression=form.schedule_expression.data, + timeout_seconds=form.timeout_seconds.data, + retry_times=form.retry_times.data, + retry_interval_seconds=form.retry_interval_seconds.data, + enabled=form.enabled.data, + created_by=current_user.id, + ) + db.session.add(api) + db.session.commit() + if api.enabled: + _scheduler_service().add_job_for_api(api) + flash("API 配置已创建", "success") + return redirect(url_for("apis.list_apis")) + return render_template("apis/form.html", form=form, api=None) + + +@apis_bp.route("//edit", methods=["GET", "POST"]) +@login_required +def edit_api(api_id: int): + api = ApiConfig.query.get_or_404(api_id) + form = ApiConfigForm(obj=api) + if form.validate_on_submit(): + form.populate_obj(api) + db.session.commit() + if api.enabled: + _scheduler_service().reschedule_job_for_api(api) + else: + _scheduler_service().remove_job_for_api(api.id) + flash("API 配置已更新", "success") + return redirect(url_for("apis.list_apis")) + return render_template("apis/form.html", form=form, api=api) + + +@apis_bp.route("//toggle", methods=["POST"]) +@login_required +def toggle_api(api_id: int): + api = ApiConfig.query.get_or_404(api_id) + api.enabled = not api.enabled + db.session.commit() + svc = _scheduler_service() + if api.enabled: + svc.add_job_for_api(api) + flash("API 已启用并加入调度", "success") + else: + svc.remove_job_for_api(api.id) + flash("API 已停用并移除调度", "info") + return redirect(url_for("apis.list_apis")) + + +@apis_bp.route("//delete", methods=["POST"]) +@login_required +def delete_api(api_id: int): + api = ApiConfig.query.get_or_404(api_id) + db.session.delete(api) + db.session.commit() + _scheduler_service().remove_job_for_api(api_id) + flash("API 配置已删除", "info") + return redirect(url_for("apis.list_apis")) + + +@apis_bp.route("//run", methods=["POST"]) +@login_required +def run_now(api_id: int): + api = ApiConfig.query.get_or_404(api_id) + + app_obj = current_app._get_current_object() + threading.Thread(target=execute_api_by_id, args=(api.id, app_obj), daemon=True).start() + flash("已触发立即执行,稍后在日志中查看结果。", "success") + return redirect(url_for("apis.list_apis")) diff --git a/app/views/auth.py b/app/views/auth.py new file mode 100644 index 0000000..d5a3bb1 --- /dev/null +++ b/app/views/auth.py @@ -0,0 +1,28 @@ +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import login_required, login_user, logout_user + +from app.extensions import db +from app.forms import LoginForm +from app.models import User + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + if not user or not user.is_active or not user.check_password(form.password.data): + flash("用户名或密码错误", "danger") + return render_template("auth/login.html", form=form) + login_user(user, remember=True) + return redirect(request.args.get("next") or url_for("apis.list_apis")) + return render_template("auth/login.html", form=form) + + +@auth_bp.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("auth.login")) diff --git a/app/views/logs.py b/app/views/logs.py new file mode 100644 index 0000000..7ee11bb --- /dev/null +++ b/app/views/logs.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from flask import Blueprint, render_template, request +from flask_login import login_required + +from app.extensions import db +from app.forms import LogFilterForm +from app.models import ApiCallLog, ApiConfig + +logs_bp = Blueprint("logs", __name__, url_prefix="/logs") + + +@logs_bp.route("", methods=["GET"]) +@login_required +def list_logs(): + form = LogFilterForm(request.args) + form.api_id.choices = [(-1, "All APIs")] + [(api.id, api.name) for api in ApiConfig.query.order_by(ApiConfig.name)] + + query = ApiCallLog.query.join(ApiConfig) + if form.api_id.data and form.api_id.data != -1: + query = query.filter(ApiCallLog.api_id == form.api_id.data) + if form.success.data in {"1", "0"}: + query = query.filter(ApiCallLog.success == (form.success.data == "1")) + start = form.parse_date(form.start_date.data or "") + end = form.parse_date(form.end_date.data or "") + if start: + query = query.filter(ApiCallLog.request_time >= start) + if end: + end_dt = datetime(end.year, end.month, end.day, 23, 59, 59) + query = query.filter(ApiCallLog.request_time <= end_dt) + + page = int(request.args.get("page", 1)) + per_page = int(request.args.get("per_page", 10)) + pagination = query.order_by(ApiCallLog.request_time.desc()).paginate(page=page, per_page=per_page, error_out=False) + return render_template("logs/list.html", form=form, pagination=pagination, logs=pagination.items) + + +@logs_bp.route("/", methods=["GET"]) +@login_required +def log_detail(log_id: int): + log_entry = ApiCallLog.query.get_or_404(log_id) + return render_template("logs/detail.html", log=log_entry) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/34b9d2435dd3_init.py b/migrations/versions/34b9d2435dd3_init.py new file mode 100644 index 0000000..7c23873 --- /dev/null +++ b/migrations/versions/34b9d2435dd3_init.py @@ -0,0 +1,77 @@ +"""init + +Revision ID: 34b9d2435dd3 +Revises: +Create Date: 2025-11-28 10:59:58.162614 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '34b9d2435dd3' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=128), nullable=True), + sa.Column('role', sa.String(length=32), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('api_configs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('url', sa.String(length=512), nullable=False), + sa.Column('http_method', sa.String(length=10), nullable=False), + sa.Column('headers', sa.Text(), nullable=True), + sa.Column('query_params', sa.Text(), nullable=True), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=True), + sa.Column('schedule_type', sa.String(length=32), nullable=False), + sa.Column('schedule_expression', sa.String(length=128), nullable=False), + sa.Column('timeout_seconds', sa.Integer(), nullable=True), + sa.Column('retry_times', sa.Integer(), nullable=True), + sa.Column('retry_interval_seconds', sa.Integer(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('api_call_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('api_id', sa.Integer(), nullable=False), + sa.Column('request_time', sa.DateTime(), nullable=False), + sa.Column('response_time', sa.DateTime(), nullable=True), + sa.Column('success', sa.Boolean(), nullable=True), + sa.Column('http_status_code', sa.Integer(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('response_body', sa.Text(), nullable=True), + sa.Column('duration_ms', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['api_id'], ['api_configs.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('api_call_logs') + op.drop_table('api_configs') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3f6422 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +Flask==3.0.2 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +Flask-APScheduler==1.13.1 +APScheduler==3.10.4 +requests==2.31.0 +python-dotenv==1.0.1 +PyMySQL==1.1.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..fef80d4 --- /dev/null +++ b/run.py @@ -0,0 +1,8 @@ +from app import create_app + +app = create_app() + + +if __name__ == "__main__": + # 仅供本地开发使用,生产环境请使用 gunicorn 等 WSGI 服务器 + app.run(host="0.0.0.0", port=5000, debug=True)