Compare commits

...

24 Commits
main ... dev2

Author SHA1 Message Date
Marsway a453ae0f3c Merge branch 'dev' into dev2 2026-01-13 10:01:26 +08:00
Marsway 214fd589dd update 2026-01-13 10:00:31 +08:00
Marsway 1435653b0b update 2026-01-13 09:58:34 +08:00
Marsway a98697e376 update 2026-01-13 09:52:22 +08:00
Marsway c56c3c0bef update 2026-01-13 09:51:52 +08:00
Marsway 65676ad64b update 2026-01-13 09:49:09 +08:00
Marsway 337091d8d1 update:筛选 2026-01-13 03:05:32 +08:00
Marsway 80fbe0874b update 2026-01-13 02:32:37 +08:00
Marsway eb2ebd13f9 update 2026-01-13 02:27:32 +08:00
Marsway 96ef2cf88c update 2026-01-13 02:22:47 +08:00
Marsway 86e689f453 update 2026-01-13 02:05:39 +08:00
Marsway c97890e2b7 update 2026-01-13 01:10:57 +08:00
Marsway beddeeb3ed update 2026-01-12 18:06:57 +08:00
Marsway e5dfb94318 update 2026-01-12 18:06:55 +08:00
Marsway 21b477d8bf update 2026-01-12 18:04:32 +08:00
Marsway 88e6274bba update 2026-01-12 17:51:22 +08:00
Marsway 64beb48074 update 2026-01-06 09:09:28 +08:00
Marsway d1f335d6a2 update 2026-01-05 17:37:28 +08:00
Marsway 14d9136a3b 严格 json 格式 2026-01-05 17:28:01 +08:00
Marsway 24c81035e8 update 2026-01-05 17:18:23 +08:00
Marsway 516177e426 update 2026-01-05 17:13:23 +08:00
Marsway e55619b632 new job 2026-01-05 17:01:26 +08:00
Marsway ceff46c47a update 镜像源 2026-01-05 16:36:37 +08:00
Marsway dd18dcd9de update 2026-01-05 16:33:29 +08:00
1323 changed files with 5350 additions and 159 deletions

2
.env
View File

@ -1,6 +1,6 @@
APP_NAME=ConnectHub APP_NAME=ConnectHub
DATA_DIR=/data DATA_DIR=/data
DB_URL=sqlite:////data/connecthub.db DB_URL=postgresql+psycopg://connecthub:connecthub_pwd_change_me@postgres:5432/connecthub
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
FERNET_KEY_PATH=/data/fernet.key FERNET_KEY_PATH=/data/fernet.key
DEV_MODE=1 DEV_MODE=1

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.db
*.log
pgdata/

View File

