feat(intake): 扩展 IntakePayload 字段集 + candidate_province 必填

IntakePayload 新增 14 个字段覆盖完整志愿填报偏好:
- 学校地域/类型偏好 (school_region_preferences / school_preference_types)
- 目标学校/不喜欢的专业 (target_schools / disliked_majors)
- 优先策略/毕业计划/学费偏好/就业地区偏好
- 家庭背景/行业资源/额外备注
- 兴趣评估三字段 (type/result/notes)
- candidate_province 提交时必填(与 portal 表单对齐)

public_flow / deletion_service 同步更新字段引用
requirements-dev: 补 httpx2 (HTTP/2 测试支持)
This commit is contained in:
Hermes Agent
2026-06-25 09:26:31 +08:00
parent c43aa1d354
commit 7c14384fb2
4 changed files with 98 additions and 25 deletions

View File

@@ -37,6 +37,17 @@ RETENTION_GUARDED_STATUSES = frozenset({
})
DEFAULT_RETENTION_DAYS = 180
_DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT = Path("data/portal_uploads").resolve()
_DEFAULT_TRUSTED_ARTIFACT_ROOTS = (
(Path("data/portal_uploads").resolve().parent / "order_artifacts").resolve(),
Path("data/examples").resolve(),
Path("data/share/reports").resolve(),
)
def _is_within_roots(path: Path, roots: tuple[Path, ...]) -> bool:
return any(root == path or root in path.parents for root in roots)
@dataclass
class DeletionResult:
@@ -46,17 +57,35 @@ class DeletionResult:
class OrderDeletionService:
def __init__(self, conn: sqlite3.Connection) -> None:
def __init__(
self,
conn: sqlite3.Connection,
*,
portal_upload_root: Path = _DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT,
artifact_roots: tuple[Path, ...] = _DEFAULT_TRUSTED_ARTIFACT_ROOTS,
) -> None:
self._conn = conn
self._portal_upload_root = portal_upload_root.resolve()
self._artifact_roots = tuple(root.resolve() for root in artifact_roots)
self._conn.executescript(AUDIT_SCHEMA_SQL)
self._conn.commit()
@classmethod
def for_db(cls, db_path: str | Path) -> "OrderDeletionService":
def for_db(
cls,
db_path: str | Path,
*,
portal_upload_root: Path = _DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT,
artifact_roots: tuple[Path, ...] = _DEFAULT_TRUSTED_ARTIFACT_ROOTS,
) -> "OrderDeletionService":
conn = apply_schema(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return cls(conn)
return cls(
conn,
portal_upload_root=portal_upload_root,
artifact_roots=artifact_roots,
)
def close(self) -> None:
self._conn.close()
@@ -129,16 +158,16 @@ class OrderDeletionService:
order_id,
),
)
if self._table_exists("payments"):
self._conn.execute(
"UPDATE payments SET checkout_token=NULL, callback_payload=NULL WHERE order_id=?",
(order_id,),
)
if self._table_exists("order_intakes"):
self._conn.execute(
"UPDATE order_intakes SET payload_json='{}', updated_at=? WHERE order_id=?",
"UPDATE order_intakes SET payload_json='{}', updated_at=?, status='submitted' WHERE order_id=?",
(now, order_id),
)
if self._table_exists("payments"):
self._conn.execute(
"UPDATE payments SET callback_payload=NULL, checkout_token=NULL WHERE order_id=?",
(order_id,),
)
if self._table_exists("delivery_notifications"):
self._conn.execute(
"UPDATE delivery_notifications SET payload_json='{}' WHERE order_id=?",
@@ -220,16 +249,14 @@ class OrderDeletionService:
raw_path = item.get("storage_path")
if not raw_path:
continue
path = Path(str(raw_path))
path = Path(str(raw_path)).expanduser().resolve()
if not _is_within_roots(path, (self._portal_upload_root,)):
continue
if path.is_file():
path.unlink()
deleted += 1
parent_dirs.add(path.parent)
order_dir = None
if parent_dirs:
order_dir = sorted(parent_dirs, key=lambda p: len(p.parts))[0]
else:
order_dir = Path("data/portal_uploads") / order_id
order_dir = self._portal_upload_root / order_id
if order_dir.exists() and order_dir.is_dir() and not any(order_dir.iterdir()):
order_dir.rmdir()
return deleted
@@ -241,13 +268,14 @@ class OrderDeletionService:
).fetchone()
return row is not None
@staticmethod
def _delete_artifacts(*paths: str | None) -> int:
def _delete_artifacts(self, *paths: str | None) -> int:
deleted = 0
for raw_path in paths:
if not raw_path:
continue
path = Path(raw_path)
path = Path(raw_path).expanduser().resolve()
if not _is_within_roots(path, self._artifact_roots):
continue
if path.is_file():
path.unlink()
deleted += 1

