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
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Webhook管理后台{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { padding-top: 20px; }
.nav-scroller { position: relative; z-index: 2; height: 2.75rem; overflow-y: hidden; }
.nav-scroller .nav { display: flex; flex-wrap: nowrap; padding-bottom: 1rem; margin-top: -1px; overflow-x: auto; text-align: center; white-space: nowrap; -webkit-overflow-scrolling: touch; }
</style>
</head>
<body>
<div class="container">
<!-- 状态栏 -->
{% if system_stats %}
<div class="alert alert-light border d-flex justify-content-between align-items-center py-2 mb-3">
<div>
<span class="badge bg-success me-2">运行中</span>
<span class="text-muted me-3">已运行: {{ system_stats.uptime }}</span>
<span class="text-muted">今日请求: {{ system_stats.today_count }}</span>
</div>
<div>
<small class="text-muted">最新日志: {{ system_stats.latest_log }}</small>
</div>
</div>
{% endif %}
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
<a href="/admin" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto link-body-emphasis text-decoration-none">
<span class="fs-4">Webhook中继管理</span>
</a>
<ul class="nav nav-pills">
<li class="nav-item"><a href="/admin" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">仪表盘</a></li>
<li class="nav-item"><a href="/admin/endpoints" class="nav-link {% if active_page == 'endpoints' %}active{% endif %}">端点管理</a></li>
<li class="nav-item"><a href="/admin/targets" class="nav-link {% if active_page == 'targets' %}active{% endif %}">目标管理</a></li>
<li class="nav-item"><a href="/admin/channels" class="nav-link {% if active_page == 'channels' %}active{% endif %}">通知渠道</a></li>
<li class="nav-item"><a href="/admin/templates" class="nav-link {% if active_page == 'templates' %}active{% endif %}">事件模板</a></li>
<li class="nav-item"><a href="/admin/logs" class="nav-link {% if active_page == 'logs' %}active{% endif %}">系统日志</a></li>
</ul>
</header>
<main>
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
{% extends "admin/base.html" %}
{% block title %}通知渠道 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>通知渠道列表</h3>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" onclick="openAddModal()">添加渠道</button>
</div>
</div>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>什么是“通知渠道”?</strong>
<p class="mb-0">
通知渠道(Notification Channels)定义了<strong>消息推送到哪里</strong>(通常是IM工具的群机器人)。<br>
<strong>类型</strong>:支持 飞书 (Feishu) 和 企业微信 (WeCom) 的群机器人。
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>类型</th>
<th>Webhook URL</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for c in channels %}
<tr>
<td>{{ c.id }}</td>
<td>{{ c.name }}</td>
<td>
{% if c.channel_type == 'feishu' %}
<span class="badge bg-success">飞书</span>
{% else %}
<span class="badge bg-primary">企业微信</span>
{% endif %}
</td>
<td class="text-truncate" style="max-width: 300px;">{{ c.webhook_url }}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="openEditModal('{{ c.id }}', '{{ c.name }}', '{{ c.channel_type }}', '{{ c.webhook_url }}')">修改</button>
<form action="/admin/channels/delete" method="post" style="display:inline" onsubmit="return confirm('确定删除?')">
<input type="hidden" name="id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modal -->
<div class="modal fade" id="channelModal" tabindex="-1">
<div class="modal-dialog">
<form id="channelForm" action="/admin/channels" method="post">
<input type="hidden" name="id" id="channelId">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">添加通知渠道</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">名称 (内部标识)</label>
<input type="text" class="form-control" name="name" id="channelName" required placeholder="例如: 运营群-飞书">
<div class="form-text">给这个渠道起个名字,例如“财务群-企微”。</div>
</div>
<div class="mb-3">
<label class="form-label">类型</label>
<select class="form-select" name="channel_type" id="channelType">
<option value="feishu">飞书 (Feishu)</option>
<option value="wecom">企业微信 (WeCom)</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-control" name="webhook_url" id="channelUrl" required placeholder="https://...">
<div class="form-text">
<strong>飞书</strong>: https://open.feishu.cn/open-apis/bot/v2/hook/...<br>
<strong>企微</strong>: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var modal = new bootstrap.Modal(document.getElementById('channelModal'));
function openAddModal() {
document.getElementById('modalTitle').innerText = "添加通知渠道";
document.getElementById('channelForm').action = "/admin/channels";
document.getElementById('channelId').value = "";
document.getElementById('channelName').value = "";
document.getElementById('channelType').value = "feishu";
document.getElementById('channelUrl').value = "";
modal.show();
}
function openEditModal(id, name, type, url) {
document.getElementById('modalTitle').innerText = "修改通知渠道";
document.getElementById('channelForm').action = "/admin/channels/update";
document.getElementById('channelId').value = id;
document.getElementById('channelName').value = name;
document.getElementById('channelType').value = type;
document.getElementById('channelUrl').value = url;
modal.show();
}
</script>
{% endblock %}
+196
View File
@@ -0,0 +1,196 @@
{% extends "admin/base.html" %}
{% block title %}仪表盘 - Webhook{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-center h-100 border-primary">
<div class="card-body d-flex flex-column justify-content-center">
<h5 class="card-title text-muted">今日请求总数</h5>
<p class="card-text display-4 text-primary fw-bold">{{ system_stats.today_count }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center h-100 border-success">
<div class="card-body d-flex flex-column justify-content-center">
<h5 class="card-title text-muted">系统运行时间</h5>
<p class="card-text display-6 text-success">{{ system_stats.uptime }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center h-100 border-info">
<div class="card-body d-flex flex-column justify-content-center">
<h5 class="card-title text-muted">最新活动</h5>
<p class="card-text lead">{{ system_stats.latest_log or '暂无数据' }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<!-- Simulation Card -->
<div class="card mb-4 border-warning">
<div class="card-header bg-warning text-dark d-flex justify-content-between align-items-center">
<h5 class="mb-0">⚡ 模拟数据推送</h5>
<small>测试您的规则配置</small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">选择端点 (Namespace)</label>
<select class="form-select" id="simNamespace">
{% for ep in endpoints %}
<option value="{{ ep.namespace }}">{{ ep.namespace }} ({{ ep.description or 'No Desc' }})</option>
{% endfor %}
</select>
{% if not endpoints %}
<div class="text-danger small mt-1">请先创建端点</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">JSON Payload</label>
<textarea class="form-control font-monospace" id="simPayload" rows="6">
{
"event_define_no": "pay.wx_scaned",
"trans_order_info": {
"remark": "imcgcd03",
"amt": 100.00
}
}</textarea>
<div class="form-text">
<a href="#" onclick="fillSample('wx'); return false;">[微信示例]</a>
<a href="#" onclick="fillSample('ali'); return false;">[支付宝示例]</a>
</div>
</div>
<button class="btn btn-warning w-100" onclick="runSimulation()" {% if not endpoints %}disabled{% endif %}>
发送模拟请求 (POST)
</button>
<div id="simResult" class="mt-3 d-none">
<hr>
<h6>执行结果:</h6>
<pre class="bg-light p-2 border rounded small" id="simOutput"></pre>
<div class="text-center mt-2">
<a href="/admin/logs" class="btn btn-sm btn-outline-secondary">查看详细日志</a>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">快速开始</h5>
</div>
<div class="card-body">
<p>欢迎使用 Webhook 中继平台。请按照以下步骤配置您的第一个流程:</p>
<ol class="list-group list-group-numbered list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">添加资源</div>
配置转发目标 (Target) 或通知渠道 (Channel)。
</div>
<a href="/admin/targets" class="btn btn-sm btn-outline-primary">去配置</a>
</li>
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">创建端点</div>
定义一个 Webhook 接收地址 (Namespace)。
</div>
<a href="/admin/endpoints" class="btn btn-sm btn-outline-primary">去创建</a>
</li>
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">设置规则</div>
在端点详情页添加入站匹配规则和动作。
</div>
</li>
</ol>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">系统状态</h5>
<span class="badge bg-success">Online</span>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<strong>API 基础地址:</strong>
<code>http://{your-host}:8090/webhook/</code>
</li>
<li class="list-group-item">
<strong>文档:</strong>
<a href="/docs" target="_blank">Swagger UI</a> / <a href="/redoc" target="_blank">Redoc</a>
</li>
<li class="list-group-item">
<strong>当前版本:</strong> v2.2.0 (Tree Rule Engine)
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function fillSample(type) {
const area = document.getElementById('simPayload');
if (type === 'wx') {
area.value = JSON.stringify({
"event_define_no": "pay.wx_scaned",
"trans_order_info": {
"remark": "imcgcd03",
"amt": 88.88
}
}, null, 2);
} else if (type === 'ali') {
area.value = JSON.stringify({
"event_define_no": "pay.ali_scaned",
"trans_order_info": {
"remark": "imcgcd02",
"amt": 66.66
}
}, null, 2);
}
}
async function runSimulation() {
const ns = document.getElementById('simNamespace').value;
const payloadStr = document.getElementById('simPayload').value;
const resultDiv = document.getElementById('simResult');
const outputPre = document.getElementById('simOutput');
if (!ns) {
alert("请选择端点");
return;
}
try {
// Validate JSON
const payload = JSON.parse(payloadStr);
outputPre.innerText = "发送中...";
resultDiv.classList.remove('d-none');
const response = await fetch(`/webhook/${ns}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await response.json();
outputPre.innerText = "状态码: " + response.status + "\n" + JSON.stringify(data, null, 2);
} catch (e) {
alert("JSON 格式错误或请求失败: " + e);
outputPre.innerText = "错误: " + e;
}
}
</script>
{% endblock %}
+467
View File
@@ -0,0 +1,467 @@
{% extends "admin/base.html" %}
{% block title %}配置端点流程 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>配置端点流程: <span class="badge bg-info text-dark">{{ ep.namespace }}</span></h3>
<p class="text-muted small">{{ ep.description or '无描述' }}</p>
</div>
<div class="col-auto">
<a href="/admin/endpoints" class="btn btn-outline-secondary">返回列表</a>
</div>
</div>
<div class="alert alert-light border">
<strong>规则引擎说明 (树状逻辑)</strong>
规则自上而下、从外向内匹配。只有父规则匹配成功,才会检查子规则。<br>
子规则会继承父规则设置的模板变量(如支付方式名称)。
</div>
<!-- Recursive Rule Macro -->
{% macro render_rule(rule, level=0) %}
<div class="card mb-3 shadow-sm" style="margin-left: {{ level * 2 }}rem; border-left: 4px solid {% if level == 0 %}#0d6efd{% else %}#6c757d{% endif %};">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
{% if level > 0 %}
<span class="text-muted me-2">↳ 并且</span>
{% else %}
<span class="fw-bold me-2">根条件:</span>
{% endif %}
字段 <code>{{ rule.match_field }}</code>
<span class="badge bg-secondary">{{ rule.operator or '等于' }}</span>
<span class="badge bg-primary">{{ rule.match_value }}</span>
</div>
<div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addRuleModal" data-parent-id="{{ rule.id }}">
+ 添加子规则
</button>
<button class="btn btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#editRuleModal"
data-rule-id="{{ rule.id }}"
data-match-field="{{ rule.match_field }}"
data-operator="{{ rule.operator }}"
data-match-value="{{ rule.match_value }}"
data-priority="{{ rule.priority or 0 }}"
data-parent-id="{{ rule.parent_rule_id or '' }}">
编辑
</button>
<button class="btn btn-outline-success"
data-bs-toggle="modal"
data-bs-target="#duplicateRuleModal"
data-rule-id="{{ rule.id }}"
data-parent-id="{{ rule.parent_rule_id or '' }}">
复制
</button>
<form action="/admin/rules/delete" method="post" style="display:inline" onsubmit="return confirm('确定删除此规则及其所有子规则?')">
<input type="hidden" name="id" value="{{ rule.id }}">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<button type="submit" class="btn btn-outline-danger">删除</button>
</form>
</div>
</div>
</div>
<div class="card-body py-2">
{% if rule.actions %}
<div class="list-group mb-2">
{% for action in rule.actions %}
<div class="list-group-item d-flex justify-content-between align-items-center py-1">
<div class="small">
{% if action.action_type == 'forward' %}
<span class="badge bg-primary me-2">转发</span>
目标: <strong>{{ action.target.name }}</strong>
{% else %}
<span class="badge bg-success me-2">通知</span>
渠道: <strong>{{ action.channel.name }}</strong> | 模板: <strong>{{ action.template.name }}</strong>
{% if action.template_vars %}
<code class="ms-2">{{ action.template_vars }}</code>
{% endif %}
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-link text-decoration-none p-0"
data-bs-toggle="modal"
data-bs-target="#editActionModal"
data-action-id="{{ action.id }}"
data-action-type="{{ action.action_type }}"
data-target-id="{{ action.target.id if action.target else '' }}"
data-channel-id="{{ action.channel.id if action.channel else '' }}"
data-template-id="{{ action.template.id if action.template else '' }}"
data-template-vars='{{ action.template_vars | tojson | safe if action.template_vars else "" }}'>
编辑
</button>
<form action="/admin/actions/duplicate" method="post" style="display:inline">
<input type="hidden" name="id" value="{{ action.id }}">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<button type="submit" class="btn btn-sm btn-link text-decoration-none p-0">复制</button>
</form>
<form action="/admin/actions/delete" method="post" style="display:inline" onsubmit="return confirm('确定移除此动作?')">
<input type="hidden" name="id" value="{{ action.id }}">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<button type="submit" class="btn btn-sm btn-link text-danger text-decoration-none p-0"></button>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="d-flex gap-2 mb-2">
<button class="btn btn-sm btn-link text-decoration-none p-0" data-bs-toggle="modal" data-bs-target="#addActionModal"
data-rule-id="{{ rule.id }}" data-type="forward">+ 添加转发</button>
<button class="btn btn-sm btn-link text-decoration-none p-0" data-bs-toggle="modal" data-bs-target="#addActionModal"
data-rule-id="{{ rule.id }}" data-type="notify">+ 添加通知/变量</button>
</div>
</div>
</div>
<!-- Render Children -->
{% for child in rule.child_rules %}
{{ render_rule(child, level + 1) }}
{% endfor %}
{% endmacro %}
<!-- Root Rules List -->
{% for rule in root_rules %}
{{ render_rule(rule) }}
{% endfor %}
<!-- Add Root Rule Button -->
<div class="card border-dashed text-center py-4 mb-4">
<div class="card-body">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addRuleModal" data-parent-id="">
添加根规则 (Root Rule)
</button>
</div>
</div>
<!-- Add Rule Modal -->
<div class="modal fade" id="addRuleModal" tabindex="-1">
<div class="modal-dialog">
<form id="ruleForm" action="/admin/endpoints/{{ ep.id }}/rules" method="post">
<input type="hidden" name="parent_rule_id" id="parentRuleId">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ruleModalTitle">添加规则</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">匹配字段 (Match Field)</label>
<input type="text" class="form-control" name="match_field" required placeholder="trans_order_info.remark">
<div class="form-text">JSON字段路径,例如: <code>event_define_no</code><code>body.status</code></div>
</div>
<div class="mb-3">
<label class="form-label">操作符</label>
<select class="form-select" name="operator">
<option value="eq">等于 (Equal)</option>
<option value="neq">不等于 (Not Equal)</option>
<option value="contains">包含 (Contains)</option>
<option value="startswith">开头是 (Starts With)</option>
<option value="regex">正则匹配 (Regex)</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">匹配值 (Match Value)</label>
<input type="text" class="form-control" name="match_value" required placeholder="success">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
<!-- Add Action Modal (Same as before) -->
<div class="modal fade" id="addActionModal" tabindex="-1">
<div class="modal-dialog">
<form id="actionForm" action="/admin/rules/0/actions" method="post">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<input type="hidden" name="action_type" id="actionTypeInput">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="actionModalTitle">添加动作</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Forward Section -->
<div id="forwardSection" class="d-none">
<div class="mb-3">
<label class="form-label">选择转发目标</label>
<select class="form-select" name="target_id">
<option value="">- 不转发 -</option>
{% for t in targets %}
<option value="{{ t.id }}">{{ t.name }} ({{ t.url }})</option>
{% endfor %}
</select>
</div>
</div>
<!-- Notify Section -->
<div id="notifySection" class="d-none">
<div class="mb-3">
<label class="form-label">选择通知渠道</label>
<select class="form-select" name="channel_id">
<option value="">- 不发送通知 (仅设置变量) -</option>
{% for c in channels %}
<option value="{{ c.id }}">{{ c.name }} ({{ c.channel_type }})</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">选择消息模板</label>
<select class="form-select" name="template_id">
<option value="">- 无模板 -</option>
{% for t in templates %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">自定义模板变量 (JSON)</label>
<textarea class="form-control" name="template_vars_str" id="templateVars" rows="2" placeholder='{"pay_method": "微信支付"}'></textarea>
<div class="form-text">
定义特有的变量值,例如将 "pay.wx_scaned" 映射为 "微信支付"。<br>
<a href="#" onclick="insertExample('wx'); return false;">[插入微信示例]</a>
<a href="#" onclick="insertExample('ali'); return false;">[插入支付宝示例]</a>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
<!-- Edit Rule Modal -->
<div class="modal fade" id="editRuleModal" tabindex="-1">
<div class="modal-dialog">
<form id="editRuleForm" action="/admin/rules/update" method="post">
<input type="hidden" name="id" id="editRuleId">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑规则</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">匹配字段</label>
<input type="text" class="form-control" name="match_field" id="editMatchField" required>
</div>
<div class="mb-3">
<label class="form-label">操作符</label>
<select class="form-select" name="operator" id="editOperator">
<option value="eq">等于</option>
<option value="neq">不等于</option>
<option value="contains">包含</option>
<option value="startswith">开头是</option>
<option value="regex">正则匹配</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">匹配值</label>
<input type="text" class="form-control" name="match_value" id="editMatchValue" required>
</div>
<div class="mb-3">
<label class="form-label">优先级</label>
<input type="number" class="form-control" name="priority" id="editPriority" value="0">
</div>
<div class="mb-3">
<label class="form-label">父规则ID(为空为根)</label>
<input type="text" class="form-control" name="parent_rule_id" id="editParentId">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
<!-- Duplicate Rule Modal -->
<div class="modal fade" id="duplicateRuleModal" tabindex="-1">
<div class="modal-dialog">
<form id="duplicateRuleForm" action="/admin/rules/duplicate" method="post">
<input type="hidden" name="rule_id" id="dupRuleId">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">复制规则</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">父规则ID(为空为根)</label>
<input type="text" class="form-control" name="parent_rule_id" id="dupParentId">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="true" id="dupIncludeChildren" name="include_children" checked>
<label class="form-check-label" for="dupIncludeChildren">
包含子规则和动作(深拷贝)
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-success">复制</button>
</div>
</div>
</form>
</div>
</div>
<!-- Edit Action Modal -->
<div class="modal fade" id="editActionModal" tabindex="-1">
<div class="modal-dialog">
<form id="editActionForm" action="/admin/actions/update" method="post">
<input type="hidden" name="id" id="editActionId">
<input type="hidden" name="endpoint_id" value="{{ ep.id }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑动作</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">动作类型</label>
<select class="form-select" name="action_type" id="editActionType">
<option value="forward">转发</option>
<option value="notify">通知/变量</option>
</select>
</div>
<div id="editForwardSection" class="mb-3 d-none">
<label class="form-label">选择转发目标</label>
<select class="form-select" name="target_id" id="editTargetId">
<option value="">- 不转发 -</option>
{% for t in targets %}
<option value="{{ t.id }}">{{ t.name }} ({{ t.url }})</option>
{% endfor %}
</select>
</div>
<div id="editNotifySection" class="mb-3 d-none">
<label class="form-label">选择通知渠道</label>
<select class="form-select" name="channel_id" id="editChannelId">
<option value="">- 不发送通知 -</option>
{% for c in channels %}
<option value="{{ c.id }}">{{ c.name }} ({{ c.channel_type }})</option>
{% endfor %}
</select>
<label class="form-label mt-2">选择消息模板</label>
<select class="form-select" name="template_id" id="editTemplateId">
<option value="">- 无模板 -</option>
{% for t in templates %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
<label class="form-label mt-2">模板变量 (JSON)</label>
<textarea class="form-control" name="template_vars_str" id="editTemplateVars" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var ruleModal = document.getElementById('addRuleModal');
ruleModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var parentId = button.getAttribute('data-parent-id');
document.getElementById('parentRuleId').value = parentId || "";
if (parentId) {
document.getElementById('ruleModalTitle').textContent = '添加子规则';
} else {
document.getElementById('ruleModalTitle').textContent = '添加根规则';
}
});
var actionModal = document.getElementById('addActionModal');
actionModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var ruleId = button.getAttribute('data-rule-id');
var type = button.getAttribute('data-type');
var form = document.getElementById('actionForm');
form.action = '/admin/rules/' + ruleId + '/actions';
document.getElementById('actionTypeInput').value = type;
if (type === 'forward') {
document.getElementById('actionModalTitle').textContent = '添加转发动作';
document.getElementById('forwardSection').classList.remove('d-none');
document.getElementById('notifySection').classList.add('d-none');
} else {
document.getElementById('actionModalTitle').textContent = '添加通知/变量动作';
document.getElementById('forwardSection').classList.add('d-none');
document.getElementById('notifySection').classList.remove('d-none');
}
});
function insertExample(type) {
var field = document.getElementById('templateVars');
if (type === 'wx') {
field.value = '{"pay_method_name": "微信支付"}';
} else if (type === 'ali') {
field.value = '{"pay_method_name": "支付宝"}';
}
}
var editRuleModal = document.getElementById('editRuleModal');
editRuleModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
document.getElementById('editRuleId').value = button.getAttribute('data-rule-id');
document.getElementById('editMatchField').value = button.getAttribute('data-match-field') || '';
document.getElementById('editOperator').value = button.getAttribute('data-operator') || 'eq';
document.getElementById('editMatchValue').value = button.getAttribute('data-match-value') || '';
document.getElementById('editPriority').value = button.getAttribute('data-priority') || '0';
document.getElementById('editParentId').value = button.getAttribute('data-parent-id') || '';
});
var duplicateRuleModal = document.getElementById('duplicateRuleModal');
duplicateRuleModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
document.getElementById('dupRuleId').value = button.getAttribute('data-rule-id');
document.getElementById('dupParentId').value = button.getAttribute('data-parent-id') || '';
document.getElementById('dupIncludeChildren').checked = true;
});
var editActionModal = document.getElementById('editActionModal');
editActionModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var type = button.getAttribute('data-action-type') || 'notify';
document.getElementById('editActionId').value = button.getAttribute('data-action-id');
document.getElementById('editActionType').value = type;
document.getElementById('editTargetId').value = button.getAttribute('data-target-id') || '';
document.getElementById('editChannelId').value = button.getAttribute('data-channel-id') || '';
document.getElementById('editTemplateId').value = button.getAttribute('data-template-id') || '';
document.getElementById('editTemplateVars').value = button.getAttribute('data-template-vars') || '';
if (type === 'forward') {
document.getElementById('editForwardSection').classList.remove('d-none');
document.getElementById('editNotifySection').classList.add('d-none');
} else {
document.getElementById('editForwardSection').classList.add('d-none');
document.getElementById('editNotifySection').classList.remove('d-none');
}
});
</script>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "admin/base.html" %}
{% block title %}端点管理 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>接收端点 (Endpoints)</h3>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">添加端点</button>
</div>
</div>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>什么是“接收端点”?</strong>
<p class="mb-0">
接收端点(Endpoints)是<strong>外部系统向本服务发送Webhook数据的入口地址</strong><br>
<strong>Namespace</strong>:URL路径的唯一标识。例如创建 namespace 为 <code>client_a</code>,则外部请求地址为 <code>/webhook/client_a</code><br>
<strong>流程配置</strong>:点击列表中的 <span class="badge bg-info text-dark">Namespace</span><button class="btn btn-sm btn-outline-primary py-0 px-1">⚙️ 配置</button> 按钮,进入端点的规则编排页面。
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Namespace</th>
<th>完整URL示例</th>
<th>描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for e in endpoints %}
<tr>
<td>{{ e.id }}</td>
<td><a href="/admin/endpoints/{{ e.id }}" class="text-decoration-none"><span class="badge bg-info text-dark fs-6">{{ e.namespace }}</span></a></td>
<td>
<code>/webhook/{{ e.namespace }}</code>
<button class="btn btn-sm btn-link text-decoration-none copy-btn" data-path="/webhook/{{ e.namespace }}" title="复制完整URL">📋</button>
</td>
<td>{{ e.description or '-' }}</td>
<td>
{% if e.is_active %}
<span class="badge bg-success">启用</span>
{% else %}
<span class="badge bg-secondary">禁用</span>
{% endif %}
</td>
<td>{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<a href="/admin/endpoints/{{ e.id }}" class="btn btn-sm btn-outline-primary">⚙️ 配置</a>
<form action="/admin/endpoints/toggle" method="post" style="display:inline">
<input type="hidden" name="id" value="{{ e.id }}">
{% if e.is_active %}
<button type="submit" class="btn btn-sm btn-warning">禁用</button>
{% else %}
<button type="submit" class="btn btn-sm btn-success">启用</button>
{% endif %}
</form>
<form action="/admin/endpoints/delete" method="post" style="display:inline" onsubmit="return confirm('确定删除?')">
<input type="hidden" name="id" value="{{ e.id }}">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Add Modal -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog">
<form action="/admin/endpoints" method="post">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">创建新端点</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Namespace (URL路径的一部分)</label>
<input type="text" class="form-control" name="namespace" required placeholder="my-service-1">
<div class="form-text">只能包含字母、数字、下划线或连字符。</div>
</div>
<div class="mb-3">
<label class="form-label">描述</label>
<input type="text" class="form-control" name="description" placeholder="用于XX业务接收">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function() {
const path = this.getAttribute('data-path');
// Construct full URL based on current window location
const fullUrl = window.location.origin + path;
navigator.clipboard.writeText(fullUrl).then(() => {
const originalContent = this.innerHTML;
this.innerHTML = '✅';
setTimeout(() => {
this.innerHTML = originalContent;
}, 1500);
}).catch(err => {
console.error('Failed to copy: ', err);
alert('复制失败,请手动复制');
});
});
});
});
</script>
{% endblock %}
+123
View File
@@ -0,0 +1,123 @@
{% extends "admin/base.html" %}
{% block title %}系统日志 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>请求日志 (最近100条)</h3>
</div>
<div class="col-auto">
<form action="/admin/logs/clear" method="post" onsubmit="return confirm('确定清空所有日志?')">
<button type="submit" class="btn btn-outline-danger">清空日志</button>
</form>
</div>
</div>
<div class="accordion" id="logsAccordion">
{% for log in logs %}
<div class="accordion-item">
<h2 class="accordion-header" id="heading{{ log.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ log.id }}">
<div class="d-flex w-100 justify-content-between me-3 align-items-center">
<div>
<span class="badge bg-secondary me-2">{{ log.received_at.strftime('%m-%d %H:%M:%S') }}</span>
<span class="fw-bold me-2">{{ log.namespace }}</span>
{% if log.status == 'replay' %}
<span class="badge bg-info text-dark me-1">重试</span>
{% endif %}
</div>
<div>
{% set success_count = 0 %}
{% set fail_count = 0 %}
{% for d in log.delivery_logs %}
{% if d.status == 'success' %}
{% set success_count = success_count + 1 %}
{% else %}
{% set fail_count = fail_count + 1 %}
{% endif %}
{% endfor %}
{% if fail_count > 0 %}
<span class="badge bg-danger">{{ fail_count }} 失败</span>
{% endif %}
<span class="badge bg-success">{{ success_count }} 成功</span>
</div>
</div>
</button>
</h2>
<div id="collapse{{ log.id }}" class="accordion-collapse collapse" data-bs-parent="#logsAccordion">
<div class="accordion-body">
<div class="row">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">原始 Payload</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="replayLog('{{ log.id }}')">
↺ 重试 (Replay)
</button>
</div>
<pre class="bg-light p-2 border rounded" style="max-height: 300px; overflow-y: auto;">{{ log.raw_body | tojson(indent=2) }}</pre>
</div>
<div class="col-md-6">
<h6>分发记录</h6>
<ul class="list-group">
{% for d in log.delivery_logs %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">
{% if d.type == 'relay' %}
<span class="badge bg-primary me-1">转发</span>
{% else %}
<span class="badge bg-success me-1">通知</span>
{% endif %}
{{ d.target_name }}
</div>
<small class="text-muted text-break">{{ d.response_summary }}</small>
</div>
{% if d.status == 'success' %}
<span class="badge bg-success rounded-pill">成功</span>
{% else %}
<span class="badge bg-danger rounded-pill">失败</span>
{% endif %}
</li>
{% endfor %}
{% if not log.delivery_logs %}
<li class="list-group-item text-muted">无分发记录</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not logs %}
<div class="text-center py-5 text-muted">
暂无日志记录
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function replayLog(id) {
if(!confirm("确定要使用此 Payload 重新触发处理流程吗?")) return;
try {
const response = await fetch(`/admin/logs/${id}/replay`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
alert("重试成功,已生成新的日志记录 ID: " + data.new_log_id);
window.location.reload();
} else {
alert("重试失败: " + (data.error || "未知错误"));
}
} catch (e) {
alert("请求错误: " + e);
}
}
</script>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "admin/base.html" %}
{% block title %}目标管理 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>转发目标列表</h3>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" onclick="openAddModal()">添加目标</button>
</div>
</div>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>什么是“转发目标”?</strong>
<p class="mb-0">
转发目标(Targets)是您希望将接收到的Webhook数据<strong>转发到的外部系统地址</strong><br>
例如:您的ERP系统接口、数据分析平台、或第三方业务系统的Webhook接收URL。
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>URL</th>
<th>超时(ms)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for t in targets %}
<tr>
<td>{{ t.id }}</td>
<td>{{ t.name }}</td>
<td>{{ t.url }}</td>
<td>{{ t.timeout_ms }}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="openEditModal('{{ t.id }}', '{{ t.name }}', '{{ t.url }}', '{{ t.timeout_ms }}')">修改</button>
<form action="/admin/targets/delete" method="post" style="display:inline" onsubmit="return confirm('确定删除?')">
<input type="hidden" name="id" value="{{ t.id }}">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modal -->
<div class="modal fade" id="targetModal" tabindex="-1">
<div class="modal-dialog">
<form id="targetForm" action="/admin/targets" method="post">
<input type="hidden" name="id" id="targetId">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">添加新目标</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">名称 (内部标识)</label>
<input type="text" class="form-control" name="name" id="targetName" required placeholder="例如: erp_system_prod">
<div class="form-text">给这个目标起个名字,方便在路由规则中选择。</div>
</div>
<div class="mb-3">
<label class="form-label">Webhook URL (对方接收地址)</label>
<input type="url" class="form-control" name="url" id="targetUrl" required placeholder="https://api.example.com/receive">
</div>
<div class="mb-3">
<label class="form-label">超时 (ms)</label>
<input type="number" class="form-control" name="timeout_ms" id="targetTimeout" value="5000">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var modal = new bootstrap.Modal(document.getElementById('targetModal'));
function openAddModal() {
document.getElementById('modalTitle').innerText = "添加新目标";
document.getElementById('targetForm').action = "/admin/targets";
document.getElementById('targetId').value = "";
document.getElementById('targetName').value = "";
document.getElementById('targetUrl').value = "";
document.getElementById('targetTimeout').value = "5000";
modal.show();
}
function openEditModal(id, name, url, timeout) {
document.getElementById('modalTitle').innerText = "修改目标";
document.getElementById('targetForm').action = "/admin/targets/update";
document.getElementById('targetId').value = id;
document.getElementById('targetName').value = name;
document.getElementById('targetUrl').value = url;
document.getElementById('targetTimeout').value = timeout;
modal.show();
}
</script>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "admin/base.html" %}
{% block title %}消息模板 - Webhook{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>消息模板资源</h3>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" onclick="openAddModal()">添加模板</button>
</div>
</div>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>什么是“消息模板”?</strong>
<p class="mb-0">
消息模板(Message Templates)是<strong>纯文本资源</strong>,供“通知动作”调用。<br>
<strong>变量替换</strong>:使用 <code>{variable}</code> 语法插入 JSON 中的数据。<br>
例如:<code>{trans_order_info_remark}</code><code>{body_status}</code>(引擎会将嵌套JSON展平为下划线连接的键)。
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>模板名称</th>
<th>内容预览</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td>{{ t.id }}</td>
<td>{{ t.name }}</td>
<td>{{ t.template_content }}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="openEditModal('{{ t.id }}', '{{ t.name }}', `{{ t.template_content }}`)">修改</button>
<form action="/admin/templates/delete" method="post" style="display:inline" onsubmit="return confirm('确定删除?')">
<input type="hidden" name="id" value="{{ t.id }}">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modal -->
<div class="modal fade" id="templateModal" tabindex="-1">
<div class="modal-dialog">
<form id="templateForm" action="/admin/templates" method="post">
<input type="hidden" name="id" id="templateId">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">添加消息模板</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">模板名称</label>
<input type="text" class="form-control" name="name" id="templateName" required placeholder="收款成功通知">
</div>
<div class="mb-3">
<label class="form-label">模板内容</label>
<textarea class="form-control" name="template_content" id="templateContent" rows="3" required placeholder="收到{trans_amt}元"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var modal = new bootstrap.Modal(document.getElementById('templateModal'));
function openAddModal() {
document.getElementById('modalTitle').innerText = "添加消息模板";
document.getElementById('templateForm').action = "/admin/templates";
document.getElementById('templateId').value = "";
document.getElementById('templateName').value = "";
document.getElementById('templateContent').value = "";
modal.show();
}
function openEditModal(id, name, content) {
document.getElementById('modalTitle').innerText = "修改消息模板";
document.getElementById('templateForm').action = "/admin/templates/update";
document.getElementById('templateId').value = id;
document.getElementById('templateName').value = name;
document.getElementById('templateContent').value = content;
modal.show();
}
</script>
{% endblock %}