# ============================================================ # 世界状态存储器(moduleId: state-store, 可重生 ✅) # M1 形态:单文件 JSON + 原子写(tmp + os.replace),结构与 data.js 对齐 # 后续可整体替换为 PostgreSQL 实现而不影响上层(契约=方法签名) # ============================================================ from __future__ import annotations # 前向类型引用 import json # JSON 序列化 import os # 路径与原子替换 import re # worldKey 清洗 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") class WorldStore: """世界状态存储器:加载/保存/重置/发号(进程内可按 worldKey 切换)。""" def __init__(self, path: str | None = None, world_key: str = "default", tenant_uuid: str | None = None) -> None: """初始化:确定存储路径并加载(无文件则播种)。权力等级 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) self._lock = threading.Lock() # 互斥锁:保护并发读写(FastAPI 多请求) self._counters: dict[str, int] = {} # ID 发号器(按类型独立计数) self._db_fingerprint: str | None = None # 主数据指纹(脏检查,避免每次 save 都回写 DB) 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 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 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 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 故障不阻塞业务写盘 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 def reset(self) -> None: """重置为种子数据(重放演示数据包;危险操作,调用方须走审计)。""" with self._lock: # 串行化 self.data = seed_world() # 重新播种(数据包优先) self._counters = {} # 清空发号器 self._write(self.data) # 落盘 self._reset_counters() # 重新校准发号器 self._sync_master_to_db() # 重置结果同步 DB 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 # ---------------- 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", # 产线-产品绑定 } 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 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) 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"))