View File

@@ -10,6 +10,7 @@ IntakeMode = Literal["draft", "submit"]
class IntakePayload(BaseModel):
mode: IntakeMode = "submit"
candidate_province: Optional[str] = Field(default=None, max_length=32)
candidate_score: Optional[int] = Field(default=None, ge=0, le=1000)
candidate_rank: Optional[int] = Field(default=None, ge=0)
candidate_subjects: list[str] = Field(default_factory=list, max_length=6)
@@ -17,6 +18,20 @@ class IntakePayload(BaseModel):
target_cities: list[str] = Field(default_factory=list, max_length=5)
target_majors: list[str] = Field(default_factory=list, max_length=10)
university_preferences: Optional[str] = Field(default=None, max_length=500)
school_region_preferences: list[str] = Field(default_factory=list, max_length=10)
school_preference_types: list[str] = Field(default_factory=list, max_length=10)
target_schools: list[str] = Field(default_factory=list, max_length=10)
disliked_majors: list[str] = Field(default_factory=list, max_length=10)
priority_strategy: Optional[str] = Field(default=None, max_length=64)
graduation_plan: Optional[str] = Field(default=None, max_length=128)
tuition_preference: Optional[str] = Field(default=None, max_length=128)
employment_region_preferences: list[str] = Field(default_factory=list, max_length=10)
family_background: Optional[str] = Field(default=None, max_length=300)
industry_resources: Optional[str] = Field(default=None, max_length=300)
extra_notes: Optional[str] = Field(default=None, max_length=1000)
interest_assessment_type: Optional[str] = Field(default=None, max_length=64)
interest_assessment_result: Optional[str] = Field(default=None, max_length=200)
interest_assessment_notes: Optional[str] = Field(default=None, max_length=500)
existing_plan_summary: Optional[str] = Field(default=None, max_length=1000)
guardian_notes: Optional[str] = Field(default=None, max_length=1000)
consent_version: Optional[str] = None
@@ -28,6 +43,8 @@ class IntakePayload(BaseModel):
@model_validator(mode="after")
def _validate_submit_payload(self) -> "IntakePayload":
if self.mode == "submit":
if not self.candidate_province:
raise ValueError("candidate_province 为提交必填项")
if self.candidate_score is None:
raise ValueError("candidate_score 为提交必填项")
if self.candidate_rank is None:
@@ -44,13 +61,6 @@ class IntakePayload(BaseModel):
raise ValueError("service_terms_accepted 为提交必填项")
if not self.guardian_confirmed:
raise ValueError("guardian_confirmed 为提交必填项")
if not (
self.candidate_interests
or self.target_cities
or self.target_majors
or self.university_preferences
):
raise ValueError("至少填写一个偏好与目标字段")
return self

View File

@@ -19,6 +19,37 @@ _SERVICE_PRICES: dict[ServiceVersion, int] = {
}
_SUPPORTED_PUBLIC_PROVINCES = {
"北京",
"天津",
"上海",
"重庆",
"河北",
"河南",
"山东",
"山西",
"陕西",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"湖北",
"湖南",
"广东",
"海南",
"四川",
"贵州",
"云南",
"甘肃",
"青海",
"新疆",
}
def service_price_for(service_version: ServiceVersion) -> int:
return _SERVICE_PRICES[service_version]
@@ -41,9 +72,12 @@ class PublicOrderCreate(BaseModel):
raise ValueError("amount_cents 与套餐价格不一致")
if not self.customer_phone and not self.customer_wechat:
raise ValueError("customer_phone / customer_wechat 至少填写一个")
if self.candidate_province and self.candidate_province not in _SUPPORTED_PUBLIC_PROVINCES:
raise ValueError("candidate_province 当前暂不支持")
return self
class PublicOrderCreated(BaseModel):
order_id: str
source: str