refactor(db): 清理遗留死代码并对齐文档与测试
删除 dbv2 从未挂载的 log_module.py(Flask /log/* 遗留)与纯 TCP 服务 用不到的 security_middleware.py 副本;db/README.md 按 pymsg 实现重写, requirements 去掉 flask/waitress;黑盒测试对 pymsg 服务改用 TCP 探测, 修复 check_service HTTP 探测导致的 db 测试整体假 skip。
This commit is contained in:
1 parent
dabd892601
commit
6692c9c4ee
9 files changed
+130
-818
No files matched your search
@@ -112,7 +112,6 @@
|
||||
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
||||
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
|
||||
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
|
||||
- 注:`db/log_module.py`(Flask `/log/*` Blueprint)为旧版 HTTP 接口的遗留文件,当前 dbv2 未挂载。
|
||||
- **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||
|
||||
### ai_review(AI 内容审核服务)
|
||||
|
||||
+94
-189
@@ -1,62 +1,55 @@
|
||||
# 跨数据库通用查询服务(基于 SQLAlchemy Core)
|
||||
# 通用数据库服务 db(pymsg/TCP + SQLAlchemy Core)
|
||||
|
||||
一个安全的 HTTP 服务,通过结构化 JSON 描述查询意图,后端自动转换为对应数据库的 SQL,无需手写 SQL,即可无缝切换 SQLite、PostgreSQL、MySQL 等数据库。
|
||||
一个安全的数据库访问服务。各业务服务通过自研 pymsg 协议(TCP 长度前缀帧 + JSON)发送结构化查询意图,db 翻译为 SQL 执行,全程参数绑定,业务侧无需直连数据库、无需手写 SQL。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配多种数据库
|
||||
- **跨数据库**:基于 SQLAlchemy Core 表达式语言,一套接口适配 SQLite / PostgreSQL / MySQL
|
||||
- **零 SQL 注入**:所有条件、值均通过 SQLAlchemy 参数绑定处理
|
||||
- **统一 JSON 接口**:`action` + `table` + `conditions` 模式
|
||||
- **支持增删改查**:select, insert, update, delete 全覆盖
|
||||
- **单线程本地监听**:waitress + 127.0.0.1:8005,简单可靠
|
||||
- **统一 JSON 接口**:`_route` + `action` + `table` + `where` 模式
|
||||
- **支持增删改查**:select / insert / update / delete 全覆盖
|
||||
- **内置日志表**:启动自动建 Logs 表,供全系统集中写日志;带后台自动清理
|
||||
- **仅监听本机**:默认 `127.0.0.1:8005`,不对外暴露
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install flask sqlalchemy waitress
|
||||
# 根据需要安装数据库驱动,例如:
|
||||
# pip install psycopg2-binary # PostgreSQL
|
||||
# pip install pymysql # MySQL
|
||||
./_set.sh # 建 venv 并安装 requirements.txt(SQLAlchemy + 本地 ../pymsg)
|
||||
```
|
||||
|
||||
### 启动服务(默认 SQLite)
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
服务运行在 `http://127.0.0.1:8005`。
|
||||
### 启动服务
|
||||
|
||||
使用其他数据库:
|
||||
```bash
|
||||
export DATABASE_URL="postgresql://user:pass@localhost/dbname"
|
||||
python app.py
|
||||
./_run.sh # venv 中执行 python3 dbv2.py
|
||||
```
|
||||
|
||||
**注意**:新接口依赖表已存在(通过反射获取结构)。首次使用时请先手动建表:
|
||||
```bash
|
||||
sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"
|
||||
```
|
||||
服务监听 `tcp://127.0.0.1:8005`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
|
||||
|
||||
## API 文档
|
||||
**注意**:`execute` 路由依赖表已存在(通过反射获取结构)。建表见 `create_sqlite3.sql`(Users / Notes;Logs 表由服务启动时自建)。
|
||||
|
||||
### 请求格式
|
||||
## 协议与 API
|
||||
|
||||
**端点**:`POST /execute`
|
||||
**Content-Type**:`application/json`
|
||||
### 传输协议(pymsg)
|
||||
|
||||
每条消息为一个完整 JSON,帧格式:`[4 字节大端长度][UTF-8 JSON]`。
|
||||
请求中的 `_route` 字段指定路由,目前仅有一个路由:**`execute`**。
|
||||
|
||||
### execute 路由 — 通用 CRUD
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "操作类型", // select | insert | update | delete
|
||||
"table": "表名",
|
||||
"columns": ["列1", "列2"], // select/insert 时可选
|
||||
"values": { // insert/update 时必填
|
||||
"列名": "值"
|
||||
},
|
||||
"where": { // 条件,支持组合操作符
|
||||
"_route": "execute",
|
||||
"action": "select", // select | insert | update | delete
|
||||
"table": "Users",
|
||||
"columns": ["id", "name"], // select 时可选(缺省全列)
|
||||
"values": {"name": "Bob"}, // insert / update 时必填
|
||||
"where": { // 可选,条件见下表
|
||||
"age": {"ge": 18, "lt": 60},
|
||||
"name": "Alice"
|
||||
},
|
||||
"order_by": [{"column": "age", "desc": true}],
|
||||
"order_by": [{"column": "id", "desc": true}],
|
||||
"limit": 10,
|
||||
"offset": 0
|
||||
}
|
||||
@@ -84,48 +77,46 @@ sqlite3 database.db "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ // 查询结果数组(insert/update/delete 时包含影响行数或 lastrowid)
|
||||
{"id": 1, "name": "Alice"}
|
||||
"data": [
|
||||
{"id": 1, "username": "Alice"}
|
||||
]
|
||||
}
|
||||
```
|
||||
- select → 行对象数组
|
||||
- insert → `[{"lastrowid": N}]`
|
||||
- update / delete → `[{"rowcount": N}]`
|
||||
|
||||
失败:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误描述"
|
||||
}
|
||||
{"success": false, "error": "错误描述"}
|
||||
```
|
||||
|
||||
## 使用示例(curl)
|
||||
## 使用示例(Python + pymsg)
|
||||
|
||||
**插入**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"insert","table":"users","values":{"name":"Bob","age":25}}'
|
||||
```
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
|
||||
**查询**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"select","table":"users","where":{"age":{"gt":20}},"order_by":[{"column":"id","desc":true}],"limit":5}'
|
||||
```
|
||||
client = MsgClient("127.0.0.1", 8005, "myservice")
|
||||
|
||||
**更新**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"update","table":"users","values":{"age":26},"where":{"name":"Bob"}}'
|
||||
```
|
||||
# 查询
|
||||
resp = client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"where": {"id": {"gt": 0}},
|
||||
"order_by": [{"column": "id", "desc": True}],
|
||||
"limit": 5,
|
||||
}))
|
||||
print(json.loads(resp))
|
||||
|
||||
**删除**:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8005/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"delete","table":"users","where":{"id":{"in":[1,2]}}}'
|
||||
# 插入
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Users",
|
||||
"values": {"username": "bob", "password_hash": "..."},
|
||||
}))
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
@@ -133,17 +124,17 @@ curl -X POST http://127.0.0.1:8005/execute \
|
||||
- 强制使用 SQLAlchemy 参数绑定,杜绝 SQL 注入。
|
||||
- 仅反射已有表,无法创建或删除表(保证结构安全)。
|
||||
- 所有异常自动回滚事务。
|
||||
- 默认监听 `127.0.0.1`,仅本机可访问。
|
||||
- 仅监听 `127.0.0.1`,仅本机服务可访问。
|
||||
|
||||
## 内置日志模块
|
||||
## 内置日志能力
|
||||
|
||||
db 服务内置了统一日志收集能力,各业务服务通过 HTTP 推送日志到 `/log/*` 系列接口,db 自身的操作日志通过 `log_internal()` 直接写入(同进程,无 HTTP 往返,无循环依赖)。
|
||||
db 内置统一日志收集:全系统各服务(waf / gateway / static / dnfs / db / ai_review / captcha / core / smtp)的日志集中写入 db 的 `Logs` 表。
|
||||
|
||||
### 为什么集成在 db 而不是独立服务
|
||||
|
||||
独立 logs 服务会导致**循环调用**:db 记日志→调 logs,logs 存库→调 db,无限递归。集成在 db 内部后,db 自己记日志直接写表,彻底消除循环依赖。
|
||||
|
||||
### 日志表
|
||||
### 日志表(启动时自建)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
@@ -155,152 +146,66 @@ CREATE TABLE IF NOT EXISTS Logs (
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
);
|
||||
-- 索引: (service,ts) / (level,ts) / (ts) / (trace_id)
|
||||
```
|
||||
|
||||
- `service`:来源服务名(`waf`/`gateway`/`static`/`dnfs`/`db`/`ai_review`/`captcha`/`core`)
|
||||
- `level`:`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`
|
||||
- `level`:`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`
|
||||
- `trace_id`:可选,请求追踪 ID
|
||||
- `extra`:可选,JSON 字符串,存放附加结构化字段
|
||||
|
||||
### 接口
|
||||
### 写入方式
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/log/ingest` | POST | 推送日志(支持批量) |
|
||||
| `/log/query` | POST | 查询日志(按服务/级别/时间/关键字过滤) |
|
||||
| `/log/stats` | POST | 统计聚合(按 service/level 分组计数) |
|
||||
| `/log/purge` | POST | 清理过期日志 |
|
||||
| `/log/health` | GET | 健康检查 |
|
||||
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表:
|
||||
|
||||
所有接口的响应格式与 `/execute` 统一:`{success, data, error}`。
|
||||
```python
|
||||
from log_client import logs
|
||||
|
||||
#### POST /log/ingest
|
||||
|
||||
```json
|
||||
请求:
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"service": "dnfs",
|
||||
"level": "INFO",
|
||||
"message": "节点新增成功 id=42",
|
||||
"ts": "2026-07-25T08:30:00.123Z",
|
||||
"trace_id": "req-abc-123",
|
||||
"extra": {"path": "/add_note", "status": 200}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
响应:
|
||||
{"success": true, "data": {"inserted": 1}}
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
```
|
||||
|
||||
- `ts` 可选,缺省自动填当前 UTC 时间
|
||||
- 单次最多 100 条
|
||||
- `message` 上限 8KB,`extra` 上限 4KB
|
||||
客户端特性:
|
||||
- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
#### POST /log/query
|
||||
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
|
||||
|
||||
```json
|
||||
请求:
|
||||
{
|
||||
"service": "dnfs", // 可选,支持数组匹配多个
|
||||
"level": "WARNING", // 可选,支持数组匹配多个
|
||||
"since": "2026-07-25T00:00:00Z", // 可选
|
||||
"until": "2026-07-25T23:59:59Z", // 可选
|
||||
"keyword": "节点", // 可选,message 模糊搜索
|
||||
"trace_id": "req-abc-123", // 可选
|
||||
"order_by": "ts", // 默认 ts,可选 id/service/level
|
||||
"desc": true, // 默认 true(最新在前)
|
||||
"limit": 50, // 默认 100,上限 1000
|
||||
"offset": 0 // 分页偏移
|
||||
}
|
||||
### 查询日志
|
||||
|
||||
响应:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 128,
|
||||
"logs": [{"id":1024,"ts":"...","service":"dnfs","level":"INFO","message":"...","trace_id":"...","extra":{...}}]
|
||||
}
|
||||
}
|
||||
```
|
||||
日志查询直接走 `execute` 路由 select Logs 表,或用工具脚本 `_view_db.sqlite3.py`:
|
||||
|
||||
#### POST /log/stats
|
||||
|
||||
```json
|
||||
请求:
|
||||
{"since":"...","until":"...","group_by":"service"}
|
||||
|
||||
响应:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"groups": [{"service":"dnfs","level":"INFO","count":320}],
|
||||
"total": 325
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`group_by` 支持 `service` / `level` / `service,level`。
|
||||
|
||||
#### POST /log/purge
|
||||
|
||||
```json
|
||||
请求: {"before": "2026-07-18T00:00:00Z"}
|
||||
响应: {"success": true, "data": {"deleted": 1523}}
|
||||
```
|
||||
|
||||
#### GET /log/health
|
||||
|
||||
```json
|
||||
{"success": true, "data": {"status": "ok", "count": 10240}}
|
||||
```bash
|
||||
python3 _view_db.sqlite3.py # 列出所有表及行数
|
||||
python3 _view_db.sqlite3.py Logs -n 20 # 最近 20 条日志
|
||||
python3 _view_db.sqlite3.py Logs -w "level='ERROR'"
|
||||
python3 _view_db.sqlite3.py -s "SELECT service, COUNT(*) FROM Logs GROUP BY service"
|
||||
```
|
||||
|
||||
### 自动清理
|
||||
|
||||
- 服务启动时开启后台守护线程,每 1 小时清理一次
|
||||
- 默认保留 30 天日志,超期自动删除
|
||||
- 日志总条数超过 100 万时,按时间倒序保留最新 N 条
|
||||
|
||||
### 各服务接入方式
|
||||
|
||||
把 `db/log_client.py` 复制到各服务目录(或加入 `PYTHONPATH`),即可使用:
|
||||
|
||||
```python
|
||||
from log_client import logs
|
||||
|
||||
# 替换原来的 print
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
|
||||
# 带追踪 ID
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
```
|
||||
|
||||
特性:
|
||||
- 使用 `urllib.request`(标准库,不引入 requests)
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
### 防递归设计
|
||||
|
||||
| 场景 | 调用路径 | 是否安全 |
|
||||
|------|---------|---------|
|
||||
| 外部服务记日志 | `服务 → POST /log/ingest → 写 Logs 表` | 安全 |
|
||||
| db 自己记日志 | `/execute → log_internal() → 直接写 Logs 表` | 安全(无 HTTP) |
|
||||
| /log/ingest 成功 | `写入成功 → 直接返回` | 安全(不再记日志) |
|
||||
| /log/ingest 失败 | `写入失败 → log_internal() → 直接写表` | 安全(log_internal 不回调 ingest) |
|
||||
- 日志总条数超过 100 万时,按时间倒序保留最新 100 万条
|
||||
|
||||
## 切换数据库
|
||||
|
||||
只需修改环境变量 `DATABASE_URL`,并安装对应驱动:
|
||||
|
||||
- SQLite:`sqlite:///database.db`
|
||||
- PostgreSQL:`postgresql://user:pass@localhost/dbname` (需 `pip install psycopg2`)
|
||||
- MySQL:`mysql+pymysql://user:pass@localhost/dbname` (需 `pip install pymysql`)
|
||||
- SQLite(默认):`sqlite:///database.db`
|
||||
- PostgreSQL:`postgresql://user:pass@localhost/dbname`(需 `pip install psycopg2`)
|
||||
- MySQL:`mysql+pymysql://user:pass@localhost/dbname`(需 `pip install pymysql`)
|
||||
|
||||
SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口调用。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) |
|
||||
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 |
|
||||
| `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 |
|
||||
| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 |
|
||||
| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 |
|
||||
@@ -1,490 +0,0 @@
|
||||
"""
|
||||
db 服务的内置日志子模块。
|
||||
|
||||
提供两部分能力:
|
||||
1. log_internal() — db 进程内部直接写 Logs 表(无 HTTP 往返,无循环依赖)
|
||||
2. /log/* HTTP 路由 — 供外部服务推送 / 查询 / 统计 / 清理日志
|
||||
|
||||
设计要点(防递归):
|
||||
- log_internal() 直接操作数据库,不经过任何 HTTP 路由
|
||||
- /log/ingest 成功后不再调 log_internal
|
||||
- /log/ingest 失败时才调 log_internal,而 log_internal 不会回调 /log/ingest
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import (
|
||||
Table, insert, select, delete, func, and_, or_, MetaData
|
||||
)
|
||||
|
||||
bp = Blueprint('log', __name__, url_prefix='/log')
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_SERVICES = {
|
||||
'waf', 'gateway', 'static', 'dnfs', 'db',
|
||||
'ai_review', 'captcha', 'core',
|
||||
}
|
||||
VALID_LEVELS = {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'}
|
||||
|
||||
MAX_BATCH = 100 # 单次 /log/ingest 最多 100 条
|
||||
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
|
||||
MAX_EXTRA_LEN = 4096 # extra 上限 4KB
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_RECORDS = 1_000_000
|
||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
||||
|
||||
# ── 表管理 ─────────────────────────────────────────────────────────────
|
||||
|
||||
_LOGS_TABLE_CACHE = {}
|
||||
|
||||
def _ensure_logs_table(metadata: MetaData, engine):
|
||||
"""确保 Logs 表已建好并已反射到 metadata(首次调用时建表 + 反射)。"""
|
||||
if 'Logs' in _LOGS_TABLE_CACHE:
|
||||
return _LOGS_TABLE_CACHE['Logs']
|
||||
|
||||
# 先用原生 SQL 建表(SQLAlchemy 反射要求表已存在)
|
||||
with engine.connect() as conn:
|
||||
conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
)
|
||||
""")
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 反射到 metadata
|
||||
table = Table('Logs', metadata, autoload_with=engine)
|
||||
_LOGS_TABLE_CACHE['Logs'] = table
|
||||
return table
|
||||
|
||||
|
||||
# ── 内部日志记录(db 自己用,无 HTTP)─────────────────────────────────
|
||||
|
||||
def log_internal(engine, metadata, service, level, message,
|
||||
trace_id=None, extra=None):
|
||||
"""
|
||||
db 进程内部记录日志 — 直接操作数据库,不走 HTTP,无循环依赖。
|
||||
|
||||
供 db 的 /execute 路由内部调用。失败时静默吞掉,不影响主业务。
|
||||
"""
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
|
||||
if len(message) > MAX_MESSAGE_LEN:
|
||||
message = message[:MAX_MESSAGE_LEN]
|
||||
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
|
||||
extra_json = extra_json[:MAX_EXTRA_LEN]
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(insert(table).values(
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
service=service,
|
||||
level=level,
|
||||
message=message,
|
||||
trace_id=trace_id,
|
||||
extra=extra_json,
|
||||
))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 日志记录失败不应影响主业务
|
||||
pass
|
||||
|
||||
|
||||
# ── HTTP 路由 ──────────────────────────────────────────────────────────
|
||||
|
||||
def init_log_routes(app, engine, metadata,
|
||||
retention_days=DEFAULT_RETENTION_DAYS,
|
||||
max_records=DEFAULT_MAX_RECORDS,
|
||||
cleanup_interval=DEFAULT_CLEANUP_INTERVAL):
|
||||
"""把日志蓝图注册到 Flask app,并注入 engine/metadata。"""
|
||||
|
||||
app.config['LOG_ENGINE'] = engine
|
||||
app.config['LOG_METADATA'] = metadata
|
||||
app.config['LOG_RETENTION_DAYS'] = retention_days
|
||||
app.config['LOG_MAX_RECORDS'] = max_records
|
||||
app.config['LOG_CLEANUP_INTERVAL'] = cleanup_interval
|
||||
|
||||
# 建表 + 反射
|
||||
try:
|
||||
_ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'Logs 表初始化失败: {e}')
|
||||
|
||||
# 注册路由
|
||||
app.register_blueprint(bp)
|
||||
|
||||
# 启动后台清理线程
|
||||
_start_cleanup_thread(engine, metadata,
|
||||
retention_days, max_records, cleanup_interval)
|
||||
|
||||
|
||||
# ── 5.1 推送日志 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/ingest', methods=['POST'])
|
||||
def ingest():
|
||||
"""接收外部日志推送(单条或批量)。"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'logs' not in data:
|
||||
return jsonify({'success': False, 'error': '请求体缺少 logs 字段'}), 400
|
||||
|
||||
logs = data['logs']
|
||||
if not isinstance(logs, list) or not logs:
|
||||
return jsonify({'success': False, 'error': 'logs 必须是非空数组'}), 400
|
||||
if len(logs) > MAX_BATCH:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条'
|
||||
}), 400
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
# 校验 + 组装
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
rows = []
|
||||
for i, log in enumerate(logs):
|
||||
if not isinstance(log, dict):
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条日志不是有效对象'}), 400
|
||||
|
||||
service = log.get('service')
|
||||
level = log.get('level')
|
||||
message = log.get('message')
|
||||
|
||||
if not service or service not in VALID_SERVICES:
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: service 无效或缺失'}), 400
|
||||
if not level or level not in VALID_LEVELS:
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: level 无效或缺失'}), 400
|
||||
if not message or not isinstance(message, str):
|
||||
return jsonify({'success': False,
|
||||
'error': f'第 {i} 条: message 缺失或非字符串'}), 400
|
||||
|
||||
if len(message) > MAX_MESSAGE_LEN:
|
||||
message = message[:MAX_MESSAGE_LEN]
|
||||
|
||||
trace_id = log.get('trace_id')
|
||||
extra = log.get('extra')
|
||||
extra_json = None
|
||||
if extra is not None:
|
||||
extra_json = json.dumps(extra, ensure_ascii=False)
|
||||
if len(extra_json) > MAX_EXTRA_LEN:
|
||||
extra_json = extra_json[:MAX_EXTRA_LEN]
|
||||
|
||||
rows.append({
|
||||
'ts': log.get('ts') or now,
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra_json,
|
||||
})
|
||||
|
||||
# 写入(成功后不再调 log_internal,防递归)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(insert(table), rows)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
# 失败时可以安全地记日志(log_internal 不回调 ingest)
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/ingest 写入失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
return jsonify({'success': True, 'data': {'inserted': len(rows)}})
|
||||
|
||||
|
||||
# ── 5.2 查询日志 ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/query', methods=['POST'])
|
||||
def query():
|
||||
"""结构化查询日志。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
clauses = []
|
||||
|
||||
# service(支持单个字符串或数组)
|
||||
svc = data.get('service')
|
||||
if svc:
|
||||
col = table.columns['service']
|
||||
if isinstance(svc, list):
|
||||
clauses.append(col.in_(svc))
|
||||
else:
|
||||
clauses.append(col == svc)
|
||||
|
||||
# level(支持单个字符串或数组)
|
||||
lvl = data.get('level')
|
||||
if lvl:
|
||||
col = table.columns['level']
|
||||
if isinstance(lvl, list):
|
||||
clauses.append(col.in_(lvl))
|
||||
else:
|
||||
clauses.append(col == lvl)
|
||||
|
||||
# trace_id
|
||||
tid = data.get('trace_id')
|
||||
if tid:
|
||||
clauses.append(table.columns['trace_id'] == tid)
|
||||
|
||||
# 时间区间
|
||||
since = data.get('since')
|
||||
until = data.get('until')
|
||||
ts_col = table.columns['ts']
|
||||
if since:
|
||||
clauses.append(ts_col >= since)
|
||||
if until:
|
||||
clauses.append(ts_col <= until)
|
||||
|
||||
# 关键字模糊搜索
|
||||
keyword = data.get('keyword')
|
||||
if keyword:
|
||||
clauses.append(table.columns['message'].like(f'%{keyword}%'))
|
||||
|
||||
where_cond = and_(*clauses) if clauses else None
|
||||
|
||||
# 排序
|
||||
order_col_name = data.get('order_by', 'ts')
|
||||
if order_col_name not in ('id', 'ts', 'service', 'level'):
|
||||
order_col_name = 'ts'
|
||||
order_col = table.columns[order_col_name]
|
||||
desc = data.get('desc', True)
|
||||
order_expr = order_col.desc() if desc else order_col.asc()
|
||||
|
||||
# 分页
|
||||
limit = min(data.get('limit', 100), 1000)
|
||||
offset = data.get('offset', 0)
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(table)
|
||||
if where_cond is not None:
|
||||
count_stmt = count_stmt.where(where_cond)
|
||||
total = conn.execute(count_stmt).scalar() or 0
|
||||
|
||||
# 查询
|
||||
stmt = select(table)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
stmt = stmt.order_by(order_expr).limit(limit).offset(offset)
|
||||
|
||||
rows = conn.execute(stmt).fetchall()
|
||||
logs = []
|
||||
for row in rows:
|
||||
d = dict(row._mapping)
|
||||
if d.get('extra'):
|
||||
try:
|
||||
d['extra'] = json.loads(d['extra'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
logs.append(d)
|
||||
|
||||
return jsonify({'success': True, 'data': {'total': total, 'logs': logs}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/query 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.3 统计聚合 ───────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/stats', methods=['POST'])
|
||||
def stats():
|
||||
"""日志分类统计。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
group_by = data.get('group_by', 'service')
|
||||
group_fields = [g.strip() for g in group_by.split(',') if g.strip()]
|
||||
for g in group_fields:
|
||||
if g not in ('service', 'level'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'group_by 仅支持 service / level,收到: {g}'
|
||||
}), 400
|
||||
|
||||
# 时间过滤
|
||||
clauses = []
|
||||
since = data.get('since')
|
||||
until = data.get('until')
|
||||
ts_col = table.columns['ts']
|
||||
if since:
|
||||
clauses.append(ts_col >= since)
|
||||
if until:
|
||||
clauses.append(ts_col <= until)
|
||||
where_cond = and_(*clauses) if clauses else None
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
cols = [table.columns[g] for g in group_fields] + [func.count().label('count')]
|
||||
stmt = select(*cols)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
stmt = stmt.group_by(*[table.columns[g] for g in group_fields])
|
||||
|
||||
rows = conn.execute(stmt).fetchall()
|
||||
groups = [dict(row._mapping) for row in rows]
|
||||
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(table)
|
||||
if where_cond is not None:
|
||||
count_stmt = count_stmt.where(where_cond)
|
||||
total = conn.execute(count_stmt).scalar() or 0
|
||||
|
||||
return jsonify({'success': True, 'data': {'groups': groups, 'total': total}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/stats 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.4 清理过期 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/purge', methods=['POST'])
|
||||
def purge():
|
||||
"""清理过期日志。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
before = data.get('before')
|
||||
if not before:
|
||||
return jsonify({'success': False, 'error': '缺少 before 字段'}), 400
|
||||
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表初始化失败: {e}'}), 500
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
stmt = delete(table).where(table.columns['ts'] < before)
|
||||
result = conn.execute(stmt)
|
||||
conn.commit()
|
||||
deleted = result.rowcount
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'/log/purge 清理 {deleted} 条 (before={before})')
|
||||
return jsonify({'success': True, 'data': {'deleted': deleted}})
|
||||
|
||||
except Exception as e:
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'/log/purge 失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 5.5 健康检查 ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""日志模块健康检查。"""
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
with engine.connect() as conn:
|
||||
count = conn.execute(select(func.count()).select_from(table)).scalar() or 0
|
||||
return jsonify({'success': True, 'data': {'status': 'ok', 'count': count}})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── 后台自动清理线程 ──────────────────────────────────────────────────
|
||||
|
||||
def _start_cleanup_thread(engine, metadata,
|
||||
retention_days=DEFAULT_RETENTION_DAYS,
|
||||
max_records=DEFAULT_MAX_RECORDS,
|
||||
interval=DEFAULT_CLEANUP_INTERVAL):
|
||||
"""启动后台守护线程,定期清理过期日志。"""
|
||||
def _cleanup_loop():
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
_do_cleanup(engine, metadata, retention_days, max_records)
|
||||
except Exception:
|
||||
# 清理失败不影响服务运行
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_cleanup_loop, daemon=True, name='logs-cleanup')
|
||||
t.start()
|
||||
|
||||
|
||||
def _do_cleanup(engine, metadata, retention_days, max_records):
|
||||
"""执行一次清理:删过期 + 裁剪超量。"""
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
|
||||
# 1. 删除过期日志
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(delete(table).where(table.columns['ts'] < cutoff))
|
||||
conn.commit()
|
||||
expired_deleted = result.rowcount
|
||||
|
||||
# 2. 裁剪超量记录(保留最新的 max_records 条)
|
||||
count = conn.execute(select(func.count()).select_from(table)).scalar() or 0
|
||||
trimmed = 0
|
||||
if count > max_records:
|
||||
excess = count - max_records
|
||||
# 找到第 max_records 条的 ts(按时间正序的第 max_records 条)
|
||||
cutoff_row = conn.execute(
|
||||
select(table.columns['ts'])
|
||||
.order_by(table.columns['ts'].asc())
|
||||
.limit(1).offset(max_records)
|
||||
).fetchone()
|
||||
if cutoff_row:
|
||||
trim_result = conn.execute(
|
||||
delete(table).where(table.columns['ts'] < cutoff_row[0])
|
||||
)
|
||||
conn.commit()
|
||||
trimmed = trim_result.rowcount
|
||||
|
||||
if expired_deleted or trimmed:
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
|
||||
@@ -1,5 +1,3 @@
|
||||
nuitka
|
||||
flask
|
||||
SQLAlchemy
|
||||
waitress
|
||||
../pymsg
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
Security headers middleware for Flask applications.
|
||||
Usage:
|
||||
from security_middleware import apply_security_headers
|
||||
apply_security_headers(app)
|
||||
"""
|
||||
import secrets
|
||||
|
||||
def apply_security_headers(app, csp_policy=None):
|
||||
"""Apply security response headers to a Flask app."""
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# XSS protection (legacy browsers)
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable camera, mic, geolocation)
|
||||
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||
|
||||
# Content Security Policy
|
||||
if csp_policy:
|
||||
response.headers['Content-Security-Policy'] = csp_policy
|
||||
else:
|
||||
response.headers['Content-Security-Policy'] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
# Remove server header info
|
||||
response.headers.pop('Server', None)
|
||||
|
||||
return response
|
||||
@@ -31,6 +31,7 @@ SERVICES = {
|
||||
"name": "db",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8005,
|
||||
"protocol": "pymsg",
|
||||
"description": "通用数据库服务 (CRUD+集中日志)",
|
||||
"test_file": "test_db.py",
|
||||
},
|
||||
|
||||
@@ -284,6 +284,21 @@ class PymsgClient:
|
||||
# 服务可用性检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_tcp_service(host: str, port: int, timeout: float = 5.0) -> tuple:
|
||||
"""检查纯 TCP 服务(如 pymsg 协议服务 db/captcha/smtp)是否可达。
|
||||
返回 (reachable: bool, detail: str)。
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True, "TCP 连接成功"
|
||||
except ConnectionRefusedError:
|
||||
return False, "连接被拒绝 (服务未启动或端口未监听)"
|
||||
except socket.timeout:
|
||||
return False, f"TCP 连接超时 ({timeout}s)"
|
||||
except OSError as e:
|
||||
return False, f"网络错误: {e}"
|
||||
|
||||
|
||||
def check_service(url: str, timeout: float = 8.0) -> tuple:
|
||||
"""检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
|
||||
返回 (reachable: bool, detail: str)。
|
||||
|
||||
+14
-92
@@ -1,45 +1,37 @@
|
||||
"""
|
||||
db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。
|
||||
db 黑盒用户模拟测试 — pymsg 协议 CRUD。
|
||||
|
||||
用户旅程:
|
||||
1. 通过 pymsg 协议执行 CRUD (select/insert)
|
||||
2. 通过 HTTP /log/ingest 写入日志
|
||||
3. 通过 HTTP /log/query 查询日志
|
||||
4. 通过 HTTP /log/stats 查看统计
|
||||
5. 通过 HTTP /log/health 健康检查
|
||||
1. 通过 pymsg 协议查询 Users 表
|
||||
2. 通过 pymsg 协议查询 Logs 表
|
||||
3. 通过 pymsg 协议带 where 条件查询
|
||||
4. 无效路由被拒绝
|
||||
|
||||
注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint,
|
||||
因此 HTTP /log/* 路由可能不可用。测试会优雅降级。
|
||||
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
|
||||
log_client.py 走 execute 路由 INSERT 写入 Logs 表。
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
from helpers import (
|
||||
http_get, http_post, try_parse_json,
|
||||
assert_eq, assert_status, assert_true, assert_json_key,
|
||||
skip, Counter, check_service, PymsgClient,
|
||||
assert_true, skip, Counter, PymsgClient, check_tcp_service,
|
||||
)
|
||||
from config import get_url
|
||||
|
||||
DB_URL = get_url("db")
|
||||
DB_HOST = "127.0.0.1"
|
||||
DB_PORT = 8005
|
||||
|
||||
|
||||
def run(url: str = None):
|
||||
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。"""
|
||||
du = url or DB_URL
|
||||
"""db 用户模拟测试入口。返回 (passed, failed, skipped)。
|
||||
|
||||
url 参数仅为保持与其他测试模块的签名一致,db 固定走 127.0.0.1:8005。
|
||||
"""
|
||||
c = Counter()
|
||||
|
||||
print(f"\n--- db 数据库与日志流程 ---\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 检查服务可用性
|
||||
# 0. 检查服务可用性(TCP 直连,db 无 HTTP 接口)
|
||||
# ------------------------------------------------------------------
|
||||
if not check_service(du)[0]:
|
||||
if not check_tcp_service(DB_HOST, DB_PORT)[0]:
|
||||
skip("全部测试", "db 服务不可达", c)
|
||||
return c.passed, c.failed, c.skipped
|
||||
|
||||
@@ -109,76 +101,6 @@ def run(url: str = None):
|
||||
"pymsg-无效路由-拒绝", c,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 第二部分: HTTP /log/* 接口 (Flask log_module)
|
||||
# ------------------------------------------------------------------
|
||||
print("\n [HTTP /log/* 接口测试]")
|
||||
|
||||
# 检查 /log/health 是否可用(Flask log_module 是否挂载)
|
||||
status, body, _ = http_get(f"{du}/log/health")
|
||||
if status == 0 or status >= 500:
|
||||
skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c)
|
||||
else:
|
||||
# 场景 5: /log/health 健康检查
|
||||
assert_eq(status, 200, "log-健康检查 (200)", c)
|
||||
|
||||
# 场景 6: /log/ingest 写入日志
|
||||
test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {
|
||||
"logs": [{
|
||||
"service": "dnfs",
|
||||
"level": "INFO",
|
||||
"message": f"blackbox_test_{random.randint(10000, 99999)}",
|
||||
"ts": test_ts,
|
||||
}],
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None and data.get("success") is True,
|
||||
"log-写入日志", c,
|
||||
f"status={status} body={body[:150]}",
|
||||
)
|
||||
|
||||
# 场景 7: /log/ingest — 空日志列表
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {"logs": []})
|
||||
assert_eq(status, 200, "log-空日志列表 (200)", c)
|
||||
|
||||
# 场景 8: /log/ingest — 缺少 logs 字段
|
||||
status, body, _ = http_post(f"{du}/log/ingest", {})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
data is not None and not data.get("success", True),
|
||||
"log-缺少logs字段-拒绝", c,
|
||||
)
|
||||
|
||||
# 场景 9: /log/query 查询日志
|
||||
status, body, _ = http_post(f"{du}/log/query", {
|
||||
"limit": 5,
|
||||
"desc": True,
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None and data.get("success") is True,
|
||||
"log-查询日志", c,
|
||||
f"status={status}",
|
||||
)
|
||||
if data and data.get("success"):
|
||||
assert_true(
|
||||
"logs" in data.get("data", {}),
|
||||
"log-查询返回logs字段", c,
|
||||
)
|
||||
|
||||
# 场景 10: /log/stats 统计
|
||||
status, body, _ = http_post(f"{du}/log/stats", {
|
||||
"group_by": "service",
|
||||
})
|
||||
data = try_parse_json(body)
|
||||
assert_true(
|
||||
status == 200 and data is not None,
|
||||
"log-统计", c,
|
||||
f"status={status}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 汇总
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -17,7 +17,7 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import SERVICES, PORT_MAP, detect_service, get_url
|
||||
from helpers import check_service
|
||||
from helpers import check_service, check_tcp_service
|
||||
|
||||
TEST_MODULES = {
|
||||
"gateway": "test_gateway",
|
||||
@@ -53,7 +53,11 @@ def run_service_test(name: str, url: str) -> tuple:
|
||||
print(f" {svc['description']} ({url})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
reachable, detail = check_service(url)
|
||||
# pymsg/TCP 服务无 HTTP 接口,用 TCP 直连探测
|
||||
if svc.get("protocol") == "pymsg":
|
||||
reachable, detail = check_tcp_service(svc["host"], svc["port"])
|
||||
else:
|
||||
reachable, detail = check_service(url)
|
||||
if not reachable:
|
||||
print(f" [WARN] 服务不可达: {url}")
|
||||
if detail:
|
||||
|
||||
Reference in new issue
Block a user