feat: 初始化Webhook中继系统项目
- 添加FastAPI应用基础结构,包括主入口、路由和模型定义
- 实现Webhook接收端点(/webhook/{namespace})和健康检查(/health)
- 添加管理后台路由和模板,支持端点、目标、渠道和模板管理
- 包含SQLite数据库模型定义和初始化逻辑
- 添加日志记录和统计服务
- 包含Dockerfile和配置示例文件
- 添加项目文档,包括设计、流程图和验收标准
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+485
@@ -0,0 +1,485 @@
|
||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session, joinedload, aliased
|
||||
from typing import List, Optional, Union
|
||||
import json
|
||||
from app.db import SessionLocal, Target, NotificationChannel, MessageTemplate, WebhookEndpoint, RequestLog, DeliveryLog, ProcessingRule, RuleAction
|
||||
from app.services.stats import stats_service
|
||||
from app.services.engine import engine
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def global_stats_processor(request: Request):
|
||||
return {
|
||||
"system_stats": {
|
||||
"uptime": stats_service.get_uptime(),
|
||||
"today_count": stats_service.get_today_count(),
|
||||
"latest_log": stats_service.get_latest_log_time()
|
||||
}
|
||||
}
|
||||
|
||||
async def render(template_name: str, context: dict):
|
||||
stats = await global_stats_processor(context["request"])
|
||||
context.update(stats)
|
||||
return templates.TemplateResponse(template_name, context)
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_index(request: Request, db: Session = Depends(get_db)):
|
||||
endpoints = db.query(WebhookEndpoint).filter(WebhookEndpoint.is_active == True).all()
|
||||
return await render("admin/dashboard.html", {"request": request, "active_page": "dashboard", "endpoints": endpoints})
|
||||
|
||||
# --- Endpoints ---
|
||||
@router.get("/endpoints", response_class=HTMLResponse)
|
||||
async def list_endpoints(request: Request, db: Session = Depends(get_db)):
|
||||
endpoints = db.query(WebhookEndpoint).order_by(WebhookEndpoint.created_at.desc()).all()
|
||||
return await render("admin/endpoints.html", {"request": request, "endpoints": endpoints, "active_page": "endpoints"})
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def add_endpoint(namespace: str = Form(...), description: str = Form(None), db: Session = Depends(get_db)):
|
||||
if not namespace.replace("-", "").replace("_", "").isalnum():
|
||||
pass
|
||||
ep = WebhookEndpoint(namespace=namespace, description=description)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/endpoints", status_code=303)
|
||||
|
||||
@router.post("/endpoints/toggle")
|
||||
async def toggle_endpoint(id: int = Form(...), db: Session = Depends(get_db)):
|
||||
ep = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == id).first()
|
||||
if ep:
|
||||
ep.is_active = not ep.is_active
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/endpoints", status_code=303)
|
||||
|
||||
@router.post("/endpoints/delete")
|
||||
async def delete_endpoint(id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(WebhookEndpoint).filter(WebhookEndpoint.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/endpoints", status_code=303)
|
||||
|
||||
# --- Endpoint Details & Rules ---
|
||||
@router.get("/endpoints/{id}", response_class=HTMLResponse)
|
||||
async def endpoint_detail(id: int, request: Request, db: Session = Depends(get_db)):
|
||||
ep = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == id).first()
|
||||
if not ep:
|
||||
return RedirectResponse(url="/admin/endpoints")
|
||||
|
||||
# Workaround: Fetch all rules for this endpoint and reconstruct tree in Python.
|
||||
all_rules = db.query(ProcessingRule).options(joinedload(ProcessingRule.actions)).filter(
|
||||
ProcessingRule.endpoint_id == id
|
||||
).order_by(ProcessingRule.priority.desc()).all()
|
||||
|
||||
# Build tree manually
|
||||
rule_map = {r.id: r for r in all_rules}
|
||||
root_rules = []
|
||||
|
||||
# Initialize children list for each rule object (dynamically attached)
|
||||
for r in all_rules:
|
||||
r.child_rules = []
|
||||
|
||||
for r in all_rules:
|
||||
if r.parent_rule_id:
|
||||
parent = rule_map.get(r.parent_rule_id)
|
||||
if parent:
|
||||
parent.child_rules.append(r)
|
||||
else:
|
||||
root_rules.append(r)
|
||||
|
||||
# Load resources for modals
|
||||
targets = db.query(Target).all()
|
||||
channels = db.query(NotificationChannel).all()
|
||||
tmpls = db.query(MessageTemplate).all()
|
||||
|
||||
return await render("admin/endpoint_detail.html", {
|
||||
"request": request,
|
||||
"ep": ep,
|
||||
"root_rules": root_rules,
|
||||
"targets": targets,
|
||||
"channels": channels,
|
||||
"templates": tmpls,
|
||||
"active_page": "endpoints"
|
||||
})
|
||||
|
||||
@router.post("/endpoints/{id}/rules")
|
||||
async def add_rule(
|
||||
id: int,
|
||||
match_field: str = Form(...),
|
||||
match_value: str = Form(...),
|
||||
operator: str = Form("eq"),
|
||||
parent_rule_id: Optional[Union[int, str]] = Form(None),
|
||||
priority: int = Form(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Handle empty string from form for optional int
|
||||
final_parent_id = None
|
||||
if parent_rule_id and str(parent_rule_id).strip():
|
||||
try:
|
||||
final_parent_id = int(parent_rule_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rule = ProcessingRule(
|
||||
endpoint_id=id,
|
||||
match_field=match_field,
|
||||
match_value=match_value,
|
||||
operator=operator,
|
||||
parent_rule_id=final_parent_id,
|
||||
priority=priority
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{id}", status_code=303)
|
||||
|
||||
@router.post("/rules/delete")
|
||||
async def delete_rule(id: int = Form(...), endpoint_id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(ProcessingRule).filter(ProcessingRule.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
@router.post("/rules/update")
|
||||
async def update_rule(
|
||||
id: int = Form(...),
|
||||
endpoint_id: int = Form(...),
|
||||
match_field: str = Form(...),
|
||||
match_value: str = Form(...),
|
||||
operator: str = Form("eq"),
|
||||
parent_rule_id: Optional[str] = Form(None),
|
||||
priority: int = Form(0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
rule = db.query(ProcessingRule).filter(ProcessingRule.id == id).first()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
rule.match_field = match_field
|
||||
rule.match_value = match_value
|
||||
rule.operator = operator
|
||||
rule.priority = priority
|
||||
if parent_rule_id is not None and str(parent_rule_id).strip() != "":
|
||||
try:
|
||||
rule.parent_rule_id = int(parent_rule_id)
|
||||
except ValueError:
|
||||
rule.parent_rule_id = None
|
||||
else:
|
||||
rule.parent_rule_id = None
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
@router.post("/actions/update")
|
||||
async def update_action(
|
||||
id: int = Form(...),
|
||||
endpoint_id: int = Form(...),
|
||||
action_type: str = Form(...),
|
||||
target_id: Optional[str] = Form(None),
|
||||
channel_id: Optional[str] = Form(None),
|
||||
template_id: Optional[str] = Form(None),
|
||||
template_vars_str: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
action = db.query(RuleAction).filter(RuleAction.id == id).first()
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
t_vars = None
|
||||
if template_vars_str and template_vars_str.strip():
|
||||
try:
|
||||
t_vars = json.loads(template_vars_str)
|
||||
except Exception:
|
||||
t_vars = None
|
||||
def clean_int(val):
|
||||
if val and str(val).strip():
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
action.action_type = action_type
|
||||
action.target_id = clean_int(target_id) if action_type == 'forward' else None
|
||||
action.channel_id = clean_int(channel_id) if action_type == 'notify' else None
|
||||
action.template_id = clean_int(template_id) if action_type == 'notify' else None
|
||||
action.template_vars = t_vars
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
def _duplicate_rule_tree(db: Session, src_rule: ProcessingRule, endpoint_id: int, new_parent_id: Optional[int]) -> int:
|
||||
new_rule = ProcessingRule(
|
||||
endpoint_id=endpoint_id,
|
||||
match_field=src_rule.match_field,
|
||||
operator=src_rule.operator,
|
||||
match_value=src_rule.match_value,
|
||||
priority=src_rule.priority,
|
||||
parent_rule_id=new_parent_id
|
||||
)
|
||||
db.add(new_rule)
|
||||
db.commit()
|
||||
db.refresh(new_rule)
|
||||
for a in src_rule.actions:
|
||||
db.add(RuleAction(
|
||||
rule_id=new_rule.id,
|
||||
action_type=a.action_type,
|
||||
target_id=a.target_id if a.action_type == 'forward' else None,
|
||||
channel_id=a.channel_id if a.action_type == 'notify' else None,
|
||||
template_id=a.template_id if a.action_type == 'notify' else None,
|
||||
template_vars=a.template_vars
|
||||
))
|
||||
db.commit()
|
||||
children = db.query(ProcessingRule).filter(ProcessingRule.parent_rule_id == src_rule.id).all()
|
||||
for child in children:
|
||||
_duplicate_rule_tree(db, child, endpoint_id, new_rule.id)
|
||||
return new_rule.id
|
||||
|
||||
@router.post("/rules/duplicate")
|
||||
async def duplicate_rule(
|
||||
rule_id: int = Form(...),
|
||||
endpoint_id: int = Form(...),
|
||||
parent_rule_id: Optional[str] = Form(None),
|
||||
include_children: Optional[str] = Form("true"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
src = db.query(ProcessingRule).filter(ProcessingRule.id == rule_id).first()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
new_parent = None
|
||||
if parent_rule_id and str(parent_rule_id).strip():
|
||||
try:
|
||||
new_parent = int(parent_rule_id)
|
||||
except ValueError:
|
||||
new_parent = None
|
||||
new_rule_id = _duplicate_rule_tree(db, src, endpoint_id, new_parent) if (include_children or include_children.lower() == "true") else None
|
||||
if not new_rule_id:
|
||||
new_rule = ProcessingRule(
|
||||
endpoint_id=endpoint_id,
|
||||
match_field=src.match_field,
|
||||
operator=src.operator,
|
||||
match_value=src.match_value,
|
||||
priority=src.priority,
|
||||
parent_rule_id=new_parent
|
||||
)
|
||||
db.add(new_rule)
|
||||
db.commit()
|
||||
db.refresh(new_rule)
|
||||
for a in src.actions:
|
||||
db.add(RuleAction(
|
||||
rule_id=new_rule.id,
|
||||
action_type=a.action_type,
|
||||
target_id=a.target_id if a.action_type == 'forward' else None,
|
||||
channel_id=a.channel_id if a.action_type == 'notify' else None,
|
||||
template_id=a.template_id if a.action_type == 'notify' else None,
|
||||
template_vars=a.template_vars
|
||||
))
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
@router.post("/actions/duplicate")
|
||||
async def duplicate_action(id: int = Form(...), endpoint_id: int = Form(...), db: Session = Depends(get_db)):
|
||||
src = db.query(RuleAction).filter(RuleAction.id == id).first()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
db.add(RuleAction(
|
||||
rule_id=src.rule_id,
|
||||
action_type=src.action_type,
|
||||
target_id=src.target_id if src.action_type == 'forward' else None,
|
||||
channel_id=src.channel_id if src.action_type == 'notify' else None,
|
||||
template_id=src.template_id if src.action_type == 'notify' else None,
|
||||
template_vars=src.template_vars
|
||||
))
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
@router.post("/rules/{rule_id}/actions")
|
||||
async def add_action(
|
||||
rule_id: int,
|
||||
endpoint_id: int = Form(...),
|
||||
action_type: str = Form(...),
|
||||
target_id: Optional[Union[int, str]] = Form(None),
|
||||
channel_id: Optional[Union[int, str]] = Form(None),
|
||||
template_id: Optional[Union[int, str]] = Form(None),
|
||||
template_vars_str: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
t_vars = None
|
||||
if template_vars_str and template_vars_str.strip():
|
||||
try:
|
||||
t_vars = json.loads(template_vars_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Helper to clean int params
|
||||
def clean_int(val):
|
||||
if val and str(val).strip():
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
action = RuleAction(
|
||||
rule_id=rule_id,
|
||||
action_type=action_type,
|
||||
target_id=clean_int(target_id) if action_type == 'forward' else None,
|
||||
channel_id=clean_int(channel_id) if action_type == 'notify' else None,
|
||||
template_id=clean_int(template_id) if action_type == 'notify' else None,
|
||||
template_vars=t_vars
|
||||
)
|
||||
db.add(action)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
@router.post("/actions/delete")
|
||||
async def delete_action(id: int = Form(...), endpoint_id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(RuleAction).filter(RuleAction.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/admin/endpoints/{endpoint_id}", status_code=303)
|
||||
|
||||
|
||||
# --- Targets ---
|
||||
@router.get("/targets", response_class=HTMLResponse)
|
||||
async def list_targets(request: Request, db: Session = Depends(get_db)):
|
||||
targets = db.query(Target).all()
|
||||
return await render("admin/targets.html", {"request": request, "targets": targets, "active_page": "targets"})
|
||||
|
||||
@router.post("/targets")
|
||||
async def add_target(name: str = Form(...), url: str = Form(...), timeout_ms: int = Form(5000), db: Session = Depends(get_db)):
|
||||
t = Target(name=name, url=url, timeout_ms=timeout_ms)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/targets", status_code=303)
|
||||
|
||||
@router.post("/targets/update")
|
||||
async def update_target(id: int = Form(...), name: str = Form(...), url: str = Form(...), timeout_ms: int = Form(5000), db: Session = Depends(get_db)):
|
||||
t = db.query(Target).filter(Target.id == id).first()
|
||||
if t:
|
||||
t.name = name
|
||||
t.url = url
|
||||
t.timeout_ms = timeout_ms
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/targets", status_code=303)
|
||||
|
||||
@router.post("/targets/delete")
|
||||
async def delete_target(id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(Target).filter(Target.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/targets", status_code=303)
|
||||
|
||||
# --- Channels ---
|
||||
@router.get("/channels", response_class=HTMLResponse)
|
||||
async def list_channels(request: Request, db: Session = Depends(get_db)):
|
||||
channels = db.query(NotificationChannel).all()
|
||||
return await render("admin/channels.html", {"request": request, "channels": channels, "active_page": "channels"})
|
||||
|
||||
@router.post("/channels")
|
||||
async def add_channel(name: str = Form(...), channel_type: str = Form(...), webhook_url: str = Form(...), db: Session = Depends(get_db)):
|
||||
c = NotificationChannel(name=name, channel_type=channel_type, webhook_url=webhook_url)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/channels", status_code=303)
|
||||
|
||||
@router.post("/channels/update")
|
||||
async def update_channel(id: int = Form(...), name: str = Form(...), channel_type: str = Form(...), webhook_url: str = Form(...), db: Session = Depends(get_db)):
|
||||
c = db.query(NotificationChannel).filter(NotificationChannel.id == id).first()
|
||||
if c:
|
||||
c.name = name
|
||||
c.channel_type = channel_type
|
||||
c.webhook_url = webhook_url
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/channels", status_code=303)
|
||||
|
||||
@router.post("/channels/delete")
|
||||
async def delete_channel(id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(NotificationChannel).filter(NotificationChannel.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/channels", status_code=303)
|
||||
|
||||
# --- Templates ---
|
||||
@router.get("/templates", response_class=HTMLResponse)
|
||||
async def list_templates(request: Request, db: Session = Depends(get_db)):
|
||||
tmpls = db.query(MessageTemplate).all()
|
||||
return await render("admin/templates.html", {"request": request, "templates": tmpls, "active_page": "templates"})
|
||||
|
||||
@router.post("/templates")
|
||||
async def add_template(name: str = Form(...), template_content: str = Form(...), db: Session = Depends(get_db)):
|
||||
t = MessageTemplate(name=name, template_content=template_content)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/templates", status_code=303)
|
||||
|
||||
@router.post("/templates/update")
|
||||
async def update_template(id: int = Form(...), name: str = Form(...), template_content: str = Form(...), db: Session = Depends(get_db)):
|
||||
t = db.query(MessageTemplate).filter(MessageTemplate.id == id).first()
|
||||
if t:
|
||||
t.name = name
|
||||
t.template_content = template_content
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/templates", status_code=303)
|
||||
|
||||
@router.post("/templates/delete")
|
||||
async def delete_template(id: int = Form(...), db: Session = Depends(get_db)):
|
||||
db.query(MessageTemplate).filter(MessageTemplate.id == id).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/templates", status_code=303)
|
||||
|
||||
# --- Logs ---
|
||||
@router.get("/logs", response_class=HTMLResponse)
|
||||
async def list_logs(request: Request, db: Session = Depends(get_db)):
|
||||
logs = db.query(RequestLog).options(joinedload(RequestLog.delivery_logs))\
|
||||
.order_by(RequestLog.received_at.desc()).limit(100).all()
|
||||
return await render("admin/logs.html", {"request": request, "logs": logs, "active_page": "logs"})
|
||||
|
||||
@router.post("/logs/clear")
|
||||
async def clear_logs(db: Session = Depends(get_db)):
|
||||
db.query(DeliveryLog).delete()
|
||||
db.query(RequestLog).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/logs", status_code=303)
|
||||
|
||||
@router.post("/logs/{id}/replay")
|
||||
async def replay_log(id: int, db: Session = Depends(get_db)):
|
||||
log = db.query(RequestLog).filter(RequestLog.id == id).first()
|
||||
if not log:
|
||||
return JSONResponse({"error": "Log not found"}, status_code=404)
|
||||
|
||||
# Find endpoint
|
||||
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.namespace == log.namespace).first()
|
||||
if not endpoint or not endpoint.is_active:
|
||||
return JSONResponse({"error": "Endpoint inactive or missing"}, status_code=400)
|
||||
|
||||
# Re-process
|
||||
routed, notified = await engine.process(endpoint.id, log.raw_body)
|
||||
|
||||
new_log = RequestLog(
|
||||
namespace=log.namespace,
|
||||
remark=log.remark,
|
||||
event_no=log.event_no,
|
||||
raw_body=log.raw_body,
|
||||
status="replay"
|
||||
)
|
||||
db.add(new_log)
|
||||
db.commit()
|
||||
db.refresh(new_log)
|
||||
|
||||
for r in routed:
|
||||
db.add(DeliveryLog(
|
||||
request_id=new_log.id,
|
||||
target_name=r.get("target"),
|
||||
type="relay",
|
||||
status="success" if r.get("ok") else "failed",
|
||||
response_summary=str(r.get("error") or "OK")
|
||||
))
|
||||
|
||||
for n in notified:
|
||||
db.add(DeliveryLog(
|
||||
request_id=new_log.id,
|
||||
target_name=n.get("channel"),
|
||||
type="notify",
|
||||
status="success" if n.get("ok") else "failed",
|
||||
response_summary=str(n.get("error") or "OK")
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return JSONResponse({"status": "ok", "new_log_id": new_log.id})
|
||||
@@ -0,0 +1,102 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, JSON, Boolean, Table, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, relationship, backref
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Target(Base):
|
||||
__tablename__ = 'targets'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True, index=True)
|
||||
url = Column(String)
|
||||
timeout_ms = Column(Integer, default=5000)
|
||||
|
||||
class NotificationChannel(Base):
|
||||
__tablename__ = 'notification_channels'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True)
|
||||
channel_type = Column(String) # feishu, wecom
|
||||
webhook_url = Column(String)
|
||||
|
||||
class MessageTemplate(Base):
|
||||
__tablename__ = 'message_templates'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, unique=True) # 方便识别,如 "收款成功通知"
|
||||
template_content = Column(Text) # "收到{amt}元"
|
||||
|
||||
class WebhookEndpoint(Base):
|
||||
__tablename__ = 'webhook_endpoints'
|
||||
id = Column(Integer, primary_key=True)
|
||||
namespace = Column(String, unique=True, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
rules = relationship("ProcessingRule", back_populates="endpoint", cascade="all, delete-orphan")
|
||||
|
||||
class ProcessingRule(Base):
|
||||
__tablename__ = 'processing_rules'
|
||||
id = Column(Integer, primary_key=True)
|
||||
endpoint_id = Column(Integer, ForeignKey('webhook_endpoints.id'))
|
||||
endpoint = relationship("WebhookEndpoint", back_populates="rules")
|
||||
|
||||
# Tree structure support
|
||||
parent_rule_id = Column(Integer, ForeignKey('processing_rules.id'), nullable=True)
|
||||
children = relationship("ProcessingRule", backref=backref('parent', remote_side=[id]), cascade="all, delete-orphan")
|
||||
priority = Column(Integer, default=0) # Higher executes first (if we want ordering)
|
||||
|
||||
match_field = Column(String) # e.g. "trans_order_info.remark" or "event_define_no"
|
||||
operator = Column(String, default="eq") # eq, neq, contains, startswith, regex
|
||||
match_value = Column(String) # e.g. "imcgcd03" or "pay.success"
|
||||
|
||||
actions = relationship("RuleAction", back_populates="rule", cascade="all, delete-orphan")
|
||||
|
||||
class RuleAction(Base):
|
||||
__tablename__ = 'rule_actions'
|
||||
id = Column(Integer, primary_key=True)
|
||||
rule_id = Column(Integer, ForeignKey('processing_rules.id'))
|
||||
rule = relationship("ProcessingRule", back_populates="actions")
|
||||
|
||||
action_type = Column(String) # "forward" or "notify"
|
||||
|
||||
# Forward params
|
||||
target_id = Column(Integer, ForeignKey('targets.id'), nullable=True)
|
||||
target = relationship("Target")
|
||||
|
||||
# Notify params
|
||||
channel_id = Column(Integer, ForeignKey('notification_channels.id'), nullable=True)
|
||||
channel = relationship("NotificationChannel")
|
||||
template_id = Column(Integer, ForeignKey('message_templates.id'), nullable=True)
|
||||
template = relationship("MessageTemplate")
|
||||
|
||||
# Extra params for templating (e.g. {"pay_method": "微信"})
|
||||
template_vars = Column(JSON, nullable=True)
|
||||
|
||||
class RequestLog(Base):
|
||||
__tablename__ = 'request_logs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
namespace = Column(String, index=True)
|
||||
remark = Column(String, nullable=True) # 保留用于快速筛选,可选
|
||||
event_no = Column(String, nullable=True) # 保留用于快速筛选,可选
|
||||
raw_body = Column(JSON)
|
||||
received_at = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String) # success, error
|
||||
delivery_logs = relationship("DeliveryLog", back_populates="request_log", cascade="all, delete-orphan")
|
||||
|
||||
class DeliveryLog(Base):
|
||||
__tablename__ = 'delivery_logs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
request_id = Column(Integer, ForeignKey('request_logs.id'))
|
||||
target_name = Column(String)
|
||||
type = Column(String) # relay, notify
|
||||
status = Column(String) # success, failed
|
||||
response_summary = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
request_log = relationship("RequestLog", back_populates="delivery_logs")
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "sqlite:///./config/data.db")
|
||||
engine = create_engine(DB_PATH, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,15 @@
|
||||
import logging
|
||||
from pythonjsonlogger import jsonlogger
|
||||
import os
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logger.setLevel(level)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
from fastapi import FastAPI, Request, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.logging import get_logger
|
||||
from app.admin import router as admin_router
|
||||
from app.db import SessionLocal, WebhookEndpoint, RequestLog, DeliveryLog, init_db
|
||||
from app.services.engine import engine
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Initialize DB
|
||||
logger.info("Initializing database...")
|
||||
init_db()
|
||||
yield
|
||||
# Shutdown logic if any
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.include_router(admin_router)
|
||||
|
||||
def save_logs(namespace: str, payload_dict: dict, routed: list, notified: list):
|
||||
try:
|
||||
db = SessionLocal()
|
||||
# Create RequestLog
|
||||
req_log = RequestLog(
|
||||
namespace=namespace,
|
||||
remark=str(payload_dict.get("remark", "")),
|
||||
event_no=str(payload_dict.get("event_define_no", "")),
|
||||
raw_body=payload_dict,
|
||||
status="success"
|
||||
)
|
||||
db.add(req_log)
|
||||
db.commit()
|
||||
db.refresh(req_log)
|
||||
|
||||
# Create DeliveryLogs
|
||||
for r in routed:
|
||||
db.add(DeliveryLog(
|
||||
request_id=req_log.id,
|
||||
target_name=r.get("target"),
|
||||
type="relay",
|
||||
status="success" if r.get("ok") else "failed",
|
||||
response_summary=str(r.get("error") or "OK")
|
||||
))
|
||||
|
||||
for n in notified:
|
||||
db.add(DeliveryLog(
|
||||
request_id=req_log.id,
|
||||
target_name=n.get("channel"),
|
||||
type="notify",
|
||||
status="success" if n.get("ok") else "failed",
|
||||
response_summary=str(n.get("error") or "OK")
|
||||
))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save logs: {e}")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/webhook/{namespace}")
|
||||
async def webhook(namespace: str, request: Request, background_tasks: BackgroundTasks):
|
||||
db = SessionLocal()
|
||||
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.namespace == namespace).first()
|
||||
|
||||
if not endpoint:
|
||||
# Auto-create for convenience if needed, or reject. For now reject if not exists.
|
||||
# Or better: log warning but don't process.
|
||||
# But per requirements "add endpoint", so we expect it to exist.
|
||||
db.close()
|
||||
return JSONResponse({"error": "Endpoint not found"}, status_code=404)
|
||||
|
||||
if not endpoint.is_active:
|
||||
db.close()
|
||||
return JSONResponse({"error": "Endpoint inactive"}, status_code=403)
|
||||
|
||||
endpoint_id = endpoint.id
|
||||
db.close()
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
# Use new engine
|
||||
routed, notified = await engine.process(endpoint_id, body)
|
||||
|
||||
# Async save logs
|
||||
background_tasks.add_task(save_logs, namespace, body, routed, notified)
|
||||
|
||||
result = {
|
||||
"namespace": namespace,
|
||||
"routed": routed,
|
||||
"notified": notified
|
||||
}
|
||||
logger.info({"event": "webhook_processed", "namespace": namespace, "routed": len(routed), "notified": len(notified)})
|
||||
return JSONResponse(result)
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
class IncomingOrderInfo(BaseModel):
|
||||
cash_resp_desc: Optional[str] = None
|
||||
ref_amt: Optional[float] = None
|
||||
|
||||
class IncomingPayload(BaseModel):
|
||||
remark: Optional[str] = None
|
||||
event_define_no: Optional[str] = None
|
||||
trans_amt: Optional[float] = None
|
||||
settlement_amt: Optional[float] = None
|
||||
out_trans_id: Optional[str] = None
|
||||
hf_seq_id: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
trans_order_info: Optional[IncomingOrderInfo] = None
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
|
||||
def idempotent_key(self) -> Optional[str]:
|
||||
return self.out_trans_id or self.hf_seq_id
|
||||
|
||||
def cash_resp_desc(self) -> str:
|
||||
v = None
|
||||
if self.trans_order_info:
|
||||
v = self.trans_order_info.cash_resp_desc
|
||||
return v or ""
|
||||
|
||||
def actual_ref_amt(self) -> Optional[float]:
|
||||
extra_val = None
|
||||
if self.extra and isinstance(self.extra, dict):
|
||||
extra_val = self.extra.get("actual_ref_amt")
|
||||
return extra_val or (self.trans_order_info.ref_amt if self.trans_order_info and self.trans_order_info.ref_amt is not None else self.settlement_amt)
|
||||
@@ -0,0 +1,228 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
import asyncio
|
||||
import re
|
||||
from app.db import SessionLocal, ProcessingRule, RuleAction, Target, NotificationChannel, MessageTemplate
|
||||
from app.logging import get_logger
|
||||
|
||||
logger = get_logger("engine")
|
||||
|
||||
class RuleEngine:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_value_by_path(self, payload: Dict[str, Any], path: str) -> Optional[str]:
|
||||
try:
|
||||
keys = path.split('.')
|
||||
value = payload
|
||||
for key in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(key)
|
||||
else:
|
||||
return None
|
||||
return str(value) if value is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def check_condition(self, actual_val: str, operator: str, match_val: str) -> bool:
|
||||
if actual_val is None:
|
||||
return False
|
||||
|
||||
operator = operator or 'eq'
|
||||
|
||||
if operator == 'eq':
|
||||
return actual_val == match_val
|
||||
elif operator == 'neq':
|
||||
return actual_val != match_val
|
||||
elif operator == 'contains':
|
||||
return match_val in actual_val
|
||||
elif operator == 'startswith':
|
||||
return actual_val.startswith(match_val)
|
||||
elif operator == 'regex':
|
||||
try:
|
||||
return re.search(match_val, actual_val) is not None
|
||||
except:
|
||||
return False
|
||||
return False
|
||||
|
||||
async def process(self, endpoint_id: int, payload: Dict[str, Any]):
|
||||
db = SessionLocal()
|
||||
tasks = []
|
||||
|
||||
try:
|
||||
# Recursive processing function
|
||||
# context stores accumulated template_vars AND active_template from parent rules
|
||||
# context = { "vars": {...}, "template_content": "..." }
|
||||
def process_rules(rules: List[ProcessingRule], context: Dict):
|
||||
for rule in rules:
|
||||
actual_val = self.get_value_by_path(payload, rule.match_field)
|
||||
|
||||
if self.check_condition(actual_val, rule.operator, rule.match_value):
|
||||
logger.info({"event": "rule_matched", "rule_id": rule.id, "match_field": rule.match_field})
|
||||
|
||||
# Prepare context for this level
|
||||
# We use shallow copy for dict structure, but deep copy for internal vars is not strictly needed
|
||||
# as long as we don't mutate active_template in place (it's a string).
|
||||
current_context = {
|
||||
"vars": context.get("vars", {}).copy(),
|
||||
"template_content": context.get("template_content")
|
||||
}
|
||||
|
||||
# 1. First Pass: Collect Vars and Templates from all actions
|
||||
# This allows a parent rule to set a template even if it doesn't send a notification itself
|
||||
for action in rule.actions:
|
||||
if action.template_vars:
|
||||
current_context["vars"].update(action.template_vars)
|
||||
|
||||
# If action has a template, it updates the current context's template
|
||||
# This template will be used by subsequent actions in this rule OR children
|
||||
if action.template:
|
||||
current_context["template_content"] = action.template.template_content
|
||||
|
||||
# 2. Second Pass: Execute Actions
|
||||
for action in rule.actions:
|
||||
if action.action_type == 'forward' and action.target:
|
||||
t_dict = {"name": action.target.name, "url": action.target.url, "timeout_ms": action.target.timeout_ms}
|
||||
tasks.append(self._exec_forward(t_dict, payload))
|
||||
|
||||
elif action.action_type == 'notify':
|
||||
# Check if we have a valid channel
|
||||
if action.channel:
|
||||
# Determine template to use: Action's own template > Inherited template
|
||||
template_content = None
|
||||
if action.template:
|
||||
template_content = action.template.template_content
|
||||
else:
|
||||
template_content = current_context.get("template_content")
|
||||
|
||||
if template_content:
|
||||
try:
|
||||
# Flatten payload + merge current context vars
|
||||
render_context = self._flatten_payload(payload)
|
||||
render_context.update(current_context["vars"])
|
||||
|
||||
msg = template_content.format(**render_context)
|
||||
|
||||
c_dict = {"channel": action.channel.channel_type, "url": action.channel.webhook_url}
|
||||
tasks.append(self._exec_notify(c_dict, msg))
|
||||
except Exception as e:
|
||||
logger.exception(f"Template render failed for action {action.id}: {e}")
|
||||
tasks.append(self._return_error("notify", action.channel.name, str(e)))
|
||||
else:
|
||||
# Channel exists but no template found anywhere
|
||||
logger.warning(f"Action {action.id} has channel but no template (own or inherited). Skipping.")
|
||||
|
||||
# 3. Process children (DFS)
|
||||
if rule.children:
|
||||
process_rules(rule.children, current_context)
|
||||
|
||||
# Start with root rules (parent_rule_id is NULL)
|
||||
root_rules = db.query(ProcessingRule).filter(
|
||||
ProcessingRule.endpoint_id == endpoint_id,
|
||||
ProcessingRule.parent_rule_id == None
|
||||
).order_by(ProcessingRule.priority.desc()).all()
|
||||
|
||||
process_rules(root_rules, {"vars": {}, "template_content": None})
|
||||
|
||||
# Wait for all actions
|
||||
results = await asyncio.gather(*tasks) if tasks else []
|
||||
|
||||
# Aggregate results
|
||||
routed_results = []
|
||||
notified_results = []
|
||||
for res in results:
|
||||
if res['type'] == 'forward':
|
||||
routed_results.append(res)
|
||||
else:
|
||||
notified_results.append(res)
|
||||
|
||||
return routed_results, notified_results
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _flatten_payload(self, y: dict) -> dict:
|
||||
out = {}
|
||||
|
||||
# Helper class to allow attribute access on dictionaries within templates
|
||||
class AttrDict(dict):
|
||||
def __getattr__(self, key):
|
||||
if key in self:
|
||||
v = self[key]
|
||||
if isinstance(v, dict):
|
||||
return AttrDict(v)
|
||||
return v
|
||||
# Return empty string or None to avoid AttributeError in templates
|
||||
return ""
|
||||
|
||||
def flatten(x, name=''):
|
||||
if isinstance(x, dict):
|
||||
for a in x:
|
||||
flatten(x[a], name + a + '_')
|
||||
if name == '':
|
||||
# Wrap top-level nested dicts so {a.b} works in templates
|
||||
out[a] = AttrDict(x[a]) if isinstance(x[a], dict) else x[a]
|
||||
else:
|
||||
if name:
|
||||
out[name[:-1]] = x
|
||||
|
||||
flatten(y)
|
||||
|
||||
# Fallback aliases for common fields referenced by templates
|
||||
# cash_resp_desc: prefer nested trans_order_info.cash_resp_desc
|
||||
try:
|
||||
if 'cash_resp_desc' not in out:
|
||||
toi = y.get('trans_order_info') or {}
|
||||
out['cash_resp_desc'] = (toi.get('cash_resp_desc') or "")
|
||||
except Exception:
|
||||
out['cash_resp_desc'] = ""
|
||||
|
||||
# actual_ref_amt: extra.actual_ref_amt > trans_order_info.ref_amt > settlement_amt
|
||||
try:
|
||||
if 'actual_ref_amt' not in out:
|
||||
extra = y.get('extra') or {}
|
||||
toi = y.get('trans_order_info') or {}
|
||||
val = extra.get('actual_ref_amt')
|
||||
if val is None:
|
||||
val = toi.get('ref_amt')
|
||||
if val is None:
|
||||
val = y.get('settlement_amt')
|
||||
out['actual_ref_amt'] = val
|
||||
except Exception:
|
||||
out['actual_ref_amt'] = y.get('settlement_amt')
|
||||
|
||||
# Ensure any dict values in context are AttrDict to support dot-notation
|
||||
for k, v in list(out.items()):
|
||||
if isinstance(v, dict) and not isinstance(v, AttrDict):
|
||||
out[k] = AttrDict(v)
|
||||
|
||||
return out
|
||||
|
||||
async def _exec_forward(self, target: dict, payload: dict):
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(target['url'], json=payload, timeout=target.get('timeout_ms', 5000)/1000)
|
||||
resp.raise_for_status()
|
||||
return {"type": "forward", "target": target['name'], "ok": True}
|
||||
except Exception as e:
|
||||
return {"type": "forward", "target": target['name'], "ok": False, "error": str(e)}
|
||||
|
||||
async def _exec_notify(self, channel: dict, msg: str):
|
||||
try:
|
||||
from app.services.notify import send_feishu, send_wecom
|
||||
channel_type = channel.get('channel')
|
||||
url = channel.get('url')
|
||||
|
||||
if channel_type == 'feishu':
|
||||
await send_feishu(url, msg)
|
||||
elif channel_type == 'wecom':
|
||||
await send_wecom(url, msg)
|
||||
return {"type": "notify", "channel": channel_type, "ok": True}
|
||||
except Exception as e:
|
||||
logger.exception(f"Notification failed for {channel.get('channel')}: {e}")
|
||||
return {"type": "notify", "channel": channel.get('channel'), "ok": False, "error": str(e)}
|
||||
|
||||
async def _return_error(self, type_str, name, err):
|
||||
return {"type": type_str, "target" if type_str == 'forward' else "channel": name, "ok": False, "error": err}
|
||||
|
||||
engine = RuleEngine()
|
||||
@@ -0,0 +1,13 @@
|
||||
import httpx
|
||||
|
||||
async def send_feishu(url: str, text: str):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
body = {"msg_type": "text", "content": {"text": text}}
|
||||
resp = await client.post(url, json=body)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def send_wecom(url: str, text: str):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
body = {"msgtype": "text", "text": {"content": text}}
|
||||
resp = await client.post(url, json=body)
|
||||
resp.raise_for_status()
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime, timedelta
|
||||
from app.db import SessionLocal, RequestLog
|
||||
from sqlalchemy import func
|
||||
|
||||
# 全局变量,记录启动时间
|
||||
START_TIME = datetime.utcnow()
|
||||
|
||||
class SystemStats:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_uptime(self) -> str:
|
||||
delta = datetime.utcnow() - START_TIME
|
||||
days = delta.days
|
||||
hours, remainder = divmod(delta.seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if days > 0:
|
||||
return f"{days}天 {hours}小时"
|
||||
elif hours > 0:
|
||||
return f"{hours}小时 {minutes}分"
|
||||
else:
|
||||
return f"{minutes}分 {seconds}秒"
|
||||
|
||||
def get_today_count(self) -> int:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
count = session.query(func.count(RequestLog.id)).filter(RequestLog.received_at >= today_start).scalar()
|
||||
return count or 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_latest_log_time(self) -> str:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
log = session.query(RequestLog).order_by(RequestLog.received_at.desc()).first()
|
||||
if log:
|
||||
# 简单转为本地时间显示(+8)
|
||||
dt = log.received_at + timedelta(hours=8)
|
||||
return dt.strftime("%H:%M:%S")
|
||||
return "无"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
stats_service = SystemStats()
|
||||
Reference in New Issue
Block a user