feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""各省高考模式、classify 校验与 API 参数规范化。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 仅京沪津批次接口与志愿接口需要 gradeType
|
||||
PROVINCES_REQUIRING_GRADE_TYPE = frozenset({"北京", "上海", "天津"})
|
||||
|
||||
# 测试环境批次接口暂不支持
|
||||
UNSUPPORTED_PROVINCES = frozenset({"西藏"})
|
||||
|
||||
MODE_33 = frozenset({"上海", "北京", "天津", "山东", "浙江", "海南"})
|
||||
MODE_312 = frozenset({
|
||||
"广东", "江苏", "河北", "湖北", "湖南", "福建", "辽宁", "重庆", "甘肃",
|
||||
"黑龙江", "吉林", "安徽", "江西", "贵州", "广西", "云南", "内蒙古",
|
||||
"四川", "宁夏", "山西", "河南", "陕西", "青海",
|
||||
})
|
||||
MODE_OLD = frozenset({"新疆"})
|
||||
|
||||
VALID_CLASSIFY_BY_MODE: dict[str, frozenset[str]] = {
|
||||
"33": frozenset({"综合"}),
|
||||
"312": frozenset({"物理", "历史"}),
|
||||
"老高考": frozenset({"文科", "理科"}),
|
||||
}
|
||||
|
||||
MODE_LABELS: dict[str, str] = {
|
||||
"33": "3+3",
|
||||
"312": "3+1+2",
|
||||
"老高考": "老高考",
|
||||
}
|
||||
|
||||
SUBJECTS_312_ELECTIVES = frozenset({"化学", "生物", "政治", "地理"})
|
||||
SUBJECTS_33_POOL = frozenset({"物理", "历史", "化学", "生物", "政治", "地理"})
|
||||
SUBJECTS_33_ZHEJIANG = SUBJECTS_33_POOL | frozenset({"技术"})
|
||||
|
||||
|
||||
def parse_subjects_list(subjects: Any) -> list[str]:
|
||||
if subjects in (None, "", "null", "NULL"):
|
||||
return []
|
||||
return [s.strip() for s in str(subjects).split(",") if s.strip()]
|
||||
|
||||
|
||||
def get_province_mode(province: str | None) -> str:
|
||||
if not province:
|
||||
return "unknown"
|
||||
if province in UNSUPPORTED_PROVINCES:
|
||||
return "unsupported"
|
||||
if province in MODE_OLD:
|
||||
return "老高考"
|
||||
if province in MODE_33:
|
||||
return "33"
|
||||
if province in MODE_312:
|
||||
return "312"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def validate_classify(province: str, classify: str | None) -> None:
|
||||
"""校验 classify 是否与省份选科模式匹配。"""
|
||||
mode = get_province_mode(province)
|
||||
if mode == "unsupported":
|
||||
raise ValueError(
|
||||
f"省份 {province} 在测试环境暂不支持志愿/批次接口(平台未接入该省数据)"
|
||||
)
|
||||
if mode == "unknown":
|
||||
return
|
||||
if not classify:
|
||||
valid = VALID_CLASSIFY_BY_MODE[mode]
|
||||
raise ValueError(
|
||||
f"缺少 classify:{province} 为{MODE_LABELS[mode]}模式,应填 {'/'.join(sorted(valid))}"
|
||||
)
|
||||
valid = VALID_CLASSIFY_BY_MODE[mode]
|
||||
if classify not in valid:
|
||||
raise ValueError(
|
||||
f"classify 与省份不匹配:{province} 为{MODE_LABELS[mode]}模式,"
|
||||
f"classify 应为 {'/'.join(sorted(valid))},当前为 {classify!r}。"
|
||||
f"错误 classify 会导致批次接口返回空列表。"
|
||||
)
|
||||
|
||||
|
||||
def infer_grade_type(
|
||||
province: str | None,
|
||||
score: int | None,
|
||||
explicit: str | None = None,
|
||||
batch_pref: str | None = None,
|
||||
) -> str | None:
|
||||
"""京沪津推断本科/专科;其他省份返回 None。"""
|
||||
if province not in PROVINCES_REQUIRING_GRADE_TYPE:
|
||||
return None
|
||||
if explicit in ("本科", "专科"):
|
||||
return explicit
|
||||
if batch_pref == "专科批":
|
||||
return "专科"
|
||||
if batch_pref == "本科批":
|
||||
return "本科"
|
||||
if score is not None and score <= 450:
|
||||
return "专科"
|
||||
return "本科"
|
||||
|
||||
|
||||
def batch_list_query_params(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从请求体构建 batch/list 查询参数。"""
|
||||
province = payload.get("province")
|
||||
classify = payload.get("classify")
|
||||
score = payload.get("score")
|
||||
if not province or not classify or score is None:
|
||||
raise ValueError("批次接口需要 province、classify、score")
|
||||
|
||||
validate_classify(str(province), str(classify))
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"province": province,
|
||||
"classify": classify,
|
||||
"score": int(score),
|
||||
}
|
||||
grade_type = payload.get("gradeType")
|
||||
if not grade_type and province in PROVINCES_REQUIRING_GRADE_TYPE:
|
||||
grade_type = infer_grade_type(
|
||||
str(province),
|
||||
int(score),
|
||||
batch_pref=payload.get("batch"),
|
||||
)
|
||||
if grade_type:
|
||||
payload["gradeType"] = grade_type
|
||||
if province in PROVINCES_REQUIRING_GRADE_TYPE and grade_type:
|
||||
params["gradeType"] = grade_type
|
||||
return params
|
||||
|
||||
|
||||
def normalize_subjects_for_api(payload: dict[str, Any]) -> None:
|
||||
"""按省份规则规范化 subjects 字段。"""
|
||||
province = payload.get("province")
|
||||
grade_type = payload.get("gradeType")
|
||||
subjects = payload.get("subjects")
|
||||
|
||||
if province == "新疆":
|
||||
if subjects in (None, "", "null", "NULL"):
|
||||
payload.pop("subjects", None)
|
||||
return
|
||||
|
||||
if province in PROVINCES_REQUIRING_GRADE_TYPE and grade_type == "专科":
|
||||
if subjects in (None, "", "null", "NULL"):
|
||||
payload["subjects"] = "null"
|
||||
return
|
||||
|
||||
if subjects in ("null", "NULL", ""):
|
||||
payload["subjects"] = None
|
||||
|
||||
|
||||
def sanitize_payload_for_api(payload: dict[str, Any]) -> None:
|
||||
"""发志愿接口前按省份规则清理请求体。"""
|
||||
province = payload.get("province")
|
||||
if province not in PROVINCES_REQUIRING_GRADE_TYPE:
|
||||
payload.pop("gradeType", None)
|
||||
normalize_subjects_for_api(payload)
|
||||
|
||||
|
||||
def validate_subjects_for_volunteer_api(payload: dict[str, Any]) -> None:
|
||||
"""志愿接口前校验 subjects 是否满足省份要求。"""
|
||||
province = str(payload.get("province") or "")
|
||||
mode = get_province_mode(province)
|
||||
label = MODE_LABELS.get(mode, mode)
|
||||
|
||||
if province == "新疆":
|
||||
return
|
||||
if province in PROVINCES_REQUIRING_GRADE_TYPE and payload.get("gradeType") == "专科":
|
||||
return
|
||||
|
||||
subjects = payload.get("subjects")
|
||||
if subjects in (None, "", "null", "NULL"):
|
||||
raise ValueError(
|
||||
f"缺少 subjects:{province}({label})需传选科组合,如 物理,化学,生物"
|
||||
)
|
||||
|
||||
parts = parse_subjects_list(subjects)
|
||||
classify = payload.get("classify")
|
||||
|
||||
if mode == "312":
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
f"subjects 格式错误:{province}(3+1+2)须传完整三科(首选科目+两门再选),"
|
||||
f"逗号分隔,如 classify=物理 时 subjects=物理,化学,生物;"
|
||||
f"不能只传两门再选科目。当前为 {subjects!r}({len(parts)} 科)"
|
||||
)
|
||||
if classify not in parts:
|
||||
raise ValueError(
|
||||
f"subjects 与 classify 不一致:{province} classify={classify!r},"
|
||||
f"subjects 须包含首选科目 {classify!r},当前为 {subjects!r}"
|
||||
)
|
||||
electives = [p for p in parts if p != classify]
|
||||
if len(electives) != 2:
|
||||
raise ValueError(
|
||||
f"subjects 格式错误:{province}(3+1+2)除首选科目外还须两门再选"
|
||||
f"(化学/生物/政治/地理),当前为 {subjects!r}"
|
||||
)
|
||||
invalid = [p for p in electives if p not in SUBJECTS_312_ELECTIVES]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"subjects 含无效再选科目 {invalid}:3+1+2 再选仅限化学/生物/政治/地理"
|
||||
)
|
||||
return
|
||||
|
||||
if mode == "33" and province not in PROVINCES_REQUIRING_GRADE_TYPE:
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
f"subjects 格式错误:{province}(3+3)须传完整三科选考科目,"
|
||||
f"逗号分隔,如 物理,化学,生物;当前为 {subjects!r}({len(parts)} 科)"
|
||||
)
|
||||
pool = SUBJECTS_33_ZHEJIANG if province == "浙江" else SUBJECTS_33_POOL
|
||||
invalid = [p for p in parts if p not in pool]
|
||||
if invalid:
|
||||
raise ValueError(f"subjects 含无效科目 {invalid}:{province} 选考范围见 reference.md")
|
||||
return
|
||||
|
||||
if mode == "33" and province in PROVINCES_REQUIRING_GRADE_TYPE:
|
||||
if payload.get("gradeType") == "本科" and len(parts) != 3:
|
||||
raise ValueError(
|
||||
f"subjects 格式错误:{province}(3+3 本科)须传完整三科,"
|
||||
f"如 物理,化学,生物;当前为 {subjects!r}"
|
||||
)
|
||||
Reference in new issue
Block a user