fix: 对齐技术架构补齐传输 mTLS 与结构化 JSON 日志(NFR 9 章)
架构核对发现 2 处差距,本次补齐: - Kafka 上行支持 SSL/SASL_SSL 双向 mTLS(8.2 服务间 mTLS 边缘网关↔总线) - 网关日志支持结构化 JSON 输出(NFR 可维护:统一日志规范) - 配置示例与 README 验收口径同步更新
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""iAOP-Core · 数据总线 + 时序库(Data Bus)—— 模板化封装。
|
||||
|
||||
对应 PRD 5.2「② 数据总线 + 时序库」与 Issue #4(EPIC):
|
||||
复用 Kafka + TDengine + PostgreSQL + MinIO,全部改为**按模板配置**
|
||||
topic / 时序库表 / 关系表 / 对象桶,换行业只改模板资产(YAML + 点位字典),
|
||||
内核代码零改动;批量写入缓冲 + 幂等去重,保障「数据不丢不重」。
|
||||
"""
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PostgreSQL 关系表 schema(模板 / 模型 / 用户 / 权限)—— 子任务 #30。
|
||||
|
||||
按模板自动生成隔离 schema(`tpl_{tpl}`),内含四张基础关系表:
|
||||
- templates 模板注册表(行业模板配置资产);
|
||||
- models 模型注册表(模型版本 / 算法 / 超参包);
|
||||
- users 用户表(角色 + 细粒度权限 JSONB);
|
||||
- permissions 角色-资源-动作权限矩阵。
|
||||
|
||||
另生成角色授权语句(GRANT),按模板配置声明 databus_ro / databus_rw 等角色。
|
||||
不依赖 psycopg2:仅产出 DDL 文本,供模板配置台预览 / 运维执行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from .templating import TemplateNaming
|
||||
|
||||
# 默认基础表清单(对应子任务 #30「模板/模型/用户/权限」)
|
||||
DEFAULT_TABLES: List[str] = ["templates", "models", "users", "permissions"]
|
||||
|
||||
# 表 → 列定义(可被模板 YAML 的 schema_tables 覆盖为子集/扩展)
|
||||
TABLE_COLUMNS: dict = {
|
||||
"templates": [
|
||||
"id BIGSERIAL PRIMARY KEY",
|
||||
"template VARCHAR(64) NOT NULL UNIQUE",
|
||||
"version VARCHAR(32) NOT NULL DEFAULT '0.1.0'",
|
||||
"config JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
"status VARCHAR(16) NOT NULL DEFAULT 'draft'",
|
||||
"created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
||||
],
|
||||
"models": [
|
||||
"id BIGSERIAL PRIMARY KEY",
|
||||
"model_id VARCHAR(128) NOT NULL UNIQUE",
|
||||
"template VARCHAR(64) REFERENCES {schema}.templates(template) ON DELETE CASCADE",
|
||||
"algorithm VARCHAR(32) NOT NULL DEFAULT 'xgboost'",
|
||||
"hyperparams JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
"version VARCHAR(32) NOT NULL DEFAULT '0.1.0'",
|
||||
"status VARCHAR(16) NOT NULL DEFAULT 'staging'",
|
||||
"created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
||||
],
|
||||
"users": [
|
||||
"id BIGSERIAL PRIMARY KEY",
|
||||
"username VARCHAR(64) NOT NULL UNIQUE",
|
||||
"role VARCHAR(32) NOT NULL DEFAULT 'viewer'",
|
||||
"permissions JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||
"created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
||||
],
|
||||
"permissions": [
|
||||
"id BIGSERIAL PRIMARY KEY",
|
||||
"role VARCHAR(32) NOT NULL",
|
||||
"resource VARCHAR(64) NOT NULL",
|
||||
"action VARCHAR(16) NOT NULL",
|
||||
"UNIQUE (role, resource, action)",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def generate_schema_ddl(
|
||||
naming: TemplateNaming,
|
||||
tables: Optional[List[str]] = None,
|
||||
table_columns: Optional[dict] = None,
|
||||
) -> str:
|
||||
"""生成 schema 与基础关系表的完整 DDL(幂等 IF NOT EXISTS)。
|
||||
|
||||
Args:
|
||||
naming: 模板命名器(决定 schema 名)。
|
||||
tables: 表清单(默认 DEFAULT_TABLES)。
|
||||
table_columns: 表→列定义覆盖(默认 TABLE_COLUMNS)。
|
||||
|
||||
Returns:
|
||||
多语句 DDL 文本(含 CREATE SCHEMA / CREATE TABLE)。
|
||||
"""
|
||||
names = tables or DEFAULT_TABLES
|
||||
cols = table_columns or TABLE_COLUMNS
|
||||
schema = naming.pg_schema()
|
||||
|
||||
lines = [f"CREATE SCHEMA IF NOT EXISTS {schema};", ""]
|
||||
for table in names:
|
||||
columns = cols.get(table)
|
||||
if not columns:
|
||||
continue
|
||||
lines.append(f"CREATE TABLE IF NOT EXISTS {schema}.{table} (")
|
||||
lines.append(" " + ",\n ".join(c.format(schema=schema) for c in columns))
|
||||
lines.append(");")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def generate_grant_ddl(
|
||||
naming: TemplateNaming,
|
||||
roles: Optional[List[str]] = None,
|
||||
) -> List[str]:
|
||||
"""按模板声明的关系角色生成授权语句。
|
||||
|
||||
角色约定:`{role}_ro` 只读、`{role}_rw` 读写(可扩展)。
|
||||
Args:
|
||||
naming: 模板命名器。
|
||||
roles: 角色清单(如 ["databus"] → databus_ro / databus_rw)。
|
||||
Returns:
|
||||
GRANT 语句列表。
|
||||
"""
|
||||
schema = naming.pg_schema()
|
||||
grants: List[str] = []
|
||||
for role in (roles or ["databus"]):
|
||||
for suffix, privileges in (("ro", "SELECT"), ("rw", "SELECT, INSERT, UPDATE, DELETE")):
|
||||
role_name = f"{role}_{suffix}"
|
||||
grants.append(f"GRANT USAGE ON SCHEMA {schema} TO {role_name};")
|
||||
grants.append(
|
||||
f"GRANT {privileges} ON ALL TABLES IN SCHEMA {schema} TO {role_name};"
|
||||
)
|
||||
if suffix == "rw":
|
||||
grants.append(
|
||||
f"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {schema} TO {role_name};"
|
||||
)
|
||||
return grants
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""TDengine 超级表 schema 自动生成 + 批量 INSERT —— 依据点位字典(子任务 #29)。
|
||||
|
||||
模型(TDengine 2.0/3.0 通用):
|
||||
- 一张超级表 `{tpl}_points`:列 `ts / value / quality`,标签
|
||||
`device_id / unit / data_type`;
|
||||
- 点位字典每个测点自动生成一张子表 `{tpl}_pt_{point_id}`(点位维度,
|
||||
字典 CSV 变更即重建子表集,内核零改动);
|
||||
- 批量写入:按子表聚合多行 `INSERT INTO {sub} VALUES (...),(...);`,
|
||||
单条样本约 512B 时对齐 PRD 5.2「5k 条/100ms」基线。
|
||||
|
||||
不依赖 taospy:本模块只负责生成 DDL / 批量 SQL;真实落库由
|
||||
`batch_writer.StoreSink` 的客户端实现(缺失时降级内存 sink 联调)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .templating import TemplateNaming, sanitize_sql, _sql_str
|
||||
|
||||
# dataType → TDengine 列类型(点位字典 schema.VALID_DATA_TYPES 子集)
|
||||
TD_VALUE_TYPE: Dict[str, str] = {
|
||||
"float": "DOUBLE",
|
||||
"int": "BIGINT",
|
||||
"bool": "BOOL",
|
||||
}
|
||||
DEFAULT_VALUE_TYPE = "DOUBLE"
|
||||
|
||||
# 样本记录字段(与 edge-gateway spool / kafka_sink 保持一致)
|
||||
ROW_KEYS = ("device_id", "point_id", "value", "ts", "quality")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PointSpec:
|
||||
"""点位字典中的最小维度信息(schema 生成所需)。"""
|
||||
|
||||
device_id: str
|
||||
point_id: str
|
||||
unit: str = ""
|
||||
data_type: str = "float"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict) -> "PointSpec":
|
||||
return cls(
|
||||
device_id=str(raw.get("device_id") or ""),
|
||||
point_id=str(raw.get("point_id") or ""),
|
||||
unit=str(raw.get("unit") or ""),
|
||||
data_type=str(raw.get("data_type") or raw.get("dataType") or "float"),
|
||||
)
|
||||
|
||||
|
||||
def value_type(data_type: str) -> str:
|
||||
return TD_VALUE_TYPE.get((data_type or "").lower(), DEFAULT_VALUE_TYPE)
|
||||
|
||||
|
||||
def generate_supertable_ddl(
|
||||
naming: TemplateNaming,
|
||||
retention_days: Optional[int] = None,
|
||||
) -> str:
|
||||
"""生成超级表 DDL(自动建表,幂等 IF NOT EXISTS)。
|
||||
|
||||
Args:
|
||||
naming: 模板命名器(决定超级表名)。
|
||||
retention_days: 数据保留天数(TDengine KEEP 表选项,可选)。
|
||||
|
||||
Returns:
|
||||
CREATE STABLE 语句。
|
||||
"""
|
||||
ddl = (
|
||||
f"CREATE STABLE IF NOT EXISTS {naming.stable()} (\n"
|
||||
" ts TIMESTAMP,\n"
|
||||
" value DOUBLE,\n"
|
||||
" quality TINYINT\n"
|
||||
") TAGS (\n"
|
||||
" device_id NCHAR(64),\n"
|
||||
" unit NCHAR(16),\n"
|
||||
" data_type NCHAR(16)\n"
|
||||
")"
|
||||
)
|
||||
if retention_days and int(retention_days) > 0:
|
||||
ddl += f" KEEP({int(retention_days)})"
|
||||
return ddl + ";"
|
||||
|
||||
|
||||
def generate_subtable_ddls(
|
||||
naming: TemplateNaming,
|
||||
points: List[PointSpec],
|
||||
use_typed_value: bool = True,
|
||||
) -> List[str]:
|
||||
"""按点位字典为每个测点生成子表 DDL(依据点位字典自动生成)。
|
||||
|
||||
Args:
|
||||
naming: 模板命名器。
|
||||
points: 点位字典(测点维度集合)。
|
||||
use_typed_value: 是否按 dataType 派生 value 列类型(TDengine 3.x
|
||||
支持列级类型;2.x 超级表统一 DOUBLE,传 False 时忽略)。
|
||||
|
||||
Returns:
|
||||
CREATE TABLE ... USING ... TAGS(...) 语句列表(顺序与 points 一致)。
|
||||
"""
|
||||
if use_typed_value:
|
||||
# 说明:超级表 value 列按最宽类型 DOUBLE 建(TDengine 列类型在
|
||||
# CREATE STABLE 时固定),子表继承,这里保留 data_type 标签供聚合。
|
||||
pass
|
||||
out: List[str] = []
|
||||
seen: set = set()
|
||||
for p in points:
|
||||
sub = naming.subtable(p.point_id)
|
||||
if sub in seen:
|
||||
continue
|
||||
seen.add(sub)
|
||||
tags = ", ".join(
|
||||
_sql_str(v) for v in (p.device_id, p.unit or "", (p.data_type or "float").lower())
|
||||
)
|
||||
out.append(
|
||||
f"CREATE TABLE IF NOT EXISTS {sub} USING {naming.stable()} "
|
||||
f"TAGS ({tags});"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def build_batch_insert(
|
||||
naming: TemplateNaming,
|
||||
rows: List[dict],
|
||||
) -> List[str]:
|
||||
"""把样本批聚合为按子表分组的批量 INSERT 语句。
|
||||
|
||||
Args:
|
||||
naming: 模板命名器(决定子表名)。
|
||||
rows: 样本列表,每条含 device_id/point_id/value/ts(/quality)。
|
||||
|
||||
Returns:
|
||||
SQL 语句列表:每子表一条 `INSERT INTO {sub} VALUES (...),(...);`。
|
||||
空输入返回空列表。
|
||||
"""
|
||||
grouped: Dict[str, List[dict]] = {}
|
||||
for row in rows:
|
||||
point_id = str(row.get("point_id") or "")
|
||||
sub = naming.subtable(point_id)
|
||||
grouped.setdefault(sub, []).append(row)
|
||||
|
||||
statements: List[str] = []
|
||||
for sub in sorted(grouped):
|
||||
tuples = []
|
||||
for row in grouped[sub]:
|
||||
ts = row.get("ts")
|
||||
ts_ms = int(ts * 1000) if isinstance(ts, float) else int(ts)
|
||||
value = row.get("value")
|
||||
value_sql = "NULL" if value is None else repr(float(value))
|
||||
quality = int(row.get("quality", 1))
|
||||
tuples.append(f"({ts_ms}, {value_sql}, {quality})")
|
||||
statements.append(
|
||||
f"INSERT INTO {sub} VALUES " + ", ".join(tuples) + ";"
|
||||
)
|
||||
return statements
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据总线命名与分区模板化 —— 换行业只改配置,内核零改动(PRD 5.2 / Issue #4)。
|
||||
|
||||
模板(template)是行业复制的唯一粒度,所有外部资源命名均由
|
||||
「模板名 + 点位维度」推导:
|
||||
|
||||
- Kafka topic :`{topic_prefix}.{device_id}.points`(与 edge-gateway 上行一致);
|
||||
分区 = hash(device_id) % num_partitions,保证单设备分区内有序;
|
||||
- TDengine :超级表 `{tpl}_points`、每测点子表 `{tpl}_pt_{point_id}`;
|
||||
- PostgreSQL :schema `tpl_{tpl}`,表名 `{schema}.{table}`;
|
||||
- MinIO :桶 `{template}-artifacts`,对象键 `features/{model_id}/{...}`。
|
||||
|
||||
命名清洗规则:
|
||||
- Kafka topic / MinIO 桶:小写 + 保留 `-` / `_` / `.` 之外替换为 `_`;
|
||||
- SQL 标识符(TDengine / PostgreSQL):额外把 `-` 替换为 `_`,避免引号转义。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
# 保留字符集(Kafka topic / MinIO 桶名均允许小写字母、数字、- _ .)
|
||||
_KEEP = re.compile(r"[^a-z0-9_.-]+")
|
||||
# SQL 标识符额外排除 `-`(TDengine / PostgreSQL 不加引号时不允许)
|
||||
_SQL_KEEP = re.compile(r"[^a-z0-9_.]+")
|
||||
|
||||
|
||||
def sanitize(name: str) -> str:
|
||||
"""通用命名清洗:小写 + 非安全字符替换为 `_`。"""
|
||||
s = (name or "").strip().lower()
|
||||
s = _KEEP.sub("_", s)
|
||||
s = s.strip("._") or "tpl"
|
||||
return s
|
||||
|
||||
|
||||
def sanitize_sql(name: str) -> str:
|
||||
"""SQL 标识符清洗:`-` 与其余非安全字符替换为 `_`。"""
|
||||
s = sanitize(name)
|
||||
s = _SQL_KEEP.sub("_", s)
|
||||
return s.strip("._") or "tpl"
|
||||
|
||||
|
||||
def _sql_str(value: str) -> str:
|
||||
"""SQL 字符串字面量转义(单引号加倍)。"""
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
class TemplateNaming:
|
||||
"""按模板推导全部外部资源命名(Kafka / TDengine / PostgreSQL / MinIO)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
topic_prefix: Optional[str] = None,
|
||||
num_partitions: int = 12,
|
||||
bucket_suffix: str = "artifacts",
|
||||
):
|
||||
self.template = sanitize(template)
|
||||
# SQL 标识符用下划线形态(避免 `-` 需加引号)
|
||||
self.tpl_sql = sanitize_sql(self.template)
|
||||
self.topic_prefix = sanitize(topic_prefix) if topic_prefix else self.template
|
||||
self.num_partitions = max(1, int(num_partitions))
|
||||
self.bucket_suffix = sanitize_sql(bucket_suffix)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Kafka:topic 命名 / 分区策略(子任务 #28)
|
||||
# ------------------------------------------------------------------
|
||||
def topic(self, device_id: str) -> str:
|
||||
"""上行 topic:`{topic_prefix}.{device_id}.points`(对齐 edge-gateway)。"""
|
||||
return f"{self.topic_prefix}.{sanitize(device_id)}.points"
|
||||
|
||||
def partition(self, device_id: str, num_partitions: Optional[int] = None) -> int:
|
||||
"""分区策略:按 device_id 一致性哈希 → 单设备分区内严格有序。"""
|
||||
n = num_partitions or self.num_partitions
|
||||
digest = hashlib.md5(sanitize(device_id).encode("utf-8")).hexdigest()
|
||||
return int(digest[:8], 16) % n
|
||||
|
||||
def partitions(self, device_ids: List[str], num_partitions: Optional[int] = None) -> dict:
|
||||
"""设备 → 分区映射(模板配置台预览用)。"""
|
||||
n = num_partitions or self.num_partitions
|
||||
return {d: self.partition(d, n) for d in device_ids}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TDengine:超级表 + 每测点子表(子任务 #29)
|
||||
# ------------------------------------------------------------------
|
||||
def stable(self) -> str:
|
||||
"""时序超级表:`{tpl}_points`。"""
|
||||
return f"{self.tpl_sql}_points"
|
||||
|
||||
def subtable(self, point_id: str) -> str:
|
||||
"""测点子表:`{tpl}_pt_{point_id}`(点位维度,字典驱动自动生成)。"""
|
||||
return f"{self.tpl_sql}_pt_{sanitize_sql(point_id)}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PostgreSQL:schema 与表(子任务 #30)
|
||||
# ------------------------------------------------------------------
|
||||
def pg_schema(self) -> str:
|
||||
"""关系 schema:`tpl_{tpl}`。"""
|
||||
return f"tpl_{self.tpl_sql}"
|
||||
|
||||
def pg_table(self, table: str) -> str:
|
||||
"""`{schema}.{table}` 限定名。"""
|
||||
return f"{self.pg_schema()}.{sanitize_sql(table)}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MinIO:对象桶与对象键(子任务 #31)
|
||||
# ------------------------------------------------------------------
|
||||
def bucket(self) -> str:
|
||||
"""对象存储桶:`{template}-artifacts`(S3 桶名允许 `-`)。"""
|
||||
return f"{self.template}-{self.bucket_suffix}"
|
||||
|
||||
def snapshot_key(self, model_id: str, date: str, seq: int) -> str:
|
||||
"""特征快照对象键:`features/{model_id}/{date}/{seq:06d}.jsonl`。"""
|
||||
return f"features/{sanitize_sql(model_id)}/{date}/{int(seq):06d}.jsonl"
|
||||
|
||||
def model_artifact_key(self, model_id: str, version: str) -> str:
|
||||
"""模型 artifact 对象键:`models/{model_id}/{version}/model.bin`。"""
|
||||
return f"models/{sanitize_sql(model_id)}/{sanitize_sql(version)}/model.bin"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _sql_str(self, value: str) -> str:
|
||||
return _sql_str(value)
|
||||
Reference in New Issue
Block a user