@ -48,9 +48,9 @@ ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成
- `env.example`:环境变量示例(由于环境限制,仓库中使用该文件名;本地运行时请手动创建 `.env` 并参考此文件) - `env.example`:环境变量示例(由于环境限制,仓库中使用该文件名;本地运行时请手动创建 `.env` 并参考此文件)
- 关键变量: - 关键变量:
- `DATA_DIR=/data`:容器内数据目录 - `DATA_DIR=/data`:容器内数据目录
- `DB_URL=sqlite:////data/connecthub.db`SQLite DB 文件 - `DB_URL=postgresql+psycopg://connecthub:connecthub_pwd_change_me@postgres:5432/connecthub`PostgreSQL 连接串(容器内通过 service name `postgres` 访问)
- `REDIS_URL=redis://redis:6379/0`Celery Broker/Backend - `REDIS_URL=redis://redis:6379/0`Celery Broker/Backend
- `FERNET_KEY_PATH=/data/fernet.key`Fernet key 文件(自动生成并持久化) - `FERNET_KEY_PATH=/data/fernet.key`Fernet key 文件(自动生成并持久化**正式环境必须保留同一个 key否则历史 secret_cfg 将无法解密**
- `LOG_DIR=/data/logs`:日志目录(可选) - `LOG_DIR=/data/logs`:日志目录(可选)
### 核心框架实现要点 ### 核心框架实现要点
@ -67,6 +67,44 @@ ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成
- 位置:`app/integrations/base.py` - 位置:`app/integrations/base.py`
- 规范:业务 Job 禁止直接写 HTTP必须通过 Client 访问外部系统(统一超时、重试、日志)。 - 规范:业务 Job 禁止直接写 HTTP必须通过 Client 访问外部系统(统一超时、重试、日志)。
#### SeeyonClient致远 OA
- 位置:`app/integrations/seeyon.py`
- 认证方式:`POST /seeyon/rest/token` 获取 `id` 作为 token并在业务请求 header 中携带 `token: <id>`(参考:[调用Rest接口](https://open.seeyoncloud.com/seeyonapi/781/))。
- 最小配置示例:
- `public_cfg`
```json
{"base_url":"https://oa.example.com"}
```
- `secret_cfg`(会被加密落库):
```json
{"rest_user":"REST帐号","rest_password":"REST密码","loginName":"可选-模拟登录名"}
```
- 注意:在 Admin 中保存 `public_cfg/secret_cfg` 时必须输入 **合法 JSON 对象(双引号、且为 `{...}`**,否则会直接报错并阻止落库。
- token 失效处理:遇到 401 或响应包含 `Invalid token`,自动刷新 token 并重试一次。
#### 示例插件sync_oa_to_didi仅演示 token 获取日志)
- 插件 Job`extensions/sync_oa_to_didi/job.py` 的 `SyncOAToDidiTokenJob`
- 在 Admin 创建 Job 时可使用:
- `handler_path`: `extensions.sync_oa_to_didi.job:SyncOAToDidiTokenJob`
- `public_cfg`
```json
{"base_url":"https://oa.example.com"}
```
- `secret_cfg`(会被加密落库):
```json
{"rest_user":"REST帐号","rest_password":"REST密码","loginName":"可选-模拟登录名"}
```
#### SecurityFernet 加解密) #### SecurityFernet 加解密)
- 位置:`app/security/fernet.py` - 位置:`app/security/fernet.py`

View File

@ -0,0 +1,44 @@
{% extends "sqladmin/edit.html" %}
{% block content %}
{{ super() }}
<div class="card mt-3">
<div class="card-body">
<div class="row mb-3">
<label class="form-label col-sm-2 col-form-label">密文配置secret_cfg</label>
<div class="col-sm-10">
<textarea id="connecthub-secret-cfg" class="form-control" rows="8" placeholder='留空表示不修改;填写将覆盖并加密保存。示例:{"token":"xxx"}'></textarea>
<div class="form-text">
出于安全考虑,编辑页不回显历史密文。留空表示不修改;填写 JSON 对象将覆盖原值并重新加密保存。
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block tail %}
{{ super() }}
<script>
(function () {
// SQLAdmin 默认 edit 页面会渲染一个 form这里将 textarea 的值注入为隐藏字段,以便提交到后端。
const form = document.querySelector("form");
const textarea = document.getElementById("connecthub-secret-cfg");
if (!form || !textarea) return;
let hidden = form.querySelector('input[name="secret_cfg"]');
if (!hidden) {
hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "secret_cfg";
form.appendChild(hidden);
}
form.addEventListener("submit", function () {
hidden.value = textarea.value || "";
});
})();
</script>
{% endblock %}

View File

@ -22,7 +22,7 @@
<div class="ms-3 d-inline-block dropdown"> <div class="ms-3 d-inline-block dropdown">
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown" <a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
Export 导出
</a> </a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1"> <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
{% for export_type in model_view.export_types %} {% for export_type in model_view.export_types %}
@ -36,7 +36,7 @@
<div class="ms-3 d-inline-block"> <div class="ms-3 d-inline-block">
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}" <a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
class="btn btn-secondary"> class="btn btn-secondary">
Export 导出
</a> </a>
</div> </div>
{% endif %} {% endif %}
@ -44,7 +44,7 @@
{% if model_view.can_create %} {% if model_view.can_create %}
<div class="ms-3 d-inline-block"> <div class="ms-3 d-inline-block">
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary"> <a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
+ New {{ model_view.name }} + 新建{{ model_view.name }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
@ -56,14 +56,14 @@
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %} <button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"> aria-haspopup="true" aria-expanded="false">
Actions 操作
</button> </button>
{% if model_view.can_delete or model_view._custom_actions_in_list %} {% if model_view.can_delete or model_view._custom_actions_in_list %}
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton"> <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
{% if model_view.can_delete %} {% if model_view.can_delete %}
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}" <a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal" data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
data-bs-target="#modal-delete">Delete selected items</a> data-bs-target="#modal-delete">删除所选</a>
{% endif %} {% endif %}
{% for custom_action, label in model_view._custom_actions_in_list.items() %} {% for custom_action, label in model_view._custom_actions_in_list.items() %}
{% if custom_action in model_view._custom_actions_confirmation %} {% if custom_action in model_view._custom_actions_confirmation %}
@ -85,9 +85,9 @@
<div class="col-md-4 text-muted"> <div class="col-md-4 text-muted">
<div class="input-group"> <div class="input-group">
<input id="search-input" type="text" class="form-control" <input id="search-input" type="text" class="form-control"
placeholder="Search: {{ model_view.search_placeholder() }}" placeholder="搜索:{{ model_view.search_placeholder() }}"
value="{{ request.query_params.get('search', '') }}"> value="{{ request.query_params.get('search', '') }}">
<button id="search-button" class="btn" type="button">Search</button> <button id="search-button" class="btn" type="button">搜索</button>
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search') <button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button> %}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
</div> </div>
@ -120,7 +120,7 @@
{% endif %} {% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th>Run Now</th> <th>立即运行</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -172,8 +172,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<td> <td>
<form class="connecthub-run-form" method="post" action="/admin/jobs/{{ get_object_identifier(row) }}/run" onsubmit="return confirm('Run this job now?');"> <form class="connecthub-run-form" method="post" action="/admin/jobs/{{ get_object_identifier(row) }}/run" onsubmit="return confirm('确认立即执行该任务?');">
<button type="submit" class="btn btn-primary btn-sm">Run Now</button> <button type="submit" class="btn btn-primary btn-sm">立即运行</button>
</form> </form>
</td> </td>
</tr> </tr>
@ -182,9 +182,9 @@
</table> </table>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2"> <div class="card-footer d-flex justify-content-between align-items-center gap-2">
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to <p class="m-0 text-muted">显示 <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span>
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count <span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span>,共 <span>{{ pagination.count
}}</span> items }}</span>
</p> </p>
<ul class="pagination m-0 ms-auto"> <ul class="pagination m-0 ms-auto">
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}"> <li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
@ -194,7 +194,7 @@
<a class="page-link" href="#"> <a class="page-link" href="#">
{% endif %} {% endif %}
<i class="fa-solid fa-chevron-left"></i> <i class="fa-solid fa-chevron-left"></i>
prev 上一页
</a> </a>
</li> </li>
{% for page_control in pagination.page_controls %} {% for page_control in pagination.page_controls %}
@ -207,21 +207,21 @@
{% else %} {% else %}
<a class="page-link" href="#"> <a class="page-link" href="#">
{% endif %} {% endif %}
next 下一页
<i class="fa-solid fa-chevron-right"></i> <i class="fa-solid fa-chevron-right"></i>
</a> </a>
</li> </li>
</ul> </ul>
<div class="dropdown text-muted"> <div class="dropdown text-muted">
Show 每页显示
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" <a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false"> aria-expanded="false">
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page {{ request.query_params.get("pageSize") or model_view.page_size }} /
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for page_size_option in model_view.page_size_options %} {% for page_size_option in model_view.page_size_options %}
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}"> <a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
{{ page_size_option }} / Page {{ page_size_option }} /
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
@ -233,7 +233,7 @@
<div class="col-md-3" style="width: 300px; flex-shrink: 0;"> <div class="col-md-3" style="width: 300px; flex-shrink: 0;">
<div id="filter-sidebar" class="card"> <div id="filter-sidebar" class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Filters</h3> <h3 class="card-title">筛选</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% for filter in model_view.get_filters() %} {% for filter in model_view.get_filters() %}
@ -245,8 +245,8 @@
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %} {% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
{% if current_filter %} {% if current_filter %}
<div class="mb-2 text-muted small"> <div class="mb-2 text-muted small">
Current: {{ current_op }} {{ current_filter }} 当前:{{ current_op }} {{ current_filter }}
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a> <a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[清除]</a>
</div> </div>
{% endif %} {% endif %}
<form method="get" class="d-flex flex-column" style="gap: 8px;"> <form method="get" class="d-flex flex-column" style="gap: 8px;">
@ -256,7 +256,7 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required> <select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
<option value="">Select operation...</option> <option value="">选择操作...</option>
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %} {% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option> <option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
{% endfor %} {% endfor %}
@ -267,7 +267,7 @@
class="form-control form-control-sm" class="form-control form-control-sm"
value="{{ current_filter }}" value="{{ current_filter }}"
required> required>
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button> <button type="submit" class="btn btn-sm btn-outline-primary">应用筛选</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -22,8 +22,8 @@
<table class="table card-table table-vcenter text-nowrap table-hover table-bordered"> <table class="table card-table table-vcenter text-nowrap table-hover table-bordered">
<thead> <thead>
<tr> <tr>
<th class="w-1">Column</th> <th class="w-1">字段</th>
<th class="w-1">Value</th> <th class="w-1"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -58,12 +58,12 @@
<div class="row connecthub-action-row"> <div class="row connecthub-action-row">
<div class="col-md-1"> <div class="col-md-1">
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn"> <a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
Go Back 返回
</a> </a>
</div> </div>
<div class="col-md-1"> <div class="col-md-1">
<form method="post" action="/admin/joblogs/{{ get_object_identifier(model) }}/retry" style="display:inline;" onsubmit="return confirm('Retry this job log?');"> <form method="post" action="/admin/joblogs/{{ get_object_identifier(model) }}/retry" style="display:inline;" onsubmit="return confirm('确认重试该任务日志?');">
<button type="submit" class="btn btn-warning">Retry</button> <button type="submit" class="btn btn-warning">重试</button>
</form> </form>
</div> </div>
{% if model_view.can_delete %} {% if model_view.can_delete %}
@ -71,14 +71,14 @@
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}" <a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal" data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
data-bs-target="#modal-delete" class="btn btn-danger"> data-bs-target="#modal-delete" class="btn btn-danger">
Delete 删除
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if model_view.can_edit %} {% if model_view.can_edit %}
<div class="col-md-1"> <div class="col-md-1">
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary"> <a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
Edit 编辑
</a> </a>
</div> </div>
{% endif %} {% endif %}

View File

@ -22,7 +22,7 @@
<div class="ms-3 d-inline-block dropdown"> <div class="ms-3 d-inline-block dropdown">
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown" <a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
Export 导出
</a> </a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1"> <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
{% for export_type in model_view.export_types %} {% for export_type in model_view.export_types %}
@ -36,7 +36,7 @@
<div class="ms-3 d-inline-block"> <div class="ms-3 d-inline-block">
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}" <a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
class="btn btn-secondary"> class="btn btn-secondary">
Export 导出
</a> </a>
</div> </div>
{% endif %} {% endif %}
@ -44,7 +44,7 @@
{% if model_view.can_create %} {% if model_view.can_create %}
<div class="ms-3 d-inline-block"> <div class="ms-3 d-inline-block">
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary"> <a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
+ New {{ model_view.name }} + 新建{{ model_view.name }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
@ -56,14 +56,14 @@
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %} <button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"> aria-haspopup="true" aria-expanded="false">
Actions 操作
</button> </button>
{% if model_view.can_delete or model_view._custom_actions_in_list %} {% if model_view.can_delete or model_view._custom_actions_in_list %}
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton"> <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
{% if model_view.can_delete %} {% if model_view.can_delete %}
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}" <a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal" data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
data-bs-target="#modal-delete">Delete selected items</a> data-bs-target="#modal-delete">删除所选</a>
{% endif %} {% endif %}
{% for custom_action, label in model_view._custom_actions_in_list.items() %} {% for custom_action, label in model_view._custom_actions_in_list.items() %}
{% if custom_action in model_view._custom_actions_confirmation %} {% if custom_action in model_view._custom_actions_confirmation %}
@ -85,9 +85,9 @@
<div class="col-md-4 text-muted"> <div class="col-md-4 text-muted">
<div class="input-group"> <div class="input-group">
<input id="search-input" type="text" class="form-control" <input id="search-input" type="text" class="form-control"
placeholder="Search: {{ model_view.search_placeholder() }}" placeholder="搜索:{{ model_view.search_placeholder() }}"
value="{{ request.query_params.get('search', '') }}"> value="{{ request.query_params.get('search', '') }}">
<button id="search-button" class="btn" type="button">Search</button> <button id="search-button" class="btn" type="button">搜索</button>
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search') <button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button> %}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
</div> </div>
@ -120,7 +120,7 @@
{% endif %} {% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th>Retry</th> <th>重试</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -172,8 +172,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<td> <td>
<form class="connecthub-retry-form" method="post" action="/admin/joblogs/{{ get_object_identifier(row) }}/retry" onsubmit="return confirm('Retry this job log?');"> <form class="connecthub-retry-form" method="post" action="/admin/joblogs/{{ get_object_identifier(row) }}/retry" onsubmit="return confirm('确认重试该任务日志?');">
<button type="submit" class="btn btn-warning btn-sm">Retry</button> <button type="submit" class="btn btn-warning btn-sm">重试</button>
</form> </form>
</td> </td>
</tr> </tr>
@ -182,9 +182,9 @@
</table> </table>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2"> <div class="card-footer d-flex justify-content-between align-items-center gap-2">
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to <p class="m-0 text-muted">显示 <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span>
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count <span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span>,共 <span>{{ pagination.count
}}</span> items }}</span>
</p> </p>
<ul class="pagination m-0 ms-auto"> <ul class="pagination m-0 ms-auto">
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}"> <li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
@ -194,7 +194,7 @@
<a class="page-link" href="#"> <a class="page-link" href="#">
{% endif %} {% endif %}
<i class="fa-solid fa-chevron-left"></i> <i class="fa-solid fa-chevron-left"></i>
prev 上一页
</a> </a>
</li> </li>
{% for page_control in pagination.page_controls %} {% for page_control in pagination.page_controls %}
@ -207,21 +207,21 @@
{% else %} {% else %}
<a class="page-link" href="#"> <a class="page-link" href="#">
{% endif %} {% endif %}
next 下一页
<i class="fa-solid fa-chevron-right"></i> <i class="fa-solid fa-chevron-right"></i>
</a> </a>
</li> </li>
</ul> </ul>
<div class="dropdown text-muted"> <div class="dropdown text-muted">
Show 每页显示
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" <a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false"> aria-expanded="false">
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page {{ request.query_params.get("pageSize") or model_view.page_size }} /
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for page_size_option in model_view.page_size_options %} {% for page_size_option in model_view.page_size_options %}
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}"> <a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
{{ page_size_option }} / Page {{ page_size_option }} /
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
@ -233,7 +233,7 @@
<div class="col-md-3" style="width: 300px; flex-shrink: 0;"> <div class="col-md-3" style="width: 300px; flex-shrink: 0;">
<div id="filter-sidebar" class="card"> <div id="filter-sidebar" class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Filters</h3> <h3 class="card-title">筛选</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% for filter in model_view.get_filters() %} {% for filter in model_view.get_filters() %}
@ -245,8 +245,8 @@
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %} {% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
{% if current_filter %} {% if current_filter %}
<div class="mb-2 text-muted small"> <div class="mb-2 text-muted small">
Current: {{ current_op }} {{ current_filter }} 当前:{{ current_op }} {{ current_filter }}
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a> <a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[清除]</a>
</div> </div>
{% endif %} {% endif %}
<form method="get" class="d-flex flex-column" style="gap: 8px;"> <form method="get" class="d-flex flex-column" style="gap: 8px;">
@ -256,7 +256,7 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required> <select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
<option value="">Select operation...</option> <option value="">选择操作...</option>
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %} {% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option> <option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
{% endfor %} {% endfor %}
@ -267,7 +267,7 @@
class="form-control form-control-sm" class="form-control form-control-sm"
value="{{ current_filter }}" value="{{ current_filter }}"
required> required>
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button> <button type="submit" class="btn btn-sm btn-outline-primary">应用筛选</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -45,8 +45,8 @@ def _truncate(s: str, n: int = 120) -> str:
class JobAdmin(ModelView, model=Job): class JobAdmin(ModelView, model=Job):
name = "Job" name = "任务"
name_plural = "Jobs" name_plural = "任务"
icon = "fa fa-cogs" icon = "fa fa-cogs"
column_list = [Job.id, Job.enabled, Job.cron_expr, Job.handler_path, Job.updated_at] column_list = [Job.id, Job.enabled, Job.cron_expr, Job.handler_path, Job.updated_at]
@ -69,13 +69,44 @@ class JobAdmin(ModelView, model=Job):
# 为 Job 详情页指定模板(用于调整按钮间距) # 为 Job 详情页指定模板(用于调整按钮间距)
details_template = "job_details.html" details_template = "job_details.html"
# 编辑页secret_cfg 只写不读(不回显密文;留空表示不更新)
edit_template = "job_edit.html"
# 列表页模板:加入每行 Run Now # 列表页模板:加入每行 Run Now
list_template = "job_list.html" list_template = "job_list.html"
# 编辑页排除 secret_cfg避免回显密文由自定义模板额外渲染一个空输入框
# 注意SQLAdmin 这里需要字段名字符串(不是 SQLAlchemy Column 对象)
form_edit_rules = ["id", "enabled", "cron_expr", "handler_path", "public_cfg"]
column_labels = {
"id": "任务ID",
"enabled": "启用",
"cron_expr": "Cron 表达式",
"handler_path": "处理器",
"public_cfg": "明文配置",
"secret_cfg": "密文配置",
"last_run_at": "上次运行时间",
"created_at": "创建时间",
"updated_at": "更新时间",
}
column_formatters = {
Job.created_at: lambda m, a: _fmt_dt_seconds(m.created_at),
Job.updated_at: lambda m, a: _fmt_dt_seconds(m.updated_at),
Job.last_run_at: lambda m, a: _fmt_dt_seconds(m.last_run_at),
}
column_formatters_detail = {
Job.created_at: lambda m, a: _fmt_dt_seconds(m.created_at),
Job.updated_at: lambda m, a: _fmt_dt_seconds(m.updated_at),
Job.last_run_at: lambda m, a: _fmt_dt_seconds(m.last_run_at),
}
@action( @action(
name="run_now", name="run_now",
label="Run Now", label="立即运行",
confirmation_message="Trigger this job now?", confirmation_message="确认立即执行该任务?",
add_in_list=True, add_in_list=True,
add_in_detail=True, add_in_detail=True,
) )
@ -106,39 +137,54 @@ class JobAdmin(ModelView, model=Job):
itr = croniter(str(cron_expr).strip(), base) itr = croniter(str(cron_expr).strip(), base)
_ = itr.get_next(datetime) _ = itr.get_next(datetime)
# public_cfg 允许以 JSON 字符串输入 # public_cfg:必须是合法 JSON 对象dict否则直接报错阻止落库
pcfg = _maybe_json(data.get("public_cfg")) pcfg = data.get("public_cfg")
if isinstance(pcfg, str): if isinstance(pcfg, str):
try:
pcfg = json.loads(pcfg)
except json.JSONDecodeError as e:
raise ValueError("public_cfg must be a JSON object") from e
if not isinstance(pcfg, dict):
raise ValueError("public_cfg must be a JSON object") raise ValueError("public_cfg must be a JSON object")
if isinstance(pcfg, dict): data["public_cfg"] = pcfg
data["public_cfg"] = pcfg
# secret_cfg若用户输入 JSON 字符串,则自动加密落库;若输入已是 token则原样保存 # secret_cfg
scfg = data.get("secret_cfg", "") # - 创建:必须是合法 JSON 对象dict并且保存时必须加密落库
if scfg is None: # - 编辑:出于安全考虑不回显密文;若留空则保留原密文不更新;若填写则按 JSON 校验并加密覆盖
data["secret_cfg"] = "" if is_created:
return scfg = data.get("secret_cfg")
if isinstance(scfg, str): if isinstance(scfg, str):
s = scfg.strip() try:
if not s: scfg = json.loads(scfg)
data["secret_cfg"] = "" except json.JSONDecodeError as e:
return raise ValueError("secret_cfg must be a JSON object") from e
parsed = _maybe_json(s) if not isinstance(scfg, dict):
if isinstance(parsed, dict): raise ValueError("secret_cfg must be a JSON object")
data["secret_cfg"] = encrypt_json(parsed)
else:
# 非 JSON视为已加密 token
data["secret_cfg"] = s
return
if isinstance(scfg, dict):
data["secret_cfg"] = encrypt_json(scfg) data["secret_cfg"] = encrypt_json(scfg)
return else:
raise ValueError("secret_cfg must be JSON object or encrypted token string") # 自定义编辑页会以 textarea 传回 secret_cfg可能不存在或为空
try:
form = await request.form()
raw = form.get("secret_cfg")
except Exception:
raw = None
raw_s = str(raw).strip() if raw is not None else ""
if not raw_s:
# 留空:不更新密文字段
data.pop("secret_cfg", None)
else:
try:
scfg2 = json.loads(raw_s)
except json.JSONDecodeError as e:
raise ValueError("secret_cfg must be a JSON object") from e
if not isinstance(scfg2, dict):
raise ValueError("secret_cfg must be a JSON object")
data["secret_cfg"] = encrypt_json(scfg2)
class JobLogAdmin(ModelView, model=JobLog): class JobLogAdmin(ModelView, model=JobLog):
name = "JobLog" name = "任务日志"
name_plural = "JobLogs" name_plural = "任务日志"
icon = "fa fa-list" icon = "fa fa-list"
can_create = False can_create = False
@ -168,6 +214,20 @@ class JobLogAdmin(ModelView, model=JobLog):
# 为 JobLog 详情页单独指定模板(用于加入 Retry 按钮) # 为 JobLog 详情页单独指定模板(用于加入 Retry 按钮)
details_template = "joblog_details.html" details_template = "joblog_details.html"
column_labels = {
"id": "日志ID",
"job_id": "任务ID",
"status": "状态",
"snapshot_params": "快照参数",
"message": "消息",
"traceback": "异常堆栈",
"run_log": "运行日志",
"celery_task_id": "Celery任务ID",
"attempt": "重试次数",
"started_at": "开始时间",
"finished_at": "结束时间",
}
column_formatters = { column_formatters = {
JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at), JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at),
JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at), JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at),
@ -177,6 +237,11 @@ class JobLogAdmin(ModelView, model=JobLog):
column_formatters_detail = { column_formatters_detail = {
JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at), JobLog.started_at: lambda m, a: _fmt_dt_seconds(m.started_at),
JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at), JobLog.finished_at: lambda m, a: _fmt_dt_seconds(m.finished_at),
JobLog.message: lambda m, a: Markup(
"<pre style='max-height:240px;overflow:auto;white-space:pre-wrap'>"
+ (m.message or "")
+ "</pre>"
),
JobLog.traceback: lambda m, a: Markup(f"<pre style='white-space:pre-wrap'>{m.traceback or ''}</pre>"), JobLog.traceback: lambda m, a: Markup(f"<pre style='white-space:pre-wrap'>{m.traceback or ''}</pre>"),
JobLog.run_log: lambda m, a: Markup( JobLog.run_log: lambda m, a: Markup(
"<pre style='max-height:480px;overflow:auto;white-space:pre-wrap'>" "<pre style='max-height:480px;overflow:auto;white-space:pre-wrap'>"

View File

@ -8,7 +8,7 @@ class Settings(BaseSettings):
app_name: str = "ConnectHub" app_name: str = "ConnectHub"
data_dir: str = "/data" data_dir: str = "/data"
db_url: str = "sqlite:////data/connecthub.db" db_url: str = "postgresql+psycopg://connecthub:connecthub_pwd_change_me@postgres:5432/connecthub"
redis_url: str = "redis://redis:6379/0" redis_url: str = "redis://redis:6379/0"
fernet_key_path: str = "/data/fernet.key" fernet_key_path: str = "/data/fernet.key"
dev_mode: bool = False dev_mode: bool = False

View File

@ -62,3 +62,39 @@ def get_job_log(session: Session, log_id: int) -> JobLog | None:
return session.get(JobLog, log_id) return session.get(JobLog, log_id)
def update_job_log(
session: Session,
log_id: int,
*,
status: JobStatus | None = None,
message: str | None = None,
traceback: str | None = None,
run_log: str | None = None,
celery_task_id: str | None = None,
attempt: int | None = None,
finished_at: datetime | None = None,
) -> JobLog | None:
log = session.get(JobLog, log_id)
if not log:
return None
if status is not None:
log.status = status
if message is not None:
log.message = message
if traceback is not None:
log.traceback = traceback
if run_log is not None:
log.run_log = run_log
if celery_task_id is not None:
log.celery_task_id = celery_task_id
if attempt is not None:
log.attempt = attempt
if finished_at is not None:
log.finished_at = finished_at
session.add(log)
session.commit()
session.refresh(log)
return log

View File

@ -6,11 +6,11 @@ from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings from app.core.config import settings
engine = create_engine( _kwargs = {"future": True}
settings.db_url, if settings.db_url.startswith("sqlite"):
connect_args={"check_same_thread": False} if settings.db_url.startswith("sqlite") else {}, _kwargs["connect_args"] = {"check_same_thread": False}
future=True,
) engine = create_engine(settings.db_url, **_kwargs)
SessionLocal = sessionmaker(bind=engine, class_=Session, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, class_=Session, autoflush=False, autocommit=False, future=True)

View File

@ -33,6 +33,7 @@ class Job(Base):
class JobStatus(str, enum.Enum): class JobStatus(str, enum.Enum):
RUNNING = "RUNNING"
SUCCESS = "SUCCESS" SUCCESS = "SUCCESS"
FAILURE = "FAILURE" FAILURE = "FAILURE"
RETRY = "RETRY" RETRY = "RETRY"

View File

@ -1,26 +1,160 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import Engine, text from sqlalchemy import Engine, inspect, text
from app.db.models import Base from app.db.models import Base
def _has_column(conn, table: str, col: str) -> bool: def _has_column(engine: Engine, table: str, col: str) -> bool:
rows = conn.execute(text(f"PRAGMA table_info({table})")).fetchall() insp = inspect(engine)
return any(r[1] == col for r in rows) # PRAGMA columns: (cid, name, type, notnull, dflt_value, pk) cols = insp.get_columns(table)
return any(c.get("name") == col for c in cols)
def _sqlite_table_sql(conn, table: str) -> str:
row = conn.execute(
text("SELECT sql FROM sqlite_master WHERE type='table' AND name=:name"),
{"name": table},
).fetchone()
return str(row[0] or "") if row else ""
def _ensure_job_logs_status_allows_running(engine: Engine) -> None:
"""
status 新增 RUNNING 时的轻量自升级
- SQLite如存在 CHECK 且不包含 RUNNING则通过重建表方式迁移移除旧 CHECK确保允许 RUNNING
- PostgreSQL如存在 status CHECK 且不包含 RUNNING drop & recreate
"""
dialect = engine.dialect.name
if dialect not in ("sqlite", "postgresql"):
return
insp = inspect(engine)
try:
cols = insp.get_columns("job_logs")
except Exception:
return
existing_cols = {c.get("name") for c in cols if c.get("name")}
with engine.begin() as conn:
if dialect == "sqlite":
sql = _sqlite_table_sql(conn, "job_logs")
# 没有 CHECK 约束则无需迁移;有 CHECK 但已包含 RUNNING 也无需迁移
if not sql or "CHECK" not in sql or "RUNNING" in sql:
return
# 重建表:去掉旧 CHECK允许 RUNNING并确保列存在缺列用默认值补齐
conn.execute(text("ALTER TABLE job_logs RENAME TO job_logs_old"))
conn.execute(
text(
"""
CREATE TABLE job_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id VARCHAR NOT NULL,
status VARCHAR(16) NOT NULL,
snapshot_params TEXT NOT NULL DEFAULT '{}',
message TEXT NOT NULL DEFAULT '',
traceback TEXT NOT NULL DEFAULT '',
run_log TEXT NOT NULL DEFAULT '',
celery_task_id VARCHAR NOT NULL DEFAULT '',
attempt INTEGER NOT NULL DEFAULT 0,
started_at DATETIME NOT NULL,
finished_at DATETIME
)
"""
)
)
def _expr(col: str, default_expr: str) -> str:
return col if col in existing_cols else f"{default_expr} AS {col}"
insert_cols = [
"id",
"job_id",
"status",
"snapshot_params",
"message",
"traceback",
"run_log",
"celery_task_id",
"attempt",
"started_at",
"finished_at",
]
select_exprs = [
_expr("id", "NULL"),
_expr("job_id", "''"),
_expr("status", "''"),
_expr("snapshot_params", "'{}'"),
_expr("message", "''"),
_expr("traceback", "''"),
_expr("run_log", "''"),
_expr("celery_task_id", "''"),
_expr("attempt", "0"),
_expr("started_at", "CURRENT_TIMESTAMP"),
_expr("finished_at", "NULL"),
]
conn.execute(
text(
f"INSERT INTO job_logs ({', '.join(insert_cols)}) "
f"SELECT {', '.join(select_exprs)} FROM job_logs_old"
)
)
conn.execute(text("DROP TABLE job_logs_old"))
# 还原 job_id 索引SQLAlchemy 默认命名 ix_job_logs_job_id
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_logs_job_id ON job_logs (job_id)"))
return
if dialect == "postgresql":
try:
checks = insp.get_check_constraints("job_logs") or []
except Exception:
checks = []
need = False
drop_names: list[str] = []
for ck in checks:
name = str(ck.get("name") or "")
sqltext = str(ck.get("sqltext") or "")
if "status" in sqltext and "RUNNING" not in sqltext:
need = True
if name:
drop_names.append(name)
if not need:
return
# 先尽力 drop 旧约束(名称不确定),再创建统一的新约束
for n in drop_names:
conn.execute(text(f'ALTER TABLE job_logs DROP CONSTRAINT IF EXISTS "{n}"'))
conn.execute(text("ALTER TABLE job_logs DROP CONSTRAINT IF EXISTS ck_job_logs_status"))
conn.execute(
text(
"ALTER TABLE job_logs "
"ADD CONSTRAINT ck_job_logs_status "
"CHECK (status IN ('RUNNING','SUCCESS','FAILURE','RETRY'))"
)
)
return
def ensure_schema(engine: Engine) -> None: def ensure_schema(engine: Engine) -> None:
""" """
SQLite 轻量自升级 轻量自升级 SQLite/PostgreSQL
- create_all 不会更新既有表结构因此用 PRAGMA + ALTER TABLE 补列 - create_all 不会更新既有表结构因此用 inspector + ALTER TABLE 补列
- 必须保证任何失败都不影响主流程上层可选择忽略异常 - 必须保证任何失败都不影响主流程上层可选择忽略异常
""" """
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with engine.begin() as conn: with engine.begin() as conn:
# job_logs.run_log # job_logs.run_log
if not _has_column(conn, "job_logs", "run_log"): if not _has_column(engine, "job_logs", "run_log"):
conn.execute(text("ALTER TABLE job_logs ADD COLUMN run_log TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE job_logs ADD COLUMN run_log TEXT NOT NULL DEFAULT ''"))
# job_logs.status: ensure new enum value RUNNING is accepted by DB constraints
_ensure_job_logs_status_allows_running(engine)

View File

@ -1,5 +1,7 @@
"""系统集成适配器""" """系统集成适配器"""
from app.integrations.base import BaseClient from app.integrations.base import BaseClient
from app.integrations.didi import DidiClient
from app.integrations.seeyon import SeeyonClient
__all__ = ["BaseClient"] __all__ = ["BaseClient", "DidiClient", "SeeyonClient"]

Binary file not shown.

View File

@ -42,11 +42,13 @@ class BaseClient:
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
url = path if path.startswith("/") else f"/{path}" url = path if path.startswith("/") else f"/{path}"
extra_headers = kwargs.pop("headers", None) or {}
merged_headers = {**self.headers, **extra_headers} if extra_headers else None
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(self.retries + 1): for attempt in range(self.retries + 1):
try: try:
start = time.time() start = time.time()
resp = self._client.request(method=method, url=url, **kwargs) resp = self._client.request(method=method, url=url, headers=merged_headers, **kwargs)
elapsed_ms = int((time.time() - start) * 1000) elapsed_ms = int((time.time() - start) * 1000)
logger.info("HTTP %s %s -> %s (%sms)", method, url, resp.status_code, elapsed_ms) logger.info("HTTP %s %s -> %s (%sms)", method, url, resp.status_code, elapsed_ms)
resp.raise_for_status() resp.raise_for_status()

411
app/integrations/didi.py Normal file
View File

@ -0,0 +1,411 @@
from __future__ import annotations
import hashlib
import json as jsonlib
import logging
import time
from typing import Any
import httpx
from app.integrations.base import BaseClient
logger = logging.getLogger("connecthub.integrations.didi")
def _contains_unsupported_sign_chars(s: str) -> bool:
# 文档提示:签名中不支持 \0 \t \n \x0B \r 以及空格进行加密处理。
# 这里仅做检测与告警,不自动清洗,避免服务端/客户端不一致。
return any(ch in s for ch in ("\0", "\t", "\n", "\x0b", "\r", " "))
class DidiClient(BaseClient):
"""
滴滴管理 API Client2024
- POST /river/Auth/authorize 获取 access_token建议缓存半小时401 刷新后重试一次
- 按文档规则生成 sign默认 MD5
参考
- https://opendocs.xiaojukeji.com/version2024/10951
- https://opendocs.xiaojukeji.com/version2024/10945
"""
def __init__(
self,
*,
base_url: str,
client_id: str,
client_secret: str,
sign_key: str,
grant_type: str = "client_credentials",
token_skew_s: int = 30,
timeout_s: float = 10.0,
retries: int = 2,
retry_backoff_s: float = 0.5,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(
base_url=base_url,
timeout_s=timeout_s,
retries=retries,
retry_backoff_s=retry_backoff_s,
headers=headers,
)
self.client_id = client_id
self.client_secret = client_secret
self.sign_key = sign_key
self.grant_type = grant_type
self.token_skew_s = token_skew_s
self._access_token: str | None = None
self._token_expires_at: float | None = None
self._token_type: str | None = None
def gen_sign(self, params: dict[str, Any], *, sign_method: str = "md5") -> str:
"""
签名算法默认 MD5
1) sign_key 加入参与签名参数不参与传递仅参与计算
2) 参数名升序排序
3) & 连接成 a=xxx&b=yyy...
4) md5/sha256 得到 sign小写 hex
文档https://opendocs.xiaojukeji.com/version2024/10945
"""
if sign_method.lower() != "md5":
raise ValueError("Only md5 sign_method is supported in this client (default)")
p = dict(params or {})
p["sign_key"] = self.sign_key
# 排序并拼接
items: list[tuple[str, str]] = []
for k in sorted(p.keys()):
v = p.get(k)
sv = "" if v is None else str(v).strip()
items.append((str(k), sv))
sign_str = "&".join([f"{k}={v}" for k, v in items])
if _contains_unsupported_sign_chars(sign_str):
logger.warning("Didi sign_str contains unsupported chars per docs (signing anyway)")
return hashlib.md5(sign_str.encode("utf-8")).hexdigest()
def authorize(self) -> str:
"""
授权获取 access_token
POST /river/Auth/authorize
文档https://opendocs.xiaojukeji.com/version2024/10951
"""
ts = int(time.time())
body: dict[str, Any] = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": self.grant_type,
"timestamp": ts,
}
body["sign"] = self.gen_sign(body)
resp = super().request(
"POST",
"/river/Auth/authorize",
json=body,
headers={"Content-Type": "application/json"},
)
data = resp.json() if resp.content else {}
access_token = str(data.get("access_token", "") or "")
expires_in = int(data.get("expires_in", 0) or 0)
token_type = str(data.get("token_type", "") or "Bearer")
if not access_token:
raise RuntimeError("Didi authorize failed (access_token missing)")
now = time.time()
skew = max(0, int(self.token_skew_s or 0))
# expires_in 单位秒;按文档通常为 1800
self._access_token = access_token
self._token_type = token_type
self._token_expires_at = now + max(0, expires_in - skew)
logger.info("Didi access_token acquired (cached) expires_in=%s token_type=%s", expires_in, token_type)
return access_token
def _get_access_token(self) -> str:
now = time.time()
if self._access_token and self._token_expires_at and now < self._token_expires_at:
return self._access_token
return self.authorize()
def _build_signed_query(self, *, company_id: str, extra_params: dict[str, Any]) -> dict[str, Any]:
"""
构造参与签名且实际传递 query 参数不包含 sign_key
- client_id/access_token/company_id/timestamp + extra_params + sign
"""
token = self._get_access_token()
ts = int(time.time())
params: dict[str, Any] = {
"client_id": self.client_id,
"access_token": token,
"company_id": company_id,
"timestamp": ts,
}
if extra_params:
params.update(extra_params)
params["sign"] = self.gen_sign({k: v for k, v in params.items() if k != "sign"})
return params
@staticmethod
def _raise_if_errno(api_name: str, payload: Any) -> None:
try:
errno = payload.get("errno")
errmsg = payload.get("errmsg")
except Exception as e: # noqa: BLE001
raise RuntimeError(f"{api_name} invalid response (not a dict)") from e
if errno is None:
raise RuntimeError(f"{api_name} invalid response (errno missing)")
try:
errno_i = int(errno)
except Exception:
errno_i = -1
if errno_i != 0:
raise RuntimeError(f"{api_name} failed errno={errno} errmsg={errmsg!r}")
def get_legal_entities(
self,
*,
company_id: str,
offset: int,
length: int,
keyword: str | None = None,
legal_entity_id: str | None = None,
out_legal_entity_id: str | None = None,
) -> dict[str, Any]:
"""
公司主体查询
GET /river/LegalEntity/get
"""
extra: dict[str, Any] = {"offset": offset, "length": length}
if keyword:
extra["keyword"] = keyword
if legal_entity_id:
extra["legal_entity_id"] = legal_entity_id
if out_legal_entity_id:
extra["out_legal_entity_id"] = out_legal_entity_id
params = self._build_signed_query(company_id=company_id, extra_params=extra)
resp = super().request(
"GET",
"/river/LegalEntity/get",
params=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
payload = resp.json() if resp.content else {}
self._raise_if_errno("LegalEntity.get", payload)
data = payload.get("data") or {}
if not isinstance(data, dict):
raise RuntimeError("LegalEntity.get invalid response (data not a dict)")
return data # {total, records}
def get_member_detail(
self,
*,
company_id: str,
employee_number: str | None = None,
member_id: str | None = None,
phone: str | None = None,
) -> dict[str, Any]:
"""
员工明细
GET /river/Member/detail
"""
extra: dict[str, Any] = {}
if member_id:
extra["member_id"] = member_id
elif employee_number:
extra["employee_number"] = employee_number
elif phone:
extra["phone"] = phone
else:
raise ValueError("member_id/employee_number/phone cannot all be empty")
params = self._build_signed_query(company_id=company_id, extra_params=extra)
resp = super().request(
"GET",
"/river/Member/detail",
params=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
payload = resp.json() if resp.content else {}
self._raise_if_errno("Member.detail", payload)
data = payload.get("data") or {}
if not isinstance(data, dict):
raise RuntimeError("Member.detail invalid response (data not a dict)")
return data
def edit_member_legal_entity(
self,
*,
company_id: str,
member_id: str | None,
employee_number: str | None,
legal_entity_id: str,
) -> None:
"""
员工修改更新员工所在公司主体legal_entity_id
POST /river/Member/edit
"""
if not member_id and not employee_number:
raise ValueError("member_id or employee_number is required")
if not legal_entity_id:
raise ValueError("legal_entity_id is required")
token = self._get_access_token()
ts = int(time.time())
data_str = self.dumps_data_for_sign({"legal_entity_id": legal_entity_id})
body: dict[str, Any] = {
"client_id": self.client_id,
"access_token": token,
"company_id": company_id,
"timestamp": ts,
"data": data_str,
}
if member_id:
body["member_id"] = member_id
if employee_number:
body["employee_number"] = employee_number
body["sign"] = self.gen_sign({k: v for k, v in body.items() if k != "sign"})
resp = super().request(
"POST",
"/river/Member/edit",
json=body,
headers={"Content-Type": "application/json"},
)
payload = resp.json() if resp.content else {}
self._raise_if_errno("Member.edit", payload)
return None
def request_authed(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any = None,
data: Any = None,
headers: dict[str, str] | None = None,
signed_params: dict[str, Any] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""
统一带 token +可选签名的请求
- Authorization: Bearer <access_token>
- signed_params 提供自动补 timestamp sign并注入到 json/params/data优先注入到 dict 类型的 json其次 params再次 data否则默认注入 json dict
- 遇到 401清空 token重新 authorize 后重试一次
"""
token = self._get_access_token()
token_type = self._token_type or "Bearer"
extra_headers = dict(headers or {})
extra_headers["Authorization"] = f"{token_type} {token}"
sp: dict[str, Any] | None = None
if signed_params is not None:
sp = dict(signed_params)
if "timestamp" not in sp:
sp["timestamp"] = int(time.time())
# 如该接口签名参数包含 access_token则需参与签名
if "access_token" in sp and not sp.get("access_token"):
sp["access_token"] = token
sp["sign"] = self.gen_sign({k: v for k, v in sp.items() if k != "sign"})
def _inject(target_json: Any, target_params: dict[str, Any] | None, target_data: Any) -> tuple[Any, dict[str, Any] | None, Any]:
if sp is None:
return target_json, target_params, target_data
if isinstance(target_json, dict):
merged = dict(target_json)
merged.update(sp)
return merged, target_params, target_data
if isinstance(target_params, dict):
merged_p = dict(target_params)
merged_p.update(sp)
return target_json, merged_p, target_data
if isinstance(target_data, dict):
merged_d = dict(target_data)
merged_d.update(sp)
return target_json, target_params, merged_d
# 默认注入到 json dict
return dict(sp), target_params, target_data
json2, params2, data2 = _inject(json, params, data)
try:
return super().request(method, path, params=params2, json=json2, data=data2, headers=extra_headers, **kwargs)
except httpx.HTTPStatusError as e:
resp = e.response
if resp.status_code != 401:
raise
# 401token 无效或过期,刷新后仅重试一次
logger.info("Didi access_token invalid (401), refreshing and retrying once")
self._access_token = None
self._token_expires_at = None
self._token_type = None
token2 = self._get_access_token()
token_type2 = self._token_type or "Bearer"
extra_headers2 = dict(headers or {})
extra_headers2["Authorization"] = f"{token_type2} {token2}"
# 若签名参数中包含 access_token需要更新并重新计算 sign
if signed_params is not None:
sp2 = dict(signed_params)
if "timestamp" not in sp2:
sp2["timestamp"] = int(time.time())
if "access_token" in sp2:
sp2["access_token"] = token2
sp2["sign"] = self.gen_sign({k: v for k, v in sp2.items() if k != "sign"})
json2_retry, params2_retry, data2_retry = _inject(json, params, data)
# _inject 使用闭包 sp这里临时覆盖行为以避免额外结构改动
if isinstance(json, dict):
json2_retry = dict(json)
json2_retry.update(sp2)
elif isinstance(params, dict):
params2_retry = dict(params)
params2_retry.update(sp2)
elif isinstance(data, dict):
data2_retry = dict(data)
data2_retry.update(sp2)
else:
json2_retry = dict(sp2)
return super().request(
method,
path,
params=params2_retry,
json=json2_retry,
data=data2_retry,
headers=extra_headers2,
**kwargs,
)
return super().request(method, path, params=params2, json=json2, data=data2, headers=extra_headers2, **kwargs)
def post_signed_json(self, path: str, *, body: dict[str, Any]) -> httpx.Response:
"""
便捷方法JSON POST + 自动补 timestamp/sign + 自动带 Authorization
注意 body 内包含复杂字段例如 data 为对象建议调用方先 json.dumps(...) 成字符串再参与签名
"""
if not isinstance(body, dict):
raise ValueError("body must be a dict")
return self.request_authed(
"POST",
path,
json=body,
signed_params=body,
headers={"Content-Type": "application/json"},
)
@staticmethod
def dumps_data_for_sign(data_obj: Any) -> str:
"""
将复杂 data 对象序列化为参与签名的字符串紧凑 JSON以贴近文档示例
"""
return jsonlib.dumps(data_obj, ensure_ascii=False, separators=(",", ":"))

118
app/integrations/seeyon.py Normal file
View File

@ -0,0 +1,118 @@
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.integrations.base import BaseClient
logger = logging.getLogger("connecthub.integrations.seeyon")
class SeeyonClient(BaseClient):
"""
致远 OA REST Client
- POST /seeyon/rest/token 获取 tokenid
- 业务请求 header 自动携带 token
- 遇到 401/Invalid token 自动刷新 token 并重试一次
"""
def __init__(self, *, base_url: str, rest_user: str, rest_password: str, loginName: str | None = None) -> None:
super().__init__(base_url=base_url)
self.rest_user = rest_user
self.rest_password = rest_password
self.loginName = loginName
self._token: str | None = None
def authenticate(self) -> str:
body: dict[str, Any] = {
"userName": self.rest_user,
"password": self.rest_password,
}
if self.loginName:
body["loginName"] = self.loginName
# 文档POST /seeyon/rest/token
resp = super().request(
"POST",
"/seeyon/rest/token",
json=body,
headers={"Accept": "application/json", "Content-Type": "application/json"},
)
data = resp.json()
token = str(data.get("id", "") or "")
if not token or token == "-1":
raise RuntimeError("Seeyon auth failed (token id missing or -1)")
self._token = token
logger.info("Seeyon token acquired")
return token
def _get_token(self) -> str:
return self._token or self.authenticate()
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[override]
token = self._get_token()
headers = dict(kwargs.pop("headers", {}) or {})
headers["token"] = token
try:
return super().request(method, path, headers=headers, **kwargs)
except httpx.HTTPStatusError as e:
# token 失效401 或返回包含 Invalid token
resp = e.response
text = ""
try:
text = resp.text or ""
except Exception:
text = ""
if resp.status_code == 401 or ("Invalid token" in text):
logger.info("Seeyon token invalid, refreshing and retrying once")
self._token = None
token2 = self._get_token()
headers["token"] = token2
# 仅重试一次;仍失败则抛出
return super().request(method, path, headers=headers, **kwargs)
raise
def export_cap4_form_soap(
self,
*,
templateCode: str,
senderLoginName: str | None = None,
rightId: str | None = None,
doTrigger: str | bool | None = None,
param: str | None = None,
extra: dict[str, Any] | None = None,
) -> httpx.Response:
"""
无流程表单导出CAP4
POST /seeyon/rest/cap4/form/soap/export
返回 httpx.Response调用方可自行读取 resp.text / resp.headers 等信息
"""
body: dict[str, Any] = {"templateCode": templateCode}
if senderLoginName:
body["senderLoginName"] = senderLoginName
if rightId:
body["rightId"] = rightId
if doTrigger is not None:
body["doTrigger"] = doTrigger
if param is not None:
body["param"] = param
if extra:
# 兜底扩展字段:仅当 key 不冲突时注入,避免覆盖已显式指定的参数
for k, v in extra.items():
if k not in body:
body[k] = v
return self.request(
"POST",
"/seeyon/rest/cap4/form/soap/export",
json=body,
headers={"Content-Type": "application/json"},
)

View File

@ -54,6 +54,29 @@ def encrypt_json(obj: dict[str, Any]) -> str:
def decrypt_json(token: str) -> dict[str, Any]: def decrypt_json(token: str) -> dict[str, Any]:
if not token: if not token:
return {} return {}
token = token.strip()
# 常见脏数据:被包了引号
if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
token = token[1:-1].strip()
# 常见脏数据:中间混入换行/空白(复制粘贴/渲染导致)
token = "".join(token.split())
# 兼容:历史/手工输入导致误存明文 JSON
if token.startswith("{"):
try:
obj = json.loads(token)
if isinstance(obj, dict):
return obj
except Exception:
pass
# 兼容:末尾 padding '=' 被裁剪导致 base64 解码失败len % 4 != 0
data_len = len(token.rstrip("="))
# base64 非 padding 字符长度为 4n+1 时不可恢复:大概率是 token 被截断/丢字符
if data_len % 4 == 1:
raise ValueError("Invalid secret_cfg token (looks truncated). Please re-save secret_cfg to re-encrypt.")
if token and (len(token) % 4) != 0:
token = token + ("=" * (-len(token) % 4))
try: try:
raw = _fernet().decrypt(token.encode("utf-8")) raw = _fernet().decrypt(token.encode("utf-8"))
except InvalidToken as e: except InvalidToken as e:

View File

@ -18,6 +18,42 @@ from app.tasks.celery_app import celery_app
logger = logging.getLogger("connecthub.tasks.execute") logger = logging.getLogger("connecthub.tasks.execute")
_MAX_MESSAGE_WARNING_LINES = 200
_MAX_MESSAGE_CHARS = 50_000
def _extract_warning_lines(run_log_text: str) -> list[str]:
"""
run_log 文本里提取 WARNING 保留原始行文本
capture_logs 的格式为'%(asctime)s %(levelname)s %(name)s %(message)s'
"""
run_log_text = run_log_text or ""
lines = run_log_text.splitlines()
return [ln for ln in lines if " WARNING " in f" {ln} "]
def _compose_message(base_message: str, warning_lines: list[str]) -> str:
"""
base_message + warnings(具体内容) + summary并做截断保护
"""
base_message = base_message or ""
warning_lines = warning_lines or []
parts: list[str] = [base_message]
if warning_lines:
parts.append(f"WARNINGS ({len(warning_lines)}):")
if len(warning_lines) <= _MAX_MESSAGE_WARNING_LINES:
parts.extend(warning_lines)
else:
parts.extend(warning_lines[:_MAX_MESSAGE_WARNING_LINES])
parts.append(f"[TRUNCATED] warnings exceeded {_MAX_MESSAGE_WARNING_LINES} lines")
parts.append(f"SUMMARY: warnings={len(warning_lines)}")
msg = "\n".join([p for p in parts if p is not None])
if len(msg) > _MAX_MESSAGE_CHARS:
msg = msg[: _MAX_MESSAGE_CHARS - 64] + "\n[TRUNCATED] message exceeded 50000 chars"
return msg
@celery_app.task(bind=True, name="connecthub.execute_job") @celery_app.task(bind=True, name="connecthub.execute_job")
def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any] | None = None) -> dict[str, Any]: def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any] | None = None) -> dict[str, Any]:
@ -42,6 +78,10 @@ def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any]
traceback = "" traceback = ""
result: dict[str, Any] = {} result: dict[str, Any] = {}
run_log_text = "" run_log_text = ""
log_id: int | None = None
celery_task_id = getattr(self.request, "id", "") or ""
attempt = int(getattr(self.request, "retries", 0) or 0)
snapshot: dict[str, Any] = {}
try: try:
with capture_logs(max_bytes=200_000) as get_run_log: with capture_logs(max_bytes=200_000) as get_run_log:
@ -61,6 +101,37 @@ def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any]
public_cfg = job.public_cfg or {} public_cfg = job.public_cfg or {}
secret_token = job.secret_cfg or "" secret_token = job.secret_cfg or ""
snapshot = snapshot_params or {
"job_id": job_id,
"handler_path": handler_path,
"public_cfg": public_cfg,
"secret_cfg": secret_token,
"meta": {
"trigger": "celery",
"celery_task_id": celery_task_id,
"started_at": started_at.isoformat(),
},
}
# 任务开始即落库一条 RUNNING 记录(若失败则降级为旧行为:结束时再 create
try:
running = crud.create_job_log(
session,
job_id=str(job_id or ""),
status=JobStatus.RUNNING,
snapshot_params=snapshot,
message="运行中",
traceback="",
run_log="",
celery_task_id=celery_task_id,
attempt=attempt,
started_at=started_at,
finished_at=None,
)
log_id = int(running.id)
except Exception:
log_id = None
secrets = decrypt_json(secret_token) secrets = decrypt_json(secret_token)
job_instance = instantiate(handler_path) job_instance = instantiate(handler_path)
out = job_instance.run(params=public_cfg, secrets=secrets) out = job_instance.run(params=public_cfg, secrets=secrets)
@ -81,6 +152,23 @@ def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any]
run_log_text = "" run_log_text = ""
finally: finally:
finished_at = datetime.utcnow() finished_at = datetime.utcnow()
warning_lines = _extract_warning_lines(run_log_text)
message = _compose_message(message, warning_lines)
# 结束时:优先更新 RUNNING 那条;若没有则创建最终记录(兼容降级)
if log_id is not None:
crud.update_job_log(
session,
log_id,
status=status,
message=message,
traceback=traceback,
run_log=run_log_text,
celery_task_id=celery_task_id,
attempt=attempt,
finished_at=finished_at,
)
else:
if not snapshot:
snapshot = snapshot_params or { snapshot = snapshot_params or {
"job_id": job_id, "job_id": job_id,
"handler_path": handler_path if "handler_path" in locals() else "", "handler_path": handler_path if "handler_path" in locals() else "",
@ -88,7 +176,7 @@ def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any]
"secret_cfg": secret_token if "secret_token" in locals() else "", "secret_cfg": secret_token if "secret_token" in locals() else "",
"meta": { "meta": {
"trigger": "celery", "trigger": "celery",
"celery_task_id": getattr(self.request, "id", "") or "", "celery_task_id": celery_task_id,
"started_at": started_at.isoformat(), "started_at": started_at.isoformat(),
}, },
} }
@ -100,8 +188,8 @@ def execute_job(self, job_id: str | None = None, snapshot_params: dict[str, Any]
message=message, message=message,
traceback=traceback, traceback=traceback,
run_log=run_log_text, run_log=run_log_text,
celery_task_id=getattr(self.request, "id", "") or "", celery_task_id=celery_task_id,
attempt=int(getattr(self.request, "retries", 0) or 0), attempt=attempt,
started_at=started_at, started_at=started_at,
finished_at=finished_at, finished_at=finished_at,
) )

Binary file not shown.

File diff suppressed because it is too large Load Diff

1
data/pgdata/PG_VERSION Normal file
View File

@ -0,0 +1 @@
16

BIN
data/pgdata/base/1/112 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/113 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1247 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1247_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1247_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1249 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1249_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1249_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1255 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1255_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1255_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1259 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1259_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/1259_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/13457 Normal file

Binary file not shown.

Binary file not shown.

BIN
data/pgdata/base/1/13457_vm Normal file

Binary file not shown.

0
data/pgdata/base/1/13460 Normal file
View File

BIN
data/pgdata/base/1/13461 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/13462 Normal file

Binary file not shown.

Binary file not shown.

BIN
data/pgdata/base/1/13462_vm Normal file

Binary file not shown.

0
data/pgdata/base/1/13465 Normal file
View File

BIN
data/pgdata/base/1/13466 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/13467 Normal file

Binary file not shown.

Binary file not shown.

BIN
data/pgdata/base/1/13467_vm Normal file

Binary file not shown.

0
data/pgdata/base/1/13470 Normal file
View File

BIN
data/pgdata/base/1/13471 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/13472 Normal file

Binary file not shown.

Binary file not shown.

BIN
data/pgdata/base/1/13472_vm Normal file

Binary file not shown.

0
data/pgdata/base/1/13475 Normal file
View File

BIN
data/pgdata/base/1/13476 Normal file

Binary file not shown.

0
data/pgdata/base/1/1417 Normal file
View File

0
data/pgdata/base/1/1418 Normal file
View File

BIN
data/pgdata/base/1/174 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/175 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2187 Normal file

Binary file not shown.

0
data/pgdata/base/1/2224 Normal file
View File

BIN
data/pgdata/base/1/2228 Normal file

Binary file not shown.

0
data/pgdata/base/1/2328 Normal file
View File

0
data/pgdata/base/1/2336 Normal file
View File

BIN
data/pgdata/base/1/2337 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2579 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2600 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2600_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2600_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2601 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2601_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2601_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2602 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2602_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2602_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2603 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2603_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2603_vm Normal file

Binary file not shown.

0
data/pgdata/base/1/2604 Normal file
View File

BIN
data/pgdata/base/1/2605 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2605_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2605_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2606 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2606_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2606_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2607 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2607_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2607_vm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2608 Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2608_fsm Normal file

Binary file not shown.

BIN
data/pgdata/base/1/2608_vm Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More