Files
gaokao-volunteer-system/data/orders/dao.py
hermes 187b2ae634 fix(compliance): A-2 admin/外部渠道补录同意审计统一化
LEGAL_PRIVACY_BASELINE §4/§6 要求任何订单创建路径必须记录同意审计字段。
portal 路径已落 consent_channel=portal / consent_operator=guardian (见
web_public.py + intake_store.save), admin 路径 6/20 之前完全不入任何 consent
字段 (admin/routes/orders.py grep 'consent' 0 命中), 形成合规盲区。

落地:
- CreateOrderRequest 新增必填 consent: ConsentInfo
  - consent_method Literal: verbal_chat / phone_recording / screenshot /
    written_form / self_declared
  - consent_note Optional[str]
  - 缺失或非法 → HTTP 422
- Order 模型 + DAO _WRITABLE_COLUMNS 加 consent_method / consent_given_at
  (冗余落库避免每次列表 join order_intakes)
- schema 增量: ALTER TABLE orders ADD COLUMN consent_method / consent_given_at
  (幂等)
- create_order 同步写 order_intakes (独立 IntakeStore.for_db, 不复用 OrdersDAO
  conn — T12-D conn ownership 修复保驾)
  - consent_channel = payload.source (xianyu/wechat/school/web)
  - consent_operator 严格按基线白名单 self/guardian/admin_import:
    - web 渠道: 'guardian' (与 intake_store.save 默认值一致)
    - 其他渠道: 'admin_import' (后台代录, 同意来源是渠道商)
  - consent_method / consent_given_at / consent_note 落库

测试:
- test_create_order_rejects_missing_consent_block[xianyu|wechat|school|web] (4 个)
- test_create_order_writes_intake_record_with_consent_audit
- test_create_order_external_channel_marks_consent_operator_as_admin
- test_create_order_rejects_invalid_consent_method
- test_order_detail_returns_consent_method_and_given_at
- 更新 test_create_order_returns_masked_payload_with_history (加 consent)
- 更新 test_admin_orders_alias_list_and_detail (加 consent)

验证:
- 25/25 admin/tests/{test_routes_orders,test_admin_alias_routes} 通过
- ruff + mypy 通过
- 端到端 smoke: 4 笔订单 (2 terminal + 1 pending + 1 paid-in-window) 实测
  包含 consent 字段全部通过
- dev-verify: 1186 passed / 2 failed (失败的 2 个均为 worktree 环境限制:
  test_backup_restore_service_level 的 subprocess 路径假设 + 6/19 已知问题,
  与本改动无关; main 上单跑同样测试通过)
2026-06-20 15:29:06 +08:00

