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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user