aps-agent/server/state/store.py

270 lines
14 KiB
Python
Raw Permalink Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 世界状态存储器(moduleId: state-store, 可重生 ✅)
# M1 形态:单文件 JSON + 原子写(tmp + os.replace),结构与 data.js 对齐
# 后续可整体替换为 PostgreSQL 实现而不影响上层(契约=方法签名)
# ============================================================
from __future__ import annotations # 前向类型引用
import json # JSON 序列化
import os # 路径与原子替换
import re # worldKey 清洗
2026-07-21 11:05:57 +08:00
import tempfile # 原子写的临时文件
import threading # 进程内互斥锁
from typing import Any # 类型标注
from server.state.seed import seed_world # 种子数据工厂
def _safe_scope(value: str, fallback: str) -> str:
cleaned = re.sub(r"[^\w\-]+", "_", (value or fallback).strip())[:64]
return cleaned or fallback
def world_path_for(world_key: str | None, tenant_uuid: str | None = None) -> str:
"""Resolve a tenant/project namespaced world path."""
key = (world_key or "default").strip() or "default"
key = _safe_scope(key, "default")
try:
from server.aps_home import default_world_path
base = default_world_path()
except Exception:
base = os.environ.get("APS_WORLD_PATH", "server/data/world.json")
tenant = _safe_scope(tenant_uuid or "platform", "platform")
if tenant == "platform":
if key in ("default", "", "__personal__"):
return base
root = os.path.dirname(base) or "."
return os.path.join(root, f"world-{key}.json")
root = os.path.dirname(base) or "."
return os.path.join(root, "tenants", tenant, "projects", key, "world.json")
2026-07-21 11:05:57 +08:00
class WorldStore:
"""世界状态存储器:加载/保存/重置/发号(进程内可按 worldKey 切换)。"""
2026-07-21 11:05:57 +08:00
def __init__(self, path: str | None = None, world_key: str = "default", tenant_uuid: str | None = None) -> None:
2026-07-21 11:05:57 +08:00
"""初始化:确定存储路径并加载(无文件则播种)。权力等级 P0(构造只读加载)。"""
self.world_key = (world_key or "default").strip() or "default"
self.tenant_uuid = tenant_uuid or "platform"
if path:
self.path = path
else:
self.path = world_path_for(self.world_key, self.tenant_uuid)
2026-07-21 11:05:57 +08:00
self._lock = threading.Lock() # 互斥锁:保护并发读写(FastAPI 多请求)
self._counters: dict[str, int] = {} # ID 发号器(按类型独立计数)
self._db_fingerprint: str | None = None # 主数据指纹(脏检查,避免每次 save 都回写 DB)
2026-07-21 11:05:57 +08:00
self.data: dict[str, Any] = self._load() # 加载世界状态(无文件时自动播种)
# 非 default 项目:切到独立 DB 项目,避免与演示厂串数据
if self.world_key in ("default", "__personal__"):
self._sync_with_db()
else:
try:
from server.db import sync as db_sync
db_sync.set_active_project(self.world_key[:32], name=self.world_key)
if db_sync.db_has_master():
db_sync.db_to_world(self.data)
self._db_fingerprint = db_sync.master_fingerprint(self.data)
except Exception:
self._db_fingerprint = None
2026-07-21 11:05:57 +08:00
self._reset_counters() # 依据现有数据校准发号器
# ---------------- 加载与持久化 ----------------
def _load(self) -> dict[str, Any]:
"""从磁盘加载世界状态;文件缺失/损坏时重新播种(P0 只读 + 自愈)。"""
try:
with open(self.path, "r", encoding="utf-8") as f: # 尝试读取现有文件
data = json.load(f) # 解析 JSON
2026-07-21 11:05:57 +08:00
except (FileNotFoundError, json.JSONDecodeError): # 缺失或损坏
data = seed_world() # 空世界(或 APS_SEED_DEMO 演示)
self._write(data)
return data
# 旧 world.json 缺表时自愈(OR-05 等增量字段)
from server.aps_domain.forecast import ensure_forecast_table
from server.aps_domain.changeover import ensure_changeover_table
from server.state.seed import purge_demo_residue
ensure_forecast_table(data)
ensure_changeover_table(data)
# 项目世界 / 非演示模式:清掉残留的智能控制器等假主数据
dirty = purge_demo_residue(data)
# 已有 SQL/柔性现场数据,但工艺模型页经典表为空 → 投影过去
if (data.get("flexMaterials") or data.get("flexRoutings")) and not (data.get("materials")):
try:
from server.importers.sql_pack import sync_flex_to_classic_master
sync_flex_to_classic_master(data)
dirty = True
except Exception:
pass
# WZ*/毛坯供应类型自愈(旧世界未标委外时补齐)
if data.get("flexRoutings") or data.get("routingSteps"):
try:
from server.aps_domain.sourcing import annotate_world_sourcing
stats = annotate_world_sourcing(data)
if any(stats.values()):
dirty = True
except Exception:
pass
if dirty:
self._write(data)
return data
2026-07-21 11:05:57 +08:00
def _sync_with_db(self) -> None:
"""主数据与 SQLite 对齐:DB 有数据 → 投影覆盖 world 主数据键;
DB 为空而 world 有主数据 → 一次性迁入(保留现场已导入数据)。
DB 故障不阻塞启动(world.json 仍可独立运行)。"""
if os.environ.get("APS_DB_DISABLED"): # 显式关闭(隔离测试用)
return
try:
from server.db import sync as db_sync
if db_sync.db_has_master(): # DB 是事实源 → 投影
db_sync.db_to_world(self.data)
self._write(self.data) # 投影结果落回 world.json
elif self.data.get("materials") or self.data.get("flexOrders"):
db_sync.world_to_db(self.data) # 首次迁移:world → DB
self._db_fingerprint = db_sync.master_fingerprint(self.data)
except Exception: # DB 不可用时降级为纯 JSON
self._db_fingerprint = None
def _sync_master_to_db(self) -> None:
"""save 收尾:主数据变更时回写 SQLite(指纹脏检查)。"""
if os.environ.get("APS_DB_DISABLED"):
return
try:
from server.db import sync as db_sync
fp = db_sync.master_fingerprint(self.data)
if fp != self._db_fingerprint:
db_sync.world_to_db(self.data)
self._db_fingerprint = fp
except Exception:
pass # DB 故障不阻塞业务写盘
2026-07-21 11:05:57 +08:00
def _write(self, data: dict[str, Any]) -> None:
"""原子写盘:先写临时文件再 os.replace(防止写一半崩溃损坏数据)。"""
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) # 确保目录存在
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp") # 同目录临时文件(保证同盘 rename)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f: # 打开临时文件
json.dump(data, f, ensure_ascii=False, indent=1) # 序列化(保留中文可读)
os.replace(tmp, self.path) # 原子替换目标文件
except BaseException: # 任意失败
if os.path.exists(tmp): # 清理残留临时文件
os.unlink(tmp)
raise # 上抛由调用方处理
def save(self) -> None:
"""持久化当前世界状态(权力等级 P2 动作的收尾必须调用)。"""
with self._lock: # 串行化写盘
self._write(self.data) # 原子落盘
self._sync_master_to_db() # 主数据变更回写 SQLite
2026-07-21 11:05:57 +08:00
def reset(self) -> None:
"""重置为种子数据(重放演示数据包;危险操作,调用方须走审计)。"""
2026-07-21 11:05:57 +08:00
with self._lock: # 串行化
self.data = seed_world() # 重新播种(数据包优先)
2026-07-21 11:05:57 +08:00
self._counters = {} # 清空发号器
self._write(self.data) # 落盘
self._reset_counters() # 重新校准发号器
self._sync_master_to_db() # 重置结果同步 DB
2026-07-21 11:05:57 +08:00
def restore(self, world: dict[str, Any]) -> None:
"""整体替换世界状态(Checkpoint 回滚专用,P2 动作放行后调用,§4.3)。"""
import copy # 局部导入(仅回滚用)
with self._lock: # 串行化
self.data = copy.deepcopy(world) # 深拷贝隔离快照与运行态
self._write(self.data) # 落盘
self._reset_counters() # 按恢复后数据重新校准发号器(防重号)
self._sync_master_to_db() # 回滚后的主数据同步 DB
2026-07-21 11:05:57 +08:00
# ---------------- ID 发号(与 data.js resetCounters/nextId 对齐) ----------------
def _reset_counters(self) -> None:
"""扫描现有数据,把每类计数器校准到当前最大 ID(防重号)。"""
scan_map = { # 类型 → 数据表 的映射
"salesOrder": "salesOrders", "productionOrder": "productionOrders",
"workOrder": "workOrders", "scheduleVersion": "scheduleVersions",
"conflict": "conflicts", "log": "logs", "audit": "auditEvents",
"maintenance": "maintenance", # 维保计划(MD-03 主数据维护可新增)
"purchaseOrder": "purchaseOrders", # 采购建议单(MRP 分解产物)
"outsourceOrder": "outsourceOrders", # 委外建议单(MRP 分解产物)
"forecastOrder": "forecastOrders", # OR-05 预测/长周期订单
"changeoverMatrix": "changeoverMatrix", # MD-06 换型矩阵
"flexScheduleVersion": "flexScheduleVersions", # 柔性排产版本
"flexVirtualLine": "flexVirtualLines", # 虚拟产线实例
"flexWorkOrder": "flexWorkOrders", # 柔性工单
"flexConflict": "flexConflicts", # 柔性冲突
"material": "materials", # 工艺模型:物料
"bom": "boms", "bomItem": "bomItems", # BOM 头 / 明细
"operation": "operations", # 工序库
"routing": "routings", "routingStep": "routingSteps", # 工艺路线 / 步骤
"lineProduct": "lineProducts", # 产线-产品绑定
2026-07-21 11:05:57 +08:00
}
for key, table in scan_map.items(): # 逐类型扫描
max_id = 0 # 当前最大 ID
for item in self.data.get(table, []): # 遍历该表
if isinstance(item.get("id"), int): # 仅数值 ID 参与
max_id = max(max_id, item["id"]) # 更新最大值
self._counters[key] = max_id # 校准计数器
def next_id(self, kind: str) -> int:
"""取下一个 ID(进程内自增,类型隔离)。"""
with self._lock: # 保护计数器
self._counters[kind] = self._counters.get(kind, 0) + 1 # 自增
return self._counters[kind] # 返回新号
# ---------------- 按租户/项目隔离的世界缓存 ----------------
_stores: dict[tuple[str, str], WorldStore] = {}
_stores_lock = threading.Lock()
def _current_world_key() -> tuple[str, str]:
from server.auth.context import get_identity
identity = get_identity()
try:
from server.state.projects import get_project_store
key = get_project_store().active_world_key()
except Exception:
key = "default"
if key in ("default", "", "__personal__") and identity.user_id:
key = f"personal-{identity.user_id}"
return identity.tenant_uuid, _safe_scope(key, "default")
def _scoped_store(tenant_uuid: str, world_key: str) -> WorldStore:
cache_key = (tenant_uuid, world_key)
with _stores_lock:
store = _stores.get(cache_key)
if store is None:
store = WorldStore(world_key=world_key, tenant_uuid=tenant_uuid)
_stores[cache_key] = store
return store
2026-07-21 11:05:57 +08:00
def peek_scoped_store(tenant_uuid: str, world_key: str) -> WorldStore | None:
"""只读窥探已加载的 scoped store(不新建、不落盘);未加载返回 None。
用于审批出卡时做输入快照指纹:生产路径世界已加载则捕获指纹;
测试/未加载路径返回 None,漂移强制随之跳过(见 round-1 计划假设 1)。
"""
cache_key = (tenant_uuid, world_key)
with _stores_lock:
return _stores.get(cache_key)
2026-07-21 11:05:57 +08:00
def get_store() -> WorldStore:
"""Return the world selected by the authenticated user's workspace."""
tenant_uuid, key = _current_world_key()
return _scoped_store(tenant_uuid, key)
def switch_store(world_key: str | None) -> WorldStore:
"""Return a scoped world after the caller has persisted its active project selection."""
from server.auth.context import get_identity
identity = get_identity()
key = (world_key or "default").strip() or "default"
if key in ("default", "__personal__") and identity.user_id:
key = f"personal-{identity.user_id}"
return _scoped_store(identity.tenant_uuid, _safe_scope(key, "default"))