fix(delivery): split validated vs delivered lifecycle to remove sent overreach
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-06-15 13:21:42 +08:00
parent 1179710991
commit 355431461c
5 changed files with 191 additions and 26 deletions

View File

@@ -380,9 +380,15 @@ def _build_portal_context(order: Order, settings: Settings) -> dict[str, Any]:
try: try:
sent_station_events = notification_service.list_events( sent_station_events = notification_service.list_events(
order.id, order.id,
status="sent", status="validated",
channel="station", channel="station",
) )
if not sent_station_events:
sent_station_events = notification_service.list_events(
order.id,
status="delivered",
channel="station",
)
finally: finally:
notification_service.close() notification_service.close()

View File

@@ -15,7 +15,8 @@ from data.orders.models import utc_now_iso
@dataclass @dataclass
class DispatchResult: class DispatchResult:
processed: int = 0 processed: int = 0
sent: int = 0 validated: int = 0
delivered: int = 0
failed: int = 0 failed: int = 0
@@ -48,7 +49,7 @@ class DeliveryDispatcher:
self, self,
*, *,
channel: str = "station", channel: str = "station",
statuses: tuple[str, ...] = ("ready", "failed"), statuses: tuple[str, ...] = ("ready", "validated"),
limit: int = 100, limit: int = 100,
) -> DispatchResult: ) -> DispatchResult:
result = DispatchResult() result = DispatchResult()
@@ -68,6 +69,29 @@ class DeliveryDispatcher:
continue continue
assert payload is not None assert payload is not None
sent_at = utc_now_iso() sent_at = utc_now_iso()
if event.status != "validated":
# Stash the rendered station notice into the validated
# payload so the portal status page can still display it
# even though ``station`` does not transition to
# ``delivered``.
persisted_payload = dict(payload)
if event.channel == "station":
persisted_payload["station_notice"] = {
"title": "报告已就绪",
"body": (
f"订单 {event.order_id} 的志愿报告已就绪,"
"可在当前状态页查看在线报告并下载 PDF。"
),
"delivered_at": sent_at,
}
self._service.mark_validated(
event.order_id,
event_type=event.event_type,
payload_json=json.dumps(
persisted_payload, ensure_ascii=False
),
)
result.validated += 1
try: try:
rendered_payload = self._deliver_event( rendered_payload = self._deliver_event(
event, event,
@@ -82,13 +106,20 @@ class DeliveryDispatcher:
) )
result.failed += 1 result.failed += 1
continue continue
self._service.mark_sent( # Only channels with a real downstream sink (currently
event.order_id, # ``email``) get marked ``delivered``. ``station`` is local
event_type=event.event_type, # render only; its persisted payload already records the
payload_json=json.dumps(rendered_payload, ensure_ascii=False), # rendered notice, so we stop at ``validated`` for it.
sent_at=sent_at, if event.channel == "email":
) self._service.mark_delivered(
result.sent += 1 event.order_id,
event_type=event.event_type,
payload_json=json.dumps(
rendered_payload, ensure_ascii=False
),
sent_at=sent_at,
)
result.delivered += 1
return result return result
@staticmethod @staticmethod
@@ -116,14 +147,19 @@ class DeliveryDispatcher:
payload: dict[str, object], payload: dict[str, object],
sent_at: str, sent_at: str,
) -> dict[str, object]: ) -> dict[str, object]:
"""Push the validated event to the real downstream sink.
Returns the rendered payload (with the channel-specific
downstream notice) so the caller can persist it via
:meth:`mark_delivered`. ``station`` is currently a local
notice only — it has no real downstream sink — so we
intentionally do not mark it as ``delivered``. The lifecycle
there is ``ready`` -> ``validated`` with the renderer's output
captured into the persisted payload. ``delivered`` is only
reached when the channel actually pushes the notice externally
(today: ``email``).
"""
rendered = dict(payload) rendered = dict(payload)
if event.channel == "station":
rendered["station_notice"] = {
"title": "报告已就绪",
"body": f"订单 {event.order_id} 的志愿报告已就绪,可在当前状态页查看在线报告并下载 PDF。",
"sent_at": sent_at,
}
return rendered
if event.channel == "email": if event.channel == "email":
if self._email_sender is None: if self._email_sender is None:
raise ValueError("email sender not configured") raise ValueError("email sender not configured")
@@ -141,7 +177,32 @@ class DeliveryDispatcher:
) )
rendered["email_notice"] = { rendered["email_notice"] = {
**send_result, **send_result,
"sent_at": sent_at, "delivered_at": sent_at,
} }
return rendered return rendered
# ``station`` and any future local-render-only channel: do not
# claim ``delivered`` because the real downstream push has not
# happened yet. ``delivered`` is reserved for actual external
# push completion.
return rendered
@staticmethod
def _render_delivered_payload(
event: DeliveryNotificationEvent,
payload: dict[str, object],
sent_at: str,
) -> dict[str, object]:
rendered = dict(payload)
if event.channel == "station":
rendered["station_notice"] = {
"title": "报告已就绪",
"body": (
f"订单 {event.order_id} 的志愿报告已就绪,"
"可在当前状态页查看在线报告并下载 PDF。"
),
"delivered_at": sent_at,
}
# email_notice is stashed by ``_deliver_event`` directly on the
# rendered dict before ``mark_delivered`` is called. Keep it as
# is so consumers see the original sender response.
return rendered return rendered

