aps-agent/server/state/store.py

109 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 世界状态存储器(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: # 尝试读取现有文件
return json.load(f) # 解析 JSON
except (FileNotFoundError, json.JSONDecodeError): # 缺失或损坏
data = seed_world() # 重新播种
self._write(data) # 立即落盘
return data # 返回新世界
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 分解产物)
}
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 # 返回单例