Files
gaokao-volunteer-system/admin/routes/orders.py

845 lines
28 KiB
Python
Raw Normal View History

2026-06-13 14:49:58 +08:00
"""订单管理路由 (T6.4).
范围:
- GET /api/orders 订单列表真实 DAO + 脱敏
- GET /api/orders/export CSV 导出默认脱敏
- GET /api/orders/{id} 订单详情 + 状态历史
- POST /api/orders 手工录单
- PATCH /api/orders/{id} 业务字段更新 / 状态流转 / 退款
"""
from __future__ import annotations
import csv
import json
2026-06-13 14:49:58 +08:00
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
2026-06-13 14:49:58 +08:00
from io import StringIO
from typing import Any, Literal, Optional, cast
from fastapi import APIRouter, Depends, Path as ApiPath, Query, Response, status
2026-06-13 14:49:58 +08:00
from pydantic import BaseModel, Field
from admin.auth import require_role
2026-06-13 14:49:58 +08:00
from admin.config import Settings, get_settings_dep
from admin.db import AdminUser
from admin.errors import (
BIZ_ORDER_INVALID_STATUS,
BIZ_ORDER_NOT_FOUND,
BIZ_ORDER_RETENTION_NOT_EXPIRED,
2026-06-13 14:49:58 +08:00
DATA_PERSIST_FAILED,
DATA_VALIDATION_FAILED,
)
from admin.errors.exceptions import BusinessError
from data.orders.dao import DuplicateOrder, OrderNotFound, OrdersDAO
from data.orders.deletion_service import (
RETENTION_GUARDED_STATUSES,
OrderDeletionService,
)
2026-06-14 10:38:04 +08:00
from data.orders.intake_store import IntakeStore
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
from data.orders.models import Order, generate_order_id, utc_now_iso
2026-06-13 14:49:58 +08:00
from data.orders.state_machine import InvalidStateTransition, next_states
def _parse_iso_utc(raw: Optional[str]) -> Optional[datetime]:
if not raw:
return None
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _assert_retention_expired(
order: Order,
*,
retention_days: int,
now: Optional[datetime] = None,
) -> None:
"""保留期门禁 — paid/serving/... 状态的订单必须过 retention_days 才允许 delete/anonymize。
口径来源: docs/DATA_RETENTION_AND_DELETION.md §2 服务完成后 180
落在 admin 层而不是 data.orders ,避免反向依赖
"""
if order.status not in RETENTION_GUARDED_STATUSES:
return
anchor = _parse_iso_utc(order.status_updated_at) or _parse_iso_utc(order.created_at)
if anchor is None:
return
from datetime import timedelta
current = now or datetime.now(timezone.utc)
elapsed = current - anchor
if elapsed < timedelta(days=retention_days):
remaining = timedelta(days=retention_days) - elapsed
raise BusinessError(
BIZ_ORDER_RETENTION_NOT_EXPIRED,
detail={
"order_id": order.id,
"status": order.status,
"status_updated_at": order.status_updated_at,
"retention_days": retention_days,
"remaining_seconds": int(remaining.total_seconds()),
},
)
2026-06-13 14:49:58 +08:00
router = APIRouter(prefix="/api/orders", tags=["orders"])
admin_router = APIRouter(prefix="/api/admin/orders", tags=["orders"])
2026-06-13 14:49:58 +08:00
OrderSource = Literal["xianyu", "wechat", "web", "school"]
ServiceVersion = Literal["audit", "basic", "standard", "premium"]
OrderStatus = Literal[
"pending", "paid", "serving", "delivered", "completed", "refunded"
]
DevSeedScenario = Literal["overdue_pending_once", "cleanup_demo_seed"]
_DEMO_SEED_TAG = "__demo_seed__"
_DEMO_SEED_SOURCE = "dashboard_hidden_tools"
2026-06-13 14:49:58 +08:00
_SUMMARY_FIELDS = (
"id",
"source",
"external_id",
"service_version",
"status",
"amount_cents",
"customer_name",
"customer_phone",
"customer_wechat",
"candidate_name",
"candidate_province",
"assigned_consultant",
"created_at",
"status_updated_at",
"tags",
)
_EXPORT_FIELDS = (
"id",
"source",
"external_id",
"service_version",
"status",
"amount_cents",
"customer_name",
"customer_phone",
"customer_wechat",
"candidate_name",
"candidate_id_card",
"candidate_province",
"candidate_score",
"assigned_consultant",
"created_at",
"status_updated_at",
"notes",
)
_ALLOWED_UPDATE_FIELDS = {
"external_id",
"service_version",
"amount_cents",
"customer_name",
"customer_wechat",
"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",
}
_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@")
class OrderSummaryResponse(BaseModel):
id: str
source: str
external_id: Optional[str] = None
service_version: str
status: str
amount_cents: int
customer_name: Optional[str] = None
customer_phone: Optional[str] = None
customer_wechat: Optional[str] = None
candidate_name: Optional[str] = None
candidate_province: Optional[str] = None
assigned_consultant: Optional[str] = None
2026-06-14 10:38:04 +08:00
intake_status: Optional[str] = None
intake_submitted_at: Optional[str] = None
2026-06-13 14:49:58 +08:00
created_at: Optional[str] = None
status_updated_at: Optional[str] = None
tags: list[str] = Field(default_factory=list)
class OrderHistoryItem(BaseModel):
id: int
order_id: str
from_status: Optional[str] = None
to_status: str
actor: Optional[str] = None
reason: Optional[str] = None
changed_at: str
class OrderDetailPayload(BaseModel):
order: dict[str, Any]
history: list[OrderHistoryItem]
available_next_statuses: list[str]
class OrderMutationResponse(OrderDetailPayload):
action: str
class OrderDeletionResponse(BaseModel):
action: str
order_id: str
files_deleted: int = 0
2026-06-13 14:49:58 +08:00
class CreateOrderRequest(BaseModel):
source: OrderSource
external_id: Optional[str] = None
service_version: ServiceVersion
amount_cents: int = Field(ge=0)
customer_name: Optional[str] = None
customer_phone: Optional[str] = None
customer_wechat: Optional[str] = None
candidate_name: Optional[str] = None
candidate_id_card: Optional[str] = None
candidate_province: Optional[str] = None
candidate_score: Optional[int] = None
candidate_rank: Optional[int] = None
candidate_subjects: list[str] = Field(default_factory=list)
candidate_interests: Optional[str] = None
candidate_strong_subjects: Optional[str] = None
candidate_weak_subjects: Optional[str] = None
candidate_family: Optional[str] = None
assigned_consultant: Optional[str] = None
notes: Optional[str] = None
tags: list[str] = Field(default_factory=list)
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
# A-2 (2026-06-20): 后台/外部渠道补录同意审计统一化
# LEGAL_PRIVACY_BASELINE §6 要求任何订单创建路径必须记录同意审计字段。
# portal 路径已自动落 consent_channel=portal / consent_operator=guardian
# (见 web_public.py + intake_store.save), admin/外部渠道补录必须显式带
# consent 块, 否则 422。空 consent_note 也允许, 表示"已确认但无备注"。
consent: "ConsentInfo"
# A-2: 同意审计子模型
# consent_method 白名单:
# - verbal_chat: 家长/考生口头同意 (微信/电话/线下沟通)
# - phone_recording: 通话录音存档
# - screenshot: 微信/聊天截图存档
# - written_form: 书面/电子签字确认单
# - self_declared: 平台代填声明 (极少使用, 仅限已签长期合作协议的渠道)
ConsentMethod = Literal[
"verbal_chat",
"phone_recording",
"screenshot",
"written_form",
"self_declared",
]
class ConsentInfo(BaseModel):
consent_method: ConsentMethod
consent_note: Optional[str] = None
2026-06-13 14:49:58 +08:00
class UpdateOrderRequest(BaseModel):
updates: Optional[dict[str, Any]] = None
to_status: Optional[OrderStatus] = None
reason: Optional[str] = None
class DevSeedRequest(BaseModel):
scenario: DevSeedScenario
class DevSeedResponse(BaseModel):
action: str
created_ids: list[str] = Field(default_factory=list)
deleted_ids: list[str] = Field(default_factory=list)
detail: dict[str, Any] = Field(default_factory=dict)
2026-06-13 14:49:58 +08:00
def _mask_order(order: Order) -> dict[str, Any]:
masked = order.to_dict(decrypt_sensitive="mask")
masked.pop("customer_phone_hash", None)
return masked
def _summary(order: Order) -> dict[str, Any]:
masked = _mask_order(order)
return {field: masked.get(field) for field in _SUMMARY_FIELDS}
2026-06-14 10:38:04 +08:00
def _attach_intake_fields(order_payload: dict[str, Any], intake: Any) -> dict[str, Any]:
enriched = dict(order_payload)
enriched["intake_status"] = getattr(intake, "status", None)
enriched["intake_submitted_at"] = getattr(intake, "submitted_at", None)
enriched["intake"] = (
dict(getattr(intake, "payload", {}) or {}) if intake is not None else None
)
2026-06-14 10:38:04 +08:00
return enriched
2026-06-13 14:49:58 +08:00
def _csv_safe_value(value: Any) -> Any:
if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES):
return f"'{value}"
return value
def _export_row(order: Order) -> dict[str, Any]:
masked = _mask_order(order)
return {field: _csv_safe_value(masked.get(field)) for field in _EXPORT_FIELDS}
def _history_payload(dao: OrdersDAO, order_id: str) -> list[dict[str, Any]]:
return [asdict(item) for item in dao.get_status_history(order_id)]
2026-06-14 10:38:04 +08:00
def _detail_payload(
dao: OrdersDAO, order: Order, *, intake: Any = None
) -> dict[str, Any]:
2026-06-13 14:49:58 +08:00
return {
2026-06-14 10:38:04 +08:00
"order": _attach_intake_fields(_mask_order(order), intake),
2026-06-13 14:49:58 +08:00
"history": _history_payload(dao, order.id),
"available_next_statuses": sorted(next_states(order.status)),
}
def _normalize_updates(
updates: Optional[dict[str, Any]], settings: Settings
) -> dict[str, Any]:
2026-06-13 14:49:58 +08:00
if not updates:
return {}
bad = sorted(set(updates) - _ALLOWED_UPDATE_FIELDS)
if bad:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"invalid_update_fields": bad},
)
normalized = dict(updates)
if "audit_report" in normalized:
normalized["audit_report"] = _validate_report_artifact_path(
normalized["audit_report"], settings
)
if "pdf_path" in normalized:
normalized["pdf_path"] = _validate_report_artifact_path(
normalized["pdf_path"], settings
)
if "plan_file" in normalized:
normalized["plan_file"] = _validate_report_artifact_path(
normalized["plan_file"], settings
)
return normalized
2026-06-13 14:49:58 +08:00
def _business_error_for_lookup(order_id: str) -> BusinessError:
return BusinessError(BIZ_ORDER_NOT_FOUND, detail={"order_id": order_id})
_REPORT_ARTIFACT_SUFFIXES = {".html", ".json", ".md", ".pdf"}
def _trusted_report_roots(settings: Settings) -> tuple[Path, ...]:
return (
Path(settings.share_report_dir).resolve(),
(
Path(settings.portal_upload_dir).resolve().parent / "order_artifacts"
).resolve(),
Path("data/examples").resolve(),
)
def _validate_report_artifact_path(raw_path: Any, settings: Settings) -> str | None:
if raw_path is None or raw_path == "":
return None
if not isinstance(raw_path, str):
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": "artifact path must be a string"},
)
path = Path(raw_path).expanduser().resolve()
if path.suffix.lower() not in _REPORT_ARTIFACT_SUFFIXES:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": f"untrusted artifact suffix: {raw_path}"},
)
trusted_roots = _trusted_report_roots(settings)
if not any(root == path or root in path.parents for root in trusted_roots):
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": f"untrusted artifact path: {raw_path}"},
)
return str(path)
def _ensure_non_prod_seed_env(settings: Settings) -> None:
if settings.env == "prod":
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": "生产环境禁用隐藏测试造数入口"},
)
def _list_demo_seed_order_ids(dao: OrdersDAO) -> list[str]:
rows = dao.conn.execute(
"SELECT id, tags FROM orders ORDER BY created_at DESC LIMIT 500"
).fetchall()
ids: list[str] = []
for row in rows:
raw_tags = row["tags"] if hasattr(row, "keys") else row[1]
try:
tags = json.loads(raw_tags) if raw_tags else []
except (TypeError, json.JSONDecodeError):
tags = []
if _DEMO_SEED_TAG in tags:
ids.append(row["id"] if hasattr(row, "keys") else row[0])
return ids
def _create_overdue_pending_seed(
settings: Settings, current_user: AdminUser
) -> tuple[list[str], dict[str, Any]]:
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(microsecond=0)
created_at = (now - timedelta(days=2)).isoformat()
order = Order(
id=generate_order_id(),
source="web",
external_id=f"demo-seed-{generate_order_id()}",
service_version="standard",
amount_cents=9900,
status="pending",
status_updated_at=created_at,
customer_name="演示造数-超时待办",
candidate_name="演示考生",
candidate_province="湖南",
notes="隐藏入口补造:超时待办演示单",
tags=[_DEMO_SEED_TAG, _DEMO_SEED_SOURCE, "overdue_pending_once"],
created_at=created_at,
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
created = dao.create(
order, actor=current_user.username, reason="admin_hidden_seed"
)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_store.save(
order_id=created.id,
payload={
"mode": "draft",
"existing_plan_summary": "隐藏入口补造的超时待办演示单",
},
submit=False,
)
finally:
intake_store.close()
return [created.id], {"scenario": "overdue_pending_once", "created_at": created_at}
def _cleanup_demo_seed_orders(settings: Settings) -> tuple[list[str], dict[str, Any]]:
deleted_ids: list[str] = []
with OrdersDAO.connect(settings.orders_db_path) as dao:
for order_id in _list_demo_seed_order_ids(dao):
if dao.delete(order_id):
deleted_ids.append(order_id)
return deleted_ids, {
"scenario": "cleanup_demo_seed",
"deleted_count": len(deleted_ids),
}
2026-06-13 14:49:58 +08:00
def _transition_error(order_id: str, to_status: str, exc: Exception) -> BusinessError:
return BusinessError(
BIZ_ORDER_INVALID_STATUS,
detail={"order_id": order_id, "to_status": to_status, "reason": str(exc)},
)
@router.get(
"",
response_model=list[OrderSummaryResponse],
summary="订单列表T6.4",
)
@admin_router.get(
"",
response_model=list[OrderSummaryResponse],
summary="订单列表T6.4",
)
2026-06-13 14:49:58 +08:00
def list_orders(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: Optional[OrderStatus] = Query(None, alias="status"),
source: Optional[OrderSource] = Query(None),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(require_role("admin")),
2026-06-13 14:49:58 +08:00
) -> list[dict[str, Any]]:
with OrdersDAO.connect(settings.orders_db_path) as dao:
orders = dao.list(
status=status_filter, source=source, limit=limit, offset=offset
)
2026-06-14 10:38:04 +08:00
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
return [
_attach_intake_fields(_summary(order), intake_store.get(order.id))
for order in orders
]
finally:
intake_store.close()
2026-06-13 14:49:58 +08:00
@router.get(
"/export",
summary="订单导出CSV默认脱敏",
)
@admin_router.get(
"/export",
summary="订单导出CSV默认脱敏",
)
2026-06-13 14:49:58 +08:00
def export_orders_csv(
limit: int = Query(1000, ge=1, le=1000),
status_filter: Optional[OrderStatus] = Query(None, alias="status"),
source: Optional[OrderSource] = Query(None),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(require_role("admin")),
2026-06-13 14:49:58 +08:00
) -> Response:
with OrdersDAO.connect(settings.orders_db_path) as dao:
orders = dao.list(status=status_filter, source=source, limit=limit, offset=0)
output = StringIO()
writer = csv.DictWriter(output, fieldnames=list(_EXPORT_FIELDS))
writer.writeheader()
for order in orders:
writer.writerow(cast(Any, _export_row(order)))
filename = "orders_export.csv"
return Response(
content=output.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get(
"/{order_id}",
response_model=OrderDetailPayload,
summary="订单详情T6.4",
)
@admin_router.get(
"/{order_id}",
response_model=OrderDetailPayload,
summary="订单详情T6.4",
)
2026-06-13 14:49:58 +08:00
def get_order(
order_id: str = ApiPath(..., min_length=1),
2026-06-13 14:49:58 +08:00
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(require_role("admin")),
2026-06-13 14:49:58 +08:00
) -> dict[str, Any]:
with OrdersDAO.connect(settings.orders_db_path) as dao:
try:
order = dao.get(order_id)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
2026-06-14 10:38:04 +08:00
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake = intake_store.get(order_id)
finally:
intake_store.close()
return _detail_payload(dao, order, intake=intake)
2026-06-13 14:49:58 +08:00
@router.post(
"",
response_model=OrderMutationResponse,
status_code=status.HTTP_201_CREATED,
summary="手工录入订单T6.4",
)
@admin_router.post(
"",
response_model=OrderMutationResponse,
status_code=status.HTTP_201_CREATED,
summary="手工录入订单T6.4",
)
2026-06-13 14:49:58 +08:00
def create_order(
payload: CreateOrderRequest,
settings: Settings = Depends(get_settings_dep),
current_user: AdminUser = Depends(require_role("admin")),
2026-06-13 14:49:58 +08:00
) -> dict[str, Any]:
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
# A-2 (2026-06-20): 后台代录/外部渠道补录的同意审计时间戳统一为"创建时间"
# portal 路径用 intake_store.save(submit=True) 内部 utc_now_iso() 自动落库
# (web_public.py + intake_store.save), admin 路径这里手动计算保持口径一致
consent_given_at = utc_now_iso()
# consent_operator:
# 严格按 LEGAL_PRIVACY_BASELINE §4 白名单: self / guardian / admin_import
# - web 渠道: "guardian" (用户本人或监护人自助 portal, 与 web_public.py 一致)
# - 其他渠道 (xianyu/wechat/school): "admin_import" (后台代录, 同意来源是渠道商)
consent_operator = "guardian" if payload.source == "web" else "admin_import"
2026-06-13 14:49:58 +08:00
order = Order(
id=generate_order_id(),
source=payload.source,
external_id=payload.external_id,
service_version=payload.service_version,
amount_cents=payload.amount_cents,
status="pending",
customer_name=payload.customer_name,
customer_phone=payload.customer_phone,
customer_wechat=payload.customer_wechat,
candidate_name=payload.candidate_name,
candidate_id_card=payload.candidate_id_card,
candidate_province=payload.candidate_province,
candidate_score=payload.candidate_score,
candidate_rank=payload.candidate_rank,
candidate_subjects=payload.candidate_subjects,
candidate_interests=payload.candidate_interests,
candidate_strong_subjects=payload.candidate_strong_subjects,
candidate_weak_subjects=payload.candidate_weak_subjects,
candidate_family=payload.candidate_family,
assigned_consultant=payload.assigned_consultant,
notes=payload.notes,
tags=payload.tags,
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
consent_method=payload.consent.consent_method,
consent_given_at=consent_given_at,
2026-06-13 14:49:58 +08:00
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
try:
created = dao.create(
order, actor=current_user.username, reason="admin_create"
)
except DuplicateOrder as exc:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": str(exc)},
) from exc
except Exception as exc: # pragma: no cover - 持久层兜底
raise BusinessError(DATA_PERSIST_FAILED) from exc
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
order_id = created.id
# 在 dao 仍持有 conn 的 with-block 内先抓 detail_payload 快照,
# 避免退出 with-block 后再调 dao.get_status_history 等接口
detail = _detail_payload(dao, created)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_payload: dict[str, Any] = {
"candidate_name": payload.candidate_name,
"candidate_province": payload.candidate_province,
"candidate_score": payload.candidate_score,
"candidate_subjects": payload.candidate_subjects,
"customer_name": payload.customer_name,
"customer_phone": payload.customer_phone,
"customer_wechat": payload.customer_wechat,
"consent_version": settings.consent_version,
"consent_scope": f"{payload.source}-{settings.consent_scope_channel_prefix}",
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
"consent_channel": payload.source,
"consent_operator": consent_operator,
"consent_method": payload.consent.consent_method,
"consent_given_at": consent_given_at,
}
if payload.consent.consent_note:
intake_payload["consent_note"] = payload.consent.consent_note
if payload.customer_phone:
intake_payload["privacy_accepted"] = False
intake_record = intake_store.save(
order_id=order_id, payload=intake_payload, submit=False
)
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
finally:
intake_store.close()
with OrdersDAO.connect(settings.orders_db_path) as dao:
refreshed = dao.get(order_id)
detail = _detail_payload(dao, refreshed, intake=intake_record)
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
return {"action": "created", **detail}
2026-06-13 14:49:58 +08:00
@router.post(
"/dev-seed",
response_model=DevSeedResponse,
include_in_schema=False,
)
@admin_router.post(
"/dev-seed",
response_model=DevSeedResponse,
include_in_schema=False,
)
def create_dev_seed_order(
payload: DevSeedRequest,
settings: Settings = Depends(get_settings_dep),
current_user: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
_ensure_non_prod_seed_env(settings)
if payload.scenario == "overdue_pending_once":
created_ids, detail = _create_overdue_pending_seed(settings, current_user)
return {
"action": "created",
"created_ids": created_ids,
"deleted_ids": [],
"detail": detail,
}
deleted_ids, detail = _cleanup_demo_seed_orders(settings)
return {
"action": "cleaned",
"created_ids": [],
"deleted_ids": deleted_ids,
"detail": detail,
}
2026-06-13 14:49:58 +08:00
@router.patch(
"/{order_id}",
response_model=OrderMutationResponse,
summary="订单更新 / 状态流转 / 退款T6.4",
)
@admin_router.patch(
"/{order_id}",
response_model=OrderMutationResponse,
summary="订单更新 / 状态流转 / 退款T6.4",
)
2026-06-13 14:49:58 +08:00
def patch_order(
payload: UpdateOrderRequest,
order_id: str = ApiPath(..., min_length=1),
2026-06-13 14:49:58 +08:00
settings: Settings = Depends(get_settings_dep),
current_user: AdminUser = Depends(require_role("admin")),
2026-06-13 14:49:58 +08:00
) -> dict[str, Any]:
updates = _normalize_updates(payload.updates, settings)
2026-06-13 14:49:58 +08:00
if not updates and payload.to_status is None:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": "至少提供 updates 或 to_status 之一"},
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
try:
current = dao.get(order_id)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
order = current
if updates:
try:
order = dao.update(
order_id,
updates,
actor=current_user.username,
reason=payload.reason or "admin_update",
)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
except DuplicateOrder as exc:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": str(exc)},
) from exc
except ValueError as exc:
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={"reason": str(exc)},
) from exc
if payload.to_status is not None:
try:
2026-06-14 10:38:04 +08:00
if payload.to_status == "serving":
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake = intake_store.get(order_id)
finally:
intake_store.close()
if intake is None or intake.status != "submitted":
raise BusinessError(
DATA_VALIDATION_FAILED,
detail={
"reason": "资料未提交,不能开始接单处理",
"required_intake_status": "submitted",
},
)
2026-06-13 14:49:58 +08:00
order = dao.transition_status(
order_id,
payload.to_status,
actor=current_user.username,
reason=payload.reason or f"admin_transition:{payload.to_status}",
)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
except InvalidStateTransition as exc:
raise _transition_error(order_id, payload.to_status, exc) from exc
action = "updated"
if payload.to_status is not None and not updates:
action = f"transitioned:{payload.to_status}"
elif payload.to_status is not None:
action = f"updated:{payload.to_status}"
2026-06-14 10:38:04 +08:00
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake = intake_store.get(order_id)
finally:
intake_store.close()
return {"action": action, **_detail_payload(dao, order, intake=intake)}
@router.delete(
"/{order_id}",
response_model=OrderDeletionResponse,
summary="订单删除 / 匿名化T12/A-4",
)
@admin_router.delete(
"/{order_id}",
response_model=OrderDeletionResponse,
summary="订单删除 / 匿名化T12/A-4",
)
def delete_or_anonymize_order(
order_id: str = ApiPath(..., min_length=1),
mode: Literal["delete", "anonymize"] = Query("delete"),
reason: str = Query(..., min_length=1),
settings: Settings = Depends(get_settings_dep),
current_user: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
# 保留期门禁: 必须先查到订单, 校验 retention_days 内不允许 delete / anonymize
with OrdersDAO.connect(settings.orders_db_path) as dao:
try:
order = dao.get(order_id)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
_assert_retention_expired(order, retention_days=settings.retention_days)
service = OrderDeletionService.for_db(
settings.orders_db_path,
portal_upload_root=Path(settings.portal_upload_dir),
artifact_roots=_trusted_report_roots(settings),
)
try:
try:
if mode == "delete":
result = service.delete_order(
order_id,
actor=current_user.username,
reason=reason,
)
else:
result = service.anonymize_order(
order_id,
actor=current_user.username,
reason=reason,
)
except OrderNotFound as exc:
raise _business_error_for_lookup(order_id) from exc
finally:
service.close()
return result.__dict__