feat: 初始化Webhook中继系统项目

- 添加FastAPI应用基础结构,包括主入口、路由和模型定义
- 实现Webhook接收端点(/webhook/{namespace})和健康检查(/health)
- 添加管理后台路由和模板,支持端点、目标、渠道和模板管理
- 包含SQLite数据库模型定义和初始化逻辑
- 添加日志记录和统计服务
- 包含Dockerfile和配置示例文件
- 添加项目文档,包括设计、流程图和验收标准
This commit is contained in:
2025-12-21 18:43:12 +08:00
commit 2bc7460f1f
42 changed files with 3177 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+485
View File
@@ -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})
+102
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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)
+32
View File
@@ -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)
+228
View File
@@ -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()
+13
View File
@@ -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()
+45
View File
@@ -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()