feat: 初始化Webhook中继系统项目
- 添加FastAPI应用基础结构,包括主入口、路由和模型定义
- 实现Webhook接收端点(/webhook/{namespace})和健康检查(/health)
- 添加管理后台路由和模板,支持端点、目标、渠道和模板管理
- 包含SQLite数据库模型定义和初始化逻辑
- 添加日志记录和统计服务
- 包含Dockerfile和配置示例文件
- 添加项目文档,包括设计、流程图和验收标准
This commit is contained in:
@@ -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