View File

@@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS delivery_notifications (
""" """
DELIVERY_EVENT_STATUSES = ("ready", "validated", "delivered", "failed", "sent")
@dataclass @dataclass
class DeliveryNotificationEvent: class DeliveryNotificationEvent:
order_id: str order_id: str
@@ -147,6 +150,50 @@ class DeliveryNotificationService:
) )
self._conn.commit() self._conn.commit()
def mark_validated(
self,
order_id: str,
event_type: str = "report_ready",
*,
payload_json: str | None = None,
validated_at: str | None = None,
) -> None:
if validated_at is None:
validated_at = utc_now_iso()
if payload_json is None:
self._conn.execute(
"UPDATE delivery_notifications SET status='validated', last_attempt_at=? WHERE order_id=? AND event_type=?",
(validated_at, order_id, event_type),
)
else:
self._conn.execute(
"UPDATE delivery_notifications SET status='validated', payload_json=?, last_attempt_at=? WHERE order_id=? AND event_type=?",
(payload_json, validated_at, order_id, event_type),
)
self._conn.commit()
def mark_delivered(
self,
order_id: str,
event_type: str = "report_ready",
*,
payload_json: str | None = None,
sent_at: str | None = None,
) -> None:
if sent_at is None:
sent_at = utc_now_iso()
if payload_json is None:
self._conn.execute(
"UPDATE delivery_notifications SET status='delivered', last_attempt_at=?, failure_reason=NULL WHERE order_id=? AND event_type=?",
(sent_at, order_id, event_type),
)
else:
self._conn.execute(
"UPDATE delivery_notifications SET status='delivered', payload_json=?, last_attempt_at=?, failure_reason=NULL WHERE order_id=? AND event_type=?",
(payload_json, sent_at, order_id, event_type),
)
self._conn.commit()
def mark_failed( def mark_failed(
self, self,
order_id: str, order_id: str,
@@ -183,7 +230,7 @@ class DeliveryNotificationService:
self, self,
*, *,
channel: str | None = None, channel: str | None = None,
statuses: tuple[str, ...] = ("ready",), statuses: tuple[str, ...] = ("ready", "validated"),
limit: int = 100, limit: int = 100,
) -> list[DeliveryNotificationEvent]: ) -> list[DeliveryNotificationEvent]:
placeholders = ",".join("?" for _ in statuses) placeholders = ",".join("?" for _ in statuses)

View File

@@ -69,6 +69,8 @@
- ✅ P1-5 退款域模型闭环已修复payment.status 一步收敛到 `refunded`,并把订单推进到 refunded 终态;幂等请求会自愈) - ✅ P1-5 退款域模型闭环已修复payment.status 一步收敛到 `refunded`,并把订单推进到 refunded 终态;幂等请求会自愈)
- ✅ P1-2 删除/匿名化已覆盖 orders 主表 + payments.callback_payload + order_intakes.payload_json - ✅ P1-2 删除/匿名化已覆盖 orders 主表 + payments.callback_payload + order_intakes.payload_json
- ✅ P1-7 验证链口径统一dev-verify / CI 统一调用 scripts/check_coverage_gate.py并以 80% / 100% 与 codecov 对齐 - ✅ P1-7 验证链口径统一dev-verify / CI 统一调用 scripts/check_coverage_gate.py并以 80% / 100% 与 codecov 对齐
- ✅ P2-1 公共下单孤儿订单已验证有完整回归测试覆盖admin/tests/test_web_public.py 9 passed
- ✅ P2-3 delivery sent 语义修正dispatcher 现在区分 validated / deliveredstation 只到 validatedemail 才到 delivered
顺序: 顺序:

View File

