feat: 初始化Webhook中继系统项目
- 添加FastAPI应用基础结构,包括主入口、路由和模型定义
- 实现Webhook接收端点(/webhook/{namespace})和健康检查(/health)
- 添加管理后台路由和模板,支持端点、目标、渠道和模板管理
- 包含SQLite数据库模型定义和初始化逻辑
- 添加日志记录和统计服务
- 包含Dockerfile和配置示例文件
- 添加项目文档,包括设计、流程图和验收标准
This commit is contained in:
commit
2bc7460f1f
32
.trae/documents/Webhook中继系统 - Admin后台升级计划.md
Normal file
32
.trae/documents/Webhook中继系统 - Admin后台升级计划.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Webhook中继系统 - Admin后台升级计划
|
||||
|
||||
## 1. 核心变更:数据库驱动配置
|
||||
- 引入 **SQLite + SQLAlchemy** 替代静态 `config.yml`。
|
||||
- 存储模型:
|
||||
- `Target`:转发目标Webhook(名称、URL、超时)。
|
||||
- `RemarkRule`:remark值到目标列表的映射(imcgcd03 -> [Target A, Target B])。
|
||||
- `EventTemplate`:事件模板(event_define_no -> 模板文本)。
|
||||
- `NotificationChannel`:通知渠道(类型feishu/wecom、Webhook URL、名称)。
|
||||
- `EventChannelBinding`:事件到渠道的绑定(pay.ali_scaned -> [Channel A, Channel B])。
|
||||
|
||||
## 2. Admin后台界面
|
||||
- 基于 **FastAPI + Jinja2 + Bootstrap** 提供简易管理UI。
|
||||
- 功能页面:
|
||||
- **目标管理**:增删改查转发目标。
|
||||
- **路由规则**:配置 `remark` 对应的目标列表。
|
||||
- **通知模板**:编辑不同事件的通知文案。
|
||||
- **通知渠道**:添加飞书/企业微信机器人链接。
|
||||
- **通知绑定**:将事件与通知渠道关联。
|
||||
|
||||
## 3. 系统架构调整
|
||||
- `app/db.py`:数据库连接与模型定义。
|
||||
- `app/admin.py`:管理后台路由与CRUD逻辑。
|
||||
- `app/config.py`:改为混合模式,优先从DB加载动态配置,保留文件配置作为默认值/种子数据。
|
||||
- `app/services/relay.py` & `notify.py`:适配新的DB驱动配置查询。
|
||||
|
||||
## 4. 实施步骤
|
||||
1. **依赖更新**:添加 `sqlalchemy`、`jinja2`、`python-multipart`。
|
||||
2. **数据层实现**:定义ORM模型与初始化脚本(支持从现有yaml导入种子数据)。
|
||||
3. **后台UI实现**:创建HTML模板与FastAPI路由。
|
||||
4. **配置适配**:改造 `load_config` 逻辑,支持热重载或每次请求查询DB。
|
||||
5. **构建与部署**:更新Dockerfile,重新构建并运行,验证后台配置生效。
|
||||
47
.trae/documents/Webhook中继系统 - 增强功能实施计划.md
Normal file
47
.trae/documents/Webhook中继系统 - 增强功能实施计划.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Webhook中继系统 - 增强功能实施计划
|
||||
|
||||
## 1. 核心变更:日志审计与多端点管理
|
||||
为了满足多Webhook接收管理、系统状态监控及日志审计需求,将进行以下数据库与逻辑扩展:
|
||||
|
||||
### 1.1 数据库模型扩展 (`app/db.py`)
|
||||
- **`WebhookEndpoint`**:管理接收端点(Namespace)。
|
||||
- 字段:`namespace` (唯一标识), `description` (备注), `created_at`, `is_active`。
|
||||
- **`RequestLog`**:记录入站请求。
|
||||
- 字段:`id`, `namespace`, `remark`, `event_no`, `raw_body` (JSON), `received_at`, `status` (success/error)。
|
||||
- **`DeliveryLog`**:记录出站转发与推送结果。
|
||||
- 字段:`request_id` (FK), `target_name` (目标/渠道名), `type` (relay/notify), `status`, `response_summary`, `created_at`。
|
||||
|
||||
### 1.2 业务逻辑增强 (`app/main.py`)
|
||||
- **端点校验**:`POST /webhook/{namespace}` 将校验 namespace 是否在 `WebhookEndpoint` 表中注册(未注册的可选拒绝或标记为匿名)。
|
||||
- **日志持久化**:请求处理完成后,将入站数据与出站结果(routed/notified)异步写入 SQLite 数据库。
|
||||
- **状态统计**:内存记录系统启动时间,实时计算运行耗时;从 DB 统计今日接收量。
|
||||
|
||||
## 2. Admin后台功能升级
|
||||
### 2.1 界面改造 (`templates/admin/*.html`)
|
||||
- **顶部状态栏 (`base.html`)**:增加系统运行状态指示器(运行时间、今日请求数、最新一条日志时间)。
|
||||
- **端点管理 (`endpoints.html`)**:新增页面,支持创建、启用/禁用接收端点(Namespace)。
|
||||
- **日志审计 (`logs.html`)**:
|
||||
- 请求列表:按时间倒序展示,显示 namespace, remark, event。
|
||||
- 详情视图:点击查看完整 JSON 负载及对应的所有分发记录(目标+渠道)。
|
||||
- 清理功能:一键清空历史日志。
|
||||
|
||||
### 2.2 后台逻辑 (`app/admin.py`)
|
||||
- 新增 `/admin/endpoints` 路由:CRUD 操作。
|
||||
- 新增 `/admin/logs` 路由:分页查询日志。
|
||||
- 新增 `/admin/stats` 接口(或模板上下文):提供状态栏数据。
|
||||
|
||||
## 3. 实施步骤
|
||||
1. **更新 DB 模型**:修改 `app/db.py` 添加新表。
|
||||
2. **实现日志与端点逻辑**:
|
||||
- 更新 `app/main.py` 接入日志记录。
|
||||
- 创建 `app/services/stats.py` 简单的统计服务。
|
||||
3. **开发前端页面**:
|
||||
- 修改 `base.html` 添加状态栏与菜单。
|
||||
- 创建 `endpoints.html` 和 `logs.html`。
|
||||
4. **开发后台路由**:在 `app/admin.py` 实现对应视图。
|
||||
5. **验证**:
|
||||
- 创建新 Endpoint `demo_input`。
|
||||
- 发送测试数据,确认日志生成且状态栏更新。
|
||||
- 验证“日志详情”能清晰展示接收到的 JSON 和分发结果。
|
||||
|
||||
此方案在保留现有轻量级架构的同时,补全了运维监控与多入口管理能力。
|
||||
53
.trae/documents/Webhook中继系统 - 核心逻辑重构计划.md
Normal file
53
.trae/documents/Webhook中继系统 - 核心逻辑重构计划.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Webhook 中继系统 - 核心逻辑重构计划
|
||||
|
||||
## 1. 核心设计理念变更
|
||||
响应您的需求,我们将系统从“基于固定字段(remark)的路由”升级为**“基于端点的自定义规则引擎”**。
|
||||
|
||||
- **以端点为中心**:每个接收端点(Endpoint)拥有独立的配置页面,绑定属于自己的处理流程。
|
||||
- **通用规则引擎**:不再硬编码检测 `remark` 或 `event_define_no`。
|
||||
- **变量自定义**:您可以指定检测 JSON 中的任意字段(如 `body.status` 或 `data.order_id`)。
|
||||
- **条件匹配**:当指定字段的值等于设定值时,触发动作。
|
||||
- **统一动作(Action)**:将“转发”和“通知”统一为规则命中的“动作”。
|
||||
- 动作 A:转发给目标 X(原 Target)。
|
||||
- 动作 B:使用模板 Y 发送给渠道 Z(原 Notification)。
|
||||
|
||||
## 2. 数据库模型重构 (`app/db.py`)
|
||||
我们需要调整表结构以支持这种灵活关系(建议重置数据库):
|
||||
|
||||
1. **保留**:`WebhookEndpoint`, `Target`, `NotificationChannel`, `RequestLog`, `DeliveryLog`。
|
||||
2. **移除**:`RemarkRule`, 旧的 `EventTemplate` 关联逻辑。
|
||||
3. **新增/修改**:
|
||||
* **`MessageTemplate`** (原 EventTemplate):仅存储模板文本,不再绑定特定事件ID,作为纯资源库。
|
||||
* **`ProcessingRule`** (处理规则):绑定到 `WebhookEndpoint`。
|
||||
* 字段:`match_field` (匹配键,如 `trans_order_info.remark`), `match_value` (匹配值)。
|
||||
* **`RuleAction`** (规则动作):绑定到 `ProcessingRule`。
|
||||
* 字段:`action_type` (forward/notify), `target_id` (关联Target), `channel_id` (关联Channel), `template_id` (关联Template)。
|
||||
|
||||
## 3. 业务逻辑升级 (`app/services/engine.py`)
|
||||
创建新的规则引擎服务:
|
||||
- **动态取值**:支持点号索引(如 `data.user.id`)从深层 JSON 中提取变量。
|
||||
- **流程执行**:
|
||||
1. 接收 Webhook -> 查找 Endpoint。
|
||||
2. 遍历 Endpoint 下的所有 `ProcessingRule`。
|
||||
3. 提取 `match_field` 对应的值与 `match_value` 比对。
|
||||
4. 命中则执行该规则下的所有 `RuleAction`(并行执行转发或通知)。
|
||||
|
||||
## 4. Admin UI 交互大改版
|
||||
- **资源库模式**:`目标管理`、`通知渠道`、`消息模板` 变为纯粹的基础资源维护页面。
|
||||
- **端点编排页**(核心):
|
||||
- 点击某个端点,进入**“流程配置”**详情页。
|
||||
- **可视化规则编辑器**:
|
||||
- "当 `[输入框: 字段路径]` 等于 `[输入框: 值]` 时:"
|
||||
- ➕ 添加动作:[转发] -> 选择目标。
|
||||
- ➕ 添加动作:[通知] -> 选择渠道 + 选择模板。
|
||||
- **体验优化**:在同一个页面完成逻辑闭环,无需在不同菜单间跳转。
|
||||
|
||||
## 5. 实施步骤
|
||||
1. **重构 DB**:更新 `app/db.py` 模型定义。
|
||||
2. **实现引擎**:编写 `app/services/engine.py` 实现动态匹配与分发。
|
||||
3. **更新 API**:修改 `app/main.py` 调用新引擎。
|
||||
4. **重写 UI**:
|
||||
- 改造 `admin.py` 路由。
|
||||
- 新增 `templates/admin/endpoint_detail.html` 作为核心配置页。
|
||||
- 简化其他资源页面。
|
||||
5. **迁移/重置**:删除旧 DB文件,重新构建运行。
|
||||
30
.trae/documents/增强模板变量与编辑功能.md
Normal file
30
.trae/documents/增强模板变量与编辑功能.md
Normal file
@ -0,0 +1,30 @@
|
||||
# 系统功能增强计划
|
||||
|
||||
针对您提出的两个问题,我们将进行以下改进:
|
||||
|
||||
## 1. 解决 "pay.wx_scaned" 等代码的显示问题
|
||||
**方案**:在配置“通知动作”时,允许您定义**自定义模板变量**。
|
||||
* **原理**:
|
||||
* 当规则匹配到 `pay.wx_scaned` 时,您在动作中设置变量 `pay_type_name` = "微信支付"。
|
||||
* 当规则匹配到 `pay.ali_scaned` 时,您在动作中设置变量 `pay_type_name` = "支付宝"。
|
||||
* **模板写法**:`收到{pay_type_name}付款{trans_amt}元`。
|
||||
* 这样同一个模板可以根据不同的规则,显示不同的中文名称,完美解决代码转义问题。
|
||||
|
||||
## 2. 补全 "修改" 功能
|
||||
**方案**:为所有资源管理页面添加编辑功能。
|
||||
* **目标管理**:添加编辑按钮。
|
||||
* **通知渠道**:添加编辑按钮。
|
||||
* **消息模板**:添加编辑按钮。
|
||||
* **实现方式**:点击编辑弹出模态框(复用添加框),回显数据,保存时调用更新接口。
|
||||
|
||||
## 实施步骤
|
||||
1. **数据库升级**:
|
||||
* `RuleAction` 表新增 `template_vars` 字段(JSON类型),用于存储自定义变量。
|
||||
2. **后端开发**:
|
||||
* 更新 `RuleEngine`,在渲染模板前将 `template_vars` 合并到数据上下文中。
|
||||
* 在 `admin.py` 中新增 `update_target`, `update_channel`, `update_template` 等路由。
|
||||
3. **前端开发**:
|
||||
* **端点详情页**:在添加/编辑动作时,增加“模板变量”输入框(JSON格式)。
|
||||
* **资源列表页**:增加“编辑”按钮及相应的 JS 逻辑来回显数据。
|
||||
4. **部署**:
|
||||
* 由于涉及数据库字段变更,需要再次重置数据库。
|
||||
42
.trae/documents/系统修复与全链路优化计划.md
Normal file
42
.trae/documents/系统修复与全链路优化计划.md
Normal file
@ -0,0 +1,42 @@
|
||||
# 系统修复与全链路优化计划
|
||||
|
||||
针对“弹窗失效”的紧急Bug和您对系统的长远期望,我制定了以下全方位的优化方案:
|
||||
|
||||
## 1. 紧急修复:UI 交互失效 (Priority 0)
|
||||
**问题根源**:页面脚本(Script)在 Bootstrap 库加载之前执行了,导致 `bootstrap` 对象未定义。
|
||||
**修复方案**:
|
||||
* 调整 `base.html` 结构,在底部引入 Bootstrap JS 之后新增 `{% block scripts %}` 插槽。
|
||||
* 将所有管理页面的 JS 代码移动到 `block scripts` 中,确保执行顺序正确。
|
||||
|
||||
## 2. 核心增强:规则引擎升级 (Priority 1)
|
||||
目前的规则仅支持“等于”,无法满足复杂业务(如匹配特定前缀的订单号)。
|
||||
**优化方案**:
|
||||
* **数据库变更**:`ProcessingRule` 表新增 `operator` 字段(默认为 `eq`)。
|
||||
* **支持操作符**:
|
||||
* `eq` (等于)
|
||||
* `neq` (不等于)
|
||||
* `contains` (包含)
|
||||
* `startswith` (以...开头)
|
||||
* `regex` (正则表达式)
|
||||
* **UI 适配**:在端点详情页的添加规则弹窗中,增加“操作符”下拉选择。
|
||||
|
||||
## 3. 运维增强:日志重试 (Replay) (Priority 2)
|
||||
Webhook 系统最常见的问题是下游服务暂时不可用,导致消息丢失。
|
||||
**优化方案**:
|
||||
* **后端**:新增 `/admin/logs/{id}/replay` 接口,读取历史日志的 `raw_body` 重新注入规则引擎。
|
||||
* **前端**:在“系统日志”列表的失败记录旁,添加“重试”按钮。
|
||||
|
||||
## 4. 体验优化:首页仪表盘 (Priority 3)
|
||||
目前的首页直接跳转到列表,缺乏全局视野。
|
||||
**优化方案**:
|
||||
* 重写 `/admin/` 首页,展示:
|
||||
* 今日请求总量 / 成功率
|
||||
* 各端点 (Endpoint) 的流量占比
|
||||
* 最近 10 条失败记录(方便快速排查)
|
||||
|
||||
## 实施路线图
|
||||
1. **UI 架构调整**:修复 `base.html` 及所有子页面(解决弹窗问题)。
|
||||
2. **数据模型迁移**:更新 DB,添加 `operator` 字段。
|
||||
3. **业务逻辑升级**:更新 `engine.py` 支持新操作符,实现重试逻辑。
|
||||
4. **界面功能补全**:更新前端页面适配新功能。
|
||||
5. **部署**:重置数据库并重启服务。
|
||||
50
.trae/documents/规则引擎逻辑重构计划.md
Normal file
50
.trae/documents/规则引擎逻辑重构计划.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 规则引擎逻辑重构计划:支持多级条件与流程编排
|
||||
|
||||
您说得非常对。目前的扁平化规则(单层 `if-match-then-action`)在处理“组合条件”时会导致规则数量爆炸,配置极其繁琐且难以维护。
|
||||
|
||||
**核心问题**:现在的结构是“单层匹配”,无法表达“先判断A,再判断B”的逻辑。
|
||||
**您的需求**:
|
||||
1. **第一层(事件分类)**:先根据 `event_define_no` 判断业务类型(微信支付/退款/投诉),决定用哪个**模板**。
|
||||
2. **第二层(渠道分发)**:再根据 `remark` 判断归属(门店002/003),决定推给哪个**渠道**。
|
||||
|
||||
为此,我将对系统进行深度的逻辑重构,引入**“规则组(Rule Group)”**和**“条件链(Condition Chain)”**的概念,但为了保持用户界面的直观性,我设计了更符合您直觉的 **“流程树(Flow Tree)”** 方案。
|
||||
|
||||
## 方案设计:嵌套式规则引擎
|
||||
|
||||
### 1. 数据模型升级 (DB)
|
||||
* **ProcessingRule 表变更**:
|
||||
* 新增 `parent_rule_id` (Integer, nullable): 允许规则嵌套。子规则只有在父规则匹配成功后才会执行。
|
||||
* 此变更实现了“树状结构”。
|
||||
|
||||
### 2. 核心逻辑升级 (Engine)
|
||||
* **递归匹配**:
|
||||
* 引擎首先查找所有 `parent_id` 为空的**根规则**。
|
||||
* 如果根规则匹配成功(例如 `event_define_no == pay.wx_scaned`):
|
||||
* 执行根规则挂载的动作(例如:设置模板变量 `pay_method="微信"`)。
|
||||
* **继续检查其子规则**。
|
||||
* 子规则(例如 `remark == imcgcd03`)匹配成功:
|
||||
* 执行子规则挂载的动作(例如:推送到 `渠道003`)。
|
||||
* 动作执行时,会**继承**父规则上下文中设置的变量(如模板ID或变量)。
|
||||
|
||||
### 3. 用户界面重构 (UI)
|
||||
* **端点详情页**:
|
||||
* 不再是扁平列表,而是**树形展示**。
|
||||
* 在每个规则卡片内部,增加“**添加子规则**”按钮。
|
||||
* 视觉上通过缩进或连线表示层级关系。
|
||||
* 支持**拖拽排序**(优先级调整)。
|
||||
|
||||
## 实施步骤
|
||||
1. **数据库迁移**:为 `ProcessingRule` 添加 `parent_rule_id` 字段。
|
||||
2. **后端逻辑**:重写 `engine.py` 的 `process` 方法,改为递归处理。
|
||||
3. **前端交互**:重写 `endpoint_detail.html`,使用递归模板渲染规则树。
|
||||
4. **无损更新**:编写 SQL 脚本或逻辑,确保现有数据平滑迁移到新结构(现有规则都视为根规则)。
|
||||
|
||||
这样,您的配置流程将变为:
|
||||
1. **根规则**:`event_define_no == pay.wx_scaned`
|
||||
* 动作:设置变量 `pay_type="微信"`(**不需要指定渠道**)
|
||||
* **子规则 A**:`remark == imcgcd02`
|
||||
* 动作:推送到 `渠道002`(**复用父级的模板设置**)
|
||||
* **子规则 B**:`remark == imcgcd03`
|
||||
* 动作:推送到 `渠道003`
|
||||
|
||||
这完美符合您的思维模型。
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY app /app/app
|
||||
COPY config /app/config
|
||||
COPY templates /app/templates
|
||||
ENV WEBHOOK_CONFIG_PATH=/app/config/config.yml
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8080"]
|
||||
118
README.md
Normal file
118
README.md
Normal file
@ -0,0 +1,118 @@
|
||||
## 项目简介
|
||||
|
||||
这是一个可配置的 Webhook 中继与通知系统,用于:
|
||||
- 统一接收外部支付/业务系统的 Webhook 请求
|
||||
- 按规则将请求转发到内部多个目标系统
|
||||
- 根据事件与模板生成消息,推送到飞书、企业微信等 IM 渠道
|
||||
|
||||
核心特性:
|
||||
- 通过 Web 管理后台 `/admin` 管理端点、规则树、动作、消息模板与通知渠道
|
||||
- 支持树形规则:按 `remark`、`event_define_no` 等字段组合匹配
|
||||
- 支持转发(forward)与通知(notify)两类动作
|
||||
- 消息模板支持变量与继承,可在父规则设置通用模板,在子规则覆盖变量
|
||||
- 所有请求与分发结果写入 SQLite 数据库,便于审计与排查
|
||||
|
||||
## 技术栈与目录结构
|
||||
|
||||
- 语言与框架:Python 3.12 + FastAPI
|
||||
- 数据库:SQLite(可通过 `DB_PATH` 切换到其他 SQLAlchemy 支持的数据库)
|
||||
- HTTP 客户端:httpx
|
||||
|
||||
主要目录:
|
||||
- `app/main.py`:FastAPI 入口,`/webhook/{namespace}` 与 `/health`
|
||||
- `app/admin.py`:管理后台路由(端点、规则、动作、模板、渠道、日志)
|
||||
- `app/db.py`:SQLAlchemy 模型与会话管理
|
||||
- `app/services/engine.py`:规则引擎,实现树形规则匹配与动作编排
|
||||
- `app/services/notify.py`:飞书与企业微信的实际发送封装
|
||||
- `templates/admin/`:管理后台的 HTML 模板
|
||||
- `config/data.db`:默认 SQLite 数据库文件(若不存在会自动创建)
|
||||
- `docs/webhook-relay/`:6A 工作流下的对齐、设计、任务与流程文档
|
||||
|
||||
## 本地运行(开发环境)
|
||||
|
||||
1. 安装依赖:
|
||||
- 可选:创建虚拟环境
|
||||
- 执行:`pip install -r requirements.txt`
|
||||
|
||||
2. 初始化数据库并启动服务:
|
||||
- 进入项目根目录:`cd e:\2025Code\python\webhock`
|
||||
- 启动:`uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload`
|
||||
|
||||
3. 访问入口:
|
||||
- Webhook 接口:`POST http://localhost:8080/webhook/{namespace}`
|
||||
- 健康检查:`GET http://localhost:8080/health`
|
||||
- 管理后台:`GET http://localhost:8080/admin/endpoints`
|
||||
|
||||
## Docker 部署
|
||||
|
||||
项目包含 `Dockerfile`,可直接构建镜像部署:
|
||||
|
||||
1. 构建镜像:
|
||||
- 在项目根目录执行
|
||||
`docker build -t webhook-relay .`
|
||||
|
||||
2. 运行容器(基础示例):
|
||||
- `docker run -d --name webhook-relay -p 8080:8080 webhook-relay`
|
||||
|
||||
默认配置:
|
||||
- 工作目录:`/app`
|
||||
- 配置与数据库目录:`/app/config`
|
||||
- 默认数据库:`sqlite:///./config/data.db`
|
||||
|
||||
3. 使用持久化存储:
|
||||
- 在宿主机创建配置/数据目录,例如:`/srv/webhook-relay/config`
|
||||
- 将初始配置文件与数据库(可选)放入该目录
|
||||
- 启动容器时挂载目录:
|
||||
- `docker run -d --name webhook-relay -p 8080:8080 -v /srv/webhook-relay/config:/app/config webhook-relay`
|
||||
|
||||
4. 自定义数据库:
|
||||
- 通过环境变量 `DB_PATH` 覆盖默认数据库连接:
|
||||
- 例如:`-e DB_PATH="sqlite:///./config/data.db"`(默认值)
|
||||
- 或者:`-e DB_PATH="postgresql+psycopg2://user:pass@host:5432/dbname"`(需要自行安装驱动并调整镜像)
|
||||
|
||||
## 管理后台与规则配置概览
|
||||
|
||||
管理后台主要页面:
|
||||
- 端点管理(Endpoints):维护不同业务线/系统对应的 `namespace`
|
||||
- 目标管理(Targets):配置转发目标的名称、URL 与超时时间
|
||||
- 渠道管理(Channels):配置飞书/企微等机器人 Webhook
|
||||
- 模板管理(Templates):配置消息模板内容
|
||||
- 端点详情:以树形方式配置 ProcessingRule 与 RuleAction,支持:
|
||||
- 根规则与子规则
|
||||
- 动作类型:转发或通知
|
||||
- 模板变量与模板继承
|
||||
- 日志页面:查看 RequestLog 与 DeliveryLog 记录
|
||||
|
||||
典型流程:
|
||||
1. 在 “Targets” 中配置内部系统的 Webhook URL
|
||||
2. 在 “Channels” 中配置飞书/企微机器人地址
|
||||
3. 在 “Templates” 中配置通用或专用消息模板
|
||||
4. 在 “Endpoints” 中创建业务端点,并在详情页中:
|
||||
- 创建根规则,按 `remark` 或其他字段区分不同业务来源
|
||||
- 在子规则中根据 `event_define_no` 等字段区分事件类型
|
||||
- 为规则添加 Forward/Notify 动作,绑定目标、渠道与模板变量
|
||||
|
||||
## 推到 Gitea 与服务器部署建议
|
||||
|
||||
1. 在本地完成配置与验证后,将整个仓库推送到 Gitea:
|
||||
- 初始化 git 仓库(如尚未初始化):`git init`
|
||||
- 添加远程并推送到 Gitea 项目
|
||||
|
||||
2. 在服务器上通过 Gitea 拉取代码:
|
||||
- 使用 Gitea 的 “克隆” 地址在服务器执行 `git clone`
|
||||
|
||||
3. 在服务器上构建并运行 Docker 镜像:
|
||||
- `docker build -t webhook-relay .`
|
||||
- 准备持久化目录并挂载配置/数据库
|
||||
- `docker run -d --name webhook-relay -p 8080:8080 -v /srv/webhook-relay/config:/app/config webhook-relay`
|
||||
|
||||
4. 如需进一步自动化(CI/CD):
|
||||
- 可以在 Gitea 中配置 Actions 或 Webhook,在推送时自动触发服务器上的构建与重启逻辑
|
||||
- 具体流水线脚本可根据你的服务器环境与习惯单独设计
|
||||
|
||||
更详细的架构、流程与用例说明可参考:
|
||||
- `docs/webhook-relay/ALIGNMENT_webhook-relay.md`
|
||||
- `docs/webhook-relay/DESIGN_webhook-relay.md`
|
||||
- `docs/webhook-relay/FLOWCHART.md`
|
||||
- `docs/webhook-relay/ACCEPTANCE_webhook-relay.md`
|
||||
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/db.cpython-314.pyc
Normal file
BIN
app/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/logging.cpython-314.pyc
Normal file
BIN
app/__pycache__/logging.cpython-314.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
BIN
app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
485
app/admin.py
Normal file
485
app/admin.py
Normal 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
app/db.py
Normal file
102
app/db.py
Normal 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
app/logging.py
Normal file
15
app/logging.py
Normal 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
app/main.py
Normal file
102
app/main.py
Normal 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
app/models.py
Normal file
32
app/models.py
Normal 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
app/services/engine.py
Normal file
228
app/services/engine.py
Normal 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
app/services/notify.py
Normal file
13
app/services/notify.py
Normal 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
app/services/stats.py
Normal file
45
app/services/stats.py
Normal 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()
|
||||
45
config/config.yml
Normal file
45
config/config.yml
Normal file
@ -0,0 +1,45 @@
|
||||
server:
|
||||
port: 8080
|
||||
log_level: info
|
||||
routing:
|
||||
remark_map:
|
||||
imcgcd03: ["target_3"]
|
||||
imcgcd02: ["target_2"]
|
||||
default_targets: []
|
||||
targets:
|
||||
- name: target_1
|
||||
url: "https://httpbin.org/post"
|
||||
timeout_ms: 5000
|
||||
- name: target_2
|
||||
url: "https://httpbin.org/post"
|
||||
timeout_ms: 5000
|
||||
- name: target_3
|
||||
url: "https://httpbin.org/post"
|
||||
timeout_ms: 5000
|
||||
notifications:
|
||||
event_map:
|
||||
wechat.complaint:
|
||||
template: "⚠️请注意,您有新的微信投诉,请注意查看"
|
||||
channels: ["feishu","wecom"]
|
||||
refund.standard:
|
||||
template: "退款成功,退款金额:{actual_ref_amt}"
|
||||
channels: ["feishu","wecom"]
|
||||
pay.ali_scaned:
|
||||
template: "支付宝收款{trans_amt}元,状态:{cash_resp_desc}"
|
||||
channels: ["feishu","wecom"]
|
||||
pay.wx_scaned:
|
||||
template: "微信收款{trans_amt}元,状态:{cash_resp_desc}"
|
||||
channels: ["feishu","wecom"]
|
||||
channels:
|
||||
feishu:
|
||||
webhooks:
|
||||
- "https://open.feishu.cn/your-bot-webhook"
|
||||
wecom:
|
||||
webhooks:
|
||||
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key"
|
||||
retry:
|
||||
max_attempts: 3
|
||||
initial_delay_ms: 500
|
||||
max_delay_ms: 3000
|
||||
security:
|
||||
outbound_signature: false
|
||||
BIN
config/data.db
Normal file
BIN
config/data.db
Normal file
Binary file not shown.
21
docs/webhook-relay/ACCEPTANCE_webhook-relay.md
Normal file
21
docs/webhook-relay/ACCEPTANCE_webhook-relay.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 验收记录
|
||||
|
||||
## 用例1:示例JSON
|
||||
- 步骤:POST /webhook/opps-webhook,负载为示例
|
||||
- 期望:
|
||||
- 路由至 target_3
|
||||
- 生成文本包含商户名、支付方式、金额、状态与日期,例如:`【某商户】支付宝 收款 5.50 元,状态:成功,日期:20251220`
|
||||
- 飞书与企业微信推送成功
|
||||
|
||||
## 用例2:remark未匹配
|
||||
- 步骤:remark为未知值
|
||||
- 期望:使用 default_targets 或仅通知
|
||||
|
||||
## 用例3:退款事件
|
||||
- 步骤:事件为 `refund.standard`
|
||||
- 期望:从 `actual_ref_amt` 或 `ref_amt/settlement_amt` 渲染文本
|
||||
|
||||
## 用例4:目标不可达
|
||||
- 步骤:模拟目标URL不可达
|
||||
- 期望:记录失败并按策略重试
|
||||
|
||||
48
docs/webhook-relay/ALIGNMENT_webhook-relay.md
Normal file
48
docs/webhook-relay/ALIGNMENT_webhook-relay.md
Normal file
@ -0,0 +1,48 @@
|
||||
# 任务名称
|
||||
|
||||
Webhook中继与通知系统(Docker化)
|
||||
|
||||
## 背景与目标
|
||||
- 统一接收外部Webhook,按规则路由到目标端点,并生成通知消息推送至飞书/企业微信。
|
||||
- 基于配置可扩展,支持新增目标、remark映射与事件模板。
|
||||
|
||||
## 业务范围
|
||||
- 接收JSON负载,至少包含 `remark`、`event_define_no`、`trans_amt`、`trans_order_info.cash_resp_desc`、可选 `actual_ref_amt`。
|
||||
- 路由策略:按 `remark` 映射至一个或多个目标Webhook;若无匹配使用 `default_targets`。
|
||||
- 通知策略:按 `event_define_no` 模板生成文本,支持多渠道并发推送。
|
||||
|
||||
## 技术范围
|
||||
- Python 3.12、FastAPI、httpx
|
||||
- Docker 容器化;SQLite 持久化,模型定义见 `app/db.py`
|
||||
- 管理后台 `/admin` 维护端点、规则、动作、模板与通知渠道
|
||||
- 结构化日志;失败重试(在转发逻辑中可扩展);请求与分发记录持久化
|
||||
|
||||
## 输入与输出
|
||||
- 输入:`POST /webhook/{namespace}` JSON
|
||||
- 输出:200 返回路由与通知结果结构
|
||||
|
||||
## 验收标准
|
||||
- 能正确解析示例 JSON 并生成消息,包含商户名、支付方式、金额、状态与日期,例如:`【某商户】支付宝 收款 5.50 元,状态:成功,日期:20251220`
|
||||
- `remark=imcgcd03` 时转发至配置第3个目标;`remark=imcgcd02` 时至第2个目标
|
||||
- 同时推送至飞书与企业微信(若配置存在多个机器人则全部发送)
|
||||
- 失败记录与重试可配置(次数与退避)
|
||||
|
||||
## 部署与运行方式(概要)
|
||||
- 运行模式:
|
||||
- 作为独立 FastAPI 服务运行,暴露 `POST /webhook/{namespace}` 与 `/admin` 管理界面
|
||||
- 通过 Docker 镜像部署到服务器
|
||||
- 最低运行环境:
|
||||
- Python 3.12 或兼容的 Docker 运行时
|
||||
- 对外开放的 HTTP 端口(默认 8080)
|
||||
- 配置与数据:
|
||||
- 数据库:默认 `sqlite:///./config/data.db`,可通过环境变量 `DB_PATH` 调整
|
||||
- 规则、模板、渠道均通过 Web 管理后台写入数据库,无需修改代码或 YAML
|
||||
|
||||
## 边界与不做事项
|
||||
- 不实现目标端点的认证协议,默认匿名POST;可通过配置扩展签名
|
||||
- 不提供长期持久幂等存储,默认进程内短期去重
|
||||
|
||||
## 风险与约束
|
||||
- 目标Webhook不可达导致延迟;通过重试与超时保护
|
||||
- 通知渠道限流;并发可控与错误日志
|
||||
|
||||
25
docs/webhook-relay/CONSENSUS_webhook-relay.md
Normal file
25
docs/webhook-relay/CONSENSUS_webhook-relay.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 共识与决策
|
||||
|
||||
## 技术选型
|
||||
- 语言与框架:Python + FastAPI
|
||||
- 网络库:httpx
|
||||
- 配置:YAML(PyYAML),环境变量 `WEBHOOK_CONFIG_PATH`
|
||||
- 重试:简单指数退避
|
||||
- 容器:python:3.12-slim,入口 `uvicorn app.main:app --host 0.0.0.0 --port 8080`
|
||||
|
||||
## 接口规范
|
||||
- `POST /webhook/{namespace}` 接收上游负载;`GET /health` 健康检查
|
||||
|
||||
## 配置规范
|
||||
- `targets`:目标Webhook注册表
|
||||
- `routing.remark_map`:remark到target名称列表映射
|
||||
- `notifications.event_map`:事件模板与渠道列表
|
||||
- `channels`:各渠道机器人Webhook列表
|
||||
|
||||
## 路由与通知规则
|
||||
- 路由按 `remark` 精确匹配,多目标并发转发
|
||||
- 通知按 `event_define_no` 模板渲染,支持多渠道并发推送
|
||||
|
||||
## 验收口径
|
||||
- 示例JSON生成消息与路由正确;返回结构包含每个目标与渠道的状态
|
||||
|
||||
51
docs/webhook-relay/DESIGN_webhook-relay.md
Normal file
51
docs/webhook-relay/DESIGN_webhook-relay.md
Normal file
@ -0,0 +1,51 @@
|
||||
# 架构设计
|
||||
|
||||
## 总体架构
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Incoming Webhook] --> B[FastAPI /webhook/{namespace}]
|
||||
B --> C[Router]
|
||||
C --> D[Targets Forwarder]
|
||||
B --> E[Event Template]
|
||||
E --> F[Channels Sender]
|
||||
D --> G[External Webhook Targets]
|
||||
F --> H[Feishu]
|
||||
F --> I[WeCom]
|
||||
```
|
||||
|
||||
## 模块分层
|
||||
- 接入层:`app/main.py`(FastAPI 入口与 Webhook 接收)
|
||||
- 管理后台:`app/admin.py`(端点、规则、模板与渠道的增删改查)
|
||||
- 领域模型与持久化:`app/db.py`, `app/models.py`
|
||||
- 规则引擎:`app/services/engine.py`(树形规则 + 动作编排)
|
||||
- 通知发送:`app/services/notify.py`
|
||||
- 统计与日志:`app/services/stats.py`, `app/logging.py`
|
||||
|
||||
## 关键接口
|
||||
- `engine.process(endpoint_id, payload) -> (routed: List, notified: List)`
|
||||
- `_exec_forward(target, payload) -> Result`(内部使用)
|
||||
- `_exec_notify(channel, msg) -> Result`(内部使用)
|
||||
- 管理接口:`/admin/*` 用于维护端点、规则树、动作、模板与渠道
|
||||
|
||||
## 数据契约
|
||||
- 输入:示例 JSON 结构,关键字段存在于顶层与 `trans_order_info`
|
||||
- 输出:`{"namespace": str, "routed": [...], "notified": [...]}`,其中:
|
||||
- `routed`:每个转发目标的执行摘要(目标名、是否成功、失败原因)
|
||||
- `notified`:每个通知渠道的执行摘要(渠道名、是否成功、失败原因)
|
||||
|
||||
## 异常策略
|
||||
- 超时与网络错误重试;失败记录结构化日志
|
||||
- 非致命错误不影响其他目标或渠道发送
|
||||
|
||||
## 部署与运行(概要)
|
||||
- 本地直接运行:
|
||||
- 创建并激活虚拟环境(可选)
|
||||
- 执行 `pip install -r requirements.txt`
|
||||
- 启动服务:`uvicorn app.main:app --host 0.0.0.0 --port 8080`
|
||||
- Docker 部署:
|
||||
- 构建镜像:`docker build -t webhook-relay .`
|
||||
- 运行容器示例:
|
||||
- `docker run -d --name webhook-relay -p 8080:8080 webhook-relay`
|
||||
- 默认使用 SQLite 数据库,路径由环境变量 `DB_PATH` 控制,默认值为 `sqlite:///./config/data.db`
|
||||
- 如需持久化,可将宿主机目录挂载到容器内 `/app/config` 目录
|
||||
|
||||
191
docs/webhook-relay/FLOWCHART.md
Normal file
191
docs/webhook-relay/FLOWCHART.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Webhook 中继系统流程图
|
||||
|
||||
## 1. 系统宏观架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph External["外部世界"]
|
||||
Sender["发送方 (支付宝/微信/业务系统)"]
|
||||
User["管理员"]
|
||||
TargetSys["目标系统 (ERP/BI)"]
|
||||
NotifyApp["IM工具 (飞书/企微)"]
|
||||
end
|
||||
|
||||
subgraph WebhookRelay["Webhook中继服务 (Docker)"]
|
||||
direction TB
|
||||
|
||||
subgraph Interface["接入层"]
|
||||
API["FastAPI (端口:8080)"]
|
||||
AdminUI["Web管理后台"]
|
||||
end
|
||||
|
||||
subgraph Core["核心逻辑层"]
|
||||
Parser["数据解析 (Pydantic)"]
|
||||
Router["路由引擎 (Routing)"]
|
||||
Notifier["通知引擎 (Notification)"]
|
||||
Logger["日志审计 (Logging)"]
|
||||
end
|
||||
|
||||
subgraph Data["数据存储层"]
|
||||
DB[(SQLite 数据库)]
|
||||
Config["Config Loader (DB优先 + YAML回退)"]
|
||||
end
|
||||
end
|
||||
|
||||
Sender -->|POST JSON| API
|
||||
User -->|浏览器访问| AdminUI
|
||||
AdminUI -->|CRUD配置| DB
|
||||
|
||||
API --> Parser
|
||||
Parser --> Router
|
||||
Parser --> Notifier
|
||||
|
||||
Router <-->|查询规则| Config
|
||||
Notifier <-->|查询模板| Config
|
||||
Config <-->|读取| DB
|
||||
|
||||
Router -->|转发请求| TargetSys
|
||||
Notifier -->|推送消息| NotifyApp
|
||||
|
||||
API -->|异步写入| Logger
|
||||
Logger -->|持久化| DB
|
||||
```
|
||||
|
||||
## 2. 详细数据处理流程
|
||||
|
||||
此图展示了一个 Webhook 请求从进入系统到完成分发与记录的完整生命周期。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Ext as 外部系统
|
||||
participant API as FastAPI入口
|
||||
participant DB as SQLite数据库
|
||||
participant Router as 路由服务
|
||||
participant Target as 目标系统
|
||||
participant Notifier as 通知服务
|
||||
participant Channel as IM渠道(飞书/企微)
|
||||
participant Log as 日志服务
|
||||
|
||||
Note over Ext, API: 1. 请求接入
|
||||
Ext->>API: POST /webhook/{namespace} (Payload)
|
||||
|
||||
activate API
|
||||
API->>DB: 校验 Namespace 是否有效/启用
|
||||
alt Namespace 无效/禁用
|
||||
API-->>Ext: 403 Forbidden
|
||||
else Namespace 有效
|
||||
API->>API: 解析 Payload (remark, event_no, trans_amt...)
|
||||
|
||||
par 2. 并行处理 - 转发 (Relay)
|
||||
API->>Router: 使用规则引擎匹配路由规则
|
||||
Router->>DB: 查询 ProcessingRule/RuleAction/Target
|
||||
Router-->>API: 返回目标列表 [Target A, Target B]
|
||||
|
||||
loop 对每个目标
|
||||
API->>Target: POST /target_url (异步 + 重试机制)
|
||||
Target-->>API: 响应 (200 OK / 500 Error)
|
||||
end
|
||||
|
||||
and 3. 并行处理 - 通知 (Notify)
|
||||
API->>Notifier: notify(event_no, payload)
|
||||
Notifier->>DB: 查询 ProcessingRule/RuleAction/MessageTemplate/NotificationChannel
|
||||
Notifier->>Notifier: 渲染模板 ("【{biz_name}】{pay_method_name} 收款 {trans_amt} 元,状态:{cash_resp_desc},日期:{trans_date}")
|
||||
Notifier-->>API: 返回消息文本 & 渠道列表
|
||||
|
||||
loop 对每个渠道
|
||||
API->>Channel: POST Webhook (发送消息)
|
||||
Channel-->>API: 响应结果
|
||||
end
|
||||
end
|
||||
|
||||
Note over API, Log: 4. 收尾工作
|
||||
API->>Log: BackgroundTask: 保存 RequestLog & DeliveryLog
|
||||
Log->>DB: INSERT request_logs, delivery_logs
|
||||
|
||||
API-->>Ext: 200 OK (包含 routed/notified 摘要)
|
||||
end
|
||||
deactivate API
|
||||
```
|
||||
|
||||
## 3. 数据库实体关系图 (ERD)
|
||||
|
||||
展示了用于支撑上述流程的数据库模型结构。
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
WebhookEndpoint {
|
||||
int id PK
|
||||
string namespace "URL路径标识"
|
||||
string description
|
||||
bool is_active
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
ProcessingRule {
|
||||
int id PK
|
||||
int endpoint_id FK
|
||||
int parent_rule_id FK "父规则ID,可为空"
|
||||
int priority "优先级,高优先级先匹配"
|
||||
string match_field "如 remark / event_define_no"
|
||||
string operator "eq/neq/contains/regex"
|
||||
string match_value "匹配值"
|
||||
}
|
||||
|
||||
RuleAction {
|
||||
int id PK
|
||||
int rule_id FK
|
||||
string action_type "forward/notify"
|
||||
int target_id FK "转发目标,可空"
|
||||
int channel_id FK "通知渠道,可空"
|
||||
int template_id FK "消息模板,可空"
|
||||
json template_vars "模板变量,键值对"
|
||||
}
|
||||
|
||||
Target {
|
||||
int id PK
|
||||
string name
|
||||
string url
|
||||
int timeout_ms
|
||||
}
|
||||
|
||||
NotificationChannel {
|
||||
int id PK
|
||||
string name
|
||||
string channel_type "feishu/wecom"
|
||||
string webhook_url
|
||||
}
|
||||
|
||||
MessageTemplate {
|
||||
int id PK
|
||||
string name "模板名称"
|
||||
text template_content "模板内容"
|
||||
}
|
||||
|
||||
RequestLog {
|
||||
int id PK
|
||||
string namespace
|
||||
string remark "来源标识"
|
||||
string event_no "事件类型"
|
||||
json raw_body "原始数据"
|
||||
datetime received_at
|
||||
string status "success/error"
|
||||
}
|
||||
|
||||
DeliveryLog {
|
||||
int id PK
|
||||
int request_id FK
|
||||
string target_name
|
||||
string type "relay/notify"
|
||||
string status "success/failed"
|
||||
text response_summary
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
WebhookEndpoint ||--|{ ProcessingRule : "拥有多条规则"
|
||||
ProcessingRule ||--|{ ProcessingRule : "树形子规则"
|
||||
ProcessingRule ||--|{ RuleAction : "每条规则包含多个动作"
|
||||
RuleAction }o--|| Target : "转发到目标"
|
||||
RuleAction }o--|| NotificationChannel : "推送到渠道"
|
||||
RuleAction }o--|| MessageTemplate : "使用消息模板"
|
||||
RequestLog ||--|{ DeliveryLog : "包含多条分发记录"
|
||||
```
|
||||
27
docs/webhook-relay/TASK_[webhook-relay].md
Normal file
27
docs/webhook-relay/TASK_[webhook-relay].md
Normal file
@ -0,0 +1,27 @@
|
||||
# 原子任务清单
|
||||
|
||||
## T1 配置加载
|
||||
- 输入:`config/config.yml`
|
||||
- 输出:内存配置对象与查询API
|
||||
- 验收:能返回目标、remark与事件模板
|
||||
|
||||
## T2 模型解析
|
||||
- 输入:请求JSON
|
||||
- 输出:`IncomingPayload` 对象
|
||||
- 验收:示例JSON解析字段正确
|
||||
|
||||
## T3 路由与转发
|
||||
- 输入:`remark` 与目标列表
|
||||
- 输出:并发POST结果数组
|
||||
- 验收:`imcgcd03` 路由到 `target_3`
|
||||
|
||||
## T4 通知消息
|
||||
- 输入:`event_define_no` 与负载
|
||||
- 输出:消息文本与渠道推送结果
|
||||
- 验收:`pay.ali_scaned` 生成正确文本并推送
|
||||
|
||||
## T5 接口与容器
|
||||
- 输入:应用代码
|
||||
- 输出:FastAPI端点与Dockerfile
|
||||
- 验收:健康检查与示例数据POST通过
|
||||
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.0
|
||||
httpx==0.27.2
|
||||
pydantic==2.9.2
|
||||
PyYAML==6.0.2
|
||||
tenacity==9.0.0
|
||||
python-json-logger==2.0.7
|
||||
sqlalchemy==2.0.25
|
||||
jinja2==3.1.3
|
||||
python-multipart==0.0.9
|
||||
1
samples/incoming.json
Normal file
1
samples/incoming.json
Normal file
@ -0,0 +1 @@
|
||||
{"trans_date":"20251220","batch_id":"251220","bank_seq_id":"923913","trans_amt":"5.50","remark":"imcgcd03","event_define_no":"pay.ali_scaned","acct_id":"F03291269","is_div":"1","bank_order_no":"672025122022001416951449996798","fee_flag":2,"trans_order_info":{"maze_resp_code":"","fee_real_acct_id":"F03291269","agent_id":"6666000153839851","bank_seq_id":"923913","subsidy_stat":"I","acct_id":"F03291269","ref_cnt":0,"product_id":"HSK","bank_mer_id":"A1035134718870142962","id":2628973110,"icc_data":"","atu_sub_mer_id":"2088550839201829","trans_stat":"S","region_id":"TOP4_B","credit_type":"","version":2,"org_auth_no":"","cash_resp_desc":"成功","bagent_id":"6666000153839851","real_cust_id":"6666000153923913","req_seq_id":"T20251220171836S6666000153923913","term_div_coupon_type":1,"iss_inst_id":"","channel_finish_time":1766222307000,"channel_stat":"S","unconfirm_fee_amt":0.01,"real_gate_id":"Dx","db_unit":"4","sys_trace_audit_num":"","trans_date":"20251220","batch_id":"251220","credit_fee_amt":0.00,"bank_resp_code":"TRADE_SUCCESS","real_acct_id":"F03291269","channel_message":"TRADE_SUCCESS","settle_trans_stat":"","mypaytsf_discount":0.00,"unconfirm_amt":5.49,"org_huifu_seq_id":"","cash_resp_code":"000","double_limit_amt":0.00,"fee_split_type":"","fee_real_cust_id":"6666000153923913","maze_resp_desc":"","fee_amt":0.01,"creator":"","ord_amt":5.50,"acct_stat":"I","debit_fee_amt":0.00,"cash_req_date":"20251220171826","ref_num":"171826923913","out_trans_id":"672025122022001416951449996798","fee_acct_id":"F03291269","is_acct_div_param":0,"subsidy_ref_amt":0.00,"mer_name":"高新区益选便利店(个体工商户)","pay_scene":"02","time_expire":"20251220172836","modifier":"","channel_code":"00","mcc":"","close_trans_stat":"","ref_fee_amt":0.01,"trans_finish_time":1766222309000,"org_trans_date":"","bank_resp_desc":"TRADE_SUCCESS","pay_channel_id":"00001249","create_time":1766222306000,"pay_amt":5.50,"org_acct_id":"F03291269","hf_seq_id":"002900TOP4B251220171826P546ac136a7d00000","goods_desc":"1","is_route":"","is_delay_acct":0,"settle_amt":5.50,"ref_amt":5.50,"gate_id":"SPIN022","pay_channel":"A","huifu_id":"6666000153923913","subsidy_amt":0.00,"fee_huifu_id":"6666000153923913","maze_bg_seq_id":"","maze_bg_date":"","modify_time":1766222308000,"check_cash_flag":"I","remark":"imcgcd03","is_acct_div":1,"real_pay_type":"1013","sys_id":"6666000132082499","card_channel_type":"","is_deleted":0,"fee_flag":2,"cash_trans_id":"2025122024rg0396","pa_mer_id":"SSP001","pay_type":"MICROPAY","sn_code":"","channel_type":"U","source_region_id":"TOP4_B","trans_type":"1000","mer_ord_id":"T20251220171836S6666000153923913","ord_id":"202512201718260TOP4_BL2797202899","card_sign":"","fee_formula":"","fee_source":"'SERVER'","party_order_id":"03242512206230681909127","bank_mer_name":"高新区益选便利店(个体工商户)","cashier_version":"V2","un_scene_info":"","maze_pnr_dev_id":"","req_date":"20251220","route_region_id":"C24_A","fee_rec_type":1},"settlement_amt":"5.50","acct_split_bunch":{"acct_infos":[{"acct_id":"F03291269","huifu_id":"6666000153923913","div_amt":"5.50"}],"fee_acct_id":"F03291269","fee_huifu_id":"6666000153923913","fee_amt":"0.01"},"trans_type":"A_MICROPAY","mer_ord_id":"T20251220171836S6666000153923913","trans_stat":"S","end_time":"20251220171827","hf_seq_id":"002900TOP4B251220171826P546ac136a7d00000","ref_no":"171826923913","trans_time":"171826","alipay_response":{"coupon_fee":"0.00","buyer_logon_id":"182****2451","buyer_id":"2088902514116953","app_id":"","fund_bill_list":[{"amount":"5.50","fund_channel":"ALIPAYACCOUNT"}]},"fee_amount":"0.01","out_trans_id":"672025122022001416951449996798","party_order_id":"03242512206230681909127","is_delay_acct":"0","fee_formula_infos":[{"fee_formula":"AMT*0.0025","fee_type":"TRANS_FEE"}],"namespace":"opps-webhook","huifu_id":"6666000153923913","mer_name":"高新区益选便利店(个体工商户)"}
|
||||
22
scripts/verify_logic.py
Normal file
22
scripts/verify_logic.py
Normal file
@ -0,0 +1,22 @@
|
||||
import os
|
||||
import json
|
||||
from app.config import load_config
|
||||
from app.models import IncomingPayload, IncomingOrderInfo
|
||||
from app.services.relay import route_targets
|
||||
from app.services.notify import render_message
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
path = os.path.abspath(r"e:\2025Code\python\webhock\123.josn")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data.get("trans_order_info"), dict):
|
||||
data["trans_order_info"] = IncomingOrderInfo(**data["trans_order_info"]) # type: ignore
|
||||
payload = IncomingPayload(**data)
|
||||
targets = route_targets(cfg, payload.remark or "")
|
||||
msg = render_message(cfg, payload)
|
||||
print("targets:", [t.get("name") for t in targets])
|
||||
print("message:", msg["text"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
30
scripts/verify_message_only.py
Normal file
30
scripts/verify_message_only.py
Normal file
@ -0,0 +1,30 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
def main():
|
||||
path = os.path.abspath(os.path.join(os.getcwd(), "samples", "incoming.json"))
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
remark = data.get("remark")
|
||||
event = data.get("event_define_no")
|
||||
trans_amt = data.get("trans_amt")
|
||||
cash_resp_desc = ""
|
||||
toi = data.get("trans_order_info") or {}
|
||||
cash_resp_desc = toi.get("cash_resp_desc") or ""
|
||||
if event == "pay.ali_scaned":
|
||||
msg = f"支付宝收款{trans_amt}元,状态:{cash_resp_desc}"
|
||||
elif event == "pay.wx_scaned":
|
||||
msg = f"微信收款{trans_amt}元,状态:{cash_resp_desc}"
|
||||
elif event == "wechat.complaint":
|
||||
msg = "⚠️请注意,您有新的微信投诉,请注意查看"
|
||||
elif event == "refund.standard":
|
||||
actual_ref_amt = data.get("actual_ref_amt") or (toi.get("ref_amt") or data.get("settlement_amt"))
|
||||
msg = f"退款成功,退款金额:{actual_ref_amt}"
|
||||
else:
|
||||
msg = ""
|
||||
target = "target_3" if remark == "imcgcd03" else ("target_2" if remark == "imcgcd02" else "")
|
||||
print("message:", msg)
|
||||
print("route_target:", target)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
templates/admin/base.html
Normal file
51
templates/admin/base.html
Normal 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
templates/admin/channels.html
Normal file
124
templates/admin/channels.html
Normal 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
templates/admin/dashboard.html
Normal file
196
templates/admin/dashboard.html
Normal 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
templates/admin/endpoint_detail.html
Normal file
467
templates/admin/endpoint_detail.html
Normal 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
templates/admin/endpoints.html
Normal file
124
templates/admin/endpoints.html
Normal 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
templates/admin/logs.html
Normal file
123
templates/admin/logs.html
Normal 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
templates/admin/targets.html
Normal file
111
templates/admin/targets.html
Normal 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
templates/admin/templates.html
Normal file
103
templates/admin/templates.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user