aps-agent/server/state/store.py

134 lines
8.6 KiB
Python
Raw 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 tempfile # 原子写的临时文件
import threading # 进程内互斥锁
from typing import Any # 类型标注
from server.state.seed import seed_world # 种子数据工厂
class WorldStore:
"""世界状态存储器:加载/保存/重置/发号(进程内单例使用)。"""
def __init__(self, path: str | None = None) -> None:
"""初始化:确定存储路径并加载(无文件则播种)。权力等级 P0(构造只读加载)。"""
# 存储路径:优先环境变量,默认仓库内 server/data/world.json
self.path = path or os.environ.get("APS_WORLD_PATH", "server/data/world.json")
self._lock = threading.Lock() # 互斥锁:保护并发读写(FastAPI 多请求)
self._counters: dict[str, int] = {} # ID 发号器(按类型独立计数)
self.data: dict[str, Any] = self._load() # 加载世界状态(无文件时自动播种)
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() # 重新播种
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
ensure_forecast_table(data)
ensure_changeover_table(data)
# 旧种子成品补产品族(不覆盖已有)
for m in data.get("materials") or []:
if m.get("type") == "FINISHED_PRODUCT" and not m.get("productFamily"):
code = str(m.get("code") or "")
if code in ("CTRL-A", "CTRL-B"):
m["productFamily"] = "CTRL-STD"
elif code == "CTRL-C":
m["productFamily"] = "CTRL-HF"
return data
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) # 原子落盘
def reset(self) -> None:
"""重置为种子数据(危险操作,仅开发/演示用;调用方须走审计)。"""
with self._lock: # 串行化
self.data = seed_world() # 重新播种
self._counters = {} # 清空发号器
self._write(self.data) # 落盘
self._reset_counters() # 重新校准发号器
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() # 按恢复后数据重新校准发号器(防重号)
# ---------------- 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] # 返回新号
# ---------------- 进程级单例(gateway 与引擎共用同一世界) ----------------
_store: WorldStore | None = None # 模块级单例槽
def get_store() -> WorldStore:
"""获取全局 WorldStore 单例(懒加载)。"""
global _store # 引用模块级槽
if _store is None: # 首次调用
_store = WorldStore() # 创建实例(自动加载/播种)
return _store # 返回单例