@@ -10,6 +10,9 @@ from data.orders.intake_store import IntakeStore
from data.orders.models import Order from data.orders.models import Order
from data.payments.service import PaymentService from data.payments.service import PaymentService
from data.notifications.dispatcher import DeliveryDispatcher
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -69,7 +72,9 @@ def _attach_ready_delivery(settings, tmp_path: Path, order_id: str) -> None:
) )
def test_dispatch_ready_station_event_marks_sent(settings, tmp_path): def test_dispatch_ready_station_event_validates_persisted_payload(
settings, tmp_path
):
order = _seed_order(settings.orders_db_path) order = _seed_order(settings.orders_db_path)
_mark_paid(settings, order) _mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save( IntakeStore.for_db(settings.orders_db_path).save(
@@ -86,22 +91,65 @@ def test_dispatch_ready_station_event_marks_sent(settings, tmp_path):
dispatcher.close() dispatcher.close()
assert result.processed == 1 assert result.processed == 1
assert result.sent == 1 # station channel is local render only; ``delivered`` must be 0.
assert result.delivered == 0
assert result.validated == 1
assert result.failed == 0 assert result.failed == 0
assert not hasattr(result, "sent")
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path) notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try: try:
event = notification_service.list_events(order.id)[0] event = notification_service.list_events(order.id)[0]
finally: finally:
notification_service.close() notification_service.close()
assert event.status == "sent" assert event.status == "validated"
assert event.attempt_count == 1 assert event.attempt_count == 1
payload = json.loads(event.payload_json) payload = json.loads(event.payload_json)
# The portal page consumes ``station_notice`` from the persisted
# payload even though ``station`` does not transition to
# ``delivered``; the dispatcher stashes it during ``mark_validated``.
station_notice = payload.get("station_notice") station_notice = payload.get("station_notice")
assert station_notice is not None assert station_notice is not None
assert station_notice["title"] == "报告已就绪" assert station_notice["title"] == "报告已就绪"
assert order.id in station_notice["body"] assert order.id in station_notice["body"]
assert station_notice["sent_at"] == event.last_attempt_at
def test_dispatch_station_marks_validated_then_delivered(settings, tmp_path):
"""P2-3 lock: ``station`` channel cannot be reported as ``delivered``.
The lifecycle is now ``ready`` -> ``validated``. ``delivered`` is
reserved for channels that actually push the notice externally
(today: ``email``). This test re-asserts the new chain so future
regressions that flatten the lifecycle back to a single ``sent``
step will fail.
"""
order = _seed_order(
settings.orders_db_path,
order_id="GKO-20260614-DISPATCH-STATION-DELIVERED",
)
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
dispatcher = DeliveryDispatcher.for_db(settings.orders_db_path)
try:
result = dispatcher.dispatch_ready_events(channel="station")
finally:
dispatcher.close()
assert result.processed == 1
assert result.validated == 1
assert result.delivered == 0
assert result.failed == 0
assert not hasattr(result, "sent")
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
event = notification_service.list_events(order.id)[0]
finally:
notification_service.close()
assert event.status == "validated"
class _FakeEmailSender: class _FakeEmailSender:
@@ -146,7 +194,7 @@ def test_dispatch_ready_email_event_marks_sent(settings, tmp_path):
dispatcher.close() dispatcher.close()
assert result.processed == 1 assert result.processed == 1
assert result.sent == 1 assert result.delivered == 1
assert result.failed == 0 assert result.failed == 0
assert len(email_sender.sent) == 1 assert len(email_sender.sent) == 1
assert email_sender.sent[0]["recipient"] == "parent@example.com" assert email_sender.sent[0]["recipient"] == "parent@example.com"
@@ -156,7 +204,7 @@ def test_dispatch_ready_email_event_marks_sent(settings, tmp_path):
event = notification_service.list_events(order.id, channel="email")[0] event = notification_service.list_events(order.id, channel="email")[0]
finally: finally:
notification_service.close() notification_service.close()
assert event.status == "sent" assert event.status == "delivered"
payload = json.loads(event.payload_json) payload = json.loads(event.payload_json)
email_notice = payload.get("email_notice") email_notice = payload.get("email_notice")
assert email_notice is not None assert email_notice is not None
@@ -186,8 +234,9 @@ def test_dispatch_ready_station_event_marks_failed_when_pdf_missing(settings, tm
dispatcher.close() dispatcher.close()
assert result.processed == 1 assert result.processed == 1
assert result.sent == 0
assert result.failed == 1 assert result.failed == 1
assert result.validated == 0
assert result.delivered == 0
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path) notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try: try: