改造前: consent_version 硬编码 't12-web-mvp-v1' / consent_scope 硬编码 改造后: 从 Settings 读取,可通过环境变量覆盖 - GAOKAO_CONSENT_VERSION (默认 privacy-policy-v2026.06-draft) - GAOKAO_CONSENT_SCOPE_PORTAL (默认 web-self-service-order-intake) - GAOKAO_CONSENT_SCOPE_CHANNEL_PREFIX (默认 channel-intake) 实现: - Settings 新增 consent_version / consent_scope_portal / consent_scope_channel_prefix - web_public._render_info_page 改为从 settings 取默认值 - orders.create_order 改为从 settings 取默认值 升级隐私政策时: - 更新 docs/PRIVACY_POLICY_DRAFT.md 版本号 - 设置环境变量 GAOKAO_CONSENT_VERSION=new-version - 无需改代码,老订单保留历史版本 测试: - test_create_order_uses_settings_consent_version 锁定新行为 - dev-verify: 1292 passed / coverage / ruff / mypy 全绿
845 lines
28 KiB
Python
845 lines
28 KiB
Python
"""订单管理路由 (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
|
||
from dataclasses import asdict
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from io import StringIO
|
||
from typing import Any, Literal, Optional, cast
|
||
|
||
from fastapi import APIRouter, Depends, Path as ApiPath, Query, Response, status
|
||
from pydantic import BaseModel, Field
|
||
|
||
from admin.auth import require_role
|
||
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,
|
||
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,
|
||
)
|
||
from data.orders.intake_store import IntakeStore
|
||
from data.orders.models import Order, generate_order_id, utc_now_iso
|
||
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()),
|
||
},
|
||
)
|
||
|
||
|
||
router = APIRouter(prefix="/api/orders", tags=["orders"])
|
||
admin_router = APIRouter(prefix="/api/admin/orders", tags=["orders"])
|
||
|
||
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"
|
||
|
||
_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
|
||
intake_status: Optional[str] = None
|
||
intake_submitted_at: Optional[str] = None
|
||
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
|
||
|
||
|
||
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)
|
||
|
||
# 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
|
||
|
||
|
||
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)
|
||
|
||
|
||
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}
|
||
|
||
|
||
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
|
||
)
|
||
return enriched
|
||
|
||
|
||
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)]
|
||
|
||
|
||
def _detail_payload(
|
||
dao: OrdersDAO, order: Order, *, intake: Any = None
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"order": _attach_intake_fields(_mask_order(order), intake),
|
||
"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]:
|
||
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
|
||
|
||
|
||
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),
|
||
}
|
||
|
||
|
||
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)",
|
||
)
|
||
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")),
|
||
) -> 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
|
||
)
|
||
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()
|
||
|
||
|
||
@router.get(
|
||
"/export",
|
||
summary="订单导出(CSV,默认脱敏)",
|
||
)
|
||
@admin_router.get(
|
||
"/export",
|
||
summary="订单导出(CSV,默认脱敏)",
|
||
)
|
||
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")),
|
||
) -> 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)",
|
||
)
|
||
def get_order(
|
||
order_id: str = ApiPath(..., min_length=1),
|
||
settings: Settings = Depends(get_settings_dep),
|
||
_: AdminUser = Depends(require_role("admin")),
|
||
) -> 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
|
||
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)
|
||
|
||
|
||
@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)",
|
||
)
|
||
def create_order(
|
||
payload: CreateOrderRequest,
|
||
settings: Settings = Depends(get_settings_dep),
|
||
current_user: AdminUser = Depends(require_role("admin")),
|
||
) -> dict[str, Any]:
|
||
# 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"
|
||
|
||
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,
|
||
consent_method=payload.consent.consent_method,
|
||
consent_given_at=consent_given_at,
|
||
)
|
||
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
|
||
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}",
|
||
"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
|
||
)
|
||
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)
|
||
|
||
return {"action": "created", **detail}
|
||
|
||
|
||
@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,
|
||
}
|
||
|
||
|
||
@router.patch(
|
||
"/{order_id}",
|
||
response_model=OrderMutationResponse,
|
||
summary="订单更新 / 状态流转 / 退款(T6.4)",
|
||
)
|
||
@admin_router.patch(
|
||
"/{order_id}",
|
||
response_model=OrderMutationResponse,
|
||
summary="订单更新 / 状态流转 / 退款(T6.4)",
|
||
)
|
||
def patch_order(
|
||
payload: UpdateOrderRequest,
|
||
order_id: str = ApiPath(..., min_length=1),
|
||
settings: Settings = Depends(get_settings_dep),
|
||
current_user: AdminUser = Depends(require_role("admin")),
|
||
) -> dict[str, Any]:
|
||
updates = _normalize_updates(payload.updates, settings)
|
||
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:
|
||
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",
|
||
},
|
||
)
|
||
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}"
|
||
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__
|