190 lines
6.8 KiB
Python
190 lines
6.8 KiB
Python
"""把存量 Factory MES definition 一次性转换为通用 OpenAPI definition。
|
||
|
||
脚本默认只预检。数据库地址只从显式的 ``ZCBOT_MIGRATION_DB_URL`` 读取,
|
||
不会加载项目 ``.env``,也不会回退到 ``ZCBOT_DB_URL``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from sqlalchemy import create_engine, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from core.external_systems.service import _normalized_config # noqa: E402
|
||
from core.storage.models import ( # noqa: E402
|
||
ExternalSystem,
|
||
ExternalSystemDefinition,
|
||
)
|
||
|
||
|
||
FACTORY_QUERY_GUIDANCE = (
|
||
"产量、良率、缺陷、库存、绩效、趋势和按日/月汇总等统计聚合查询,"
|
||
"统一先调用 BI dataset list,再执行匹配的数据集。日志和业务明细列表用于"
|
||
"用户明确要求查看逐条记录、编号或追溯过程的场景。未匹配到 dataset 时,"
|
||
"先限定范围或向用户确认明细查询需求。"
|
||
)
|
||
|
||
|
||
def migrated_config(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""物化旧 preset,并收敛为 query 模式的通用 OpenAPI 配置。"""
|
||
source = dict(raw or {})
|
||
policies = {
|
||
str(key).strip(): str(value).strip().lower()
|
||
for key, value in (source.get("operation_policies") or {}).items()
|
||
if str(key).strip()
|
||
}
|
||
for operation_id in source.get("allowed_post_operations") or []:
|
||
if str(operation_id).strip():
|
||
policies.setdefault(str(operation_id).strip(), "read")
|
||
policies.setdefault("bi_dataset_exec", "read")
|
||
source.update(
|
||
{
|
||
"auth_type": source.get("auth_type") or "password_jwt",
|
||
"login_path": source.get("login_path") or "/api/auth/token/",
|
||
"username_field": source.get("username_field") or "username",
|
||
"password_field": source.get("password_field") or "password",
|
||
"token_field": source.get("token_field") or "access",
|
||
"auth_header_name": source.get("auth_header_name") or "Authorization",
|
||
"auth_header_template": source.get("auth_header_template")
|
||
or "Bearer {token}",
|
||
"operation_mode": "query",
|
||
"operation_policies": policies,
|
||
"query_guidance": source.get("query_guidance")
|
||
or FACTORY_QUERY_GUIDANCE,
|
||
"recommended_operation_ids": source.get("recommended_operation_ids")
|
||
or ["bi_dataset_list", "bi_dataset_exec"],
|
||
}
|
||
)
|
||
source.pop("allowed_post_operations", None)
|
||
return _normalized_config("generic_openapi", source)
|
||
|
||
|
||
def _conflicting_definition(
|
||
session: Session, definition: ExternalSystemDefinition
|
||
) -> ExternalSystemDefinition | None:
|
||
owner_match = (
|
||
ExternalSystemDefinition.owner_type == "platform"
|
||
if definition.owner_type == "platform"
|
||
else ExternalSystemDefinition.owner_user_id == definition.owner_user_id
|
||
)
|
||
return session.execute(
|
||
select(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.provider == "generic_openapi",
|
||
ExternalSystemDefinition.name == definition.name,
|
||
owner_match,
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
def migrate(session: Session, *, apply: bool) -> tuple[int, int]:
|
||
statement = (
|
||
select(ExternalSystemDefinition)
|
||
.where(ExternalSystemDefinition.provider == "factory_mes")
|
||
.order_by(ExternalSystemDefinition.name)
|
||
)
|
||
if apply:
|
||
statement = statement.with_for_update()
|
||
definitions = (
|
||
session.execute(statement)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
connection_count = 0
|
||
prepared: list[tuple[ExternalSystemDefinition, dict[str, Any], int]] = []
|
||
for definition in definitions:
|
||
conflict = _conflicting_definition(session, definition)
|
||
if conflict is not None:
|
||
raise RuntimeError(
|
||
f"definition name conflict: {definition.name} "
|
||
f"({definition.definition_id} vs {conflict.definition_id})"
|
||
)
|
||
config = migrated_config(definition.config)
|
||
count = len(
|
||
session.execute(
|
||
select(ExternalSystem.external_system_id).where(
|
||
ExternalSystem.definition_id == definition.definition_id
|
||
)
|
||
).all()
|
||
)
|
||
connection_count += count
|
||
prepared.append((definition, config, count))
|
||
print(
|
||
f"[INFO] {definition.definition_id} name={definition.name!r} "
|
||
f"connections={count}"
|
||
)
|
||
|
||
print(f"[INFO] factory_mes definitions: {len(prepared)}")
|
||
print(f"[INFO] affected connections: {connection_count}")
|
||
if not apply:
|
||
return len(prepared), connection_count
|
||
|
||
for definition, config, _ in prepared:
|
||
definition.provider = "generic_openapi"
|
||
definition.config = config
|
||
definition.revision += 1
|
||
active_connections = (
|
||
session.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.definition_id == definition.definition_id,
|
||
ExternalSystem.status == "active",
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
for connection in active_connections:
|
||
connection.verified_revision = definition.revision
|
||
session.flush()
|
||
remaining = session.execute(
|
||
select(ExternalSystemDefinition.definition_id).where(
|
||
ExternalSystemDefinition.provider == "factory_mes"
|
||
)
|
||
).first()
|
||
if remaining is not None:
|
||
raise RuntimeError("factory_mes definitions remain after migration")
|
||
print("[OK] remaining factory_mes definitions: 0")
|
||
return len(prepared), connection_count
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--apply",
|
||
action="store_true",
|
||
help="执行写入;省略时只做预检并回滚事务",
|
||
)
|
||
args = parser.parse_args()
|
||
database_url = os.environ.get("ZCBOT_MIGRATION_DB_URL", "").strip()
|
||
if not database_url:
|
||
print("[ERR] ZCBOT_MIGRATION_DB_URL is required", file=sys.stderr)
|
||
return 2
|
||
engine = create_engine(database_url, pool_pre_ping=True, future=True)
|
||
try:
|
||
with Session(engine, future=True) as session:
|
||
try:
|
||
definitions, connections = migrate(session, apply=args.apply)
|
||
if args.apply:
|
||
session.commit()
|
||
else:
|
||
session.rollback()
|
||
except Exception:
|
||
session.rollback()
|
||
raise
|
||
finally:
|
||
engine.dispose()
|
||
action = "migrated" if args.apply else "validated"
|
||
print(f"[OK] {action} definitions: {definitions}")
|
||
print(f"[OK] affected connections: {connections}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|