952 lines
35 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.
"""订单 DAO 数据访问层 (T4.2)
提供订单表的 CRUD、事务、加密字段透明处理、6 态状态机守护的转换写入。
设计原则
--------
1. **加密透明化**API 入口接收 ``Order`` dataclass明文 PII
DAO 负责落盘前加密、读取后解密,调用方无需关心 ``*_enc`` 字段。
2. **状态机守护**:所有状态转换走 ``transition_status()``,单事务内
写 ``orders.status`` + ``order_status_history``;非法转换抛
:class:`InvalidStateTransition` 并回滚。
3. **事务显式**:默认每方法一次 ``commit``;批量操作走 ``transaction()``
上下文管理器(异常时统一 ``rollback``)。
4. **行工厂统一**DAO 内部强制 ``sqlite3.Row`` 工厂,调用方传入的
``row_factory`` 不会被污染(使用前保存 / 使用后恢复)。
5. **去重路径**``(source, external_id)`` 唯一索引上的 ``upsert_by_external_id()``
接管 :mod:`data.channel_sync.dao_extension` 的同名函数(向下兼容)。
依赖
----
- :class:`data.orders.models.Order`
- :func:`data.orders.schema.apply_schema`
- :mod:`data.orders.state_machine`
- :func:`data.orders.crypto.encrypt` / :func:`decrypt`
替代与回滚
----------
- :class:`data.channel_sync.dao_extension.UpsertResult` 与
:func:`data.channel_sync.dao_extension.upsert_by_external_id` 由本模块的
:class:`UpsertResult` / :meth:`OrdersDAO.upsert_by_external_id` 取代。
T8.1 现存调用迁移到本 DAO 后即可删除 dao_extension.py。
"""
from __future__ import annotations
import contextlib
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator, List, Optional, Union
from data.notifications.email_service import DeliveryNotificationService
from .models import Order, generate_order_id, utc_now_iso
from .schema import apply_schema
from .state_machine import (
InvalidStateTransition,
assert_valid_transition,
is_known_status,
)
# ---------------------------------------------------------------------------
# 异常与结果类型
# ---------------------------------------------------------------------------
class OrderNotFound(LookupError):
"""按主键或唯一键查询订单时未命中。"""
class DuplicateOrder(ValueError):
"""尝试插入违反唯一约束的订单(手机号 hash 或 external_id 冲突)。"""
@dataclass
class UpsertResult:
"""upsert_by_external_id 的返回结构。
字段含义:
- ``action='inserted'``:原 DB 不存在该 ``(source, external_id)``,已新建。
- ``action='updated'``:已存在且状态可推进,已更新 status / status_updated_at
并写入一条 status_history。
- ``action='unchanged'``:已存在且状态未变,未写入。
- ``action='illegal_transition'``:已存在但状态转换非法(未写入),
调用方应降级为 ``decision='rejected'``。
"""
order_id: str
action: str # 'inserted' | 'updated' | 'unchanged' | 'illegal_transition'
old_status: Optional[str] = None
new_status: Optional[str] = None
error: Optional[str] = None
@dataclass
class StatusChange:
"""状态历史记录(来自 order_status_history 表)。"""
id: int
order_id: str
from_status: Optional[str]
to_status: str
actor: Optional[str]
reason: Optional[str]
changed_at: str
# ---------------------------------------------------------------------------
# 内部常量
# ---------------------------------------------------------------------------
# 与 schema.py 对齐的 orders 表可写列清单。
# 加密字段customer_phone_enc / candidate_id_card_enc 来自 Order.to_db_row()。
_WRITABLE_COLUMNS: tuple[str, ...] = (
"id",
"source",
"external_id",
"service_version",
"amount_cents",
"status",
"status_updated_at",
"customer_name",
"customer_phone_enc",
"customer_phone_hash",
"customer_wechat",
"customer_email",
"candidate_name",
"candidate_id_card_enc",
"candidate_province",
"candidate_score",
"candidate_rank",
"candidate_subjects",
"candidate_interests",
"candidate_strong_subjects",
"candidate_weak_subjects",
"candidate_family",
"assigned_consultant",
"plan_file",
"audit_report",
"pdf_path",
"created_at",
"paid_at",
"started_at",
"delivered_at",
"completed_at",
"notes",
"tags",
"upgrade_from",
# A-2 (2026-06-20) — 后台/外部渠道补录同意审计统一化
# 冗余字段, 避免每次列表 join order_intakes。
"consent_method",
"consent_given_at",
)
# 历史阶段字段映射:状态进入时自动置位的 timestamp 字段。
# 状态 → timestamp 字段名COALESCE 写入:已有则保留)。
_STATUS_TIMESTAMP: dict[str, str] = {
"paid": "paid_at",
"serving": "started_at",
"delivered": "delivered_at",
"completed": "completed_at",
}
# ---------------------------------------------------------------------------
# DAO 主类
# ---------------------------------------------------------------------------
class OrdersDAO:
"""订单表 DAO。
两种初始化方式:
1. 接管已建立的连接::
conn = apply_schema("/path/orders.db")
dao = OrdersDAO(conn)
DAO 不会关闭 conn调用方负责。
2. 接管数据库路径::
with OrdersDAO.connect("/path/orders.db") as dao:
dao.create(order)
退出上下文时自动 commit/close。
"""
def __init__(self, conn: sqlite3.Connection, *, owns_conn: bool = False) -> None:
"""构造 DAO。
- ``owns_conn=False``(默认): ``conn`` 由调用方持有与关闭,``__exit__``
只 commit/rollback**不** close。这允许上层 service 把自己的连接
包成 DAO 走 with-block典型场景循环里多次复用同一连接执行多个
写操作,再由 service 统一 commit/close。T12-D regression。
- ``owns_conn=True``: ``OrdersDAO.connect()`` 内部 ``apply_schema`` 创建
的连接由 DAO 自己关闭,``__exit__`` 在 commit/rollback 后再 close。
"""
self._conn = conn
self._owns_conn = owns_conn
self._tx_depth = 0 # 嵌套事务深度0 = 顶层)
# DAO 假设 conn 已启用 foreign_keys不强制重设调用方控制
# ------------------------------------------------------------------
# 构造/连接管理
# ------------------------------------------------------------------
@classmethod
def connect(
cls,
db_path: Union[str, Path],
*,
row_factory: bool = True,
) -> "OrdersDAO":
"""按路径建立连接并应用 schema幂等返回 DAO。
``row_factory=True`` 时强制设为 ``sqlite3.Row``,便于 ``dict(row)``。
用法::
with OrdersDAO.connect("data/orders.db") as dao:
dao.create(order)
"""
conn = apply_schema(db_path)
if row_factory:
conn.row_factory = sqlite3.Row
# ``connect`` 自己创建了连接DAO 拥有所有权__exit__ 必须 close。
return cls(conn, owns_conn=True)
@property
def conn(self) -> sqlite3.Connection:
"""暴露底层连接(只读引用,调用方不应自行 commit/close"""
return self._conn
def close(self) -> None:
"""关闭底层连接。"""
self._conn.close()
@contextlib.contextmanager
def transaction(
self, *, begin_mode: str = "deferred"
) -> Iterator[sqlite3.Connection]:
"""事务上下文。
进入时自动 ``BEGIN``,异常时 ``ROLLBACK`` 并重新抛出;
正常退出时 ``COMMIT``。
嵌套语义: 内部 ``create()`` / ``update()`` / ``transition_status()``
自身会再调 ``transaction()``。当外层已在事务中时,内层不再开新事务,
直接复用外层 — 任何一层的异常都会触发外层回滚。这是经典的 SAVEPOINT
简化版(无部分回滚),适合本 DAO 的写多读少场景。
用法::
with dao.transaction() as conn:
conn.execute(...)
conn.execute(...) # 同事务
"""
if begin_mode not in {"deferred", "immediate"}:
raise ValueError(f"unsupported begin_mode: {begin_mode}")
self._tx_depth += 1
try:
if self._tx_depth == 1:
if begin_mode == "immediate":
self._conn.execute("BEGIN IMMEDIATE")
yield self._conn
self._conn.commit()
else:
# 嵌套:复用外层事务,不 commit/rollback
yield self._conn
except Exception:
if self._tx_depth == 1:
self._conn.rollback()
raise
finally:
self._tx_depth -= 1
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
@contextlib.contextmanager
def _row_factory_ctx(self) -> Iterator[None]:
"""临时把 conn.row_factory 设为 sqlite3.Row退出时恢复。"""
prior = self._conn.row_factory
self._conn.row_factory = sqlite3.Row
try:
yield
finally:
self._conn.row_factory = prior
@staticmethod
def _coerce_for_db(key: str, value: Any) -> Any:
"""保证 tags / candidate_subjects 落盘为 JSON 字符串。"""
if key in ("tags", "candidate_subjects") and isinstance(value, (list, tuple)):
return json.dumps(list(value), ensure_ascii=False)
return value
def _row_to_order(self, row: sqlite3.Row) -> Order:
"""sqlite3.Row → Order解密 + JSON 解析由 from_db_row 负责)。"""
return Order.from_db_row(dict(row))
def _select_columns(self) -> str:
return ", ".join(_WRITABLE_COLUMNS)
# ------------------------------------------------------------------
# 写入create / update
# ------------------------------------------------------------------
def create(
self,
order: Order,
*,
actor: Optional[str] = None,
reason: Optional[str] = None,
) -> Order:
"""插入新订单,并写入首条 status_historyfrom=None → status
- 重复主键 / 重复 external_id 抛 :class:`DuplicateOrder`。
- 重复 phone_hash非唯一索引仅用于查询允许 — 同一手机号
下不同省份/年份可以下多单。
返回: 写入后的 Order数据库回读字段已对齐 SQLite 默认值)。
"""
db_row = order.to_db_row()
# 防御:过滤掉 schema 中不存在的列
valid_cols = set(_WRITABLE_COLUMNS)
db_row = {
k: self._coerce_for_db(k, v) for k, v in db_row.items() if k in valid_cols
}
# 落盘 timestamp 不能为空
if not db_row.get("status_updated_at"):
db_row["status_updated_at"] = utc_now_iso()
if not db_row.get("created_at"):
db_row["created_at"] = utc_now_iso()
cols = list(db_row.keys())
placeholders = ",".join("?" for _ in cols)
values = [db_row[c] for c in cols]
with self.transaction():
try:
self._conn.execute(
f"INSERT INTO orders ({','.join(cols)}) VALUES ({placeholders})",
values,
)
except sqlite3.IntegrityError as exc:
msg = str(exc).lower()
if "unique" in msg or "primary key" in msg:
raise DuplicateOrder(
f"订单已存在: id={order.id} source={order.source} external_id={order.external_id} ({exc})"
) from exc
raise
self._insert_status_history(
order_id=order.id,
from_status=None,
to_status=order.status,
actor=actor or "dao_create",
reason=reason or "create",
)
# 读回行(确保返回字段与 DB 对齐)
created_id = order.id
with self._row_factory_ctx():
row = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE id=?",
(created_id,),
).fetchone()
return self._row_to_order(row)
def update(
self,
order_id: str,
updates: dict[str, Any],
*,
actor: Optional[str] = None,
reason: Optional[str] = None,
) -> Order:
"""按主键更新订单业务字段(非 status 字段)。
适用字段customer_name / customer_wechat / customer_email / candidate_name /
candidate_province / candidate_score / candidate_rank /
candidate_subjects / candidate_interests / candidate_strong_subjects /
candidate_weak_subjects / candidate_family / assigned_consultant /
plan_file / audit_report / pdf_path / notes / tags / amount_cents /
service_version / external_id。
**禁止**通过本方法改 ``status`` —— 改状态请走 :meth:`transition_status`
以保证状态机校验和历史写入。
- 不存在抛 :class:`OrderNotFound`。
- 重复 external_id 抛 :class:`DuplicateOrder`。
返回: 更新后的 Order。
"""
if "status" in updates:
raise ValueError("禁止通过 update() 改 status请使用 transition_status()")
allowed = set(_WRITABLE_COLUMNS) - {"id", "status", "status_updated_at"}
bad = set(updates) - allowed
if bad:
raise ValueError(
f"update() 不允许字段: {sorted(bad)}(仅业务字段,不含 status/timestamp"
)
with self.transaction():
with self._row_factory_ctx():
row = self._conn.execute(
"SELECT id FROM orders WHERE id=?",
(order_id,),
).fetchone()
if row is None:
raise OrderNotFound(f"订单不存在: {order_id}")
set_clauses: list[str] = []
values: list[Any] = []
for k, v in updates.items():
set_clauses.append(f"{k}=?")
values.append(self._coerce_for_db(k, v))
# 业务字段更新不影响 status_updated_at只有 transition_status 才动
values.append(order_id)
try:
self._conn.execute(
f"UPDATE orders SET {','.join(set_clauses)} WHERE id=?",
values,
)
except sqlite3.IntegrityError as exc:
msg = str(exc).lower()
if "unique" in msg:
raise DuplicateOrder(
f"订单更新违反唯一约束: id={order_id} ({exc})"
) from exc
raise
with self._row_factory_ctx():
row = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE id=?",
(order_id,),
).fetchone()
return self._row_to_order(row)
def upgrade_order(
self,
order_id: str,
*,
target_service_version: str,
target_amount_cents: int,
actor: Optional[str] = None,
reason: Optional[str] = None,
) -> Order:
"""基于已有订单创建补差价升级单,并回写原单升级标记。"""
original = self.get(order_id)
if target_amount_cents <= original.amount_cents:
raise ValueError(
"target_amount_cents 必须高于原订单金额,才能生成补差价升级单"
)
with self._row_factory_ctx():
existing = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE upgrade_from=? LIMIT 1",
(order_id,),
).fetchone()
if existing is not None:
existing_order = self._row_to_order(existing)
raise ValueError(f"原订单已存在升级订单: {existing_order.id}")
delta_amount = target_amount_cents - original.amount_cents
upgrade_id = generate_order_id()
source_tags = list(original.tags or [])
if "upgraded" not in source_tags:
source_tags.append("upgraded")
marker = f"升级至 {target_service_version},升级单 {upgrade_id}"
source_note_prefix = (original.notes + "\n") if original.notes else ""
source_note = f"{source_note_prefix}{marker}"
upgraded = Order(
id=upgrade_id,
source=original.source,
external_id=None,
service_version=target_service_version,
amount_cents=delta_amount,
status="pending",
customer_name=original.customer_name,
customer_phone=original.customer_phone,
customer_wechat=original.customer_wechat,
customer_email=original.customer_email,
candidate_name=original.candidate_name,
candidate_id_card=original.candidate_id_card,
candidate_province=original.candidate_province,
candidate_score=original.candidate_score,
candidate_rank=original.candidate_rank,
candidate_subjects=list(original.candidate_subjects),
candidate_interests=original.candidate_interests,
candidate_strong_subjects=original.candidate_strong_subjects,
candidate_weak_subjects=original.candidate_weak_subjects,
candidate_family=original.candidate_family,
assigned_consultant=original.assigned_consultant,
plan_file=None,
audit_report=None,
pdf_path=None,
created_at=None,
paid_at=None,
started_at=None,
delivered_at=None,
completed_at=None,
notes=f"升级自 {original.id}"
if not original.notes
else f"{original.notes}\n升级自 {original.id}",
tags=list(original.tags or []),
upgrade_from=original.id,
)
with self.transaction():
self.update(
order_id,
{"notes": source_note, "tags": source_tags},
actor=actor,
reason=reason or f"upgrade_source:{target_service_version}",
)
return self.create(
upgraded,
actor=actor or "dao_upgrade",
reason=reason or f"upgrade_from:{order_id}",
)
# ------------------------------------------------------------------
# 状态转换
# ------------------------------------------------------------------
def transition_status(
self,
order_id: str,
to_status: str,
*,
actor: Optional[str] = None,
reason: Optional[str] = None,
) -> Order:
"""状态机守护的状态转换。
流程(单事务):
1. 读现状 ``SELECT status FROM orders WHERE id=?``
2. ``assert_valid_transition(from, to)`` 校验;非法抛
:class:`InvalidStateTransition` 并回滚
3. ``UPDATE orders SET status=?, status_updated_at=?, <status timestamp>=COALESCE(?, ?)``
4. ``INSERT INTO order_status_history(from, to, actor, reason)``
5. 读回返回
返回: 转换后的 Order。
"""
if not is_known_status(to_status):
raise InvalidStateTransition(f"未知目标状态: {to_status!r}")
with self.transaction():
with self._row_factory_ctx():
row = self._conn.execute(
"SELECT status FROM orders WHERE id=?",
(order_id,),
).fetchone()
if row is None:
raise OrderNotFound(f"订单不存在: {order_id}")
from_status = row["status"]
# 状态机校验(非法时抛 InvalidStateTransition
assert_valid_transition(from_status, to_status)
now_iso = utc_now_iso()
# 对应时间戳字段COALESCE(原值, 新值) — 已有则保留
ts_col = _STATUS_TIMESTAMP.get(to_status)
if ts_col is not None:
self._conn.execute(
f"""
UPDATE orders SET
status=?,
status_updated_at=?,
{ts_col} = COALESCE({ts_col}, ?)
WHERE id=?
""",
(to_status, now_iso, now_iso, order_id),
)
else:
# refunded / pending 等没有专用时间戳
self._conn.execute(
"""
UPDATE orders SET
status=?,
status_updated_at=?
WHERE id=?
""",
(to_status, now_iso, order_id),
)
self._insert_status_history(
order_id=order_id,
from_status=from_status,
to_status=to_status,
actor=actor or "dao_transition",
reason=reason,
changed_at=now_iso,
)
with self._row_factory_ctx():
row = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE id=?",
(order_id,),
).fetchone()
order = self._row_to_order(row)
if (
to_status == "delivered"
and (order.audit_report or order.plan_file)
and order.pdf_path
):
notifier = DeliveryNotificationService.from_connection(self._conn)
payload = json.dumps(
{
"order_id": order_id,
"audit_report": order.audit_report,
"plan_file": order.plan_file,
"pdf_path": order.pdf_path,
},
ensure_ascii=False,
)
notifier.notify_report_ready(order_id, payload)
if order.customer_email:
notifier.notify_event(
order_id,
event_type="report_ready",
channel="email",
payload_json=json.dumps(
{
"order_id": order_id,
"audit_report": order.audit_report,
"plan_file": order.plan_file,
"pdf_path": order.pdf_path,
"customer_email": order.customer_email,
},
ensure_ascii=False,
),
)
return order
def _insert_status_history(
self,
*,
order_id: str,
from_status: Optional[str],
to_status: str,
actor: Optional[str] = None,
reason: Optional[str] = None,
changed_at: Optional[str] = None,
) -> int:
"""插入一条 order_status_history 记录,返回 rowid。
不 commit —— 由外层 transaction() 统一提交。
"""
if changed_at is None:
changed_at = utc_now_iso()
cur = self._conn.execute(
"""
INSERT INTO order_status_history(
order_id, from_status, to_status, actor, reason, changed_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(order_id, from_status, to_status, actor, reason, changed_at),
)
return int(cur.lastrowid or 0)
def get_status_history(self, order_id: str) -> List[StatusChange]:
"""读订单完整状态历史(按 changed_at 升序)。"""
with self._row_factory_ctx():
rows = self._conn.execute(
"""
SELECT id, order_id, from_status, to_status, actor, reason, changed_at
FROM order_status_history
WHERE order_id=?
ORDER BY changed_at ASC, id ASC
""",
(order_id,),
).fetchall()
return [
StatusChange(
id=int(r["id"]),
order_id=r["order_id"],
from_status=r["from_status"],
to_status=r["to_status"],
actor=r["actor"],
reason=r["reason"],
changed_at=r["changed_at"],
)
for r in rows
]
# ------------------------------------------------------------------
# 查询get / list / find
# ------------------------------------------------------------------
def get(self, order_id: str) -> Order:
"""按主键读取订单(解密 PII。不存在抛 :class:`OrderNotFound`。"""
with self._row_factory_ctx():
row = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE id=?",
(order_id,),
).fetchone()
if row is None:
raise OrderNotFound(f"订单不存在: {order_id}")
return self._row_to_order(row)
def get_by_external_id(self, source: str, external_id: str) -> Optional[Order]:
"""按 (source, external_id) 查询;找不到返回 None。"""
with self._row_factory_ctx():
row = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders "
"WHERE source=? AND external_id=? LIMIT 1",
(source, external_id),
).fetchone()
return self._row_to_order(row) if row is not None else None
def find_by_phone(self, phone: str) -> List[Order]:
"""按手机号 hash 查询(去重 / 客户识别用),返回全部匹配。
phone 接受明文DAO 内部按 SHA-256 hash 查询。
"""
from .crypto import hash_for_index
with self._row_factory_ctx():
rows = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders "
"WHERE customer_phone_hash=? ORDER BY created_at DESC",
(hash_for_index(phone),),
).fetchall()
return [self._row_to_order(r) for r in rows]
def list(
self,
*,
status: Optional[str] = None,
source: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> List[Order]:
"""按筛选条件列订单(默认按 created_at DESC
- ``status`` 必须是已知 6 态之一;传未知值抛 :class:`ValueError`。
- ``limit`` 取值 1..1000;越界抛 :class:`ValueError`。
- ``offset`` ≥ 0。
"""
if status is not None and not is_known_status(status):
raise ValueError(f"未知 status: {status!r}")
if not (1 <= limit <= 1000):
raise ValueError(f"limit 越界 (1..1000): {limit}")
if offset < 0:
raise ValueError(f"offset 不能为负: {offset}")
clauses: list[str] = []
params: list[Any] = []
if status is not None:
clauses.append("status=?")
params.append(status)
if source is not None:
clauses.append("source=?")
params.append(source)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
sql = (
f"SELECT {self._select_columns()} FROM orders "
f"{where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
)
params.extend([limit, offset])
with self._row_factory_ctx():
rows = self._conn.execute(sql, params).fetchall()
return [self._row_to_order(r) for r in rows]
def count(
self, *, status: Optional[str] = None, source: Optional[str] = None
) -> int:
"""统计订单数(同样支持 status / source 过滤)。"""
if status is not None and not is_known_status(status):
raise ValueError(f"未知 status: {status!r}")
clauses: list[str] = []
params: list[Any] = []
if status is not None:
clauses.append("status=?")
params.append(status)
if source is not None:
clauses.append("source=?")
params.append(source)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
row = self._conn.execute(
f"SELECT COUNT(*) AS n FROM orders {where}",
params,
).fetchone()
# COUNT 总是返回 1 行;防御性 default
return int(row[0] if row else 0)
def stats_by_status(self) -> dict[str, int]:
"""按 status 分组统计订单数(含 0 计数的完整 6 态)。"""
rows = self._conn.execute(
"SELECT status, COUNT(*) AS n FROM orders GROUP BY status"
).fetchall()
result: dict[str, int] = {
s: 0
for s in (
"pending",
"paid",
"serving",
"delivered",
"completed",
"refunded",
)
}
for r in rows:
# 允许 sqlite3.Row / tuple 两种形态
status_key = r["status"] if hasattr(r, "keys") else r[0]
count_val = r["n"] if hasattr(r, "keys") else r[1]
if status_key in result:
result[status_key] = int(count_val)
return result
# ------------------------------------------------------------------
# 幂等 upsert与 T8.1 dao_extension 对齐)
# ------------------------------------------------------------------
def upsert_by_external_id(
self,
order: Order,
*,
actor: str = "channel_sync",
reason: Optional[str] = None,
) -> UpsertResult:
"""按 (source, external_id) 唯一索引写入或更新订单。
行为(与 :mod:`data.channel_sync.dao_extension.upsert_by_external_id` 对齐):
- **external_id 缺失** → ``action='illegal_transition'`` + error
- **不存在** → 插入新行 + 写 status_history(from=None → status)
- **已存在且状态不变** → ``action='unchanged'``,不写 status_history
- **已存在且状态可推进** → 更新 status / status_updated_at + 写 status_history
- **已存在但状态非法转换** → ``action='illegal_transition'`` + error
返回: :class:`UpsertResult`。
"""
if not order.external_id:
return UpsertResult(
order_id=order.id,
action="illegal_transition",
error="external_id 缺失,无法做幂等 upsert",
)
# 1) 查重
with self._row_factory_ctx():
row = self._conn.execute(
"SELECT * FROM orders WHERE source=? AND external_id=? LIMIT 1",
(order.source, order.external_id),
).fetchone()
if row is None:
# INSERT — 沿用调用方传入的 reason/actor
try:
created = self.create(order, actor=actor, reason=reason)
except DuplicateOrder as exc:
return UpsertResult(
order_id=order.id,
action="illegal_transition",
error=f"重复订单: {exc}",
)
return UpsertResult(
order_id=created.id,
action="inserted",
old_status=None,
new_status=created.status,
)
# 2) 已存在:判断状态转换
existing = self._row_to_order(row)
old_status = existing.status
if old_status == order.status:
return UpsertResult(
order_id=existing.id,
action="unchanged",
old_status=old_status,
new_status=order.status,
)
try:
assert_valid_transition(old_status, order.status)
except InvalidStateTransition as exc:
return UpsertResult(
order_id=existing.id,
action="illegal_transition",
old_status=old_status,
new_status=order.status,
error=str(exc),
)
# 3) 合法推进:走 transition_status
self.transition_status(
existing.id,
order.status,
actor=actor,
reason=reason or f"upsert_{order.source}",
)
return UpsertResult(
order_id=existing.id,
action="updated",
old_status=old_status,
new_status=order.status,
)
# ------------------------------------------------------------------
# 删除(保留 — 业务上极少使用,但测试 + GDPR 流程可能需要)
# ------------------------------------------------------------------
def delete(self, order_id: str, *, hard: bool = False) -> bool:
"""删除订单。
- ``hard=False``默认仅删除订单行order_status_history
由 ``ON DELETE CASCADE`` 自动清理。**该模式用于业务侧强制
删除(如恶意订单)**;请注意:已加密的 PII 字段随行一起
消失,状态历史同样消失。
- ``hard=True``:当前等价于 ``hard=False``;预留 ``PRAGMA
secure_delete`` 配置接口。
- 不存在返回 False成功删除返回 True。
注意:状态机不提供"删除"操作 — 这是物理删除,不会写 status_history。
如需审计可改用 :class:`DataDeletionAudit` 单独的审计表。
"""
del hard # 当前未使用 — 预留
with self.transaction():
cur = self._conn.execute("DELETE FROM orders WHERE id=?", (order_id,))
return cur.rowcount > 0
# ------------------------------------------------------------------
# Dunder
# ------------------------------------------------------------------
def __enter__(self) -> "OrdersDAO":
return self
def __exit__(self, exc_type, exc, tb) -> None:
try:
if exc_type is None:
self._conn.commit()
else:
self._conn.rollback()
finally:
# 只在 DAO 自己拥有连接时才关闭;外部传入的连接由调用方负责生命周期。
# T12-D regression: deletion_service.anonymize_order 多次复用 service
# 持有的 self._conn若 DAO 退出时 close 会导致后续所有 DAO 操作崩。
if self._owns_conn:
self._conn.close()
__all__ = [
"OrdersDAO",
"UpsertResult",
"StatusChange",
"OrderNotFound",
"DuplicateOrder",
]