Commit e2c16e6e authored by Data Governance Dev's avatar Data Governance Dev

test+docs(web3): 任务端到端测试 + DDL 文档

测试
- tests/test_task_conn_fields.py
  - 验证 conn_json 全字段解析(conn_name/host/port/db/user/
    jdbc_params/schema)
  - 验证编辑留空密码时后端兜底保留旧值
  - 验证显式新密码能正确覆盖
- tests/test_task_persistence.py
  - POST 写入 → taskkill /F 关后端 → 重启 → GET 读回
  - 同时直接 sqlite3.connect() 读 DB 文件确认磁盘数据
  - 证明任务 CRUD 真的落到 SQLite,重启不丢

DDL 文档 web3/docs/db-schema.md
- 启动流程(init_db() → schema.sql + seed)
- 表关系总览(task_group/task/field/rule/validation_run/violation)
- 当前已启用 2 张表(task_group + task)的列结构 + 索引 + 种子
- conn_json 内嵌字段全清单
- P4 起预留表结构预览
- CRUD 路由对照
- 验证脚本清单
- 踩坑表(含本次状态 UI 移除)
parent 2ef2965e
"""临时端到端测试:验证 conn_json 全部字段解析 + 密码兜底"""
import urllib.request
import json
import sqlite3
BASE = "http://localhost:8767/api"
def req(method, path, body=None):
headers = {"Content-Type": "application/json"}
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
r = urllib.request.Request(f"{BASE}{path}", method=method, data=data, headers=headers)
with urllib.request.urlopen(r) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
# 1) 创建
payload = {
"name": "测试任务_完整连接",
"group": "身份证",
"db_type": "MySQL",
"data_source_path": None,
"source_table": "t_user_info",
"status": "启用",
"description": "测试用",
"conn_json": json.dumps(
{
"connName": "prod_db",
"host": "192.168.1.100",
"port": "3306",
"db": "customer_db",
"user": "data_check",
"password": "secret123",
"jdbcParams": "useSSL=false",
"schema": "public",
"dbType": "MySQL",
"table": "t_user_info",
},
ensure_ascii=False,
),
}
created = req("POST", "/tasks", payload)
tid = created["id"]
print(f"[1] CREATE id={tid} name={created['name']}")
# 2) GET 解析字段
got = req("GET", f"/tasks/{tid}")
print("[2] GET parsed conn fields:")
for k in ["conn_name", "host", "port", "db", "user", "jdbc_params", "schema"]:
print(f" {k} = {got.get(k)!r}")
# 校验
assert got["conn_name"] == "prod_db"
assert got["host"] == "192.168.1.100"
assert got["port"] == "3306"
assert got["db"] == "customer_db"
assert got["user"] == "data_check"
assert got["jdbc_params"] == "useSSL=false"
assert got["schema"] == "public"
print(" [OK] all conn_* fields parsed correctly")
# 3) PUT 修改名 + description,密码留空(验证密码兜底)
# 注意:status UI 已移除,但 DB 列仍在,后端接受 status 字段
upd = {
"name": "测试任务_完整连接_v2",
"description": "改名测试",
"conn_json": json.dumps(
{
"connName": "prod_db",
"host": "192.168.1.100",
"port": "3306",
"db": "customer_db",
"user": "data_check",
"password": "",
"jdbcParams": "useSSL=false",
"schema": "public",
"dbType": "MySQL",
"table": "t_user_info",
},
ensure_ascii=False,
),
}
updated = req("PUT", f"/tasks/{tid}", upd)
print(f"[3] UPDATE name={updated['name']!r} description={updated['description']!r}")
# 4) 从 DB 读 conn_json,验证密码保留
con = sqlite3.connect("web3/data/web3.db")
row = con.execute("SELECT conn_json FROM task WHERE id=?", (tid,)).fetchone()
conn = json.loads(row[0])
print(f"[4] Stored password after empty PUT: {conn.get('password')!r}")
assert conn.get("password") == "secret123", "[FAIL] password wiped!"
print(" [OK] password preserved when front sends empty")
# 5) PUT 显式带新密码
upd["conn_json"] = json.dumps({**conn, "password": "new_secret_456"}, ensure_ascii=False)
_ = req("PUT", f"/tasks/{tid}", upd)
row = con.execute("SELECT conn_json FROM task WHERE id=?", (tid,)).fetchone()
conn2 = json.loads(row[0])
print(f"[5] Stored password after explicit PUT: {conn2.get('password')!r}")
assert conn2.get("password") == "new_secret_456", "[FAIL] password not updated"
print(" [OK] password updated when explicit value sent")
# 6) 清理
req("DELETE", f"/tasks/{tid}")
print("[6] cleaned up, task deleted")
print()
print("[ALL PASSED]")
\ No newline at end of file
"""持久化验证:写入 -> 重启后端 -> 读回
证明:任务的 CRUD 真的落到 SQLite 文件(web3/data/web3.db),重启不丢。
"""
import os
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request
import json
BASE = "http://localhost:8767/api"
DB_PATH = "web3/data/web3.db"
PID_FILE = "web3/backend/logs/backend.pid"
def http(method, path, body=None):
headers = {"Content-Type": "application/json"}
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
r = urllib.request.Request(f"{BASE}{path}", method=method, data=data, headers=headers)
with urllib.request.urlopen(r) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
def wait_backend(timeout=20):
for _ in range(timeout):
try:
http("GET", "/health")
return True
except Exception:
time.sleep(1)
return False
def start_backend():
p = subprocess.Popen(
[sys.executable, "-m", "web3.backend.start"],
stdout=open("web3/backend/logs/console.log", "ab"),
stderr=subprocess.STDOUT,
)
with open(PID_FILE, "w") as f:
f.write(str(p.pid))
print(f" start backend, pid={p.pid}")
if not wait_backend():
raise RuntimeError("backend failed to start")
print(" backend ready")
def stop_backend():
pid = int(open(PID_FILE).read().strip())
print(f" stop backend, pid={pid}")
# 用 taskkill(Windows)保险
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
time.sleep(2)
# ---- 阶段 1:启动后端 ----
print("[1] start backend first time ...")
start_backend()
# ---- 阶段 2:建一个任务 ----
print("[2] POST /api/tasks with full conn_json ...")
payload = {
"name": "持久化测试_身份证表",
"group": "身份证",
"db_type": "MySQL",
"data_source_path": None,
"source_table": "t_user_info",
"status": "启用",
"description": "持久化测试用",
"conn_json": json.dumps({
"connName": "persist_test_db",
"host": "10.20.30.40",
"port": "3306",
"db": "test_db",
"user": "tester",
"password": "persist_pwd_888",
"jdbcParams": "useSSL=false",
"schema": "public",
"dbType": "MySQL",
"table": "t_user_info",
}, ensure_ascii=False),
}
created = http("POST", "/tasks", payload)
tid = created["id"]
print(f" created id={tid}")
# 直接读文件确认在 SQLite 里
con = sqlite3.connect(DB_PATH)
row = con.execute("SELECT name, status, db_type, conn_json FROM task WHERE id=?", (tid,)).fetchone()
assert row is not None, "row not in DB!"
print(f" [DB] task in SQLite: {row[0]!r} | status={row[1]} | db_type={row[2]}")
# ---- 阶段 3:停后端 ----
print("[3] stop backend ...")
stop_backend()
# ---- 阶段 4:重新启动后端 ----
print("[4] start backend second time ...")
start_backend()
# ---- 阶段 5:API 再读 ----
print("[5] GET /api/tasks/<id> after restart ...")
got = http("GET", f"/tasks/{tid}")
print(f" name = {got['name']}")
print(f" status = {got['status']}")
print(f" host = {got['host']}")
print(f" db = {got['db']}")
assert got["name"] == "持久化测试_身份证表"
assert got["host"] == "10.20.30.40"
assert got["status"] == "启用"
# 直接再读一次 SQLite 文件确认磁盘上
con = sqlite3.connect(DB_PATH)
row = con.execute("SELECT name, status, db_type, conn_json FROM task WHERE id=?", (tid,)).fetchone()
assert row is not None, "row lost after restart!"
conn = json.loads(row[3])
print(f" [DB] still in SQLite: {row[0]!r} | conn.password={conn['password']!r}")
assert conn["password"] == "persist_pwd_888"
print(" [OK] data survived restart")
# ---- 阶段 6:清理 ----
print("[6] cleanup ...")
http("DELETE", f"/tasks/{tid}")
print(f" task {tid} deleted")
# 关后端
stop_backend()
try:
os.remove(PID_FILE)
except Exception:
pass
print()
print("[ALL PASSED] task CRUD is fully persisted in SQLite")
\ No newline at end of file
# web3 数据库 DDL · 完整记录
> **数据库文件**:`web3/data/web3.db`(SQLite)
> **DDL 源文件**:[web3/backend/db/schema.sql](../backend/db/schema.sql)
> **ORM 模型**:[web3/backend/models/](../backend/models/)(`task.py` / `task_group.py`)
> **首次初始化**:`db/database.py::init_db()` —— 文件不存在时跑 schema.sql + seed
---
## 0. 启动流程
```
python -m web3.backend.start
└─ app.py:init_db()
├─ 存在 web3.db? → 直接返回
└─ 不存在?
├─ 读 schema.sql 全文,按 ; 切语句,依次 exec_driver_sql 建表
└─ seed.seed_task_groups() 灌 6 个默认分组
```
⚠️ **当前无 Alembic**:`init_db()` 只在 DB 文件不存在时建表;**已存在的 DB 文件不会被改**。
后续 schema 变更流程:
1. 删 `web3/data/web3.db`(开发环境直接重来)
2. 改 `schema.sql` / `seed.py` / ORM 模型
3. 重启后端 → 重建
迁移机制后面接 Alembic 时再补。
---
## 1. 表关系总览
```
task_group (1) ──< (N) task (1) ──< (N) field (1) ──< (N) rule
│
│
task (1) ──< (N) validation_run (1) ──< (N) violation
```
| 表 | 行数预估 | 何时启用 |
|---|---|---|
| `task_group` | ~10 | P2(已用)|
| `task` | ~50 | P3(已用,本次)|
| `field` | ~500 | P4(下一步)|
| `rule` | ~1500 | P4 |
| `validation_run` | 不限(按检测次数累) | P6/P7 |
| `violation` | 不限 | P6/P7 |
---
## 2. 当前已启用的两张表(P3 阶段)
### 2.1 `task_group` · 任务分组码表
> 左侧菜单「校验任务」+ 「任务配置」分组下拉 + 任务弹窗分组下拉的统一数据源
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | |
| `name` | TEXT | NOT NULL UNIQUE | 分组名(中文:身份证 / 手机号 / 邮箱 / 地址信息 / 银行卡号 / 日期格式)|
| `ord` | INTEGER | NOT NULL DEFAULT 0 | 显示顺序,小的靠前 |
| `created_at` | TIMESTAMP | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
索引:`idx_task_group_ord`(`ord`)
种子数据(`seed.py::DEFAULT_TASK_GROUPS`):
```sql
('身份证', 10),
('手机号', 20),
('邮箱', 30),
('地址信息', 40),
('银行卡号', 50),
('日期格式', 60),
```
### 2.2 `task` · 任务
> 一个任务 = 一个分组 + 一张源表 + 一组字段 + 一组规则 + 一次最近检测
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | |
| `name` | TEXT | NOT NULL UNIQUE | 任务名(用户下拉框看到的)|
| `group_id` | INTEGER | NOT NULL FK → `task_group.id` | 分组 |
| `data_source_path` | TEXT | NULL | `data/sources/**/*.sqlite` 路径(P5 用)|
| `source_table` | TEXT | NULL | 该 sqlite 文件里的表名 |
| `status` | TEXT | NOT NULL DEFAULT `'启用'` | 启用 / 停用;**P3 阶段 UI 不暴露**,仅 DB 列保留(未来扩展用)|
| `description` | TEXT | NULL | 任务说明 |
| `db_type` | TEXT | NOT NULL DEFAULT `'MySQL'` | UI 冗余:MySQL / Oracle / SQL Server / PostgreSQL / 达梦 DM / JDBC 自定义 |
| `conn_json` | TEXT | NULL | UI 数据连接表单 JSON(详见下表)|
| `created_at` | TIMESTAMP | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| `updated_at` | TIMESTAMP | NOT NULL DEFAULT CURRENT_TIMESTAMP,onupdate 自动 | |
索引:`idx_task_group`(`group_id`)
`conn_json` 内嵌字段(前端 Tab「数据连接」完整覆盖):
```json
{
"connName": "customer_db_prod", // 连接名称
"host": "192.168.101.126", // 主机地址
"port": "3306", // 端口
"db": "customer_db", // 数据库名
"user": "data_check", // 用户名
"password": "******", // 密码(编辑留空 → 后端兜底保留旧值)
"jdbcParams": "useUnicode=true&characterEncoding=utf8&useSSL=false", // JDBC 额外参数
"schema": "public", // Schema
"dbType": "MySQL", // 连接方式(冗余,对应 db_type 列)
"table": "t_user_info" // 数据表名(冗余,对应 source_table 列)
}
```
---
## 3. 后续阶段预留(P4 起)
> 表结构已经在 schema.sql 里建好;ORM 模型 + 路由 + 前端按阶段接入。
### 3.1 `field` · 字段(每字段挂一个 task)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | |
| `task_id` | INTEGER FK → `task(id)` ON DELETE CASCADE | |
| `field_key` | TEXT NOT NULL | 英文字段名 |
| `field_cn` | TEXT | 中文名 |
| `data_type` | TEXT | BIGINT/VARCHAR(18) 等 |
| `show_default` | INTEGER DEFAULT 1 | 0/1:结果表默认展示 |
| `ord` | INTEGER DEFAULT 0 | 显示顺序 |
约束:`UNIQUE(task_id, field_key)`
### 3.2 `rule` · 规则(每条挂一个 field)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | |
| `field_id` | INTEGER FK → `field(id)` ON DELETE CASCADE | |
| `desc` | TEXT NOT NULL | 规则说明(自然语言)|
| `regex` | TEXT | 正则表达式(可空)|
| `ord` | INTEGER DEFAULT 0 | |
### 3.3 `validation_run` · 一次检测任务(历史持久化)
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | |
| `task_id` | INTEGER FK → `task(id)` | |
| `status` | TEXT DEFAULT `pending` | pending / running / success / failed |
| `total_rows` | INTEGER DEFAULT 0 | |
| `bad_rows` | INTEGER DEFAULT 0 | |
| `started_at` / `finished_at` | TIMESTAMP | |
| `error_msg` | TEXT | |
运行时进度走内存(`core/job_manager.py`),不走这张表。
### 3.4 `violation` · 不合规明细
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | |
| `run_id` | INTEGER FK → `validation_run(id)` ON DELETE CASCADE | |
| `row_pk` | TEXT | 源表业务主键值 |
| `payload_json` | TEXT | 该行字段快照 |
| `issues_json` | TEXT | 不合规说明列表 |
| `bad_field_id` | INTEGER FK → `field(id)` | |
索引:`idx_violation_run`(`run_id`)
---
## 4. CRUD 对照表
| 资源 | 路由前缀 | 端点 |
|---|---|---|
| 任务分组 | `/api/task-groups` | `GET /`、`POST /` |
| 任务 | `/api/tasks` | `GET /`、`POST /`、`GET /{id}`、`PUT /{id}`、`DELETE /{id}`、`POST /{id}/copy` |
---
## 5. 验证脚本
| 脚本 | 覆盖 |
|---|---|
| [tests/test_task_conn_fields.py](../../tests/test_task_conn_fields.py) | conn_json 全字段解析 + 密码兜底 |
| [tests/test_task_persistence.py](../../tests/test_task_persistence.py) | 写入 → 重启 → 读回(证明真持久化到 SQLite)|
跑法:
```bash
python tests/test_task_conn_fields.py # 需要后端在跑
python tests/test_task_persistence.py # 自启停后端
```
---
## 6. 踩坑(持续追加)
| 日期 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 2026-08-20 | 编辑任务时连接信息全丢 | `TaskModal.vue` 编辑 watch 只回填 3 个字段;后端 `_row_to_out()` 只解析 1 个字段 | 前后端双向补全:后端 `TaskOut` 加 6 个 conn_* 字段;前端 watch 还原 13 个字段 |
| 2026-08-20 | 编辑后密码被清空 | 前端不回填密码 → 保存时发空字符串 → 后端直接覆盖 | 后端 PUT 时检测 `conn_json.password` 为空 → 兜底用旧值 |
| 2026-08-20 | 用户要求去掉启用/停用状态 | UI 多余的开关;后端 copy 默认 停用 对用户也莫名其妙 | UI 全砍(TaskModal Tab1 + TaskConfigView 列表列),后端 copy 沿用源 status;DB `status` 列保留 |
| | | | |
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment