Files

153 lines
4.6 KiB
Python
Raw Permalink 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.
"""通用状态机服务。
设计:
- 单据状态变化走 StateMachine.transition(),不在 model.save() 里散写
- 支持 guard(条件校验)、action(副作用:扣库存/生成应收)、hook(插件/AI Agent/Webhook)
- 可被 view / service / task 统一调用
- 默认包 transaction.atomic();use_transaction=False 用于纯内存测试
用法:
sm = StateMachine(
states=("draft", "confirmed", "posted", "closed", "cancelled"),
initial="draft",
transitions={
"draft": {"confirm": "confirmed", "cancel": "cancelled"},
"confirmed": {"post": "posted", "cancel": "cancelled"},
"posted": {"close": "closed"},
},
guards={
("draft", "confirmed", "confirm"): require_draft,
},
actions={
("draft", "confirmed", "confirm"): on_confirm,
},
hooks={"post": [notify_webhook, log_audit]},
)
sm.transition(instance, "confirm", user=request.user)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable, Optional
from django.db import transaction
@dataclass
class TransitionContext:
instance: object
from_state: str
to_state: str
event: str
user: Optional[object] = None
extra: dict = field(default_factory=dict)
class StateTransitionError(Exception):
"""状态机非法迁移。"""
pass
class StateMachine:
def __init__(
self,
states: Iterable[str],
initial: str,
transitions: dict,
guards: dict | None = None,
actions: dict | None = None,
hooks: dict | None = None,
) -> None:
self.states = tuple(states)
self.initial = initial
self.transitions = {s: dict(es) for s, es in transitions.items()}
self.guards = guards or {}
self.actions = actions or {}
self.hooks = hooks or {}
if initial not in self.states:
raise ValueError(f"initial state {initial!r} not in states")
# --- 公共 API ---
def get_next_states(self, current: str) -> list[str]:
return list(self.transitions.get(current, {}).values())
def get_events(self, current: str) -> list[str]:
return list(self.transitions.get(current, {}).keys())
def transition(
self,
instance,
event: str,
*,
user=None,
extra: dict | None = None,
use_transaction: bool = True,
) -> TransitionContext:
"""执行一次状态迁移。
1. guard 校验
2. 改 instance.state
3. action 执行
4. hook 触发
5. 返回 TransitionContext
use_transaction=False 时不包 transaction.atomic()(用于纯内存测试)。
"""
if use_transaction:
with transaction.atomic():
return self._run(instance, event, user=user, extra=extra)
return self._run(instance, event, user=user, extra=extra)
def _run(self, instance, event, *, user, extra):
extra = dict(extra or {})
from_state = getattr(instance, "state", None)
if from_state not in self.transitions:
raise StateTransitionError(
f"current state {from_state!r} has no outgoing transitions"
)
if event not in self.transitions[from_state]:
raise StateTransitionError(
f"event {event!r} not allowed from state {from_state!r}"
)
to_state = self.transitions[from_state][event]
ctx = TransitionContext(
instance=instance,
from_state=from_state,
to_state=to_state,
event=event,
user=user,
extra=extra,
)
guard = self.guards.get((from_state, to_state, event))
if guard:
try:
ok = guard(ctx)
except Exception:
raise
else:
if ok is False:
raise StateTransitionError(
f"guard rejected transition {from_state} -[ {event} ]-> {to_state}"
)
instance.state = to_state
# save 由调用方负责(Django model.save() 或测试 stub)
action = self.actions.get((from_state, to_state, event))
if action:
action(ctx)
for hook in self.hooks.get(event, []):
hook(ctx)
return ctx
def can_transition(self, instance, event: str) -> bool:
from_state = getattr(instance, "state", None)
return event in self.transitions.get(from_state, {})