store.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. # -*- coding: utf-8 -*-
  2. """对象库存取: JSON 单文件 (轻量自建裁决; 写入必过 validate 闸).
  3. 2026-08-28 修: F8 Decision 禁静默覆盖 (实质差异默认 raise, force=True 时旧版本进 props.history,
  4. 逻辑时钟 when 由调用方显式传 — 如 pandas 数据窗末; 禁 datetime.now 之外的编造时间);
  5. F12 save 原子写 (tmp + os.replace, 防写一半崩掉留下截断 json)."""
  6. import json, os, pathlib
  7. from .schema import validate, ConstraintError
  8. class Store:
  9. def __init__(self, path):
  10. self.path = pathlib.Path(path)
  11. self.objects = {}
  12. if self.path.exists():
  13. # encoding 必须显式给: Python 文本 I/O 默认用**系统 locale 编码**, 中文 Windows 上是 cp936(GBK)。
  14. # 对象库是 UTF-8 的 JSON, 不写 encoding 会在中文 Windows 上读出乱码/解码失败
  15. # (开发机是 UTF-8 locale 所以一直没暴露; 2026-09-11 从零重算时逮到)。
  16. self.objects = json.loads(self.path.read_text(encoding='utf-8'))
  17. _DECISION_KINDS = {'验收树', '情景沙盘', '用户裁决', '验收状态机', '滚动元数据', '排程计划'}
  18. @staticmethod
  19. def _decision_kind(props):
  20. """F7 (Critic 逮): Decision 装 5 种异构形状, 消费方只能猜 props 有什么键 → 写入时按形状显式分型."""
  21. if 'branches' in props: return '验收树'
  22. if 'scenarios' in props: return '情景沙盘'
  23. if 'slots' in props or 'repair_done' in props: return '验收状态机'
  24. if 'decided_by' in props: return '用户裁决'
  25. if 'data_month' in props: return '滚动元数据'
  26. return '排程计划'
  27. def put(self, obj, force=False, when=None):
  28. if obj.get('type') == 'Decision':
  29. pr = obj.setdefault('props', {})
  30. pr.setdefault('decision_kind', self._decision_kind(pr))
  31. """写入 (过 validate 闸). Decision 类型 F8 保护:
  32. id 已存在且 props 实质差异 (history 除外) → 默认 raise;
  33. force=True 才覆盖, 且必须显式传 when (逻辑时钟), 旧版本追加进 props['history']."""
  34. validate(obj) # 不过闸不落库, 响亮 raise
  35. oid = obj['id']
  36. if obj.get('type') == 'Decision' and oid in self.objects:
  37. old = self.objects[oid]
  38. if obj is old:
  39. raise ConstraintError(f'[{oid}] F8: 禁以库内同一引用就地改后 put (差异检测失效); '
  40. f'先 copy.deepcopy(store.get(id)) 再改再 put')
  41. old_core = {k: v for k, v in old.get('props', {}).items() if k != 'history'}
  42. new_core = {k: v for k, v in obj.get('props', {}).items() if k != 'history'}
  43. if old_core != new_core:
  44. if not force:
  45. raise ConstraintError(
  46. f'[{oid}] F8: Decision 已存在且 props 实质差异 — 禁静默覆盖决策记录; '
  47. f'确需更新用 put(obj, force=True, when=<逻辑时钟>), 旧版本会进 props.history')
  48. if when in (None, ''):
  49. raise ConstraintError(
  50. f'[{oid}] F8: force 覆盖 Decision 必须显式传 when (逻辑时钟, 由调用方给 — '
  51. f'如 pandas 数据窗末), 禁编造时间')
  52. hist = list(old.get('props', {}).get('history', []))
  53. hist.append(dict(superseded_when=str(when), props=old_core))
  54. obj.setdefault('props', {})['history'] = hist
  55. self.objects[oid] = obj
  56. return oid
  57. def get(self, oid):
  58. if oid not in self.objects:
  59. raise KeyError(f'对象不存在: {oid}')
  60. return self.objects[oid]
  61. def by_type(self, t):
  62. return [o for o in self.objects.values() if o['type'] == t]
  63. def linked(self, oid, rel):
  64. return [self.get(x) for x in self.get(oid).get('links', {}).get(rel, [])]
  65. def save(self):
  66. # 落盘前全库再过一遍闸 (防手改文件绕闸); registry 传入 → C2 引用完整性/C3 声明面=证据面 全库校验
  67. for o in self.objects.values():
  68. validate(o, registry=self.objects)
  69. self.path.parent.mkdir(parents=True, exist_ok=True)
  70. # F12 原子写: tmp + os.replace (同目录保证同文件系统, replace 原子)
  71. tmp = self.path.with_suffix(self.path.suffix + '.tmp')
  72. tmp.write_text(json.dumps(self.objects, ensure_ascii=False, indent=1), encoding='utf-8')
  73. os.replace(tmp, self.path)
  74. return str(self.path)