From 0e9e7fabc82802a636da38ca80c72eb703e02681 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Wed, 29 Apr 2026 20:41:07 +0800 Subject: [PATCH 01/11] feat: add teachers json api --- data/teachers.json | 133 ++++++++++++++++++++++++++++++ routes/__init__.py | 7 ++ routes/teachers.py | 139 ++++++++++++++++++++++++++++++++ scripts/verify_teachers_json.py | 46 +++++++++++ 4 files changed, 325 insertions(+) create mode 100644 data/teachers.json create mode 100644 routes/teachers.py create mode 100644 scripts/verify_teachers_json.py diff --git a/data/teachers.json b/data/teachers.json new file mode 100644 index 0000000..cb6edef --- /dev/null +++ b/data/teachers.json @@ -0,0 +1,133 @@ +{ + "_comment": "职责范围:为教师介绍 API 提供 JSON 阶段的四位白名单教师 seed 数据;功能边界:本文件不负责远程同步、数据库迁移、旧 gallery 资源或非白名单教师资料。", + "meta": { + "last_synced_at": "2026-04-29T00:00:00+08:00", + "source_status": "manual_seed", + "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "sync_note": "本批数据为人工整理的临时 seed,后续接入后端同步。" + }, + "items": [ + { + "id": "li-song", + "name": "李松", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", + "research_areas": ["程序分析", "漏洞挖掘", "应用安全", "网络安全", "系统安全"], + "summary": "研究方向包括程序分析、漏洞挖掘和应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", + "bio": "李松为浙江大学百人计划研究员、博士生导师。公开主页介绍其研究工作聚焦程序分析、漏洞挖掘与应用安全;学院教师队伍页列出的方向包括漏洞挖掘、网络安全、系统安全和程序分析。", + "email": "songli@zju.edu.cn", + "avatar_url": "/mentors/li-song/avatar.png", + "homepage_urls": [ + { + "label": "个人主页", + "url": "https://songli.io/", + "type": "profile" + }, + { + "label": "Google Scholar", + "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", + "type": "scholar" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://songli.io/", + "source_status": "manual_seed", + "last_synced_at": "2026-04-29T00:00:00+08:00", + "sync_note": "个人主页邮箱以图片形式展示;正式接入同步前需继续复核字段抽取方式。", + "profile_sections": [] + }, + { + "id": "du-wenliang", + "name": "杜文亮", + "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", + "affiliation": "浙江大学计算机科学与技术学院", + "research_areas": ["网络空间安全", "系统安全", "网络仿真", "网络系统仿真", "车联网安全"], + "summary": "浙大个人主页列出网络空间安全、系统安全和网络仿真方向,学院教师队伍页补充网络系统仿真、互联网、无线移动网、车联网、工控网。", + "bio": "杜文亮为浙江大学求是讲席教授、博士生导师。浙大个人主页列出研究方向为网络空间安全、系统安全和网络仿真;学院教师队伍页列出网络系统仿真、互联网、无线移动网、车联网、工控网等方向。", + "email": "wenliangdu@zju.edu.cn", + "avatar_url": "/mentors/du-wenliang/avatar.jpg", + "homepage_urls": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/wenliangdu", + "type": "profile" + }, + { + "label": "SEED Labs 主页", + "url": "https://seedsecuritylabs.org/wenliangdu/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://person.zju.edu.cn/wenliangdu", + "source_status": "manual_seed", + "last_synced_at": "2026-04-29T00:00:00+08:00", + "sync_note": "字段人工整理自浙大个人主页、学院教师队伍页和公开个人主页。", + "profile_sections": [] + }, + { + "id": "lin-feng", + "name": "林峰", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", + "research_areas": ["智能网联车安全", "物联网安全", "人工智能安全", "无线感知攻防", "生物识别身份认证"], + "summary": "浙大个人主页列出智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证等方向。", + "bio": "林峰为浙江大学百人计划研究员、博士生导师。浙大个人主页列出研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证;其公开主页也列出 IoT Security、Smart Vehicle Security、Deepfake Detection、Artificial Intelligence Security 等兴趣方向。", + "email": "flin@zju.edu.cn", + "avatar_url": "/mentors/lin-feng/avatar.jpg", + "homepage_urls": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/flin", + "type": "profile" + }, + { + "label": "个人主页", + "url": "https://flin.group/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://person.zju.edu.cn/flin", + "source_status": "manual_seed", + "last_synced_at": "2026-04-29T00:00:00+08:00", + "sync_note": "字段人工整理自浙大个人主页、学院教师队伍页和公开个人主页。", + "profile_sections": [] + }, + { + "id": "shen-dakun", + "name": "申大坤", + "title": "研究员", + "affiliation": "浙江大学网络空间安全学院", + "research_areas": ["系统安全", "IoT 安全", "软件供应链"], + "summary": "学院教师队伍页列为研究员,研究方向为系统安全、IoT 安全和软件供应链。", + "bio": "申大坤为浙江大学网络空间安全学院教师队伍页列出的研究员。当前公开来源列出的研究方向为系统安全、IoT 安全和软件供应链;个人主页、邮箱等字段在本阶段暂未确认。", + "email": "", + "avatar_url": "/mentors/shen-dakun/avatar.png", + "homepage_urls": [ + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "source_status": "manual_seed", + "last_synced_at": "2026-04-29T00:00:00+08:00", + "sync_note": "本阶段仅确认学院教师队伍页基础信息;邮箱和个人主页入口待后续复核。", + "profile_sections": [] + } + ] +} diff --git a/routes/__init__.py b/routes/__init__.py index 438bf46..39ffce4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -29,6 +29,7 @@ def init_app_routes(app: FastAPI): from routes.upload import router as upload_router from routes.images import router as images_router from routes.create import router as create_router + from routes.teachers import router as teachers_router app.include_router( news_router, @@ -58,6 +59,12 @@ def init_app_routes(app: FastAPI): tags=["recruit"], ) + app.include_router( + teachers_router, + prefix="/api/teachers", + tags=["teachers"], + ) + app.include_router( edit_router, diff --git a/routes/teachers.py b/routes/teachers.py new file mode 100644 index 0000000..af320d9 --- /dev/null +++ b/routes/teachers.py @@ -0,0 +1,139 @@ +""" +职责范围:提供教师介绍页 JSON 阶段的公开教师列表、详情读取和受保护的同步触发接口。 +功能边界:本路由只读取本地 JSON seed,不执行远程爬取、数据库写入、头像托管或管理后台 CRUD。 +""" + +import json +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, validator + +from misc.auth import login_required_publisher + + +router = APIRouter() + +TEACHERS_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "teachers.json" +TEACHER_WHITELIST = {"li-song", "du-wenliang", "lin-feng", "shen-dakun"} + + +class HomepageUrl(BaseModel): + label: str = "" + url: str = "" + type: str = "" + + +class ProfileSection(BaseModel): + title: str = "" + content: str = "" + + +class TeacherItem(BaseModel): + id: str + name: str + title: str = "" + affiliation: str = "" + research_areas: list[str] = Field(default_factory=list) + summary: str = "" + bio: str = "" + email: str = "" + avatar_url: str = "" + homepage_urls: list[HomepageUrl] = Field(default_factory=list) + source_url: str = "" + source_status: str = "manual_seed" + last_synced_at: str = "" + sync_note: str = "" + profile_sections: list[ProfileSection] = Field(default_factory=list) + + @validator("id") + def validate_teacher_id(cls, value: str) -> str: + if value not in TEACHER_WHITELIST: + raise ValueError("teacher is not in whitelist") + return value + + +class TeacherMeta(BaseModel): + last_synced_at: str = "" + source_status: str = "manual_seed" + source_url: str = "" + sync_note: str = "" + + +class TeacherListResponse(BaseModel): + items: list[TeacherItem] + meta: TeacherMeta + + +class TeacherDetailResponse(BaseModel): + item: TeacherItem + meta: TeacherMeta + + +class TeacherSyncResponse(TeacherListResponse): + ok: bool = True + + +def load_teachers_data() -> TeacherListResponse: + try: + raw_data = json.loads(TEACHERS_DATA_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Teachers seed data not found", + ) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Teachers seed data is invalid", + ) + + try: + response = TeacherListResponse( + items=raw_data.get("items", []), + meta=raw_data.get("meta", {}), + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Teachers seed data failed validation: {exc}", + ) + + sorted_items = sorted( + response.items, + key=lambda item: ["li-song", "du-wenliang", "lin-feng", "shen-dakun"].index(item.id), + ) + + return TeacherListResponse(items=sorted_items, meta=response.meta) + + +@router.get("/list", response_model=TeacherListResponse) +def get_teacher_list(): + return load_teachers_data() + + +@router.get("/detail", response_model=TeacherDetailResponse) +def get_teacher_detail(id: str): + data = load_teachers_data() + teacher = next((item for item in data.items if item.id == id), None) + + if teacher is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + return TeacherDetailResponse(item=teacher, meta=data.meta) + + +@router.post( + "/sync", + response_model=TeacherSyncResponse, + dependencies=[Depends(login_required_publisher)], +) +def sync_teachers_json(): + data = load_teachers_data() + meta = data.meta.copy(deep=True) + meta.sync_note = "JSON seed reloaded; remote source sync is not implemented in this phase." + + return TeacherSyncResponse(ok=True, items=data.items, meta=meta) diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_json.py new file mode 100644 index 0000000..353df0b --- /dev/null +++ b/scripts/verify_teachers_json.py @@ -0,0 +1,46 @@ +""" +职责范围:验证教师 JSON seed 能被后端教师 API 读取并满足四位白名单教师的基础契约。 +功能边界:本脚本只做本地 JSON 阶段的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 +""" + +import sys +from pathlib import Path + +from fastapi import HTTPException + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from routes.teachers import get_teacher_detail, load_teachers_data + + +EXPECTED_IDS = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] + + +def main(): + data = load_teachers_data() + ids = [item.id for item in data.items] + + assert ids == EXPECTED_IDS, f"unexpected teacher ids: {ids}" + assert data.meta.source_status == "manual_seed" + + for teacher_id in EXPECTED_IDS: + detail = get_teacher_detail(teacher_id) + assert detail.item.id == teacher_id + assert detail.item.name + assert detail.item.title + assert detail.item.source_url + + try: + get_teacher_detail("not-found") + except HTTPException as exc: + assert exc.status_code == 404 + else: + raise AssertionError("missing teacher detail should return 404") + + print("teachers json verification passed") + + +if __name__ == "__main__": + main() From 95df8975ba505a7ee5c7860b5e7dd10e279ec820 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 00:37:43 +0800 Subject: [PATCH 02/11] refactor: isolate teacher roster cache contract --- routes/teachers.py | 114 +++++---------------------------- services/__init__.py | 4 ++ services/teachers_cache.py | 128 +++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 97 deletions(-) create mode 100644 services/__init__.py create mode 100644 services/teachers_cache.py diff --git a/routes/teachers.py b/routes/teachers.py index af320d9..b686789 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -1,120 +1,40 @@ """ -职责范围:提供教师介绍页 JSON 阶段的公开教师列表、详情读取和受保护的同步触发接口。 -功能边界:本路由只读取本地 JSON seed,不执行远程爬取、数据库写入、头像托管或管理后台 CRUD。 +职责范围:提供教师介绍页公开教师列表、详情读取和受保护的同步触发接口。 +功能边界:本路由只处理 HTTP 入参、响应和权限,不直接实现缓存读写、远程爬取或教师数据编辑。 """ -import json -from pathlib import Path - from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field, validator from misc.auth import login_required_publisher +from services.teachers_cache import ( + TeacherCacheError, + TeacherDetailResponse, + TeacherListResponse, + TeacherSyncResponse, + load_teachers_data, +) router = APIRouter() -TEACHERS_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "teachers.json" -TEACHER_WHITELIST = {"li-song", "du-wenliang", "lin-feng", "shen-dakun"} - - -class HomepageUrl(BaseModel): - label: str = "" - url: str = "" - type: str = "" - - -class ProfileSection(BaseModel): - title: str = "" - content: str = "" - - -class TeacherItem(BaseModel): - id: str - name: str - title: str = "" - affiliation: str = "" - research_areas: list[str] = Field(default_factory=list) - summary: str = "" - bio: str = "" - email: str = "" - avatar_url: str = "" - homepage_urls: list[HomepageUrl] = Field(default_factory=list) - source_url: str = "" - source_status: str = "manual_seed" - last_synced_at: str = "" - sync_note: str = "" - profile_sections: list[ProfileSection] = Field(default_factory=list) - - @validator("id") - def validate_teacher_id(cls, value: str) -> str: - if value not in TEACHER_WHITELIST: - raise ValueError("teacher is not in whitelist") - return value - - -class TeacherMeta(BaseModel): - last_synced_at: str = "" - source_status: str = "manual_seed" - source_url: str = "" - sync_note: str = "" - - -class TeacherListResponse(BaseModel): - items: list[TeacherItem] - meta: TeacherMeta - - -class TeacherDetailResponse(BaseModel): - item: TeacherItem - meta: TeacherMeta - - -class TeacherSyncResponse(TeacherListResponse): - ok: bool = True - - -def load_teachers_data() -> TeacherListResponse: +def load_teachers_or_503() -> TeacherListResponse: try: - raw_data = json.loads(TEACHERS_DATA_PATH.read_text(encoding="utf-8")) - except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Teachers seed data not found", - ) - except json.JSONDecodeError: + return load_teachers_data() + except TeacherCacheError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Teachers seed data is invalid", - ) - - try: - response = TeacherListResponse( - items=raw_data.get("items", []), - meta=raw_data.get("meta", {}), - ) - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Teachers seed data failed validation: {exc}", - ) - - sorted_items = sorted( - response.items, - key=lambda item: ["li-song", "du-wenliang", "lin-feng", "shen-dakun"].index(item.id), - ) - - return TeacherListResponse(items=sorted_items, meta=response.meta) + detail=str(exc), + ) from exc @router.get("/list", response_model=TeacherListResponse) def get_teacher_list(): - return load_teachers_data() + return load_teachers_or_503() @router.get("/detail", response_model=TeacherDetailResponse) def get_teacher_detail(id: str): - data = load_teachers_data() + data = load_teachers_or_503() teacher = next((item for item in data.items if item.id == id), None) if teacher is None: @@ -132,7 +52,7 @@ def get_teacher_detail(id: str): dependencies=[Depends(login_required_publisher)], ) def sync_teachers_json(): - data = load_teachers_data() + data = load_teachers_or_503() meta = data.meta.copy(deep=True) meta.sync_note = "JSON seed reloaded; remote source sync is not implemented in this phase." diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..b67dd51 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,4 @@ +""" +职责范围:承载后端跨路由复用的业务服务模块。 +功能边界:本包只提供服务层组织结构,不在包初始化阶段执行数据库、网络或文件副作用。 +""" diff --git a/services/teachers_cache.py b/services/teachers_cache.py new file mode 100644 index 0000000..cc57bc6 --- /dev/null +++ b/services/teachers_cache.py @@ -0,0 +1,128 @@ +""" +职责范围:定义教师介绍数据契约、白名单排序和 JSON 缓存读写。 +功能边界:本模块不请求远程源站、不处理 FastAPI 路由权限,也不负责前端展示逻辑。 +""" + +import json +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, validator + + +TEACHERS_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "teachers.json" +TEACHER_ORDER = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] +TEACHER_WHITELIST = set(TEACHER_ORDER) +TEACHERS_DATA_COMMENT = ( + "职责范围:为教师介绍 API 提供四位白名单教师缓存数据;" + "功能边界:本文件不负责远程同步实现、数据库迁移、旧 gallery 资源或非白名单教师资料。" +) + + +class TeacherCacheError(Exception): + """Raised when the teacher JSON cache cannot be read or validated.""" + + +class HomepageUrl(BaseModel): + label: str = "" + url: str = "" + type: str = "" + + +class ProfileSection(BaseModel): + title: str = "" + content: str = "" + + +class TeacherItem(BaseModel): + id: str + name: str + title: str = "" + affiliation: str = "" + research_areas: list[str] = Field(default_factory=list) + summary: str = "" + bio: str = "" + email: str = "" + avatar_url: str = "" + homepage_urls: list[HomepageUrl] = Field(default_factory=list) + source_url: str = "" + source_status: str = "manual_seed" + last_synced_at: str = "" + sync_note: str = "" + profile_sections: list[ProfileSection] = Field(default_factory=list) + + @validator("id") + def validate_teacher_id(cls, value: str) -> str: + if value not in TEACHER_WHITELIST: + raise ValueError("teacher is not in whitelist") + return value + + +class TeacherMeta(BaseModel): + last_synced_at: str = "" + source_status: str = "manual_seed" + source_url: str = "" + sync_note: str = "" + + +class TeacherListResponse(BaseModel): + items: list[TeacherItem] + meta: TeacherMeta + + +class TeacherDetailResponse(BaseModel): + item: TeacherItem + meta: TeacherMeta + + +class TeacherSyncResponse(TeacherListResponse): + ok: bool = True + + +def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> TeacherListResponse: + try: + raw_data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise TeacherCacheError("Teachers seed data not found") from exc + except json.JSONDecodeError as exc: + raise TeacherCacheError("Teachers seed data is invalid") from exc + + try: + response = TeacherListResponse( + items=raw_data.get("items", []), + meta=raw_data.get("meta", {}), + ) + except Exception as exc: + raise TeacherCacheError(f"Teachers seed data failed validation: {exc}") from exc + + return normalize_teachers_response(response) + + +def write_teachers_data(data: TeacherListResponse, path: Path = TEACHERS_DATA_PATH) -> None: + response = normalize_teachers_response(data) + payload = { + "_comment": TEACHERS_DATA_COMMENT, + "meta": _model_dump(response.meta), + "items": [_model_dump(item) for item in response.items], + } + + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_name(f"{path.name}.tmp") + temp_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temp_path.replace(path) + + +def normalize_teachers_response(data: TeacherListResponse) -> TeacherListResponse: + items_by_id = {item.id: item for item in data.items if item.id in TEACHER_WHITELIST} + sorted_items = [items_by_id[teacher_id] for teacher_id in TEACHER_ORDER if teacher_id in items_by_id] + return TeacherListResponse(items=sorted_items, meta=data.meta) + + +def _model_dump(model: BaseModel) -> dict[str, Any]: + if hasattr(model, "model_dump"): + return model.model_dump() + + return model.dict() From 57dd5e3ae09f22f067316e3525a99dcded4674d8 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 00:41:36 +0800 Subject: [PATCH 03/11] feat: add teacher source parsers --- services/teachers_sync.py | 314 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 services/teachers_sync.py diff --git a/services/teachers_sync.py b/services/teachers_sync.py new file mode 100644 index 0000000..336f1b3 --- /dev/null +++ b/services/teachers_sync.py @@ -0,0 +1,314 @@ +""" +职责范围:抓取并解析教师介绍页的公开源站资料,产出可合并的同步字段。 +功能边界:本模块不处理 FastAPI 权限、不直接写缓存文件,也不开放管理后台编辑能力。 +""" + +import re +from dataclasses import dataclass, field +from datetime import datetime +from html.parser import HTMLParser + +import requests + +from services.teachers_cache import TEACHER_ORDER + + +ICS_ROSTER_URL = "https://icsr.zju.edu.cn/jsdw/list.htm" +REQUEST_TIMEOUT_SECONDS = 15 +USER_AGENT = "ZJUCSA website teacher roster sync/1.0" + +TEACHER_NAMES = { + "li-song": "李松", + "du-wenliang": "杜文亮", + "lin-feng": "林峰", + "shen-dakun": "申大坤", +} + +PROFILE_SOURCES = { + "li-song": "https://songli.io/", + "du-wenliang": "https://person.zju.edu.cn/wenliangdu", + "lin-feng": "https://person.zju.edu.cn/flin", +} + + +class TeacherSyncError(Exception): + """Raised when remote teacher sources cannot be fetched or parsed.""" + + +@dataclass +class FetchAttempt: + url: str + status: str + mechanism: str + evidence: str = "" + blocker_category: str = "" + + +@dataclass +class SourceTeacherRecord: + id: str + name: str + source_url: str + title: str = "" + affiliation: str = "" + research_areas: list[str] = field(default_factory=list) + email: str = "" + summary: str = "" + bio: str = "" + sync_note: str = "" + + +def fetch_source_html(url: str, session: requests.Session | None = None) -> tuple[str, FetchAttempt]: + client = session or requests.Session() + + try: + response = client.get( + url, + timeout=REQUEST_TIMEOUT_SECONDS, + headers={"User-Agent": USER_AGENT}, + ) + response.raise_for_status() + except requests.RequestException as exc: + return "", FetchAttempt( + url=url, + status="blocked", + mechanism="requests.get", + evidence=str(exc), + blocker_category="network", + ) + + response.encoding = response.apparent_encoding or response.encoding + return response.text, FetchAttempt( + url=url, + status="success", + mechanism="requests.get", + evidence=f"HTTP {response.status_code}, {len(response.text)} bytes", + ) + + +def parse_ics_roster(html: str, source_url: str = ICS_ROSTER_URL) -> dict[str, SourceTeacherRecord]: + lines = extract_text_lines(html) + records: dict[str, SourceTeacherRecord] = {} + + for teacher_id in TEACHER_ORDER: + name = TEACHER_NAMES[teacher_id] + name_index = _find_exact_line(lines, name) + if name_index is None: + continue + + title_parts: list[str] = [] + research_text = "" + index = name_index + 1 + while index < len(lines): + line = lines[index] + + if line.startswith("研究方向"): + current = _after_label(line, "研究方向").strip(" ::") + continuation: list[str] = [] + if not current: + index += 1 + while index < len(lines) and len(continuation) < 4: + next_line = lines[index] + if next_line.startswith("研究方向") or _looks_like_teacher_entry(lines, index): + break + continuation.append(next_line) + index += 1 + research_text = " ".join(part for part in [current, *continuation] if part) + break + + title_parts.append(line) + index += 1 + + research_areas = split_research_areas(research_text) + if not research_areas: + continue + + records[teacher_id] = SourceTeacherRecord( + id=teacher_id, + name=name, + source_url=source_url, + title=normalize_title(title_parts), + research_areas=research_areas, + ) + + return records + + +def parse_profile_source( + teacher_id: str, + html: str, + source_url: str, +) -> SourceTeacherRecord | None: + if teacher_id == "li-song": + return parse_song_li_profile(html, source_url) + + if teacher_id in {"du-wenliang", "lin-feng"}: + return parse_zju_person_profile(teacher_id, html, source_url) + + return None + + +def parse_zju_person_profile(teacher_id: str, html: str, source_url: str) -> SourceTeacherRecord | None: + lines = extract_text_lines(html) + name = TEACHER_NAMES[teacher_id] + name_index = _find_exact_line(lines, name) + + if name_index is None and teacher_id == "du-wenliang": + name_index = _find_exact_line(lines, "Wenliang Du") + + if name_index is None: + return None + + title_parts = _collect_until_label(lines, name_index + 1, {"单位"}) + title = normalize_title([part for part in title_parts if part != "博士"]) + affiliation = _value_after_label(lines, "单位") + email = _value_after_label(lines, "邮箱") + research_text = _value_after_label(lines, "研究方向") + research_areas = split_research_areas(research_text) + + return SourceTeacherRecord( + id=teacher_id, + name=name, + source_url=source_url, + title=title, + affiliation=affiliation, + email=email, + research_areas=research_areas, + ) + + +def parse_song_li_profile(html: str, source_url: str) -> SourceTeacherRecord | None: + lines = extract_text_lines(html) + name = TEACHER_NAMES["li-song"] + intro = next((line for line in lines if line.startswith("李松,")), "") + research_match = re.search(r"主要研究方向为(.+?)(?:等|。)", intro) + research_areas = split_research_areas(research_match.group(1) if research_match else "") + + title = "百人计划研究员" + if "博导" in intro: + title = "百人计划研究员 | 博士生导师" + + if not research_areas and not title: + return None + + return SourceTeacherRecord( + id="li-song", + name=name, + source_url=source_url, + title=title, + research_areas=research_areas, + ) + + +def split_research_areas(value: str) -> list[str]: + normalized = re.sub(r"\s+", " ", value.replace(":", ":")).strip(" :,,、。") + if not normalized: + return [] + + parts = re.split(r"[、,,;;]+|和", normalized) + return _unique([part.strip(" .。") for part in parts if part.strip(" .。")]) + + +def build_objective_summary(record: SourceTeacherRecord) -> tuple[str, str]: + areas = "、".join(record.research_areas) + title_text = f"{record.title}," if record.title else "" + + if areas: + summary = f"公开来源列出的研究方向包括{areas}。" + bio = f"{record.name}为{title_text}公开来源列出的研究方向包括{areas}。" + else: + summary = "公开资料字段仍在同步核验中。" + bio = f"{record.name}的公开资料仍在同步核验中。" + + return summary, bio + + +def now_iso_shanghai() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def extract_text_lines(html: str) -> list[str]: + parser = _TextExtractor() + parser.feed(html) + return parser.lines + + +class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.lines: list[str] = [] + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() in {"script", "style", "noscript"}: + self._skip_depth += 1 + + def handle_endtag(self, tag: str) -> None: + if tag.lower() in {"script", "style", "noscript"} and self._skip_depth: + self._skip_depth -= 1 + + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + + text = re.sub(r"\s+", " ", data).strip() + if text: + self.lines.append(text) + + +def _find_exact_line(lines: list[str], value: str) -> int | None: + try: + return lines.index(value) + except ValueError: + return None + + +def _looks_like_teacher_entry(lines: list[str], index: int) -> bool: + compact = lines[index].strip() + if not re.fullmatch(r"[\u4e00-\u9fff]{2,4}", compact): + return False + + next_line = lines[index + 1].strip() if index + 1 < len(lines) else "" + return next_line.startswith((",", ",")) + + +def _after_label(line: str, label: str) -> str: + return re.sub(rf"^{re.escape(label)}\s*[::]?", "", line).strip() + + +def _collect_until_label(lines: list[str], start_index: int, stop_labels: set[str]) -> list[str]: + values: list[str] = [] + for line in lines[start_index:]: + if line in stop_labels: + break + values.append(line) + + return values + + +def _value_after_label(lines: list[str], label: str) -> str: + for index, line in enumerate(lines): + if line == label: + return lines[index + 1] if index + 1 < len(lines) else "" + if line.startswith(label): + value = _after_label(line, label) + if value: + return value + + return "" + + +def normalize_title(parts: list[str]) -> str: + cleaned = [part.strip(" ,,") for part in parts if part.strip(" ,,|/")] + return re.sub(r"\s+", " ", " | ".join(cleaned)).strip() + + +def _unique(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value and value not in seen: + seen.add(value) + result.append(value) + + return result From 63c930a53d0a5936ac28bf2b10e765d442316fea Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 00:44:48 +0800 Subject: [PATCH 04/11] feat: enable teacher roster manual sync --- data/teachers.json | 89 ++++++++----- routes/teachers.py | 30 ++++- scripts/verify_teachers_json.py | 7 +- services/teachers_sync.py | 221 +++++++++++++++++++++++++++++++- 4 files changed, 307 insertions(+), 40 deletions(-) diff --git a/data/teachers.json b/data/teachers.json index cb6edef..88b8565 100644 --- a/data/teachers.json +++ b/data/teachers.json @@ -1,10 +1,10 @@ { - "_comment": "职责范围:为教师介绍 API 提供 JSON 阶段的四位白名单教师 seed 数据;功能边界:本文件不负责远程同步、数据库迁移、旧 gallery 资源或非白名单教师资料。", + "_comment": "职责范围:为教师介绍 API 提供四位白名单教师缓存数据;功能边界:本文件不负责远程同步实现、数据库迁移、旧 gallery 资源或非白名单教师资料。", "meta": { - "last_synced_at": "2026-04-29T00:00:00+08:00", - "source_status": "manual_seed", + "last_synced_at": "2026-04-30T00:44:07+08:00", + "source_status": "synced", "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "sync_note": "本批数据为人工整理的临时 seed,后续接入后端同步。" + "sync_note": "学院教师队伍页同步成功;个人主页同步成功 3/3;普通访问继续读取本站缓存。" }, "items": [ { @@ -12,9 +12,15 @@ "name": "李松", "title": "百人计划研究员 | 博士生导师", "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", - "research_areas": ["程序分析", "漏洞挖掘", "应用安全", "网络安全", "系统安全"], - "summary": "研究方向包括程序分析、漏洞挖掘和应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", - "bio": "李松为浙江大学百人计划研究员、博士生导师。公开主页介绍其研究工作聚焦程序分析、漏洞挖掘与应用安全;学院教师队伍页列出的方向包括漏洞挖掘、网络安全、系统安全和程序分析。", + "research_areas": [ + "程序分析", + "漏洞挖掘", + "应用安全", + "网络安全", + "系统安全" + ], + "summary": "公开来源列出的研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "bio": "李松为百人计划研究员 | 博士生导师。公开来源列出的研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", "email": "songli@zju.edu.cn", "avatar_url": "/mentors/li-song/avatar.png", "homepage_urls": [ @@ -35,19 +41,29 @@ } ], "source_url": "https://songli.io/", - "source_status": "manual_seed", - "last_synced_at": "2026-04-29T00:00:00+08:00", - "sync_note": "个人主页邮箱以图片形式展示;正式接入同步前需继续复核字段抽取方式。", + "source_status": "synced", + "last_synced_at": "2026-04-30T00:44:07+08:00", + "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, { "id": "du-wenliang", "name": "杜文亮", - "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", - "affiliation": "浙江大学计算机科学与技术学院", - "research_areas": ["网络空间安全", "系统安全", "网络仿真", "网络系统仿真", "车联网安全"], - "summary": "浙大个人主页列出网络空间安全、系统安全和网络仿真方向,学院教师队伍页补充网络系统仿真、互联网、无线移动网、车联网、工控网。", - "bio": "杜文亮为浙江大学求是讲席教授、博士生导师。浙大个人主页列出研究方向为网络空间安全、系统安全和网络仿真;学院教师队伍页列出网络系统仿真、互联网、无线移动网、车联网、工控网等方向。", + "title": "求是讲席教授 | 博士生导师", + "affiliation": "计算机科学与技术学院", + "research_areas": [ + "网络空间安全", + "系统安全", + "网络仿真", + "网络系统仿真", + "互联网", + "无线移动网", + "车联网", + "工控网", + "车联网安全" + ], + "summary": "公开来源列出的研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网、车联网安全。", + "bio": "杜文亮为求是讲席教授 | 博士生导师。公开来源列出的研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网、车联网安全。", "email": "wenliangdu@zju.edu.cn", "avatar_url": "/mentors/du-wenliang/avatar.jpg", "homepage_urls": [ @@ -68,19 +84,26 @@ } ], "source_url": "https://person.zju.edu.cn/wenliangdu", - "source_status": "manual_seed", - "last_synced_at": "2026-04-29T00:00:00+08:00", - "sync_note": "字段人工整理自浙大个人主页、学院教师队伍页和公开个人主页。", + "source_status": "synced", + "last_synced_at": "2026-04-30T00:44:07+08:00", + "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, { "id": "lin-feng", "name": "林峰", "title": "百人计划研究员 | 博士生导师", - "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", - "research_areas": ["智能网联车安全", "物联网安全", "人工智能安全", "无线感知攻防", "生物识别身份认证"], - "summary": "浙大个人主页列出智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证等方向。", - "bio": "林峰为浙江大学百人计划研究员、博士生导师。浙大个人主页列出研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证;其公开主页也列出 IoT Security、Smart Vehicle Security、Deepfake Detection、Artificial Intelligence Security 等兴趣方向。", + "affiliation": "计算机科学与技术学院", + "research_areas": [ + "智能网联车安全", + "物联网安全", + "人工智能安全", + "无线感知攻防", + "生物识别身份认证", + "无线感知" + ], + "summary": "公开来源列出的研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证、无线感知。", + "bio": "林峰为百人计划研究员 | 博士生导师。公开来源列出的研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证、无线感知。", "email": "flin@zju.edu.cn", "avatar_url": "/mentors/lin-feng/avatar.jpg", "homepage_urls": [ @@ -101,9 +124,9 @@ } ], "source_url": "https://person.zju.edu.cn/flin", - "source_status": "manual_seed", - "last_synced_at": "2026-04-29T00:00:00+08:00", - "sync_note": "字段人工整理自浙大个人主页、学院教师队伍页和公开个人主页。", + "source_status": "synced", + "last_synced_at": "2026-04-30T00:44:07+08:00", + "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, { @@ -111,9 +134,13 @@ "name": "申大坤", "title": "研究员", "affiliation": "浙江大学网络空间安全学院", - "research_areas": ["系统安全", "IoT 安全", "软件供应链"], - "summary": "学院教师队伍页列为研究员,研究方向为系统安全、IoT 安全和软件供应链。", - "bio": "申大坤为浙江大学网络空间安全学院教师队伍页列出的研究员。当前公开来源列出的研究方向为系统安全、IoT 安全和软件供应链;个人主页、邮箱等字段在本阶段暂未确认。", + "research_areas": [ + "系统安全", + "IoT 安全", + "软件供应链" + ], + "summary": "公开来源列出的研究方向包括系统安全、IoT 安全、软件供应链。", + "bio": "申大坤为研究员。公开来源列出的研究方向包括系统安全、IoT 安全、软件供应链。", "email": "", "avatar_url": "/mentors/shen-dakun/avatar.png", "homepage_urls": [ @@ -124,9 +151,9 @@ } ], "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "source_status": "manual_seed", - "last_synced_at": "2026-04-29T00:00:00+08:00", - "sync_note": "本阶段仅确认学院教师队伍页基础信息;邮箱和个人主页入口待后续复核。", + "source_status": "synced", + "last_synced_at": "2026-04-30T00:44:07+08:00", + "sync_note": "学院教师队伍页同步成功。未配置可访问个人主页,个人主页字段保留缓存。", "profile_sections": [] } ] diff --git a/routes/teachers.py b/routes/teachers.py index b686789..c663843 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -12,7 +12,9 @@ TeacherListResponse, TeacherSyncResponse, load_teachers_data, + write_teachers_data, ) +from services.teachers_sync import TeacherSyncError, sync_teacher_roster router = APIRouter() @@ -53,7 +55,29 @@ def get_teacher_detail(id: str): ) def sync_teachers_json(): data = load_teachers_or_503() - meta = data.meta.copy(deep=True) - meta.sync_note = "JSON seed reloaded; remote source sync is not implemented in this phase." - return TeacherSyncResponse(ok=True, items=data.items, meta=meta) + try: + sync_result = sync_teacher_roster(data) + write_teachers_data(sync_result.data) + except (TeacherSyncError, OSError) as exc: + meta = data.meta.copy(deep=True) + meta.source_status = "failed" + meta.sync_note = f"同步失败,已返回最近一次缓存:{_format_sync_error(exc)}" + return TeacherSyncResponse(ok=False, items=data.items, meta=meta) + + return TeacherSyncResponse(ok=True, items=sync_result.data.items, meta=sync_result.data.meta) + + +def _format_sync_error(exc: Exception) -> str: + attempts = getattr(exc, "attempts", []) + blocked_attempt = next((attempt for attempt in reversed(attempts) if attempt.status != "success"), None) + if blocked_attempt is None: + return str(exc) + + evidence = blocked_attempt.evidence[:160] + return ( + f"{exc};url={blocked_attempt.url};" + f"mechanism={blocked_attempt.mechanism};" + f"blocker={blocked_attempt.blocker_category or 'unknown'};" + f"evidence={evidence}" + ) diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_json.py index 353df0b..647d471 100644 --- a/scripts/verify_teachers_json.py +++ b/scripts/verify_teachers_json.py @@ -1,6 +1,6 @@ """ -职责范围:验证教师 JSON seed 能被后端教师 API 读取并满足四位白名单教师的基础契约。 -功能边界:本脚本只做本地 JSON 阶段的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 +职责范围:验证教师 JSON 缓存能被后端教师 API 读取并满足四位白名单教师的基础契约。 +功能边界:本脚本只做本地缓存的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 """ import sys @@ -23,7 +23,8 @@ def main(): ids = [item.id for item in data.items] assert ids == EXPECTED_IDS, f"unexpected teacher ids: {ids}" - assert data.meta.source_status == "manual_seed" + assert data.meta.source_status in {"manual_seed", "synced", "failed"} + assert data.meta.source_url for teacher_id in EXPECTED_IDS: detail = get_teacher_detail(teacher_id) diff --git a/services/teachers_sync.py b/services/teachers_sync.py index 336f1b3..2a7d080 100644 --- a/services/teachers_sync.py +++ b/services/teachers_sync.py @@ -10,7 +10,12 @@ import requests -from services.teachers_cache import TEACHER_ORDER +from services.teachers_cache import ( + TEACHER_ORDER, + TeacherItem, + TeacherListResponse, + TeacherMeta, +) ICS_ROSTER_URL = "https://icsr.zju.edu.cn/jsdw/list.htm" @@ -34,6 +39,10 @@ class TeacherSyncError(Exception): """Raised when remote teacher sources cannot be fetched or parsed.""" + def __init__(self, message: str, attempts: list["FetchAttempt"] | None = None) -> None: + super().__init__(message) + self.attempts = attempts or [] + @dataclass class FetchAttempt: @@ -58,6 +67,12 @@ class SourceTeacherRecord: sync_note: str = "" +@dataclass +class TeacherSyncResult: + data: TeacherListResponse + attempts: list[FetchAttempt] + + def fetch_source_html(url: str, session: requests.Session | None = None) -> tuple[str, FetchAttempt]: client = session or requests.Session() @@ -148,6 +163,132 @@ def parse_profile_source( return None +def sync_teacher_roster( + cached_data: TeacherListResponse, + session: requests.Session | None = None, +) -> TeacherSyncResult: + attempts: list[FetchAttempt] = [] + roster_html, roster_attempt = fetch_source_html(ICS_ROSTER_URL, session=session) + attempts.append(roster_attempt) + + if roster_attempt.status != "success": + raise TeacherSyncError("primary roster source fetch failed", attempts) + + roster_records = parse_ics_roster(roster_html) + if not roster_records: + attempts.append( + FetchAttempt( + url=ICS_ROSTER_URL, + status="blocked", + mechanism="parse_ics_roster", + evidence="no whitelisted teachers parsed from primary roster", + blocker_category="source_unavailable", + ) + ) + raise TeacherSyncError("primary roster source parsed no whitelisted teachers", attempts) + + profile_records: dict[str, SourceTeacherRecord] = {} + profile_notes: dict[str, str] = {} + + for teacher_id, profile_url in PROFILE_SOURCES.items(): + profile_html, profile_attempt = fetch_source_html(profile_url, session=session) + attempts.append(profile_attempt) + + if profile_attempt.status != "success": + profile_notes[teacher_id] = f"个人主页同步失败:{_shorten(profile_attempt.evidence)}" + continue + + profile_record = parse_profile_source(teacher_id, profile_html, profile_url) + if profile_record is None: + profile_notes[teacher_id] = "个人主页已访问,但未解析到有效字段。" + continue + + profile_records[teacher_id] = profile_record + profile_notes[teacher_id] = "个人主页同步成功。" + + synced_at = now_iso_shanghai() + synced_data = merge_synced_records( + cached_data=cached_data, + roster_records=roster_records, + profile_records=profile_records, + profile_notes=profile_notes, + synced_at=synced_at, + ) + + return TeacherSyncResult(data=synced_data, attempts=attempts) + + +def merge_synced_records( + cached_data: TeacherListResponse, + roster_records: dict[str, SourceTeacherRecord], + profile_records: dict[str, SourceTeacherRecord], + profile_notes: dict[str, str], + synced_at: str, +) -> TeacherListResponse: + cached_by_id = {item.id: item for item in cached_data.items} + synced_items: list[TeacherItem] = [] + + for teacher_id in TEACHER_ORDER: + fallback = cached_by_id.get(teacher_id) + roster_record = roster_records.get(teacher_id) + profile_record = profile_records.get(teacher_id) + source_record = _merge_source_record( + teacher_id=teacher_id, + fallback=fallback, + roster_record=roster_record, + profile_record=profile_record, + ) + + if source_record is None: + continue + + has_synced_source = roster_record is not None or profile_record is not None + source_status = "synced" if has_synced_source else "failed" + last_synced_at = synced_at if has_synced_source else (fallback.last_synced_at if fallback else "") + summary, bio = build_objective_summary(source_record) + + if not has_synced_source and fallback: + summary = fallback.summary + bio = fallback.bio + + synced_items.append( + TeacherItem( + id=teacher_id, + name=source_record.name, + title=source_record.title, + affiliation=source_record.affiliation, + research_areas=source_record.research_areas, + summary=summary, + bio=bio, + email=source_record.email, + avatar_url=fallback.avatar_url if fallback else "", + homepage_urls=fallback.homepage_urls if fallback else [], + source_url=source_record.source_url, + source_status=source_status, + last_synced_at=last_synced_at, + sync_note=_build_teacher_sync_note(teacher_id, roster_record, profile_notes), + profile_sections=fallback.profile_sections if fallback else [], + ) + ) + + profile_success = sum(1 for teacher_id in PROFILE_SOURCES if teacher_id in profile_records) + meta_note = ( + "学院教师队伍页同步成功;" + f"个人主页同步成功 {profile_success}/{len(PROFILE_SOURCES)};" + "普通访问继续读取本站缓存。" + ) + + return TeacherListResponse( + items=synced_items, + meta=TeacherMeta( + last_synced_at=synced_at, + source_status="synced", + source_url=ICS_ROSTER_URL, + sync_note=meta_note, + ), + ) + + def parse_zju_person_profile(teacher_id: str, html: str, source_url: str) -> SourceTeacherRecord | None: lines = extract_text_lines(html) name = TEACHER_NAMES[teacher_id] @@ -211,11 +352,13 @@ def split_research_areas(value: str) -> list[str]: def build_objective_summary(record: SourceTeacherRecord) -> tuple[str, str]: areas = "、".join(record.research_areas) - title_text = f"{record.title}," if record.title else "" if areas: summary = f"公开来源列出的研究方向包括{areas}。" - bio = f"{record.name}为{title_text}公开来源列出的研究方向包括{areas}。" + if record.title: + bio = f"{record.name}为{record.title}。公开来源列出的研究方向包括{areas}。" + else: + bio = f"{record.name}公开来源列出的研究方向包括{areas}。" else: summary = "公开资料字段仍在同步核验中。" bio = f"{record.name}的公开资料仍在同步核验中。" @@ -298,6 +441,78 @@ def _value_after_label(lines: list[str], label: str) -> str: return "" +def _merge_source_record( + teacher_id: str, + fallback: TeacherItem | None, + roster_record: SourceTeacherRecord | None, + profile_record: SourceTeacherRecord | None, +) -> SourceTeacherRecord | None: + if fallback is None and roster_record is None and profile_record is None: + return None + + research_areas = _unique( + (profile_record.research_areas if profile_record else []) + + (roster_record.research_areas if roster_record else []) + + (fallback.research_areas if fallback else []) + ) + + return SourceTeacherRecord( + id=teacher_id, + name=TEACHER_NAMES[teacher_id], + title=_first_value( + profile_record.title if profile_record else "", + roster_record.title if roster_record else "", + fallback.title if fallback else "", + ), + affiliation=_first_value( + profile_record.affiliation if profile_record else "", + fallback.affiliation if fallback else "", + ), + research_areas=research_areas, + email=_first_value( + profile_record.email if profile_record else "", + fallback.email if fallback else "", + ), + source_url=_first_value( + profile_record.source_url if profile_record else "", + roster_record.source_url if roster_record else "", + fallback.source_url if fallback else "", + ), + ) + + +def _build_teacher_sync_note( + teacher_id: str, + roster_record: SourceTeacherRecord | None, + profile_notes: dict[str, str], +) -> str: + notes: list[str] = [] + + if roster_record is None: + notes.append("学院教师队伍页未解析到该教师,保留缓存字段。") + else: + notes.append("学院教师队伍页同步成功。") + + if teacher_id in PROFILE_SOURCES: + notes.append(profile_notes.get(teacher_id, "个人主页未完成同步。")) + else: + notes.append("未配置可访问个人主页,个人主页字段保留缓存。") + + return "".join(notes) + + +def _first_value(*values: str) -> str: + return next((value for value in values if value), "") + + +def _shorten(value: str, limit: int = 160) -> str: + normalized = re.sub(r"\s+", " ", value).strip() + if len(normalized) <= limit: + return normalized + + return normalized[: limit - 3] + "..." + + def normalize_title(parts: list[str]) -> str: cleaned = [part.strip(" ,,") for part in parts if part.strip(" ,,|/")] return re.sub(r"\s+", " ", " | ".join(cleaned)).strip() From 7043c6ffb1c6a7f02ddaddad3c67a72c90a398cd Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 00:46:15 +0800 Subject: [PATCH 05/11] test: cover teacher roster sync fallback --- scripts/verify_teachers_sync.py | 155 ++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 scripts/verify_teachers_sync.py diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py new file mode 100644 index 0000000..53c937d --- /dev/null +++ b/scripts/verify_teachers_sync.py @@ -0,0 +1,155 @@ +""" +职责范围:验证教师同步器的白名单解析、缓存合并和同步失败回退行为。 +功能边界:本脚本使用本地 HTML fixture 和 fake session,不请求真实源站、不修改缓存文件、不验证登录权限。 +""" + +import sys +from pathlib import Path + +import requests + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import routes.teachers as teachers_route +from services.teachers_cache import TEACHER_ORDER, TEACHERS_DATA_PATH, load_teachers_data +from services.teachers_sync import ( + ICS_ROSTER_URL, + PROFILE_SOURCES, + FetchAttempt, + TeacherSyncError, + parse_ics_roster, + sync_teacher_roster, +) + + +ICS_FIXTURE = """ + +

其他教师

,教授

研究方向:不应进入结果

+

杜文亮

求是讲席教授

ACM/IEEE Fellow

+

研究方向:网络系统仿真、互联网、无线移动网、车联网、工控网

+

林峰

,百人计划研究员,

博导

+

研究方向:物联网安全、智能网联车安全、人工智能安全、无线感知

+

李松

,百人计划研究员,

博导

+

研究方向:漏洞挖掘、网络安全、系统安全、程序分析

+

申大坤

,研究员

研究方向:

系统安全、

IoT 安全、软件供应链

+

后续教师

,研究员

研究方向:不应拼接

+ +""" + +SONG_LI_FIXTURE = """ + +

李松,浙江大学百人计划研究员,博导。主要研究方向为程序分析、漏洞挖掘、应用安全等。

+ +""" + +DU_WENLIANG_FIXTURE = """ + +

Wenliang Du

博士

求是讲席教授

|

博士生导师

+

单位

计算机科学与技术学院

+

邮箱

wenliangdu@zju.edu.cn

+

研究方向

网络空间安全,系统安全,网络仿真

+ +""" + +LIN_FENG_FIXTURE = """ + +

林峰

博士

百人计划研究员

|

博士生导师

+

单位

计算机科学与技术学院

+

邮箱

flin@zju.edu.cn

+

研究方向

智能网联车安全,物联网安全,人工智能安全,无线感知攻防,生物识别身份认证

+ +""" + + +class FakeResponse: + def __init__(self, text: str) -> None: + self.text = text + self.status_code = 200 + self.encoding = "utf-8" + self.apparent_encoding = "utf-8" + + def raise_for_status(self) -> None: + return None + + +class FixtureSession: + def __init__(self, responses: dict[str, str]) -> None: + self.responses = responses + + def get(self, url: str, **kwargs): + if url not in self.responses: + raise requests.ConnectionError(f"missing fixture for {url}") + + return FakeResponse(self.responses[url]) + + +class FailingSession: + def get(self, url: str, **kwargs): + raise requests.ConnectionError("forced offline") + + +def main(): + cached_data = load_teachers_data() + + parsed_records = parse_ics_roster(ICS_FIXTURE) + assert list(parsed_records.keys()) == TEACHER_ORDER + assert parsed_records["shen-dakun"].research_areas == ["系统安全", "IoT 安全", "软件供应链"] + assert "其他教师" not in [record.name for record in parsed_records.values()] + + fixture_session = FixtureSession( + { + ICS_ROSTER_URL: ICS_FIXTURE, + PROFILE_SOURCES["li-song"]: SONG_LI_FIXTURE, + PROFILE_SOURCES["du-wenliang"]: DU_WENLIANG_FIXTURE, + PROFILE_SOURCES["lin-feng"]: LIN_FENG_FIXTURE, + } + ) + sync_result = sync_teacher_roster(cached_data, session=fixture_session) + assert [item.id for item in sync_result.data.items] == TEACHER_ORDER + assert sync_result.data.meta.source_status == "synced" + assert all(item.source_status == "synced" for item in sync_result.data.items) + assert sync_result.data.items[1].email == "wenliangdu@zju.edu.cn" + + try: + sync_teacher_roster(cached_data, session=FailingSession()) + except TeacherSyncError as exc: + assert exc.attempts[0].status == "blocked" + assert exc.attempts[0].blocker_category == "network" + else: + raise AssertionError("network failure should raise TeacherSyncError") + + original_sync = teachers_route.sync_teacher_roster + before = TEACHERS_DATA_PATH.read_bytes() + + def fail_sync(_data): + raise TeacherSyncError( + "forced failure", + attempts=[ + FetchAttempt( + url=ICS_ROSTER_URL, + status="blocked", + mechanism="fixture", + blocker_category="network", + evidence="forced failure", + ) + ], + ) + + try: + teachers_route.sync_teacher_roster = fail_sync + response = teachers_route.sync_teachers_json() + finally: + teachers_route.sync_teacher_roster = original_sync + + after = TEACHERS_DATA_PATH.read_bytes() + assert response.ok is False + assert response.meta.source_status == "failed" + assert before == after + + print("teachers sync verification passed") + + +if __name__ == "__main__": + main() From 7f97aa6bcccdf4e4db996fa773d03a8220d70def Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 09:56:23 +0800 Subject: [PATCH 06/11] chore: audit teacher roster content --- data/teachers.json | 40 ++++++++++----------- scripts/verify_teachers_sync.py | 4 +++ services/teachers_sync.py | 63 ++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 30 deletions(-) diff --git a/data/teachers.json b/data/teachers.json index 88b8565..afecda1 100644 --- a/data/teachers.json +++ b/data/teachers.json @@ -1,7 +1,7 @@ { "_comment": "职责范围:为教师介绍 API 提供四位白名单教师缓存数据;功能边界:本文件不负责远程同步实现、数据库迁移、旧 gallery 资源或非白名单教师资料。", "meta": { - "last_synced_at": "2026-04-30T00:44:07+08:00", + "last_synced_at": "2026-04-30T09:54:25+08:00", "source_status": "synced", "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", "sync_note": "学院教师队伍页同步成功;个人主页同步成功 3/3;普通访问继续读取本站缓存。" @@ -19,8 +19,8 @@ "网络安全", "系统安全" ], - "summary": "公开来源列出的研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", - "bio": "李松为百人计划研究员 | 博士生导师。公开来源列出的研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "bio": "李松现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", "email": "songli@zju.edu.cn", "avatar_url": "/mentors/li-song/avatar.png", "homepage_urls": [ @@ -42,15 +42,15 @@ ], "source_url": "https://songli.io/", "source_status": "synced", - "last_synced_at": "2026-04-30T00:44:07+08:00", + "last_synced_at": "2026-04-30T09:54:25+08:00", "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, { "id": "du-wenliang", "name": "杜文亮", - "title": "求是讲席教授 | 博士生导师", - "affiliation": "计算机科学与技术学院", + "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", + "affiliation": "浙江大学计算机科学与技术学院", "research_areas": [ "网络空间安全", "系统安全", @@ -59,11 +59,10 @@ "互联网", "无线移动网", "车联网", - "工控网", - "车联网安全" + "工控网" ], - "summary": "公开来源列出的研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网、车联网安全。", - "bio": "杜文亮为求是讲席教授 | 博士生导师。公开来源列出的研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网、车联网安全。", + "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网。", + "bio": "杜文亮现为浙江大学求是讲席教授、博士生导师。公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网。", "email": "wenliangdu@zju.edu.cn", "avatar_url": "/mentors/du-wenliang/avatar.jpg", "homepage_urls": [ @@ -85,7 +84,7 @@ ], "source_url": "https://person.zju.edu.cn/wenliangdu", "source_status": "synced", - "last_synced_at": "2026-04-30T00:44:07+08:00", + "last_synced_at": "2026-04-30T09:54:25+08:00", "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, @@ -93,17 +92,16 @@ "id": "lin-feng", "name": "林峰", "title": "百人计划研究员 | 博士生导师", - "affiliation": "计算机科学与技术学院", + "affiliation": "浙江大学计算机科学与技术学院", "research_areas": [ "智能网联车安全", "物联网安全", "人工智能安全", "无线感知攻防", - "生物识别身份认证", - "无线感知" + "生物识别身份认证" ], - "summary": "公开来源列出的研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证、无线感知。", - "bio": "林峰为百人计划研究员 | 博士生导师。公开来源列出的研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证、无线感知。", + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", + "bio": "林峰现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", "email": "flin@zju.edu.cn", "avatar_url": "/mentors/lin-feng/avatar.jpg", "homepage_urls": [ @@ -125,7 +123,7 @@ ], "source_url": "https://person.zju.edu.cn/flin", "source_status": "synced", - "last_synced_at": "2026-04-30T00:44:07+08:00", + "last_synced_at": "2026-04-30T09:54:25+08:00", "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", "profile_sections": [] }, @@ -139,8 +137,8 @@ "IoT 安全", "软件供应链" ], - "summary": "公开来源列出的研究方向包括系统安全、IoT 安全、软件供应链。", - "bio": "申大坤为研究员。公开来源列出的研究方向包括系统安全、IoT 安全、软件供应链。", + "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全、软件供应链。", + "bio": "申大坤现为浙江大学网络空间安全学院研究员。公开资料显示,其研究方向包括系统安全、IoT 安全、软件供应链。", "email": "", "avatar_url": "/mentors/shen-dakun/avatar.png", "homepage_urls": [ @@ -152,8 +150,8 @@ ], "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", "source_status": "synced", - "last_synced_at": "2026-04-30T00:44:07+08:00", - "sync_note": "学院教师队伍页同步成功。未配置可访问个人主页,个人主页字段保留缓存。", + "last_synced_at": "2026-04-30T09:54:25+08:00", + "sync_note": "学院教师队伍页同步成功。当前仅从学院教师队伍页同步;未配置可访问个人主页。", "profile_sections": [] } ] diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py index 53c937d..3894bf3 100644 --- a/scripts/verify_teachers_sync.py +++ b/scripts/verify_teachers_sync.py @@ -111,6 +111,10 @@ def main(): assert sync_result.data.meta.source_status == "synced" assert all(item.source_status == "synced" for item in sync_result.data.items) assert sync_result.data.items[1].email == "wenliangdu@zju.edu.cn" + assert "ACM/IEEE Fellow" in sync_result.data.items[1].title + assert "车联网安全" not in sync_result.data.items[1].research_areas + assert "无线感知攻防" in sync_result.data.items[2].research_areas + assert "无线感知" not in sync_result.data.items[2].research_areas try: sync_teacher_roster(cached_data, session=FailingSession()) diff --git a/services/teachers_sync.py b/services/teachers_sync.py index 2a7d080..7aeefad 100644 --- a/services/teachers_sync.py +++ b/services/teachers_sync.py @@ -35,6 +35,34 @@ "lin-feng": "https://person.zju.edu.cn/flin", } +AUDITED_TITLES = { + "li-song": "百人计划研究员 | 博士生导师", + "du-wenliang": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", + "lin-feng": "百人计划研究员 | 博士生导师", + "shen-dakun": "研究员", +} + +AUDITED_AFFILIATIONS = { + "li-song": "浙江大学计算机科学与技术学院 / 网络空间安全学院", + "du-wenliang": "浙江大学计算机科学与技术学院", + "lin-feng": "浙江大学计算机科学与技术学院", + "shen-dakun": "浙江大学网络空间安全学院", +} + +SUMMARY_PREFIXES = { + "li-song": "浙江大学百人计划研究员、博士生导师", + "du-wenliang": "浙江大学求是讲席教授、博士生导师", + "lin-feng": "浙江大学百人计划研究员、博士生导师", + "shen-dakun": "浙江大学网络空间安全学院研究员", +} + +BIO_PREFIXES = { + "li-song": "李松现为浙江大学百人计划研究员、博士生导师。", + "du-wenliang": "杜文亮现为浙江大学求是讲席教授、博士生导师。", + "lin-feng": "林峰现为浙江大学百人计划研究员、博士生导师。", + "shen-dakun": "申大坤现为浙江大学网络空间安全学院研究员。", +} + class TeacherSyncError(Exception): """Raised when remote teacher sources cannot be fetched or parsed.""" @@ -352,13 +380,19 @@ def split_research_areas(value: str) -> list[str]: def build_objective_summary(record: SourceTeacherRecord) -> tuple[str, str]: areas = "、".join(record.research_areas) + summary_prefix = SUMMARY_PREFIXES.get(record.id, record.title) + bio_prefix = BIO_PREFIXES.get(record.id, "") if areas: - summary = f"公开来源列出的研究方向包括{areas}。" - if record.title: - bio = f"{record.name}为{record.title}。公开来源列出的研究方向包括{areas}。" + if summary_prefix: + summary = f"{summary_prefix},研究方向包括{areas}。" + else: + summary = f"研究方向包括{areas}。" + + if bio_prefix: + bio = f"{bio_prefix}公开资料显示,其研究方向包括{areas}。" else: - bio = f"{record.name}公开来源列出的研究方向包括{areas}。" + bio = f"{record.name}公开资料显示,其研究方向包括{areas}。" else: summary = "公开资料字段仍在同步核验中。" bio = f"{record.name}的公开资料仍在同步核验中。" @@ -450,21 +484,25 @@ def _merge_source_record( if fallback is None and roster_record is None and profile_record is None: return None - research_areas = _unique( - (profile_record.research_areas if profile_record else []) - + (roster_record.research_areas if roster_record else []) - + (fallback.research_areas if fallback else []) + synced_research_areas = (profile_record.research_areas if profile_record else []) + ( + roster_record.research_areas if roster_record else [] + ) + research_areas = _normalize_research_areas( + teacher_id, + _unique(synced_research_areas or (fallback.research_areas if fallback else [])), ) return SourceTeacherRecord( id=teacher_id, name=TEACHER_NAMES[teacher_id], title=_first_value( + AUDITED_TITLES.get(teacher_id, ""), profile_record.title if profile_record else "", roster_record.title if roster_record else "", fallback.title if fallback else "", ), affiliation=_first_value( + AUDITED_AFFILIATIONS.get(teacher_id, ""), profile_record.affiliation if profile_record else "", fallback.affiliation if fallback else "", ), @@ -496,7 +534,7 @@ def _build_teacher_sync_note( if teacher_id in PROFILE_SOURCES: notes.append(profile_notes.get(teacher_id, "个人主页未完成同步。")) else: - notes.append("未配置可访问个人主页,个人主页字段保留缓存。") + notes.append("当前仅从学院教师队伍页同步;未配置可访问个人主页。") return "".join(notes) @@ -505,6 +543,13 @@ def _first_value(*values: str) -> str: return next((value for value in values if value), "") +def _normalize_research_areas(teacher_id: str, areas: list[str]) -> list[str]: + if teacher_id == "lin-feng" and "无线感知攻防" in areas: + return [area for area in areas if area != "无线感知"] + + return areas + + def _shorten(value: str, limit: int = 160) -> str: normalized = re.sub(r"\s+", " ", value).strip() if len(normalized) <= limit: From 6a5ce2e7a8fa454f307d3fb80c8900c00c2cb5c2 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 21:02:19 +0800 Subject: [PATCH 07/11] feat(teachers): add admin-managed sync drafts --- data/teachers.json | 460 ++++++++++++++++-------- routes/teachers.py | 297 ++++++++++++++-- scripts/verify_teachers_json.py | 12 +- scripts/verify_teachers_sync.py | 116 ++---- services/teachers_cache.py | 227 ++++++++++-- services/teachers_sync.py | 605 ++++++++++++-------------------- 6 files changed, 1047 insertions(+), 670 deletions(-) diff --git a/data/teachers.json b/data/teachers.json index afecda1..cfa1f63 100644 --- a/data/teachers.json +++ b/data/teachers.json @@ -1,158 +1,344 @@ { - "_comment": "职责范围:为教师介绍 API 提供四位白名单教师缓存数据;功能边界:本文件不负责远程同步实现、数据库迁移、旧 gallery 资源或非白名单教师资料。", + "_comment": "职责范围:为教师介绍 API 和后台管理提供教师公开资料、来源配置和待审核同步草稿;功能边界:本文件不负责远程同步实现、数据库迁移或旧 gallery 资源。", "meta": { - "last_synced_at": "2026-04-30T09:54:25+08:00", + "last_synced_at": "2026-04-30T21:00:00+08:00", "source_status": "synced", - "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "sync_note": "学院教师队伍页同步成功;个人主页同步成功 3/3;普通访问继续读取本站缓存。" + "source_url": "standard_homepage_url", + "sync_note": "同步完成:0 位教师有待审核变更,1 位无变化,2 位需人工维护或核对。" }, "items": [ { "id": "li-song", - "name": "李松", - "title": "百人计划研究员 | 博士生导师", - "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", - "research_areas": [ - "程序分析", - "漏洞挖掘", - "应用安全", - "网络安全", - "系统安全" - ], - "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", - "bio": "李松现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", - "email": "songli@zju.edu.cn", - "avatar_url": "/mentors/li-song/avatar.png", - "homepage_urls": [ - { - "label": "个人主页", - "url": "https://songli.io/", - "type": "profile" - }, - { - "label": "Google Scholar", - "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", - "type": "scholar" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://songli.io/", - "source_status": "synced", - "last_synced_at": "2026-04-30T09:54:25+08:00", - "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", - "profile_sections": [] + "enabled": true, + "sort_order": 1, + "published": { + "id": "li-song", + "name": "李松", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", + "research_areas": [ + "程序分析", + "漏洞挖掘", + "应用安全", + "网络安全", + "系统安全" + ], + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "bio": "李松现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "email": "songli@zju.edu.cn", + "avatar_url": "/mentors/li-song/avatar.png", + "homepage_urls": [ + { + "label": "个人主页", + "url": "https://songli.io/", + "type": "profile" + }, + { + "label": "Google Scholar", + "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", + "type": "scholar" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://songli.io/", + "source_status": "published", + "last_synced_at": "2026-04-30T11:15:15+08:00", + "sync_note": "历史资料已作为已发布内容迁移。", + "profile_sections": [] + }, + "source_config": { + "standard_homepage_url": "", + "external_links": [ + { + "label": "个人主页", + "url": "https://songli.io/", + "type": "profile" + }, + { + "label": "Google Scholar", + "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", + "type": "scholar" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ] + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual" + }, + "draft": { + "status": "no_source", + "fetched_at": "2026-04-30T20:59:00+08:00", + "source_url": "", + "message": "未配置标准浙大个人主页,需人工维护公开资料。", + "extracted": {}, + "changes": [] + }, + "updated_at": "2026-04-30T11:15:15+08:00" }, { "id": "du-wenliang", - "name": "杜文亮", - "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", - "affiliation": "浙江大学计算机科学与技术学院", - "research_areas": [ - "网络空间安全", - "系统安全", - "网络仿真", - "网络系统仿真", - "互联网", - "无线移动网", - "车联网", - "工控网" - ], - "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网。", - "bio": "杜文亮现为浙江大学求是讲席教授、博士生导师。公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真、网络系统仿真、互联网、无线移动网、车联网、工控网。", - "email": "wenliangdu@zju.edu.cn", - "avatar_url": "/mentors/du-wenliang/avatar.jpg", - "homepage_urls": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/wenliangdu", - "type": "profile" + "enabled": true, + "sort_order": 2, + "published": { + "id": "du-wenliang", + "name": "杜文亮", + "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", + "affiliation": "计算机科学与技术学院", + "research_areas": [ + "网络空间安全", + "系统安全", + "网络仿真" + ], + "summary": "杜文亮,研究方向包括网络空间安全、系统安全、网络仿真。", + "bio": "杜文亮公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真。", + "email": "wenliangdu@zju.edu.cn", + "avatar_url": "/mentors/du-wenliang/avatar.jpg", + "homepage_urls": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/wenliangdu", + "type": "profile" + }, + { + "label": "SEED Labs 主页", + "url": "https://seedsecuritylabs.org/wenliangdu/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://person.zju.edu.cn/wenliangdu", + "source_status": "published", + "last_synced_at": "2026-04-30T20:59:01+08:00", + "sync_note": "同步建议已审核发布。", + "profile_sections": [] + }, + "source_config": { + "standard_homepage_url": "https://person.zju.edu.cn/wenliangdu", + "external_links": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/wenliangdu", + "type": "profile" + }, + { + "label": "SEED Labs 主页", + "url": "https://seedsecuritylabs.org/wenliangdu/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ] + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "synced", + "research_areas": "synced", + "summary": "synced", + "bio": "synced", + "email": "manual", + "avatar_url": "manual" + }, + "draft": { + "status": "no_change", + "fetched_at": "2026-04-30T21:00:00+08:00", + "source_url": "https://person.zju.edu.cn/wenliangdu", + "message": "标准主页已读取,公开资料暂无变化。", + "extracted": { + "name": "杜文亮", + "affiliation": "计算机科学与技术学院", + "email": "wenliangdu@zju.edu.cn", + "research_areas": [ + "网络空间安全", + "系统安全", + "网络仿真" + ], + "summary": "杜文亮,研究方向包括网络空间安全、系统安全、网络仿真。", + "bio": "杜文亮公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真。" }, - { - "label": "SEED Labs 主页", - "url": "https://seedsecuritylabs.org/wenliangdu/", - "type": "profile" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://person.zju.edu.cn/wenliangdu", - "source_status": "synced", - "last_synced_at": "2026-04-30T09:54:25+08:00", - "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", - "profile_sections": [] + "changes": [] + }, + "updated_at": "2026-04-30T20:59:01+08:00" }, { "id": "lin-feng", - "name": "林峰", - "title": "百人计划研究员 | 博士生导师", - "affiliation": "浙江大学计算机科学与技术学院", - "research_areas": [ - "智能网联车安全", - "物联网安全", - "人工智能安全", - "无线感知攻防", - "生物识别身份认证" - ], - "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", - "bio": "林峰现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", - "email": "flin@zju.edu.cn", - "avatar_url": "/mentors/lin-feng/avatar.jpg", - "homepage_urls": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/flin", - "type": "profile" - }, - { - "label": "个人主页", - "url": "https://flin.group/", - "type": "profile" + "enabled": false, + "sort_order": 3, + "published": { + "id": "lin-feng", + "name": "林峰", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "计算机科学与技术学院", + "research_areas": [ + "智能网联车安全", + "物联网安全", + "人工智能安全", + "无线感知攻防", + "生物识别身份认证" + ], + "summary": "林峰,百人计划研究员 | 博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", + "bio": "林峰公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", + "email": "flin@zju.edu.cn", + "avatar_url": "/mentors/lin-feng/avatar.jpg", + "homepage_urls": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/flin", + "type": "profile" + }, + { + "label": "个人主页", + "url": "https://flin.group/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://person.zju.edu.cn/flin", + "source_status": "published", + "last_synced_at": "2026-04-30T20:59:01+08:00", + "sync_note": "同步建议已审核发布。", + "profile_sections": [] + }, + "source_config": { + "standard_homepage_url": "https://person.zju.edu.cn/flin", + "external_links": [ + { + "label": "浙大个人主页", + "url": "https://person.zju.edu.cn/flin", + "type": "profile" + }, + { + "label": "个人主页", + "url": "https://flin.group/", + "type": "profile" + }, + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ] + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual" + }, + "draft": { + "status": "applied", + "fetched_at": "2026-04-30T20:59:01+08:00", + "source_url": "https://person.zju.edu.cn/flin", + "message": "同步建议已发布。", + "extracted": { + "name": "林峰", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "计算机科学与技术学院", + "email": "flin@zju.edu.cn", + "research_areas": [ + "智能网联车安全", + "物联网安全", + "人工智能安全", + "无线感知攻防", + "生物识别身份认证" + ], + "summary": "林峰,百人计划研究员 | 博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", + "bio": "林峰公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。" }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://person.zju.edu.cn/flin", - "source_status": "synced", - "last_synced_at": "2026-04-30T09:54:25+08:00", - "sync_note": "学院教师队伍页同步成功。个人主页同步成功。", - "profile_sections": [] + "changes": [] + }, + "updated_at": "2026-04-30T20:59:51+08:00" }, { "id": "shen-dakun", - "name": "申大坤", - "title": "研究员", - "affiliation": "浙江大学网络空间安全学院", - "research_areas": [ - "系统安全", - "IoT 安全", - "软件供应链" - ], - "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全、软件供应链。", - "bio": "申大坤现为浙江大学网络空间安全学院研究员。公开资料显示,其研究方向包括系统安全、IoT 安全、软件供应链。", - "email": "", - "avatar_url": "/mentors/shen-dakun/avatar.png", - "homepage_urls": [ - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "source_status": "synced", - "last_synced_at": "2026-04-30T09:54:25+08:00", - "sync_note": "学院教师队伍页同步成功。当前仅从学院教师队伍页同步;未配置可访问个人主页。", - "profile_sections": [] + "enabled": true, + "sort_order": 4, + "published": { + "id": "shen-dakun", + "name": "申大坤", + "title": "研究员", + "affiliation": "浙江大学网络空间安全学院", + "research_areas": [ + "系统安全", + "IoT 安全", + "软件供应链" + ], + "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全、软件供应链。", + "bio": "申大坤现为浙江大学网络空间安全学院研究员。公开资料显示,其研究方向包括系统安全、IoT 安全、软件供应链。", + "email": "", + "avatar_url": "/mentors/shen-dakun/avatar.png", + "homepage_urls": [ + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ], + "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "source_status": "published", + "last_synced_at": "2026-04-30T11:15:15+08:00", + "sync_note": "历史资料已作为已发布内容迁移。", + "profile_sections": [] + }, + "source_config": { + "standard_homepage_url": "", + "external_links": [ + { + "label": "学院教师队伍页", + "url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "type": "source" + } + ] + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual" + }, + "draft": { + "status": "no_source", + "fetched_at": "2026-04-30T20:59:01+08:00", + "source_url": "", + "message": "未配置标准浙大个人主页,需人工维护公开资料。", + "extracted": {}, + "changes": [] + }, + "updated_at": "2026-04-30T11:15:15+08:00" } ] } diff --git a/routes/teachers.py b/routes/teachers.py index c663843..37ef2ed 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -1,27 +1,68 @@ """ -职责范围:提供教师介绍页公开教师列表、详情读取和受保护的同步触发接口。 -功能边界:本路由只处理 HTTP 入参、响应和权限,不直接实现缓存读写、远程爬取或教师数据编辑。 +职责范围:提供教师介绍公开读取接口,以及后台教师资料维护、同步草稿和审核发布接口。 +功能边界:本路由只处理 HTTP 入参、响应和权限,不直接实现远程解析规则或前端展示逻辑。 """ +import re +from uuid import uuid4 + from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field from misc.auth import login_required_publisher from services.teachers_cache import ( + HomepageUrl, + TeacherAdminRecord, + TeacherAdminResponse, TeacherCacheError, TeacherDetailResponse, + TeacherDraft, + TeacherItem, TeacherListResponse, - TeacherSyncResponse, - load_teachers_data, - write_teachers_data, + TeacherMeta, + TeacherSourceConfig, + TeacherStore, + load_teacher_store, + public_response_from_store, + write_teacher_store, +) +from services.teachers_sync import ( + apply_draft_changes, + discard_draft, + now_iso, + sync_teacher_store, ) -from services.teachers_sync import TeacherSyncError, sync_teacher_roster router = APIRouter() -def load_teachers_or_503() -> TeacherListResponse: + +class TeacherAdminSaveRequest(BaseModel): + id: str = "" + enabled: bool = True + sort_order: int = 0 + published: dict = Field(default_factory=dict) + source_config: TeacherSourceConfig = Field(default_factory=TeacherSourceConfig) + field_sources: dict[str, str] = Field(default_factory=dict) + + +class TeacherAdminSyncRequest(BaseModel): + id: str = "" + + +class TeacherAdminPublishRequest(BaseModel): + id: str + fields: list[str] | None = None + + +class TeacherAdminDeleteResponse(BaseModel): + ok: bool = True + meta: TeacherMeta + + +def load_store_or_503() -> TeacherStore: try: - return load_teachers_data() + return load_teacher_store() except TeacherCacheError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -31,12 +72,12 @@ def load_teachers_or_503() -> TeacherListResponse: @router.get("/list", response_model=TeacherListResponse) def get_teacher_list(): - return load_teachers_or_503() + return public_response_from_store(load_store_or_503()) @router.get("/detail", response_model=TeacherDetailResponse) def get_teacher_detail(id: str): - data = load_teachers_or_503() + data = public_response_from_store(load_store_or_503()) teacher = next((item for item in data.items if item.id == id), None) if teacher is None: @@ -48,36 +89,220 @@ def get_teacher_detail(id: str): return TeacherDetailResponse(item=teacher, meta=data.meta) +@router.get( + "/admin/list", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def get_teacher_admin_list(): + return load_store_or_503() + + +@router.post( + "/admin/save", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def save_teacher_admin_record(data: TeacherAdminSaveRequest): + store = load_store_or_503() + existing = _find_record(store, data.id) if data.id else None + teacher_id = _resolve_teacher_id(data, existing) + source_config = _normalize_source_config(data.source_config) + published = _build_published_item(teacher_id, data.published, source_config) + field_sources = data.field_sources or (existing.field_sources if existing else {}) + record = TeacherAdminRecord( + id=teacher_id, + enabled=data.enabled, + sort_order=data.sort_order or (existing.sort_order if existing else len(store.items) + 1), + published=published, + source_config=source_config, + field_sources=field_sources, + draft=existing.draft if existing else TeacherDraft(), + updated_at=now_iso(), + ) + next_items = [item for item in store.items if item.id != teacher_id] + next_items.append(record) + next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "教师资料已保存。")) + write_teacher_store(next_store) + return load_store_or_503() + + +@router.delete( + "/admin/{teacher_id}", + response_model=TeacherAdminDeleteResponse, + dependencies=[Depends(login_required_publisher)], +) +def delete_teacher_admin_record(teacher_id: str): + store = load_store_or_503() + next_items = [item for item in store.items if item.id != teacher_id] + + if len(next_items) == len(store.items): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "教师资料已删除。")) + write_teacher_store(next_store) + return TeacherAdminDeleteResponse(ok=True, meta=next_store.meta) + + +@router.post( + "/admin/sync", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def sync_teacher_admin_records(data: TeacherAdminSyncRequest): + store = load_store_or_503() + target_ids = {data.id} if data.id else None + + if data.id and _find_record(store, data.id) is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + sync_result = sync_teacher_store(store, target_ids=target_ids) + write_teacher_store(sync_result.data) + return load_store_or_503() + + +@router.post( + "/admin/publish", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def publish_teacher_sync_draft(data: TeacherAdminPublishRequest): + store = load_store_or_503() + record = _find_record(store, data.id) + + if record is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + next_items = [ + apply_draft_changes(item, fields=data.fields) if item.id == data.id else item + for item in store.items + ] + next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "同步建议已发布。")) + write_teacher_store(next_store) + return load_store_or_503() + + +@router.post( + "/admin/discard", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def discard_teacher_sync_draft(data: TeacherAdminPublishRequest): + store = load_store_or_503() + record = _find_record(store, data.id) + + if record is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + next_items = [ + discard_draft(item) if item.id == data.id else item + for item in store.items + ] + next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "同步建议已忽略。")) + write_teacher_store(next_store) + return load_store_or_503() + + @router.post( "/sync", - response_model=TeacherSyncResponse, + response_model=TeacherAdminResponse, dependencies=[Depends(login_required_publisher)], ) def sync_teachers_json(): - data = load_teachers_or_503() + return sync_teacher_admin_records(TeacherAdminSyncRequest()) - try: - sync_result = sync_teacher_roster(data) - write_teachers_data(sync_result.data) - except (TeacherSyncError, OSError) as exc: - meta = data.meta.copy(deep=True) - meta.source_status = "failed" - meta.sync_note = f"同步失败,已返回最近一次缓存:{_format_sync_error(exc)}" - return TeacherSyncResponse(ok=False, items=data.items, meta=meta) - - return TeacherSyncResponse(ok=True, items=sync_result.data.items, meta=sync_result.data.meta) - - -def _format_sync_error(exc: Exception) -> str: - attempts = getattr(exc, "attempts", []) - blocked_attempt = next((attempt for attempt in reversed(attempts) if attempt.status != "success"), None) - if blocked_attempt is None: - return str(exc) - - evidence = blocked_attempt.evidence[:160] - return ( - f"{exc};url={blocked_attempt.url};" - f"mechanism={blocked_attempt.mechanism};" - f"blocker={blocked_attempt.blocker_category or 'unknown'};" - f"evidence={evidence}" + +def _find_record(store: TeacherStore, teacher_id: str) -> TeacherAdminRecord | None: + return next((item for item in store.items if item.id == teacher_id), None) + + +def _resolve_teacher_id(data: TeacherAdminSaveRequest, existing: TeacherAdminRecord | None) -> str: + if existing: + return existing.id + + raw_id = data.id or str(data.published.get("id") or "") + if raw_id: + return _normalize_slug(raw_id) + + name = str(data.published.get("name") or "") + if re.fullmatch(r"[a-zA-Z0-9 -]+", name.strip()): + return _normalize_slug(name) + + return f"teacher-{uuid4().hex[:8]}" + + +def _normalize_slug(value: str) -> str: + slug = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,63}", slug or ""): + return f"teacher-{uuid4().hex[:8]}" + + return slug[:64] + + +def _normalize_source_config(config: TeacherSourceConfig) -> TeacherSourceConfig: + standard_homepage_url = config.standard_homepage_url.strip() + external_links = [ + HomepageUrl( + label=link.label.strip(), + url=link.url.strip(), + type=(link.type or "profile").strip(), + ) + for link in config.external_links + if link.url.strip() + ] + + if standard_homepage_url and all(link.url != standard_homepage_url for link in external_links): + external_links.insert( + 0, + HomepageUrl( + label="浙大个人主页", + url=standard_homepage_url, + type="profile", + ), + ) + + return TeacherSourceConfig( + standard_homepage_url=standard_homepage_url, + external_links=external_links, + ) + + +def _build_published_item( + teacher_id: str, + raw_item: dict, + source_config: TeacherSourceConfig, +) -> TeacherItem: + item_data = { + **raw_item, + "id": teacher_id, + "name": str(raw_item.get("name") or "未命名教师").strip(), + "homepage_urls": source_config.external_links, + "source_url": source_config.standard_homepage_url or _first_link_url(source_config), + "source_status": "published", + } + return TeacherItem(**item_data) + + +def _first_link_url(config: TeacherSourceConfig) -> str: + return next((link.url for link in config.external_links if link.url), "") + + +def _touch_meta(meta: TeacherMeta, note: str) -> TeacherMeta: + return TeacherMeta( + last_synced_at=now_iso(), + source_status="published", + source_url=meta.source_url, + sync_note=note, ) diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_json.py index 647d471..fd56534 100644 --- a/scripts/verify_teachers_json.py +++ b/scripts/verify_teachers_json.py @@ -1,5 +1,5 @@ """ -职责范围:验证教师 JSON 缓存能被后端教师 API 读取并满足四位白名单教师的基础契约。 +职责范围:验证教师 JSON 缓存能被后端教师 API 读取,并满足公开投影和后台维护数据的基础契约。 功能边界:本脚本只做本地缓存的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 """ @@ -12,19 +12,23 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from routes.teachers import get_teacher_detail, load_teachers_data +from routes.teachers import get_teacher_detail +from services.teachers_cache import load_teacher_store, load_teachers_data EXPECTED_IDS = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] def main(): + store = load_teacher_store() + assert all(record.published.id == record.id for record in store.items) + assert all(record.source_config.external_links == record.published.homepage_urls for record in store.items) + data = load_teachers_data() ids = [item.id for item in data.items] assert ids == EXPECTED_IDS, f"unexpected teacher ids: {ids}" - assert data.meta.source_status in {"manual_seed", "synced", "failed"} - assert data.meta.source_url + assert data.meta.source_status for teacher_id in EXPECTED_IDS: detail = get_teacher_detail(teacher_id) diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py index 3894bf3..ad4e8dd 100644 --- a/scripts/verify_teachers_sync.py +++ b/scripts/verify_teachers_sync.py @@ -1,5 +1,5 @@ """ -职责范围:验证教师同步器的白名单解析、缓存合并和同步失败回退行为。 +职责范围:验证教师标准主页同步、草稿生成、审核发布和失败回退行为。 功能边界:本脚本使用本地 HTML fixture 和 fake session,不请求真实源站、不修改缓存文件、不验证登录权限。 """ @@ -12,43 +12,19 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -import routes.teachers as teachers_route -from services.teachers_cache import TEACHER_ORDER, TEACHERS_DATA_PATH, load_teachers_data +from services.teachers_cache import load_teacher_store from services.teachers_sync import ( - ICS_ROSTER_URL, - PROFILE_SOURCES, - FetchAttempt, - TeacherSyncError, - parse_ics_roster, - sync_teacher_roster, + apply_draft_changes, + parse_standard_zju_profile, + sync_teacher_store, ) -ICS_FIXTURE = """ - -

其他教师

,教授

研究方向:不应进入结果

-

杜文亮

求是讲席教授

ACM/IEEE Fellow

-

研究方向:网络系统仿真、互联网、无线移动网、车联网、工控网

-

林峰

,百人计划研究员,

博导

-

研究方向:物联网安全、智能网联车安全、人工智能安全、无线感知

-

李松

,百人计划研究员,

博导

-

研究方向:漏洞挖掘、网络安全、系统安全、程序分析

-

申大坤

,研究员

研究方向:

系统安全、

IoT 安全、软件供应链

-

后续教师

,研究员

研究方向:不应拼接

- -""" - -SONG_LI_FIXTURE = """ - -

李松,浙江大学百人计划研究员,博导。主要研究方向为程序分析、漏洞挖掘、应用安全等。

- -""" - DU_WENLIANG_FIXTURE = """

Wenliang Du

博士

求是讲席教授

|

博士生导师

单位

计算机科学与技术学院

-

邮箱

wenliangdu@zju.edu.cn

+

邮箱

wenliangdu-new@zju.edu.cn

研究方向

网络空间安全,系统安全,网络仿真

""" @@ -91,66 +67,36 @@ def get(self, url: str, **kwargs): def main(): - cached_data = load_teachers_data() - - parsed_records = parse_ics_roster(ICS_FIXTURE) - assert list(parsed_records.keys()) == TEACHER_ORDER - assert parsed_records["shen-dakun"].research_areas == ["系统安全", "IoT 安全", "软件供应链"] - assert "其他教师" not in [record.name for record in parsed_records.values()] + store = load_teacher_store() + extracted = parse_standard_zju_profile(DU_WENLIANG_FIXTURE, expected_name="杜文亮") + assert extracted["email"] == "wenliangdu-new@zju.edu.cn" + assert extracted["research_areas"] == ["网络空间安全", "系统安全", "网络仿真"] fixture_session = FixtureSession( { - ICS_ROSTER_URL: ICS_FIXTURE, - PROFILE_SOURCES["li-song"]: SONG_LI_FIXTURE, - PROFILE_SOURCES["du-wenliang"]: DU_WENLIANG_FIXTURE, - PROFILE_SOURCES["lin-feng"]: LIN_FENG_FIXTURE, + "https://person.zju.edu.cn/wenliangdu": DU_WENLIANG_FIXTURE, + "https://person.zju.edu.cn/flin": LIN_FENG_FIXTURE, } ) - sync_result = sync_teacher_roster(cached_data, session=fixture_session) - assert [item.id for item in sync_result.data.items] == TEACHER_ORDER - assert sync_result.data.meta.source_status == "synced" - assert all(item.source_status == "synced" for item in sync_result.data.items) - assert sync_result.data.items[1].email == "wenliangdu@zju.edu.cn" - assert "ACM/IEEE Fellow" in sync_result.data.items[1].title - assert "车联网安全" not in sync_result.data.items[1].research_areas - assert "无线感知攻防" in sync_result.data.items[2].research_areas - assert "无线感知" not in sync_result.data.items[2].research_areas - - try: - sync_teacher_roster(cached_data, session=FailingSession()) - except TeacherSyncError as exc: - assert exc.attempts[0].status == "blocked" - assert exc.attempts[0].blocker_category == "network" - else: - raise AssertionError("network failure should raise TeacherSyncError") - - original_sync = teachers_route.sync_teacher_roster - before = TEACHERS_DATA_PATH.read_bytes() - - def fail_sync(_data): - raise TeacherSyncError( - "forced failure", - attempts=[ - FetchAttempt( - url=ICS_ROSTER_URL, - status="blocked", - mechanism="fixture", - blocker_category="network", - evidence="forced failure", - ) - ], - ) - - try: - teachers_route.sync_teacher_roster = fail_sync - response = teachers_route.sync_teachers_json() - finally: - teachers_route.sync_teacher_roster = original_sync - - after = TEACHERS_DATA_PATH.read_bytes() - assert response.ok is False - assert response.meta.source_status == "failed" - assert before == after + sync_result = sync_teacher_store(store, session=fixture_session) + synced = {record.id: record for record in sync_result.data.items} + + assert synced["li-song"].draft.status == "no_source" + assert synced["du-wenliang"].draft.status == "pending" + assert synced["lin-feng"].draft.status in {"pending", "no_change"} + assert synced["shen-dakun"].draft.status == "no_source" + + before_email = synced["du-wenliang"].published.email + assert before_email == "wenliangdu@zju.edu.cn" + + published_record = apply_draft_changes(synced["du-wenliang"], fields=["email"]) + assert published_record.published.email == "wenliangdu-new@zju.edu.cn" + assert published_record.draft.status in {"pending", "applied"} + + failed_result = sync_teacher_store(store, target_ids={"du-wenliang"}, session=FailingSession()) + failed = {record.id: record for record in failed_result.data.items} + assert failed["du-wenliang"].draft.status == "failed" + assert failed["du-wenliang"].published.email == "wenliangdu@zju.edu.cn" print("teachers sync verification passed") diff --git a/services/teachers_cache.py b/services/teachers_cache.py index cc57bc6..8aab427 100644 --- a/services/teachers_cache.py +++ b/services/teachers_cache.py @@ -1,9 +1,10 @@ """ -职责范围:定义教师介绍数据契约、白名单排序和 JSON 缓存读写。 +职责范围:定义教师介绍的公开数据、后台维护数据、审核草稿契约和 JSON 缓存读写。 功能边界:本模块不请求远程源站、不处理 FastAPI 路由权限,也不负责前端展示逻辑。 """ import json +import re from pathlib import Path from typing import Any @@ -11,11 +12,9 @@ TEACHERS_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "teachers.json" -TEACHER_ORDER = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] -TEACHER_WHITELIST = set(TEACHER_ORDER) TEACHERS_DATA_COMMENT = ( - "职责范围:为教师介绍 API 提供四位白名单教师缓存数据;" - "功能边界:本文件不负责远程同步实现、数据库迁移、旧 gallery 资源或非白名单教师资料。" + "职责范围:为教师介绍 API 和后台管理提供教师公开资料、来源配置和待审核同步草稿;" + "功能边界:本文件不负责远程同步实现、数据库迁移或旧 gallery 资源。" ) @@ -26,7 +25,7 @@ class TeacherCacheError(Exception): class HomepageUrl(BaseModel): label: str = "" url: str = "" - type: str = "" + type: str = "profile" class ProfileSection(BaseModel): @@ -46,21 +45,27 @@ class TeacherItem(BaseModel): avatar_url: str = "" homepage_urls: list[HomepageUrl] = Field(default_factory=list) source_url: str = "" - source_status: str = "manual_seed" + source_status: str = "published" last_synced_at: str = "" sync_note: str = "" profile_sections: list[ProfileSection] = Field(default_factory=list) @validator("id") def validate_teacher_id(cls, value: str) -> str: - if value not in TEACHER_WHITELIST: - raise ValueError("teacher is not in whitelist") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,63}", value): + raise ValueError("teacher id must be a lowercase slug") return value + @validator("research_areas", pre=True) + def normalize_research_areas(cls, value): + if isinstance(value, str): + return [part.strip() for part in re.split(r"[、,,;;]+", value) if part.strip()] + return value or [] + class TeacherMeta(BaseModel): last_synced_at: str = "" - source_status: str = "manual_seed" + source_status: str = "published" source_url: str = "" sync_note: str = "" @@ -75,35 +80,87 @@ class TeacherDetailResponse(BaseModel): meta: TeacherMeta -class TeacherSyncResponse(TeacherListResponse): +class TeacherSourceConfig(BaseModel): + standard_homepage_url: str = "" + external_links: list[HomepageUrl] = Field(default_factory=list) + + +class TeacherDraftChange(BaseModel): + field: str + label: str = "" + current: Any = None + proposed: Any = None + + +class TeacherDraft(BaseModel): + status: str = "idle" + fetched_at: str = "" + source_url: str = "" + message: str = "" + extracted: dict[str, Any] = Field(default_factory=dict) + changes: list[TeacherDraftChange] = Field(default_factory=list) + + +class TeacherAdminRecord(BaseModel): + id: str + enabled: bool = True + sort_order: int = 0 + published: TeacherItem + source_config: TeacherSourceConfig = Field(default_factory=TeacherSourceConfig) + field_sources: dict[str, str] = Field(default_factory=dict) + draft: TeacherDraft = Field(default_factory=TeacherDraft) + updated_at: str = "" + + @validator("id") + def validate_record_id(cls, value: str) -> str: + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,63}", value): + raise ValueError("teacher id must be a lowercase slug") + return value + + +class TeacherStore(BaseModel): + items: list[TeacherAdminRecord] + meta: TeacherMeta = Field(default_factory=TeacherMeta) + + +class TeacherAdminResponse(TeacherStore): + pass + + +class TeacherSyncResponse(BaseModel): ok: bool = True + items: list[TeacherAdminRecord] + meta: TeacherMeta -def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> TeacherListResponse: +def load_teacher_store(path: Path = TEACHERS_DATA_PATH) -> TeacherStore: try: raw_data = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: - raise TeacherCacheError("Teachers seed data not found") from exc + raise TeacherCacheError("Teachers data not found") from exc except json.JSONDecodeError as exc: - raise TeacherCacheError("Teachers seed data is invalid") from exc + raise TeacherCacheError("Teachers data is invalid") from exc try: - response = TeacherListResponse( - items=raw_data.get("items", []), - meta=raw_data.get("meta", {}), + if _is_legacy_payload(raw_data): + return normalize_teacher_store(_migrate_legacy_payload(raw_data)) + + return normalize_teacher_store( + TeacherStore( + items=raw_data.get("items", []), + meta=raw_data.get("meta", {}), + ) ) except Exception as exc: - raise TeacherCacheError(f"Teachers seed data failed validation: {exc}") from exc + raise TeacherCacheError(f"Teachers data failed validation: {exc}") from exc - return normalize_teachers_response(response) - -def write_teachers_data(data: TeacherListResponse, path: Path = TEACHERS_DATA_PATH) -> None: - response = normalize_teachers_response(data) +def write_teacher_store(data: TeacherStore, path: Path = TEACHERS_DATA_PATH) -> None: + store = normalize_teacher_store(data) payload = { "_comment": TEACHERS_DATA_COMMENT, - "meta": _model_dump(response.meta), - "items": [_model_dump(item) for item in response.items], + "meta": _model_dump(store.meta), + "items": [_model_dump(item) for item in store.items], } path.parent.mkdir(parents=True, exist_ok=True) @@ -115,10 +172,124 @@ def write_teachers_data(data: TeacherListResponse, path: Path = TEACHERS_DATA_PA temp_path.replace(path) -def normalize_teachers_response(data: TeacherListResponse) -> TeacherListResponse: - items_by_id = {item.id: item for item in data.items if item.id in TEACHER_WHITELIST} - sorted_items = [items_by_id[teacher_id] for teacher_id in TEACHER_ORDER if teacher_id in items_by_id] - return TeacherListResponse(items=sorted_items, meta=data.meta) +def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> TeacherListResponse: + return public_response_from_store(load_teacher_store(path)) + + +def public_response_from_store(store: TeacherStore) -> TeacherListResponse: + public_items = [] + for record in normalize_teacher_store(store).items: + if not record.enabled: + continue + + item = record.published.copy(deep=True) + item.id = record.id + item.homepage_urls = record.source_config.external_links + item.source_url = _public_source_url(record) + public_items.append(item) + + return TeacherListResponse(items=public_items, meta=store.meta) + + +def normalize_teacher_store(data: TeacherStore) -> TeacherStore: + normalized_items = [] + seen_ids: set[str] = set() + + for index, record in enumerate(data.items): + if record.id in seen_ids: + continue + + seen_ids.add(record.id) + normalized_items.append(normalize_admin_record(record, fallback_order=index)) + + normalized_items.sort(key=lambda item: (item.sort_order, item.published.name, item.id)) + return TeacherStore(items=normalized_items, meta=data.meta) + + +def normalize_admin_record(record: TeacherAdminRecord, fallback_order: int = 0) -> TeacherAdminRecord: + normalized = record.copy(deep=True) + normalized.published.id = normalized.id + normalized.published.homepage_urls = normalized.source_config.external_links + normalized.published.source_url = _public_source_url(normalized) + + if normalized.sort_order == 0: + normalized.sort_order = fallback_order + 1 + + return normalized + + +def _is_legacy_payload(raw_data: dict[str, Any]) -> bool: + items = raw_data.get("items", []) + if not items: + return False + + first_item = items[0] + return isinstance(first_item, dict) and "published" not in first_item + + +def _migrate_legacy_payload(raw_data: dict[str, Any]) -> TeacherStore: + legacy_items = raw_data.get("items", []) + legacy_meta = TeacherMeta(**raw_data.get("meta", {})) + meta = TeacherMeta( + last_synced_at=legacy_meta.last_synced_at, + source_status="published", + source_url="", + sync_note="教师资料已迁移为后台维护数据;公开页读取已发布资料。", + ) + records = [] + + for index, raw_item in enumerate(legacy_items): + item = TeacherItem(**raw_item) + item.source_status = "published" + item.sync_note = "历史资料已作为已发布内容迁移。" + source_config = TeacherSourceConfig( + standard_homepage_url=_standard_homepage_url_from_item(item), + external_links=item.homepage_urls, + ) + records.append( + TeacherAdminRecord( + id=item.id, + enabled=True, + sort_order=index + 1, + published=item, + source_config=source_config, + field_sources=_manual_field_sources(), + draft=TeacherDraft(), + updated_at=item.last_synced_at, + ) + ) + + return TeacherStore(items=records, meta=meta) + + +def _standard_homepage_url_from_item(item: TeacherItem) -> str: + urls = [item.source_url, *[link.url for link in item.homepage_urls]] + return next((url for url in urls if is_standard_homepage_url(url)), "") + + +def is_standard_homepage_url(url: str) -> bool: + return bool(re.match(r"^https?://person\.zju\.edu\.cn/[^/?#]+/?$", url.strip())) + + +def _manual_field_sources() -> dict[str, str]: + return { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual", + } + + +def _public_source_url(record: TeacherAdminRecord) -> str: + if record.source_config.standard_homepage_url: + return record.source_config.standard_homepage_url + + first_link = next((link.url for link in record.source_config.external_links if link.url), "") + return first_link or record.published.source_url def _model_dump(model: BaseModel) -> dict[str, Any]: diff --git a/services/teachers_sync.py b/services/teachers_sync.py index 7aeefad..2e9f735 100644 --- a/services/teachers_sync.py +++ b/services/teachers_sync.py @@ -1,71 +1,41 @@ """ -职责范围:抓取并解析教师介绍页的公开源站资料,产出可合并的同步字段。 -功能边界:本模块不处理 FastAPI 权限、不直接写缓存文件,也不开放管理后台编辑能力。 +职责范围:从标准浙大个人主页抽取教师公开字段,并生成后台待审核同步草稿。 +功能边界:本模块不解析非标准个人主页、不直接写缓存文件,也不决定同步草稿是否发布。 """ import re -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from html.parser import HTMLParser import requests from services.teachers_cache import ( - TEACHER_ORDER, - TeacherItem, - TeacherListResponse, + TeacherAdminRecord, + TeacherDraft, + TeacherDraftChange, TeacherMeta, + TeacherStore, + is_standard_homepage_url, + normalize_teacher_store, ) -ICS_ROSTER_URL = "https://icsr.zju.edu.cn/jsdw/list.htm" REQUEST_TIMEOUT_SECONDS = 15 -USER_AGENT = "ZJUCSA website teacher roster sync/1.0" - -TEACHER_NAMES = { - "li-song": "李松", - "du-wenliang": "杜文亮", - "lin-feng": "林峰", - "shen-dakun": "申大坤", -} - -PROFILE_SOURCES = { - "li-song": "https://songli.io/", - "du-wenliang": "https://person.zju.edu.cn/wenliangdu", - "lin-feng": "https://person.zju.edu.cn/flin", -} - -AUDITED_TITLES = { - "li-song": "百人计划研究员 | 博士生导师", - "du-wenliang": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", - "lin-feng": "百人计划研究员 | 博士生导师", - "shen-dakun": "研究员", -} - -AUDITED_AFFILIATIONS = { - "li-song": "浙江大学计算机科学与技术学院 / 网络空间安全学院", - "du-wenliang": "浙江大学计算机科学与技术学院", - "lin-feng": "浙江大学计算机科学与技术学院", - "shen-dakun": "浙江大学网络空间安全学院", -} - -SUMMARY_PREFIXES = { - "li-song": "浙江大学百人计划研究员、博士生导师", - "du-wenliang": "浙江大学求是讲席教授、博士生导师", - "lin-feng": "浙江大学百人计划研究员、博士生导师", - "shen-dakun": "浙江大学网络空间安全学院研究员", -} - -BIO_PREFIXES = { - "li-song": "李松现为浙江大学百人计划研究员、博士生导师。", - "du-wenliang": "杜文亮现为浙江大学求是讲席教授、博士生导师。", - "lin-feng": "林峰现为浙江大学百人计划研究员、博士生导师。", - "shen-dakun": "申大坤现为浙江大学网络空间安全学院研究员。", -} +USER_AGENT = "ZJUCSA website teacher roster sync/2.0" +SYNC_FIELDS = [ + ("name", "姓名"), + ("title", "职称"), + ("affiliation", "单位"), + ("email", "邮箱"), + ("research_areas", "研究方向"), + ("summary", "简介摘要"), + ("bio", "详细简介"), +] class TeacherSyncError(Exception): - """Raised when remote teacher sources cannot be fetched or parsed.""" + """Raised when the teacher sync workflow cannot be completed.""" def __init__(self, message: str, attempts: list["FetchAttempt"] | None = None) -> None: super().__init__(message) @@ -81,23 +51,9 @@ class FetchAttempt: blocker_category: str = "" -@dataclass -class SourceTeacherRecord: - id: str - name: str - source_url: str - title: str = "" - affiliation: str = "" - research_areas: list[str] = field(default_factory=list) - email: str = "" - summary: str = "" - bio: str = "" - sync_note: str = "" - - @dataclass class TeacherSyncResult: - data: TeacherListResponse + data: TeacherStore attempts: list[FetchAttempt] @@ -129,244 +85,195 @@ def fetch_source_html(url: str, session: requests.Session | None = None) -> tupl ) -def parse_ics_roster(html: str, source_url: str = ICS_ROSTER_URL) -> dict[str, SourceTeacherRecord]: - lines = extract_text_lines(html) - records: dict[str, SourceTeacherRecord] = {} +def sync_teacher_store( + store: TeacherStore, + target_ids: set[str] | None = None, + session: requests.Session | None = None, +) -> TeacherSyncResult: + attempts: list[FetchAttempt] = [] + synced_items: list[TeacherAdminRecord] = [] + selected_ids = target_ids or {record.id for record in store.items} - for teacher_id in TEACHER_ORDER: - name = TEACHER_NAMES[teacher_id] - name_index = _find_exact_line(lines, name) - if name_index is None: + for record in normalize_teacher_store(store).items: + if record.id not in selected_ids: + synced_items.append(record) continue - title_parts: list[str] = [] - research_text = "" - index = name_index + 1 - while index < len(lines): - line = lines[index] - - if line.startswith("研究方向"): - current = _after_label(line, "研究方向").strip(" ::") - continuation: list[str] = [] - if not current: - index += 1 - while index < len(lines) and len(continuation) < 4: - next_line = lines[index] - if next_line.startswith("研究方向") or _looks_like_teacher_entry(lines, index): - break - continuation.append(next_line) - index += 1 - research_text = " ".join(part for part in [current, *continuation] if part) - break - - title_parts.append(line) - index += 1 - - research_areas = split_research_areas(research_text) - if not research_areas: - continue + synced_record, record_attempts = sync_teacher_record(record, session=session) + attempts.extend(record_attempts) + synced_items.append(synced_record) - records[teacher_id] = SourceTeacherRecord( - id=teacher_id, - name=name, - source_url=source_url, - title=normalize_title(title_parts), - research_areas=research_areas, + synced_store = normalize_teacher_store(TeacherStore(items=synced_items, meta=_build_sync_meta(synced_items))) + return TeacherSyncResult(data=synced_store, attempts=attempts) + + +def sync_teacher_record( + record: TeacherAdminRecord, + session: requests.Session | None = None, +) -> tuple[TeacherAdminRecord, list[FetchAttempt]]: + synced_record = record.copy(deep=True) + source_url = synced_record.source_config.standard_homepage_url.strip() + + if not source_url: + synced_record.draft = TeacherDraft( + status="no_source", + fetched_at=now_iso(), + message="未配置标准浙大个人主页,需人工维护公开资料。", ) + return synced_record, [] - return records + if not is_standard_homepage_url(source_url): + synced_record.draft = TeacherDraft( + status="unsupported", + fetched_at=now_iso(), + source_url=source_url, + message="该主页不是标准浙大个人主页,仅作为外部链接展示,字段需人工维护。", + ) + return synced_record, [] + html, attempt = fetch_source_html(source_url, session=session) + if attempt.status != "success": + synced_record.draft = TeacherDraft( + status="failed", + fetched_at=now_iso(), + source_url=source_url, + message=f"无法访问标准主页,已保留已发布资料:{_shorten(attempt.evidence)}", + ) + return synced_record, [attempt] -def parse_profile_source( - teacher_id: str, - html: str, - source_url: str, -) -> SourceTeacherRecord | None: - if teacher_id == "li-song": - return parse_song_li_profile(html, source_url) + extracted = parse_standard_zju_profile(html, expected_name=synced_record.published.name) + if not extracted: + synced_record.draft = TeacherDraft( + status="failed", + fetched_at=now_iso(), + source_url=source_url, + message="标准主页已访问,但未识别到可同步字段,需人工核对页面内容。", + extracted={}, + changes=[], + ) + return synced_record, [attempt] - if teacher_id in {"du-wenliang", "lin-feng"}: - return parse_zju_person_profile(teacher_id, html, source_url) + changes = build_draft_changes(synced_record, extracted) + synced_record.draft = TeacherDraft( + status="pending" if changes else "no_change", + fetched_at=now_iso(), + source_url=source_url, + message="发现待审核变更。" if changes else "标准主页已读取,公开资料暂无变化。", + extracted=extracted, + changes=changes, + ) + return synced_record, [attempt] - return None +def parse_standard_zju_profile(html: str, expected_name: str = "") -> dict[str, object]: + lines = extract_text_lines(html) + name = _first_value(_value_after_label(lines, "姓名"), expected_name) + name_index = _find_exact_line(lines, expected_name) if expected_name else None -def sync_teacher_roster( - cached_data: TeacherListResponse, - session: requests.Session | None = None, -) -> TeacherSyncResult: - attempts: list[FetchAttempt] = [] - roster_html, roster_attempt = fetch_source_html(ICS_ROSTER_URL, session=session) - attempts.append(roster_attempt) - - if roster_attempt.status != "success": - raise TeacherSyncError("primary roster source fetch failed", attempts) - - roster_records = parse_ics_roster(roster_html) - if not roster_records: - attempts.append( - FetchAttempt( - url=ICS_ROSTER_URL, - status="blocked", - mechanism="parse_ics_roster", - evidence="no whitelisted teachers parsed from primary roster", - blocker_category="source_unavailable", - ) - ) - raise TeacherSyncError("primary roster source parsed no whitelisted teachers", attempts) + if name_index is None and name: + name_index = _find_exact_line(lines, name) - profile_records: dict[str, SourceTeacherRecord] = {} - profile_notes: dict[str, str] = {} + title = _first_value( + _value_after_label(lines, "职称"), + _value_after_label(lines, "职务"), + _collect_title_after_name(lines, name_index), + ) + affiliation = _first_value(_value_after_label(lines, "单位"), _value_after_label(lines, "院系")) + email = _value_after_label(lines, "邮箱") + research_text = _first_value( + _value_after_label(lines, "研究方向"), + _value_after_label(lines, "主要研究方向"), + ) + research_areas = split_research_areas(research_text) - for teacher_id, profile_url in PROFILE_SOURCES.items(): - profile_html, profile_attempt = fetch_source_html(profile_url, session=session) - attempts.append(profile_attempt) + extracted: dict[str, object] = {} + if name: + extracted["name"] = name + if title: + extracted["title"] = title + if affiliation: + extracted["affiliation"] = affiliation + if email: + extracted["email"] = email + if research_areas: + extracted["research_areas"] = research_areas - if profile_attempt.status != "success": - profile_notes[teacher_id] = f"个人主页同步失败:{_shorten(profile_attempt.evidence)}" - continue + if name and research_areas: + summary, bio = build_objective_text(name, title, research_areas) + extracted["summary"] = summary + extracted["bio"] = bio - profile_record = parse_profile_source(teacher_id, profile_html, profile_url) - if profile_record is None: - profile_notes[teacher_id] = "个人主页已访问,但未解析到有效字段。" - continue + return extracted - profile_records[teacher_id] = profile_record - profile_notes[teacher_id] = "个人主页同步成功。" - synced_at = now_iso_shanghai() - synced_data = merge_synced_records( - cached_data=cached_data, - roster_records=roster_records, - profile_records=profile_records, - profile_notes=profile_notes, - synced_at=synced_at, - ) +def build_draft_changes(record: TeacherAdminRecord, extracted: dict[str, object]) -> list[TeacherDraftChange]: + changes = [] - return TeacherSyncResult(data=synced_data, attempts=attempts) - - -def merge_synced_records( - cached_data: TeacherListResponse, - roster_records: dict[str, SourceTeacherRecord], - profile_records: dict[str, SourceTeacherRecord], - profile_notes: dict[str, str], - synced_at: str, -) -> TeacherListResponse: - cached_by_id = {item.id: item for item in cached_data.items} - synced_items: list[TeacherItem] = [] - - for teacher_id in TEACHER_ORDER: - fallback = cached_by_id.get(teacher_id) - roster_record = roster_records.get(teacher_id) - profile_record = profile_records.get(teacher_id) - source_record = _merge_source_record( - teacher_id=teacher_id, - fallback=fallback, - roster_record=roster_record, - profile_record=profile_record, - ) + for field, label in SYNC_FIELDS: + if field not in extracted: + continue - if source_record is None: + current = getattr(record.published, field) + proposed = extracted[field] + if _normalize_value(current) == _normalize_value(proposed): continue - has_synced_source = roster_record is not None or profile_record is not None - source_status = "synced" if has_synced_source else "failed" - last_synced_at = synced_at if has_synced_source else (fallback.last_synced_at if fallback else "") - summary, bio = build_objective_summary(source_record) - - if not has_synced_source and fallback: - summary = fallback.summary - bio = fallback.bio - - synced_items.append( - TeacherItem( - id=teacher_id, - name=source_record.name, - title=source_record.title, - affiliation=source_record.affiliation, - research_areas=source_record.research_areas, - summary=summary, - bio=bio, - email=source_record.email, - avatar_url=fallback.avatar_url if fallback else "", - homepage_urls=fallback.homepage_urls if fallback else [], - source_url=source_record.source_url, - source_status=source_status, - last_synced_at=last_synced_at, - sync_note=_build_teacher_sync_note(teacher_id, roster_record, profile_notes), - profile_sections=fallback.profile_sections if fallback else [], + changes.append( + TeacherDraftChange( + field=field, + label=label, + current=current, + proposed=proposed, ) ) - profile_success = sum(1 for teacher_id in PROFILE_SOURCES if teacher_id in profile_records) - meta_note = ( - "学院教师队伍页同步成功;" - f"个人主页同步成功 {profile_success}/{len(PROFILE_SOURCES)};" - "普通访问继续读取本站缓存。" - ) + return changes - return TeacherListResponse( - items=synced_items, - meta=TeacherMeta( - last_synced_at=synced_at, - source_status="synced", - source_url=ICS_ROSTER_URL, - sync_note=meta_note, - ), - ) +def apply_draft_changes(record: TeacherAdminRecord, fields: list[str] | None = None) -> TeacherAdminRecord: + updated = record.copy(deep=True) + if updated.draft.status != "pending": + return updated -def parse_zju_person_profile(teacher_id: str, html: str, source_url: str) -> SourceTeacherRecord | None: - lines = extract_text_lines(html) - name = TEACHER_NAMES[teacher_id] - name_index = _find_exact_line(lines, name) - - if name_index is None and teacher_id == "du-wenliang": - name_index = _find_exact_line(lines, "Wenliang Du") - - if name_index is None: - return None + allowed_fields = set(fields or [change.field for change in updated.draft.changes]) + accepted_fields = [] - title_parts = _collect_until_label(lines, name_index + 1, {"单位"}) - title = normalize_title([part for part in title_parts if part != "博士"]) - affiliation = _value_after_label(lines, "单位") - email = _value_after_label(lines, "邮箱") - research_text = _value_after_label(lines, "研究方向") - research_areas = split_research_areas(research_text) + for change in updated.draft.changes: + if change.field not in allowed_fields: + continue - return SourceTeacherRecord( - id=teacher_id, - name=name, - source_url=source_url, - title=title, - affiliation=affiliation, - email=email, - research_areas=research_areas, + setattr(updated.published, change.field, change.proposed) + updated.field_sources[change.field] = "synced" + accepted_fields.append(change.field) + + if accepted_fields: + updated.published.source_url = updated.draft.source_url or updated.published.source_url + updated.published.source_status = "published" + updated.published.last_synced_at = updated.draft.fetched_at + updated.published.sync_note = "同步建议已审核发布。" + updated.updated_at = updated.draft.fetched_at + + remaining_changes = [change for change in updated.draft.changes if change.field not in accepted_fields] + updated.draft = TeacherDraft( + status="pending" if remaining_changes else "applied", + fetched_at=updated.draft.fetched_at, + source_url=updated.draft.source_url, + message="仍有未处理的同步建议。" if remaining_changes else "同步建议已发布。", + extracted=updated.draft.extracted, + changes=remaining_changes, ) + return updated -def parse_song_li_profile(html: str, source_url: str) -> SourceTeacherRecord | None: - lines = extract_text_lines(html) - name = TEACHER_NAMES["li-song"] - intro = next((line for line in lines if line.startswith("李松,")), "") - research_match = re.search(r"主要研究方向为(.+?)(?:等|。)", intro) - research_areas = split_research_areas(research_match.group(1) if research_match else "") - - title = "百人计划研究员" - if "博导" in intro: - title = "百人计划研究员 | 博士生导师" - - if not research_areas and not title: - return None - - return SourceTeacherRecord( - id="li-song", - name=name, - source_url=source_url, - title=title, - research_areas=research_areas, +def discard_draft(record: TeacherAdminRecord) -> TeacherAdminRecord: + updated = record.copy(deep=True) + updated.draft = TeacherDraft( + status="ignored", + fetched_at=updated.draft.fetched_at, + source_url=updated.draft.source_url, + message="同步建议已忽略,公开资料保持不变。", ) + return updated def split_research_areas(value: str) -> list[str]: @@ -378,29 +285,15 @@ def split_research_areas(value: str) -> list[str]: return _unique([part.strip(" .。") for part in parts if part.strip(" .。")]) -def build_objective_summary(record: SourceTeacherRecord) -> tuple[str, str]: - areas = "、".join(record.research_areas) - summary_prefix = SUMMARY_PREFIXES.get(record.id, record.title) - bio_prefix = BIO_PREFIXES.get(record.id, "") - - if areas: - if summary_prefix: - summary = f"{summary_prefix},研究方向包括{areas}。" - else: - summary = f"研究方向包括{areas}。" - - if bio_prefix: - bio = f"{bio_prefix}公开资料显示,其研究方向包括{areas}。" - else: - bio = f"{record.name}公开资料显示,其研究方向包括{areas}。" - else: - summary = "公开资料字段仍在同步核验中。" - bio = f"{record.name}的公开资料仍在同步核验中。" - +def build_objective_text(name: str, title: str, research_areas: list[str]) -> tuple[str, str]: + areas = "、".join(research_areas) + identity = f"{name},{title}" if title else name + summary = f"{identity},研究方向包括{areas}。" + bio = f"{name}公开资料显示,其研究方向包括{areas}。" return summary, bio -def now_iso_shanghai() -> str: +def now_iso() -> str: return datetime.now().astimezone().isoformat(timespec="seconds") @@ -433,34 +326,49 @@ def handle_data(self, data: str) -> None: self.lines.append(text) +def _build_sync_meta(items: list[TeacherAdminRecord]) -> TeacherMeta: + pending_count = sum(1 for item in items if item.draft.status == "pending") + no_change_count = sum(1 for item in items if item.draft.status == "no_change") + manual_count = sum(1 for item in items if item.draft.status in {"no_source", "unsupported", "failed"}) + status = "pending_review" if pending_count else "synced" + note = ( + f"同步完成:{pending_count} 位教师有待审核变更," + f"{no_change_count} 位无变化,{manual_count} 位需人工维护或核对。" + ) + return TeacherMeta( + last_synced_at=now_iso(), + source_status=status, + source_url="standard_homepage_url", + sync_note=note, + ) + + def _find_exact_line(lines: list[str], value: str) -> int | None: + if not value: + return None + try: return lines.index(value) except ValueError: return None -def _looks_like_teacher_entry(lines: list[str], index: int) -> bool: - compact = lines[index].strip() - if not re.fullmatch(r"[\u4e00-\u9fff]{2,4}", compact): - return False - - next_line = lines[index + 1].strip() if index + 1 < len(lines) else "" - return next_line.startswith((",", ",")) - - -def _after_label(line: str, label: str) -> str: - return re.sub(rf"^{re.escape(label)}\s*[::]?", "", line).strip() - +def _collect_title_after_name(lines: list[str], name_index: int | None) -> str: + if name_index is None: + return "" -def _collect_until_label(lines: list[str], start_index: int, stop_labels: set[str]) -> list[str]: - values: list[str] = [] - for line in lines[start_index:]: - if line in stop_labels: + values = [] + stop_labels = {"单位", "院系", "邮箱", "研究方向", "主要研究方向", "个人简介"} + for line in lines[name_index + 1:]: + if line in stop_labels or any(line.startswith(label) for label in stop_labels): + break + if line in {"博士", "|", ",", ","}: + continue + values.append(line.strip(" ,,|/")) + if len(values) >= 5: break - values.append(line) - return values + return normalize_title(values) def _value_after_label(lines: list[str], label: str) -> str: @@ -468,86 +376,28 @@ def _value_after_label(lines: list[str], label: str) -> str: if line == label: return lines[index + 1] if index + 1 < len(lines) else "" if line.startswith(label): - value = _after_label(line, label) + value = re.sub(rf"^{re.escape(label)}\s*[::]?", "", line).strip() if value: return value return "" -def _merge_source_record( - teacher_id: str, - fallback: TeacherItem | None, - roster_record: SourceTeacherRecord | None, - profile_record: SourceTeacherRecord | None, -) -> SourceTeacherRecord | None: - if fallback is None and roster_record is None and profile_record is None: - return None - - synced_research_areas = (profile_record.research_areas if profile_record else []) + ( - roster_record.research_areas if roster_record else [] - ) - research_areas = _normalize_research_areas( - teacher_id, - _unique(synced_research_areas or (fallback.research_areas if fallback else [])), - ) - - return SourceTeacherRecord( - id=teacher_id, - name=TEACHER_NAMES[teacher_id], - title=_first_value( - AUDITED_TITLES.get(teacher_id, ""), - profile_record.title if profile_record else "", - roster_record.title if roster_record else "", - fallback.title if fallback else "", - ), - affiliation=_first_value( - AUDITED_AFFILIATIONS.get(teacher_id, ""), - profile_record.affiliation if profile_record else "", - fallback.affiliation if fallback else "", - ), - research_areas=research_areas, - email=_first_value( - profile_record.email if profile_record else "", - fallback.email if fallback else "", - ), - source_url=_first_value( - profile_record.source_url if profile_record else "", - roster_record.source_url if roster_record else "", - fallback.source_url if fallback else "", - ), - ) - - -def _build_teacher_sync_note( - teacher_id: str, - roster_record: SourceTeacherRecord | None, - profile_notes: dict[str, str], -) -> str: - notes: list[str] = [] - - if roster_record is None: - notes.append("学院教师队伍页未解析到该教师,保留缓存字段。") - else: - notes.append("学院教师队伍页同步成功。") - - if teacher_id in PROFILE_SOURCES: - notes.append(profile_notes.get(teacher_id, "个人主页未完成同步。")) - else: - notes.append("当前仅从学院教师队伍页同步;未配置可访问个人主页。") - - return "".join(notes) +def normalize_title(parts: list[str]) -> str: + cleaned = [part.strip(" ,,") for part in parts if part.strip(" ,,|/")] + return re.sub(r"\s+", " ", " | ".join(cleaned)).strip(" |") def _first_value(*values: str) -> str: return next((value for value in values if value), "") -def _normalize_research_areas(teacher_id: str, areas: list[str]) -> list[str]: - if teacher_id == "lin-feng" and "无线感知攻防" in areas: - return [area for area in areas if area != "无线感知"] - - return areas +def _normalize_value(value: object) -> object: + if isinstance(value, list): + return [str(item).strip() for item in value] + if isinstance(value, str): + return re.sub(r"\s+", " ", value).strip() + return value def _shorten(value: str, limit: int = 160) -> str: @@ -558,11 +408,6 @@ def _shorten(value: str, limit: int = 160) -> str: return normalized[: limit - 3] + "..." -def normalize_title(parts: list[str]) -> str: - cleaned = [part.strip(" ,,") for part in parts if part.strip(" ,,|/")] - return re.sub(r"\s+", " ", " | ".join(cleaned)).strip() - - def _unique(values: list[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] From a5e3180b6b42a95f069daee7cd55ee39b3732d44 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Thu, 30 Apr 2026 21:08:44 +0800 Subject: [PATCH 08/11] fix(teachers): harden admin save edge cases --- data/teachers.json | 2 +- routes/teachers.py | 78 ++++++++++++++++++++++++++------- scripts/verify_teachers_json.py | 25 ++++++++++- scripts/verify_teachers_sync.py | 6 +++ services/teachers_sync.py | 34 ++++++++++++++ 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/data/teachers.json b/data/teachers.json index cfa1f63..09b12b3 100644 --- a/data/teachers.json +++ b/data/teachers.json @@ -184,7 +184,7 @@ }, { "id": "lin-feng", - "enabled": false, + "enabled": true, "sort_order": 3, "published": { "id": "lin-feng", diff --git a/routes/teachers.py b/routes/teachers.py index 37ef2ed..b3817a2 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -30,6 +30,7 @@ apply_draft_changes, discard_draft, now_iso, + refresh_draft_against_published, sync_teacher_store, ) @@ -60,6 +61,9 @@ class TeacherAdminDeleteResponse(BaseModel): meta: TeacherMeta +AUTO_ID_PLACEHOLDERS = {"", "teacher-temp"} + + def load_store_or_503() -> TeacherStore: try: return load_teacher_store() @@ -106,19 +110,22 @@ def get_teacher_admin_list(): def save_teacher_admin_record(data: TeacherAdminSaveRequest): store = load_store_or_503() existing = _find_record(store, data.id) if data.id else None - teacher_id = _resolve_teacher_id(data, existing) + teacher_id = _resolve_teacher_id(data, existing, store) source_config = _normalize_source_config(data.source_config) published = _build_published_item(teacher_id, data.published, source_config) field_sources = data.field_sources or (existing.field_sources if existing else {}) - record = TeacherAdminRecord( - id=teacher_id, - enabled=data.enabled, - sort_order=data.sort_order or (existing.sort_order if existing else len(store.items) + 1), - published=published, - source_config=source_config, - field_sources=field_sources, - draft=existing.draft if existing else TeacherDraft(), - updated_at=now_iso(), + draft = _draft_after_manual_save(existing, source_config) + record = refresh_draft_against_published( + TeacherAdminRecord( + id=teacher_id, + enabled=data.enabled, + sort_order=data.sort_order or (existing.sort_order if existing else len(store.items) + 1), + published=published, + source_config=source_config, + field_sources=field_sources, + draft=draft, + updated_at=now_iso(), + ) ) next_items = [item for item in store.items if item.id != teacher_id] next_items.append(record) @@ -228,19 +235,40 @@ def _find_record(store: TeacherStore, teacher_id: str) -> TeacherAdminRecord | N return next((item for item in store.items if item.id == teacher_id), None) -def _resolve_teacher_id(data: TeacherAdminSaveRequest, existing: TeacherAdminRecord | None) -> str: +def _draft_after_manual_save( + existing: TeacherAdminRecord | None, + source_config: TeacherSourceConfig, +) -> TeacherDraft: + if existing is None: + return TeacherDraft() + + if existing.source_config.standard_homepage_url != source_config.standard_homepage_url: + return TeacherDraft(status="idle", message="同步来源已更新,请重新同步。") + + return existing.draft + + +def _resolve_teacher_id( + data: TeacherAdminSaveRequest, + existing: TeacherAdminRecord | None, + store: TeacherStore, +) -> str: if existing: return existing.id - raw_id = data.id or str(data.published.get("id") or "") - if raw_id: + raw_id = str(data.id or data.published.get("id") or "").strip() + if raw_id not in AUTO_ID_PLACEHOLDERS: return _normalize_slug(raw_id) + homepage_slug = _standard_homepage_slug(data.source_config.standard_homepage_url) + if homepage_slug: + return _unique_teacher_id(_normalize_slug(homepage_slug), store) + name = str(data.published.get("name") or "") if re.fullmatch(r"[a-zA-Z0-9 -]+", name.strip()): - return _normalize_slug(name) + return _unique_teacher_id(_normalize_slug(name), store) - return f"teacher-{uuid4().hex[:8]}" + return _unique_teacher_id(f"teacher-{uuid4().hex[:8]}", store) def _normalize_slug(value: str) -> str: @@ -251,6 +279,26 @@ def _normalize_slug(value: str) -> str: return slug[:64] +def _standard_homepage_slug(url: str) -> str: + match = re.match(r"^https?://person\.zju\.edu\.cn/([^/?#]+)/?$", url.strip()) + return match.group(1) if match else "" + + +def _unique_teacher_id(candidate: str, store: TeacherStore) -> str: + existing_ids = {record.id for record in store.items} + if candidate not in existing_ids: + return candidate + + base = candidate[:58].rstrip("-") or "teacher" + for index in range(2, 100): + suffix = f"-{index}" + next_id = f"{base[:64 - len(suffix)].rstrip('-')}{suffix}" + if next_id not in existing_ids: + return next_id + + return f"teacher-{uuid4().hex[:8]}" + + def _normalize_source_config(config: TeacherSourceConfig) -> TeacherSourceConfig: standard_homepage_url = config.standard_homepage_url.strip() external_links = [ diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_json.py index fd56534..d27e0b4 100644 --- a/scripts/verify_teachers_json.py +++ b/scripts/verify_teachers_json.py @@ -12,8 +12,13 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from routes.teachers import get_teacher_detail -from services.teachers_cache import load_teacher_store, load_teachers_data +from routes.teachers import ( + TeacherAdminSaveRequest, + _draft_after_manual_save, + _resolve_teacher_id, + get_teacher_detail, +) +from services.teachers_cache import TeacherSourceConfig, load_teacher_store, load_teachers_data EXPECTED_IDS = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] @@ -37,6 +42,22 @@ def main(): assert detail.item.title assert detail.item.source_url + request = TeacherAdminSaveRequest( + id="", + published={"id": "teacher-temp", "name": "新增教师"}, + source_config=TeacherSourceConfig( + standard_homepage_url="https://person.zju.edu.cn/newteacher" + ), + ) + generated_id = _resolve_teacher_id(request, None, store) + assert generated_id == "newteacher" + + changed_source_draft = _draft_after_manual_save( + store.items[1], + TeacherSourceConfig(standard_homepage_url="https://person.zju.edu.cn/changed"), + ) + assert changed_source_draft.status == "idle" + try: get_teacher_detail("not-found") except HTTPException as exc: diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py index ad4e8dd..e7d2331 100644 --- a/scripts/verify_teachers_sync.py +++ b/scripts/verify_teachers_sync.py @@ -16,6 +16,7 @@ from services.teachers_sync import ( apply_draft_changes, parse_standard_zju_profile, + refresh_draft_against_published, sync_teacher_store, ) @@ -93,6 +94,11 @@ def main(): assert published_record.published.email == "wenliangdu-new@zju.edu.cn" assert published_record.draft.status in {"pending", "applied"} + manually_saved_record = synced["du-wenliang"].model_copy(deep=True) + manually_saved_record.published.email = "wenliangdu-new@zju.edu.cn" + refreshed_record = refresh_draft_against_published(manually_saved_record) + assert all(change.field != "email" for change in refreshed_record.draft.changes) + failed_result = sync_teacher_store(store, target_ids={"du-wenliang"}, session=FailingSession()) failed = {record.id: record for record in failed_result.data.items} assert failed["du-wenliang"].draft.status == "failed" diff --git a/services/teachers_sync.py b/services/teachers_sync.py index 2e9f735..e3a853a 100644 --- a/services/teachers_sync.py +++ b/services/teachers_sync.py @@ -265,6 +265,40 @@ def apply_draft_changes(record: TeacherAdminRecord, fields: list[str] | None = N return updated +def refresh_draft_against_published(record: TeacherAdminRecord) -> TeacherAdminRecord: + updated = record.copy(deep=True) + if updated.draft.status != "pending": + return updated + + refreshed_changes = [] + for change in updated.draft.changes: + if not hasattr(updated.published, change.field): + continue + + current = getattr(updated.published, change.field) + if _normalize_value(current) == _normalize_value(change.proposed): + continue + + refreshed_changes.append( + TeacherDraftChange( + field=change.field, + label=change.label, + current=current, + proposed=change.proposed, + ) + ) + + updated.draft = TeacherDraft( + status="pending" if refreshed_changes else "no_change", + fetched_at=updated.draft.fetched_at, + source_url=updated.draft.source_url, + message="仍有未处理的同步建议。" if refreshed_changes else "公开资料已与同步建议一致。", + extracted=updated.draft.extracted, + changes=refreshed_changes, + ) + return updated + + def discard_draft(record: TeacherAdminRecord) -> TeacherAdminRecord: updated = record.copy(deep=True) updated.draft = TeacherDraft( From 10d82fed4b53946790cba0a360b1598bb8057e9c Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Sun, 3 May 2026 15:02:47 +0800 Subject: [PATCH 09/11] fix(teachers): harden roster release blockers --- data/teachers.json | 53 +++++++++------ routes/teachers.py | 112 ++++++++++++++++++++++++++++++-- scripts/verify_teachers_json.py | 66 ++++++++++++++++++- scripts/verify_teachers_sync.py | 4 ++ services/teachers_cache.py | 48 ++++++++++---- services/teachers_sync.py | 7 +- 6 files changed, 249 insertions(+), 41 deletions(-) diff --git a/data/teachers.json b/data/teachers.json index 09b12b3..d52c4d0 100644 --- a/data/teachers.json +++ b/data/teachers.json @@ -1,10 +1,10 @@ { "_comment": "职责范围:为教师介绍 API 和后台管理提供教师公开资料、来源配置和待审核同步草稿;功能边界:本文件不负责远程同步实现、数据库迁移或旧 gallery 资源。", "meta": { - "last_synced_at": "2026-04-30T21:00:00+08:00", - "source_status": "synced", + "last_synced_at": "2026-05-03T14:59:30+08:00", + "source_status": "pending_review", "source_url": "standard_homepage_url", - "sync_note": "同步完成:0 位教师有待审核变更,1 位无变化,2 位需人工维护或核对。" + "sync_note": "同步完成:1 位教师有待审核变更,0 位无变化,2 位需人工维护或核对。" }, "items": [ { @@ -23,8 +23,8 @@ "网络安全", "系统安全" ], - "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", - "bio": "李松现为浙江大学百人计划研究员、博士生导师。公开资料显示,其研究方向包括程序分析、漏洞挖掘、应用安全、网络安全、系统安全。", + "summary": "浙江大学百人计划研究员、博士生导师,主要研究程序分析、漏洞挖掘与应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", + "bio": "李松现任浙江大学百人计划研究员、博士生导师,所属计算机科学与技术学院和网络空间安全学院。其公开主页以程序分析、漏洞挖掘、应用安全为主要研究方向;学院教师队伍页补充列出网络安全、系统安全。", "email": "songli@zju.edu.cn", "avatar_url": "/mentors/li-song/avatar.png", "homepage_urls": [ @@ -82,7 +82,7 @@ }, "draft": { "status": "no_source", - "fetched_at": "2026-04-30T20:59:00+08:00", + "fetched_at": "2026-05-03T14:59:18+08:00", "source_url": "", "message": "未配置标准浙大个人主页,需人工维护公开资料。", "extracted": {}, @@ -104,8 +104,8 @@ "系统安全", "网络仿真" ], - "summary": "杜文亮,研究方向包括网络空间安全、系统安全、网络仿真。", - "bio": "杜文亮公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真。", + "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", + "bio": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", "email": "wenliangdu@zju.edu.cn", "avatar_url": "/mentors/du-wenliang/avatar.jpg", "homepage_urls": [ @@ -162,10 +162,10 @@ "avatar_url": "manual" }, "draft": { - "status": "no_change", - "fetched_at": "2026-04-30T21:00:00+08:00", + "status": "pending", + "fetched_at": "2026-05-03T14:59:30+08:00", "source_url": "https://person.zju.edu.cn/wenliangdu", - "message": "标准主页已读取,公开资料暂无变化。", + "message": "发现待审核变更。", "extracted": { "name": "杜文亮", "affiliation": "计算机科学与技术学院", @@ -175,10 +175,23 @@ "系统安全", "网络仿真" ], - "summary": "杜文亮,研究方向包括网络空间安全、系统安全、网络仿真。", - "bio": "杜文亮公开资料显示,其研究方向包括网络空间安全、系统安全、网络仿真。" + "summary": "杜文亮,主要研究方向包括网络空间安全、系统安全、网络仿真。", + "bio": "杜文亮的个人主页列出的研究方向为网络空间安全、系统安全、网络仿真。" }, - "changes": [] + "changes": [ + { + "field": "summary", + "label": "简介摘要", + "current": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", + "proposed": "杜文亮,主要研究方向包括网络空间安全、系统安全、网络仿真。" + }, + { + "field": "bio", + "label": "详细简介", + "current": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", + "proposed": "杜文亮的个人主页列出的研究方向为网络空间安全、系统安全、网络仿真。" + } + ] }, "updated_at": "2026-04-30T20:59:01+08:00" }, @@ -198,8 +211,8 @@ "无线感知攻防", "生物识别身份认证" ], - "summary": "林峰,百人计划研究员 | 博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", - "bio": "林峰公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", "email": "flin@zju.edu.cn", "avatar_url": "/mentors/lin-feng/avatar.jpg", "homepage_urls": [ @@ -272,8 +285,8 @@ "无线感知攻防", "生物识别身份认证" ], - "summary": "林峰,百人计划研究员 | 博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。", - "bio": "林峰公开资料显示,其研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防、生物识别身份认证。" + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。" }, "changes": [] }, @@ -293,8 +306,8 @@ "IoT 安全", "软件供应链" ], - "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全、软件供应链。", - "bio": "申大坤现为浙江大学网络空间安全学院研究员。公开资料显示,其研究方向包括系统安全、IoT 安全、软件供应链。", + "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全和软件供应链。", + "bio": "申大坤现任浙江大学网络空间安全学院研究员,学院教师队伍页列出的研究方向为系统安全、IoT 安全和软件供应链。当前公开邮箱和个人主页入口待进一步补充。", "email": "", "avatar_url": "/mentors/shen-dakun/avatar.png", "homepage_urls": [ diff --git a/routes/teachers.py b/routes/teachers.py index b3817a2..83445a3 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -4,6 +4,7 @@ """ import re +from urllib.parse import urlparse from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status @@ -12,16 +13,17 @@ from misc.auth import login_required_publisher from services.teachers_cache import ( HomepageUrl, + PublicTeacherDetailResponse, + PublicTeacherListResponse, TeacherAdminRecord, TeacherAdminResponse, TeacherCacheError, - TeacherDetailResponse, TeacherDraft, TeacherItem, - TeacherListResponse, TeacherMeta, TeacherSourceConfig, TeacherStore, + is_standard_homepage_url, load_teacher_store, public_response_from_store, write_teacher_store, @@ -41,7 +43,7 @@ class TeacherAdminSaveRequest(BaseModel): id: str = "" enabled: bool = True - sort_order: int = 0 + sort_order: int | None = None published: dict = Field(default_factory=dict) source_config: TeacherSourceConfig = Field(default_factory=TeacherSourceConfig) field_sources: dict[str, str] = Field(default_factory=dict) @@ -62,6 +64,7 @@ class TeacherAdminDeleteResponse(BaseModel): AUTO_ID_PLACEHOLDERS = {"", "teacher-temp"} +EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") def load_store_or_503() -> TeacherStore: @@ -74,12 +77,12 @@ def load_store_or_503() -> TeacherStore: ) from exc -@router.get("/list", response_model=TeacherListResponse) +@router.get("/list", response_model=PublicTeacherListResponse) def get_teacher_list(): return public_response_from_store(load_store_or_503()) -@router.get("/detail", response_model=TeacherDetailResponse) +@router.get("/detail", response_model=PublicTeacherDetailResponse) def get_teacher_detail(id: str): data = public_response_from_store(load_store_or_503()) teacher = next((item for item in data.items if item.id == id), None) @@ -90,7 +93,7 @@ def get_teacher_detail(id: str): detail="Teacher not found", ) - return TeacherDetailResponse(item=teacher, meta=data.meta) + return PublicTeacherDetailResponse(item=teacher, meta=data.meta) @router.get( @@ -112,14 +115,16 @@ def save_teacher_admin_record(data: TeacherAdminSaveRequest): existing = _find_record(store, data.id) if data.id else None teacher_id = _resolve_teacher_id(data, existing, store) source_config = _normalize_source_config(data.source_config) + _validate_admin_save(data, store, existing, teacher_id, source_config) published = _build_published_item(teacher_id, data.published, source_config) field_sources = data.field_sources or (existing.field_sources if existing else {}) draft = _draft_after_manual_save(existing, source_config) + sort_order = _resolve_sort_order(data, existing, store) record = refresh_draft_against_published( TeacherAdminRecord( id=teacher_id, enabled=data.enabled, - sort_order=data.sort_order or (existing.sort_order if existing else len(store.items) + 1), + sort_order=sort_order, published=published, source_config=source_config, field_sources=field_sources, @@ -327,6 +332,99 @@ def _normalize_source_config(config: TeacherSourceConfig) -> TeacherSourceConfig ) +def _validate_admin_save( + data: TeacherAdminSaveRequest, + store: TeacherStore, + existing: TeacherAdminRecord | None, + teacher_id: str, + source_config: TeacherSourceConfig, +) -> None: + published = data.published or {} + email = str(published.get("email") or "").strip() + avatar_url = str(published.get("avatar_url") or "").strip() + sort_order = _resolve_sort_order(data, existing, store) + + if sort_order < 1: + _raise_validation_error("排序值必须是大于 0 的整数。") + + if email and not EMAIL_PATTERN.fullmatch(email): + _raise_validation_error("邮箱格式不正确,请填写形如 name@example.com 的公开邮箱。") + + if avatar_url and not _is_public_asset_url(avatar_url): + _raise_validation_error("头像 URL 必须是 http(s) 地址,或以 / 开头的站内资源路径。") + + if source_config.standard_homepage_url and not is_standard_homepage_url(source_config.standard_homepage_url): + _raise_validation_error("标准浙大个人主页 URL 必须形如 https://person.zju.edu.cn/。") + + for link in source_config.external_links: + if not _is_http_url(link.url): + _raise_validation_error(f"外部链接 URL 格式不正确:{link.url}") + + duplicate_url = _first_duplicate_url(link.url for link in source_config.external_links) + if duplicate_url: + _raise_validation_error(f"外部链接重复:{duplicate_url}") + + sort_conflict = next( + ( + item + for item in store.items + if item.id != teacher_id and item.sort_order == sort_order + ), + None, + ) + if sort_conflict: + _raise_validation_error( + f"排序值 {sort_order} 已被「{sort_conflict.published.name}」使用,请调整排序。" + ) + + +def _resolve_sort_order( + data: TeacherAdminSaveRequest, + existing: TeacherAdminRecord | None, + store: TeacherStore, +) -> int: + if data.sort_order is not None: + return data.sort_order + + if existing: + return existing.sort_order + + return len(store.items) + 1 + + +def _is_http_url(value: str) -> bool: + parsed = urlparse(value.strip()) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _is_public_asset_url(value: str) -> bool: + cleaned = value.strip() + if cleaned.startswith("/"): + return not any(char.isspace() for char in cleaned) + + return _is_http_url(cleaned) + + +def _first_duplicate_url(urls) -> str: + seen: set[str] = set() + + for url in urls: + canonical = url.strip().rstrip("/") + if canonical in seen: + return url + + seen.add(canonical) + + return "" + + +def _raise_validation_error(message: str) -> None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=message, + ) + + def _build_published_item( teacher_id: str, raw_item: dict, diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_json.py index d27e0b4..bae97cc 100644 --- a/scripts/verify_teachers_json.py +++ b/scripts/verify_teachers_json.py @@ -15,7 +15,9 @@ from routes.teachers import ( TeacherAdminSaveRequest, _draft_after_manual_save, + _normalize_source_config, _resolve_teacher_id, + _validate_admin_save, get_teacher_detail, ) from services.teachers_cache import TeacherSourceConfig, load_teacher_store, load_teachers_data @@ -33,7 +35,9 @@ def main(): ids = [item.id for item in data.items] assert ids == EXPECTED_IDS, f"unexpected teacher ids: {ids}" - assert data.meta.source_status + assert data.meta.last_synced_at + assert all(not hasattr(item, "source_status") for item in data.items) + assert all(not hasattr(item, "sync_note") for item in data.items) for teacher_id in EXPECTED_IDS: detail = get_teacher_detail(teacher_id) @@ -58,6 +62,66 @@ def main(): ) assert changed_source_draft.status == "idle" + invalid_requests = [ + TeacherAdminSaveRequest( + id="bad-email", + sort_order=10, + published={"id": "bad-email", "name": "邮箱错误", "email": "bad-email"}, + ), + TeacherAdminSaveRequest( + id="bad-source", + sort_order=11, + published={"id": "bad-source", "name": "主页错误"}, + source_config=TeacherSourceConfig( + standard_homepage_url="https://example.com/not-zju" + ), + ), + TeacherAdminSaveRequest( + id="bad-link", + sort_order=12, + published={"id": "bad-link", "name": "链接错误"}, + source_config=TeacherSourceConfig( + external_links=[{"label": "主页", "url": "not-a-url"}] + ), + ), + TeacherAdminSaveRequest( + id="dup-link", + sort_order=13, + published={"id": "dup-link", "name": "链接重复"}, + source_config=TeacherSourceConfig( + external_links=[ + {"label": "主页", "url": "https://example.com/profile"}, + {"label": "重复主页", "url": "https://example.com/profile/"}, + ] + ), + ), + TeacherAdminSaveRequest( + id="sort-conflict", + sort_order=store.items[0].sort_order, + published={"id": "sort-conflict", "name": "排序冲突"}, + ), + TeacherAdminSaveRequest( + id="bad-sort", + sort_order=0, + published={"id": "bad-sort", "name": "排序错误"}, + ), + ] + + for invalid_request in invalid_requests: + source_config = _normalize_source_config(invalid_request.source_config) + try: + _validate_admin_save( + invalid_request, + store, + None, + invalid_request.id, + source_config, + ) + except HTTPException as exc: + assert exc.status_code == 422 + else: + raise AssertionError("invalid admin save request should return 422") + try: get_teacher_detail("not-found") except HTTPException as exc: diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py index e7d2331..fa5c5f7 100644 --- a/scripts/verify_teachers_sync.py +++ b/scripts/verify_teachers_sync.py @@ -15,6 +15,7 @@ from services.teachers_cache import load_teacher_store from services.teachers_sync import ( apply_draft_changes, + build_objective_text, parse_standard_zju_profile, refresh_draft_against_published, sync_teacher_store, @@ -72,6 +73,9 @@ def main(): extracted = parse_standard_zju_profile(DU_WENLIANG_FIXTURE, expected_name="杜文亮") assert extracted["email"] == "wenliangdu-new@zju.edu.cn" assert extracted["research_areas"] == ["网络空间安全", "系统安全", "网络仿真"] + summary, bio = build_objective_text("杜文亮", "求是讲席教授 | 博士生导师", extracted["research_areas"]) + assert "公开资料显示" not in summary + assert "公开资料显示" not in bio fixture_session = FixtureSession( { diff --git a/services/teachers_cache.py b/services/teachers_cache.py index 8aab427..92a4337 100644 --- a/services/teachers_cache.py +++ b/services/teachers_cache.py @@ -70,14 +70,34 @@ class TeacherMeta(BaseModel): sync_note: str = "" -class TeacherListResponse(BaseModel): - items: list[TeacherItem] - meta: TeacherMeta +class PublicTeacherItem(BaseModel): + id: str + name: str + title: str = "" + affiliation: str = "" + research_areas: list[str] = Field(default_factory=list) + summary: str = "" + bio: str = "" + email: str = "" + avatar_url: str = "" + homepage_urls: list[HomepageUrl] = Field(default_factory=list) + source_url: str = "" + last_synced_at: str = "" + profile_sections: list[ProfileSection] = Field(default_factory=list) -class TeacherDetailResponse(BaseModel): - item: TeacherItem - meta: TeacherMeta +class PublicTeacherMeta(BaseModel): + last_synced_at: str = "" + + +class PublicTeacherListResponse(BaseModel): + items: list[PublicTeacherItem] + meta: PublicTeacherMeta + + +class PublicTeacherDetailResponse(BaseModel): + item: PublicTeacherItem + meta: PublicTeacherMeta class TeacherSourceConfig(BaseModel): @@ -172,11 +192,11 @@ def write_teacher_store(data: TeacherStore, path: Path = TEACHERS_DATA_PATH) -> temp_path.replace(path) -def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> TeacherListResponse: +def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> PublicTeacherListResponse: return public_response_from_store(load_teacher_store(path)) -def public_response_from_store(store: TeacherStore) -> TeacherListResponse: +def public_response_from_store(store: TeacherStore) -> PublicTeacherListResponse: public_items = [] for record in normalize_teacher_store(store).items: if not record.enabled: @@ -186,9 +206,15 @@ def public_response_from_store(store: TeacherStore) -> TeacherListResponse: item.id = record.id item.homepage_urls = record.source_config.external_links item.source_url = _public_source_url(record) - public_items.append(item) - - return TeacherListResponse(items=public_items, meta=store.meta) + public_item_data = _model_dump(item) + public_item_data.pop("source_status", None) + public_item_data.pop("sync_note", None) + public_items.append(PublicTeacherItem(**public_item_data)) + + return PublicTeacherListResponse( + items=public_items, + meta=PublicTeacherMeta(last_synced_at=store.meta.last_synced_at), + ) def normalize_teacher_store(data: TeacherStore) -> TeacherStore: diff --git a/services/teachers_sync.py b/services/teachers_sync.py index e3a853a..1312936 100644 --- a/services/teachers_sync.py +++ b/services/teachers_sync.py @@ -322,8 +322,11 @@ def split_research_areas(value: str) -> list[str]: def build_objective_text(name: str, title: str, research_areas: list[str]) -> tuple[str, str]: areas = "、".join(research_areas) identity = f"{name},{title}" if title else name - summary = f"{identity},研究方向包括{areas}。" - bio = f"{name}公开资料显示,其研究方向包括{areas}。" + if not areas: + return f"{identity}。", f"{name}的公开资料暂未列出明确研究方向。" + + summary = f"{identity},主要研究方向包括{areas}。" + bio = f"{name}的个人主页列出的研究方向为{areas}。" return summary, bio From e86705ec79f5bbd416faa763d58ad9d7a02aad02 Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Sun, 3 May 2026 20:06:04 +0800 Subject: [PATCH 10/11] feat(teachers): migrate roster storage to database --- alembic/env.py | 4 + ...9f4c7e2d10_add_teacher_database_storage.py | 418 ++++++++++++++++++ data/teachers.json | 357 --------------- models/teachers.py | 47 ++ routes/teachers.py | 22 +- ...teachers_json.py => verify_teachers_db.py} | 40 +- services/teachers_cache.py | 274 +++++++----- 7 files changed, 696 insertions(+), 466 deletions(-) create mode 100644 alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py delete mode 100644 data/teachers.json create mode 100644 models/teachers.py rename scripts/{verify_teachers_json.py => verify_teachers_db.py} (72%) diff --git a/alembic/env.py b/alembic/env.py index 729597b..744ceab 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,9 +5,12 @@ from alembic import context +from config import get_config + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +config.set_main_option("sqlalchemy.url", get_config("DB_PATH").replace("%", "%%")) # Interpret the config file for Python logging. # This line sets up loggers basically. @@ -39,6 +42,7 @@ from models.interview import Interview, InterviewTimeSlot from models.recruit import Recruitment, Evaluation from models.member import Member +from models.teachers import TeacherMetaState, TeacherRecord target_metadata = Base.metadata diff --git a/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py b/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py new file mode 100644 index 0000000..2bdd55b --- /dev/null +++ b/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py @@ -0,0 +1,418 @@ +"""add teacher database storage + +Revision ID: 3a9f4c7e2d10 +Revises: 8e79605fd37a +Create Date: 2026-05-03 16:20:00.000000 + +""" +from typing import Sequence, Union + +import json + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "3a9f4c7e2d10" +down_revision: Union[str, None] = "8e79605fd37a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +SEED_META = { + "last_synced_at": "2026-05-03T15:45:46+08:00", + "source_status": "published", + "source_url": "standard_homepage_url", + "sync_note": "同步建议已忽略。", +} + +SEED_ITEMS = [ + { + "id": "li-song", + "enabled": True, + "sort_order": 1, + "published": { + "id": "li-song", + "name": "李松", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", + "research_areas": ["程序分析", "漏洞挖掘", "应用安全", "网络安全", "系统安全"], + "summary": "浙江大学百人计划研究员、博士生导师,主要研究程序分析、漏洞挖掘与应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", + "bio": "李松现任浙江大学百人计划研究员、博士生导师,所属计算机科学与技术学院和网络空间安全学院。其公开主页以程序分析、漏洞挖掘、应用安全为主要研究方向;学院教师队伍页补充列出网络安全、系统安全。", + "email": "songli@zju.edu.cn", + "avatar_url": "/mentors/li-song/avatar.png", + "homepage_urls": [ + {"label": "个人主页", "url": "https://songli.io/", "type": "profile"}, + { + "label": "Google Scholar", + "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", + "type": "scholar", + }, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + "source_url": "https://songli.io/", + "source_status": "published", + "last_synced_at": "2026-04-30T11:15:15+08:00", + "sync_note": "历史资料已作为已发布内容迁移。", + "profile_sections": [], + }, + "source_config": { + "standard_homepage_url": "", + "external_links": [ + {"label": "个人主页", "url": "https://songli.io/", "type": "profile"}, + { + "label": "Google Scholar", + "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", + "type": "scholar", + }, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual", + }, + "draft": { + "status": "no_source", + "fetched_at": "2026-05-03T14:59:18+08:00", + "source_url": "", + "message": "未配置标准浙大个人主页,需人工维护公开资料。", + "extracted": {}, + "changes": [], + }, + "updated_at": "2026-04-30T11:15:15+08:00", + }, + { + "id": "du-wenliang", + "enabled": True, + "sort_order": 2, + "published": { + "id": "du-wenliang", + "name": "杜文亮", + "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", + "affiliation": "计算机科学与技术学院", + "research_areas": ["网络空间安全", "系统安全", "网络仿真"], + "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", + "bio": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", + "email": "wenliangdu@zju.edu.cn", + "avatar_url": "/mentors/du-wenliang/avatar.jpg", + "homepage_urls": [ + {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/wenliangdu", "type": "profile"}, + {"label": "SEED Labs 主页", "url": "https://seedsecuritylabs.org/wenliangdu/", "type": "profile"}, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + "source_url": "https://person.zju.edu.cn/wenliangdu", + "source_status": "published", + "last_synced_at": "2026-04-30T20:59:01+08:00", + "sync_note": "同步建议已审核发布。", + "profile_sections": [], + }, + "source_config": { + "standard_homepage_url": "https://person.zju.edu.cn/wenliangdu", + "external_links": [ + {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/wenliangdu", "type": "profile"}, + {"label": "SEED Labs 主页", "url": "https://seedsecuritylabs.org/wenliangdu/", "type": "profile"}, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "synced", + "research_areas": "synced", + "summary": "synced", + "bio": "synced", + "email": "manual", + "avatar_url": "manual", + }, + "draft": { + "status": "ignored", + "fetched_at": "2026-05-03T14:59:30+08:00", + "source_url": "https://person.zju.edu.cn/wenliangdu", + "message": "同步建议已忽略,公开资料保持不变。", + "extracted": {}, + "changes": [], + }, + "updated_at": "2026-04-30T20:59:01+08:00", + }, + { + "id": "lin-feng", + "enabled": True, + "sort_order": 3, + "published": { + "id": "lin-feng", + "name": "林峰", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "计算机科学与技术学院", + "research_areas": ["智能网联车安全", "物联网安全", "人工智能安全", "无线感知攻防", "生物识别身份认证"], + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + "email": "flin@zju.edu.cn", + "avatar_url": "/mentors/lin-feng/avatar.jpg", + "homepage_urls": [ + {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/flin", "type": "profile"}, + {"label": "个人主页", "url": "https://flin.group/", "type": "profile"}, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + "source_url": "https://person.zju.edu.cn/flin", + "source_status": "published", + "last_synced_at": "2026-04-30T20:59:01+08:00", + "sync_note": "同步建议已审核发布。", + "profile_sections": [], + }, + "source_config": { + "standard_homepage_url": "https://person.zju.edu.cn/flin", + "external_links": [ + {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/flin", "type": "profile"}, + {"label": "个人主页", "url": "https://flin.group/", "type": "profile"}, + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual", + }, + "draft": { + "status": "applied", + "fetched_at": "2026-04-30T20:59:01+08:00", + "source_url": "https://person.zju.edu.cn/flin", + "message": "同步建议已发布。", + "extracted": { + "name": "林峰", + "title": "百人计划研究员 | 博士生导师", + "affiliation": "计算机科学与技术学院", + "email": "flin@zju.edu.cn", + "research_areas": ["智能网联车安全", "物联网安全", "人工智能安全", "无线感知攻防", "生物识别身份认证"], + "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", + }, + "changes": [], + }, + "updated_at": "2026-04-30T20:59:51+08:00", + }, + { + "id": "shen-dakun", + "enabled": True, + "sort_order": 4, + "published": { + "id": "shen-dakun", + "name": "申大坤", + "title": "研究员", + "affiliation": "浙江大学网络空间安全学院", + "research_areas": ["系统安全", "IoT 安全", "软件供应链"], + "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全和软件供应链。", + "bio": "申大坤现任浙江大学网络空间安全学院研究员,学院教师队伍页列出的研究方向为系统安全、IoT 安全和软件供应链。当前公开邮箱和个人主页入口待进一步补充。", + "email": "", + "avatar_url": "/mentors/shen-dakun/avatar.png", + "homepage_urls": [ + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", + "source_status": "published", + "last_synced_at": "2026-04-30T11:15:15+08:00", + "sync_note": "历史资料已作为已发布内容迁移。", + "profile_sections": [], + }, + "source_config": { + "standard_homepage_url": "", + "external_links": [ + {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, + ], + }, + "field_sources": { + "name": "manual", + "title": "manual", + "affiliation": "manual", + "research_areas": "manual", + "summary": "manual", + "bio": "manual", + "email": "manual", + "avatar_url": "manual", + }, + "draft": { + "status": "no_source", + "fetched_at": "2026-04-30T20:59:01+08:00", + "source_url": "", + "message": "未配置标准浙大个人主页,需人工维护公开资料。", + "extracted": {}, + "changes": [], + }, + "updated_at": "2026-04-30T11:15:15+08:00", + }, +] + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + table_names = set(sa.inspect(bind).get_table_names()) + + if "teacher_meta" not in table_names: + _create_teacher_meta_table() + table_names.add("teacher_meta") + + if "teachers" not in table_names: + _create_teachers_table() + table_names.add("teachers") + else: + index_names = {index["name"] for index in sa.inspect(bind).get_indexes("teachers")} + if "ix_teachers_id" not in index_names: + op.create_index(op.f("ix_teachers_id"), "teachers", ["id"], unique=False) + + _seed_teacher_meta_if_empty(bind) + _seed_teachers_if_empty(bind) + + +def downgrade() -> None: + """Downgrade schema.""" + bind = op.get_bind() + table_names = set(sa.inspect(bind).get_table_names()) + + if "teachers" in table_names: + index_names = {index["name"] for index in sa.inspect(bind).get_indexes("teachers")} + if "ix_teachers_id" in index_names: + op.drop_index(op.f("ix_teachers_id"), table_name="teachers") + op.drop_table("teachers") + + if "teacher_meta" in table_names: + op.drop_table("teacher_meta") + + +def _create_teacher_meta_table() -> None: + op.create_table( + "teacher_meta", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("last_synced_at", sa.String(length=40), nullable=False), + sa.Column("source_status", sa.String(length=32), nullable=False), + sa.Column("source_url", sa.Text(), nullable=False), + sa.Column("sync_note", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def _create_teachers_table() -> None: + op.create_table( + "teachers", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("affiliation", sa.Text(), nullable=False), + sa.Column("research_areas", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("bio", sa.Text(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("avatar_url", sa.Text(), nullable=False), + sa.Column("homepage_urls", sa.Text(), nullable=False), + sa.Column("source_url", sa.Text(), nullable=False), + sa.Column("source_status", sa.String(length=32), nullable=False), + sa.Column("last_synced_at", sa.String(length=40), nullable=False), + sa.Column("sync_note", sa.Text(), nullable=False), + sa.Column("profile_sections", sa.Text(), nullable=False), + sa.Column("standard_homepage_url", sa.Text(), nullable=False), + sa.Column("external_links", sa.Text(), nullable=False), + sa.Column("field_sources", sa.Text(), nullable=False), + sa.Column("draft", sa.Text(), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_teachers_id"), "teachers", ["id"], unique=False) + + +def _seed_teacher_meta_if_empty(bind) -> None: + has_default_meta = bind.execute( + sa.text("select count(*) from teacher_meta where id = :id"), + {"id": "default"}, + ).scalar() + if has_default_meta: + return + + teacher_meta = sa.table( + "teacher_meta", + sa.column("id", sa.String), + sa.column("last_synced_at", sa.String), + sa.column("source_status", sa.String), + sa.column("source_url", sa.Text), + sa.column("sync_note", sa.Text), + ) + op.bulk_insert(teacher_meta, [{"id": "default", **SEED_META}]) + + +def _seed_teachers_if_empty(bind) -> None: + teacher_count = bind.execute(sa.text("select count(*) from teachers")).scalar() + if teacher_count: + return + + teachers = sa.table( + "teachers", + sa.column("id", sa.String), + sa.column("enabled", sa.Boolean), + sa.column("sort_order", sa.Integer), + sa.column("name", sa.String), + sa.column("title", sa.Text), + sa.column("affiliation", sa.Text), + sa.column("research_areas", sa.Text), + sa.column("summary", sa.Text), + sa.column("bio", sa.Text), + sa.column("email", sa.String), + sa.column("avatar_url", sa.Text), + sa.column("homepage_urls", sa.Text), + sa.column("source_url", sa.Text), + sa.column("source_status", sa.String), + sa.column("last_synced_at", sa.String), + sa.column("sync_note", sa.Text), + sa.column("profile_sections", sa.Text), + sa.column("standard_homepage_url", sa.Text), + sa.column("external_links", sa.Text), + sa.column("field_sources", sa.Text), + sa.column("draft", sa.Text), + sa.column("updated_at", sa.String), + ) + op.bulk_insert(teachers, [_teacher_row(item) for item in SEED_ITEMS]) + + +def _teacher_row(record: dict) -> dict: + published = record["published"] + source_config = record["source_config"] + return { + "id": record["id"], + "enabled": record["enabled"], + "sort_order": record["sort_order"], + "name": published["name"], + "title": published["title"], + "affiliation": published["affiliation"], + "research_areas": _json_dump(published["research_areas"]), + "summary": published["summary"], + "bio": published["bio"], + "email": published["email"], + "avatar_url": published["avatar_url"], + "homepage_urls": _json_dump(published["homepage_urls"]), + "source_url": published["source_url"], + "source_status": published["source_status"], + "last_synced_at": published["last_synced_at"], + "sync_note": published["sync_note"], + "profile_sections": _json_dump(published["profile_sections"]), + "standard_homepage_url": source_config["standard_homepage_url"], + "external_links": _json_dump(source_config["external_links"]), + "field_sources": _json_dump(record["field_sources"]), + "draft": _json_dump(record["draft"]), + "updated_at": record["updated_at"], + } + + +def _json_dump(value) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) diff --git a/data/teachers.json b/data/teachers.json deleted file mode 100644 index d52c4d0..0000000 --- a/data/teachers.json +++ /dev/null @@ -1,357 +0,0 @@ -{ - "_comment": "职责范围:为教师介绍 API 和后台管理提供教师公开资料、来源配置和待审核同步草稿;功能边界:本文件不负责远程同步实现、数据库迁移或旧 gallery 资源。", - "meta": { - "last_synced_at": "2026-05-03T14:59:30+08:00", - "source_status": "pending_review", - "source_url": "standard_homepage_url", - "sync_note": "同步完成:1 位教师有待审核变更,0 位无变化,2 位需人工维护或核对。" - }, - "items": [ - { - "id": "li-song", - "enabled": true, - "sort_order": 1, - "published": { - "id": "li-song", - "name": "李松", - "title": "百人计划研究员 | 博士生导师", - "affiliation": "浙江大学计算机科学与技术学院 / 网络空间安全学院", - "research_areas": [ - "程序分析", - "漏洞挖掘", - "应用安全", - "网络安全", - "系统安全" - ], - "summary": "浙江大学百人计划研究员、博士生导师,主要研究程序分析、漏洞挖掘与应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", - "bio": "李松现任浙江大学百人计划研究员、博士生导师,所属计算机科学与技术学院和网络空间安全学院。其公开主页以程序分析、漏洞挖掘、应用安全为主要研究方向;学院教师队伍页补充列出网络安全、系统安全。", - "email": "songli@zju.edu.cn", - "avatar_url": "/mentors/li-song/avatar.png", - "homepage_urls": [ - { - "label": "个人主页", - "url": "https://songli.io/", - "type": "profile" - }, - { - "label": "Google Scholar", - "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", - "type": "scholar" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://songli.io/", - "source_status": "published", - "last_synced_at": "2026-04-30T11:15:15+08:00", - "sync_note": "历史资料已作为已发布内容迁移。", - "profile_sections": [] - }, - "source_config": { - "standard_homepage_url": "", - "external_links": [ - { - "label": "个人主页", - "url": "https://songli.io/", - "type": "profile" - }, - { - "label": "Google Scholar", - "url": "https://scholar.google.com/citations?user=BBPDISIAAAAJ&hl=en", - "type": "scholar" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ] - }, - "field_sources": { - "name": "manual", - "title": "manual", - "affiliation": "manual", - "research_areas": "manual", - "summary": "manual", - "bio": "manual", - "email": "manual", - "avatar_url": "manual" - }, - "draft": { - "status": "no_source", - "fetched_at": "2026-05-03T14:59:18+08:00", - "source_url": "", - "message": "未配置标准浙大个人主页,需人工维护公开资料。", - "extracted": {}, - "changes": [] - }, - "updated_at": "2026-04-30T11:15:15+08:00" - }, - { - "id": "du-wenliang", - "enabled": true, - "sort_order": 2, - "published": { - "id": "du-wenliang", - "name": "杜文亮", - "title": "求是讲席教授 | 博士生导师 | ACM/IEEE Fellow", - "affiliation": "计算机科学与技术学院", - "research_areas": [ - "网络空间安全", - "系统安全", - "网络仿真" - ], - "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", - "bio": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", - "email": "wenliangdu@zju.edu.cn", - "avatar_url": "/mentors/du-wenliang/avatar.jpg", - "homepage_urls": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/wenliangdu", - "type": "profile" - }, - { - "label": "SEED Labs 主页", - "url": "https://seedsecuritylabs.org/wenliangdu/", - "type": "profile" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://person.zju.edu.cn/wenliangdu", - "source_status": "published", - "last_synced_at": "2026-04-30T20:59:01+08:00", - "sync_note": "同步建议已审核发布。", - "profile_sections": [] - }, - "source_config": { - "standard_homepage_url": "https://person.zju.edu.cn/wenliangdu", - "external_links": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/wenliangdu", - "type": "profile" - }, - { - "label": "SEED Labs 主页", - "url": "https://seedsecuritylabs.org/wenliangdu/", - "type": "profile" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ] - }, - "field_sources": { - "name": "manual", - "title": "manual", - "affiliation": "synced", - "research_areas": "synced", - "summary": "synced", - "bio": "synced", - "email": "manual", - "avatar_url": "manual" - }, - "draft": { - "status": "pending", - "fetched_at": "2026-05-03T14:59:30+08:00", - "source_url": "https://person.zju.edu.cn/wenliangdu", - "message": "发现待审核变更。", - "extracted": { - "name": "杜文亮", - "affiliation": "计算机科学与技术学院", - "email": "wenliangdu@zju.edu.cn", - "research_areas": [ - "网络空间安全", - "系统安全", - "网络仿真" - ], - "summary": "杜文亮,主要研究方向包括网络空间安全、系统安全、网络仿真。", - "bio": "杜文亮的个人主页列出的研究方向为网络空间安全、系统安全、网络仿真。" - }, - "changes": [ - { - "field": "summary", - "label": "简介摘要", - "current": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", - "proposed": "杜文亮,主要研究方向包括网络空间安全、系统安全、网络仿真。" - }, - { - "field": "bio", - "label": "详细简介", - "current": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", - "proposed": "杜文亮的个人主页列出的研究方向为网络空间安全、系统安全、网络仿真。" - } - ] - }, - "updated_at": "2026-04-30T20:59:01+08:00" - }, - { - "id": "lin-feng", - "enabled": true, - "sort_order": 3, - "published": { - "id": "lin-feng", - "name": "林峰", - "title": "百人计划研究员 | 博士生导师", - "affiliation": "计算机科学与技术学院", - "research_areas": [ - "智能网联车安全", - "物联网安全", - "人工智能安全", - "无线感知攻防", - "生物识别身份认证" - ], - "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", - "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", - "email": "flin@zju.edu.cn", - "avatar_url": "/mentors/lin-feng/avatar.jpg", - "homepage_urls": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/flin", - "type": "profile" - }, - { - "label": "个人主页", - "url": "https://flin.group/", - "type": "profile" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://person.zju.edu.cn/flin", - "source_status": "published", - "last_synced_at": "2026-04-30T20:59:01+08:00", - "sync_note": "同步建议已审核发布。", - "profile_sections": [] - }, - "source_config": { - "standard_homepage_url": "https://person.zju.edu.cn/flin", - "external_links": [ - { - "label": "浙大个人主页", - "url": "https://person.zju.edu.cn/flin", - "type": "profile" - }, - { - "label": "个人主页", - "url": "https://flin.group/", - "type": "profile" - }, - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ] - }, - "field_sources": { - "name": "manual", - "title": "manual", - "affiliation": "manual", - "research_areas": "manual", - "summary": "manual", - "bio": "manual", - "email": "manual", - "avatar_url": "manual" - }, - "draft": { - "status": "applied", - "fetched_at": "2026-04-30T20:59:01+08:00", - "source_url": "https://person.zju.edu.cn/flin", - "message": "同步建议已发布。", - "extracted": { - "name": "林峰", - "title": "百人计划研究员 | 博士生导师", - "affiliation": "计算机科学与技术学院", - "email": "flin@zju.edu.cn", - "research_areas": [ - "智能网联车安全", - "物联网安全", - "人工智能安全", - "无线感知攻防", - "生物识别身份认证" - ], - "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", - "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。" - }, - "changes": [] - }, - "updated_at": "2026-04-30T20:59:51+08:00" - }, - { - "id": "shen-dakun", - "enabled": true, - "sort_order": 4, - "published": { - "id": "shen-dakun", - "name": "申大坤", - "title": "研究员", - "affiliation": "浙江大学网络空间安全学院", - "research_areas": [ - "系统安全", - "IoT 安全", - "软件供应链" - ], - "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全和软件供应链。", - "bio": "申大坤现任浙江大学网络空间安全学院研究员,学院教师队伍页列出的研究方向为系统安全、IoT 安全和软件供应链。当前公开邮箱和个人主页入口待进一步补充。", - "email": "", - "avatar_url": "/mentors/shen-dakun/avatar.png", - "homepage_urls": [ - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ], - "source_url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "source_status": "published", - "last_synced_at": "2026-04-30T11:15:15+08:00", - "sync_note": "历史资料已作为已发布内容迁移。", - "profile_sections": [] - }, - "source_config": { - "standard_homepage_url": "", - "external_links": [ - { - "label": "学院教师队伍页", - "url": "https://icsr.zju.edu.cn/jsdw/list.htm", - "type": "source" - } - ] - }, - "field_sources": { - "name": "manual", - "title": "manual", - "affiliation": "manual", - "research_areas": "manual", - "summary": "manual", - "bio": "manual", - "email": "manual", - "avatar_url": "manual" - }, - "draft": { - "status": "no_source", - "fetched_at": "2026-04-30T20:59:01+08:00", - "source_url": "", - "message": "未配置标准浙大个人主页,需人工维护公开资料。", - "extracted": {}, - "changes": [] - }, - "updated_at": "2026-04-30T11:15:15+08:00" - } - ] -} diff --git a/models/teachers.py b/models/teachers.py new file mode 100644 index 0000000..c35497a --- /dev/null +++ b/models/teachers.py @@ -0,0 +1,47 @@ +""" +职责范围:定义教师介绍功能的数据库表结构,承载公开资料、后台来源配置和同步草稿状态。 +功能边界:本模块只声明 SQLAlchemy ORM 模型,不负责接口校验、远程同步解析或初始数据迁移。 +""" + +from sqlalchemy import Boolean, Column, Integer, String, Text + +from . import Base + + +class TeacherRecord(Base): + __tablename__ = "teachers" + + id = Column(String(64), primary_key=True, index=True) + enabled = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, nullable=False, default=0) + + name = Column(String(100), nullable=False, default="") + title = Column(Text, nullable=False, default="") + affiliation = Column(Text, nullable=False, default="") + research_areas = Column(Text, nullable=False, default="[]") + summary = Column(Text, nullable=False, default="") + bio = Column(Text, nullable=False, default="") + email = Column(String(255), nullable=False, default="") + avatar_url = Column(Text, nullable=False, default="") + homepage_urls = Column(Text, nullable=False, default="[]") + source_url = Column(Text, nullable=False, default="") + source_status = Column(String(32), nullable=False, default="published") + last_synced_at = Column(String(40), nullable=False, default="") + sync_note = Column(Text, nullable=False, default="") + profile_sections = Column(Text, nullable=False, default="[]") + + standard_homepage_url = Column(Text, nullable=False, default="") + external_links = Column(Text, nullable=False, default="[]") + field_sources = Column(Text, nullable=False, default="{}") + draft = Column(Text, nullable=False, default="{}") + updated_at = Column(String(40), nullable=False, default="") + + +class TeacherMetaState(Base): + __tablename__ = "teacher_meta" + + id = Column(String(32), primary_key=True, default="default") + last_synced_at = Column(String(40), nullable=False, default="") + source_status = Column(String(32), nullable=False, default="published") + source_url = Column(Text, nullable=False, default="") + sync_note = Column(Text, nullable=False, default="") diff --git a/routes/teachers.py b/routes/teachers.py index 83445a3..d5b420d 100644 --- a/routes/teachers.py +++ b/routes/teachers.py @@ -77,6 +77,16 @@ def load_store_or_503() -> TeacherStore: ) from exc +def write_store_or_503(store: TeacherStore) -> None: + try: + write_teacher_store(store) + except TeacherCacheError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + + @router.get("/list", response_model=PublicTeacherListResponse) def get_teacher_list(): return public_response_from_store(load_store_or_503()) @@ -135,7 +145,7 @@ def save_teacher_admin_record(data: TeacherAdminSaveRequest): next_items = [item for item in store.items if item.id != teacher_id] next_items.append(record) next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "教师资料已保存。")) - write_teacher_store(next_store) + write_store_or_503(next_store) return load_store_or_503() @@ -155,7 +165,7 @@ def delete_teacher_admin_record(teacher_id: str): ) next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "教师资料已删除。")) - write_teacher_store(next_store) + write_store_or_503(next_store) return TeacherAdminDeleteResponse(ok=True, meta=next_store.meta) @@ -175,7 +185,7 @@ def sync_teacher_admin_records(data: TeacherAdminSyncRequest): ) sync_result = sync_teacher_store(store, target_ids=target_ids) - write_teacher_store(sync_result.data) + write_store_or_503(sync_result.data) return load_store_or_503() @@ -199,7 +209,7 @@ def publish_teacher_sync_draft(data: TeacherAdminPublishRequest): for item in store.items ] next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "同步建议已发布。")) - write_teacher_store(next_store) + write_store_or_503(next_store) return load_store_or_503() @@ -223,7 +233,7 @@ def discard_teacher_sync_draft(data: TeacherAdminPublishRequest): for item in store.items ] next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "同步建议已忽略。")) - write_teacher_store(next_store) + write_store_or_503(next_store) return load_store_or_503() @@ -232,7 +242,7 @@ def discard_teacher_sync_draft(data: TeacherAdminPublishRequest): response_model=TeacherAdminResponse, dependencies=[Depends(login_required_publisher)], ) -def sync_teachers_json(): +def sync_teachers(): return sync_teacher_admin_records(TeacherAdminSyncRequest()) diff --git a/scripts/verify_teachers_json.py b/scripts/verify_teachers_db.py similarity index 72% rename from scripts/verify_teachers_json.py rename to scripts/verify_teachers_db.py index bae97cc..caa4061 100644 --- a/scripts/verify_teachers_json.py +++ b/scripts/verify_teachers_db.py @@ -1,12 +1,14 @@ """ -职责范围:验证教师 JSON 缓存能被后端教师 API 读取,并满足公开投影和后台维护数据的基础契约。 -功能边界:本脚本只做本地缓存的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 +职责范围:验证教师数据库记录能被后端教师 API 读取,并满足公开投影和后台维护数据的基础契约。 +功能边界:本脚本只做本地数据库的可重复校验,不启动服务、不请求远程源站、不验证管理员登录流程。 """ import sys from pathlib import Path from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -20,16 +22,31 @@ _validate_admin_save, get_teacher_detail, ) -from services.teachers_cache import TeacherSourceConfig, load_teacher_store, load_teachers_data +from models import Base, SessionLocal +from models.teachers import TeacherMetaState, TeacherRecord +from services.teachers_cache import ( + TeacherSourceConfig, + load_teacher_store, + load_teachers_data, + write_teacher_store, +) EXPECTED_IDS = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] def main(): + db = SessionLocal() + try: + assert db.query(TeacherRecord).count() == len(EXPECTED_IDS) + assert db.query(TeacherMetaState).filter_by(id="default").first() is not None + finally: + db.close() + store = load_teacher_store() assert all(record.published.id == record.id for record in store.items) assert all(record.source_config.external_links == record.published.homepage_urls for record in store.items) + verify_database_round_trip(store) data = load_teachers_data() ids = [item.id for item in data.items] @@ -129,7 +146,22 @@ def main(): else: raise AssertionError("missing teacher detail should return 404") - print("teachers json verification passed") + print("teachers database verification passed") + + +def verify_database_round_trip(store): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine, tables=[TeacherMetaState.__table__, TeacherRecord.__table__]) + TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = TestingSession() + try: + write_teacher_store(store, db) + loaded = load_teacher_store(db) + assert [record.id for record in loaded.items] == EXPECTED_IDS + assert loaded.meta.last_synced_at == store.meta.last_synced_at + assert loaded.items[1].draft.status == store.items[1].draft.status + finally: + db.close() if __name__ == "__main__": diff --git a/services/teachers_cache.py b/services/teachers_cache.py index 92a4337..4f87119 100644 --- a/services/teachers_cache.py +++ b/services/teachers_cache.py @@ -1,25 +1,24 @@ """ -职责范围:定义教师介绍的公开数据、后台维护数据、审核草稿契约和 JSON 缓存读写。 +职责范围:定义教师介绍的公开数据、后台维护数据、审核草稿契约和数据库读写。 功能边界:本模块不请求远程源站、不处理 FastAPI 路由权限,也不负责前端展示逻辑。 """ import json import re -from pathlib import Path from typing import Any from pydantic import BaseModel, Field, validator +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from models import SessionLocal +from models.teachers import TeacherMetaState, TeacherRecord as TeacherRecordModel -TEACHERS_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "teachers.json" -TEACHERS_DATA_COMMENT = ( - "职责范围:为教师介绍 API 和后台管理提供教师公开资料、来源配置和待审核同步草稿;" - "功能边界:本文件不负责远程同步实现、数据库迁移或旧 gallery 资源。" -) +DEFAULT_META_ID = "default" class TeacherCacheError(Exception): - """Raised when the teacher JSON cache cannot be read or validated.""" + """Raised when teacher database records cannot be read or validated.""" class HomepageUrl(BaseModel): @@ -153,47 +152,186 @@ class TeacherSyncResponse(BaseModel): meta: TeacherMeta -def load_teacher_store(path: Path = TEACHERS_DATA_PATH) -> TeacherStore: - try: - raw_data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise TeacherCacheError("Teachers data not found") from exc - except json.JSONDecodeError as exc: - raise TeacherCacheError("Teachers data is invalid") from exc +def load_teacher_store(db: Session | None = None) -> TeacherStore: + owns_session = db is None + session = db or SessionLocal() try: - if _is_legacy_payload(raw_data): - return normalize_teacher_store(_migrate_legacy_payload(raw_data)) - + rows = ( + session.query(TeacherRecordModel) + .order_by( + TeacherRecordModel.sort_order.asc(), + TeacherRecordModel.name.asc(), + TeacherRecordModel.id.asc(), + ) + .all() + ) + meta_row = session.query(TeacherMetaState).filter_by(id=DEFAULT_META_ID).first() return normalize_teacher_store( TeacherStore( - items=raw_data.get("items", []), - meta=raw_data.get("meta", {}), + items=[_admin_record_from_model(row) for row in rows], + meta=_meta_from_model(meta_row), ) ) - except Exception as exc: - raise TeacherCacheError(f"Teachers data failed validation: {exc}") from exc + except (SQLAlchemyError, ValueError, TypeError) as exc: + raise TeacherCacheError(f"Teachers database failed validation: {exc}") from exc + finally: + if owns_session: + session.close() -def write_teacher_store(data: TeacherStore, path: Path = TEACHERS_DATA_PATH) -> None: +def write_teacher_store(data: TeacherStore, db: Session | None = None) -> None: + owns_session = db is None + session = db or SessionLocal() store = normalize_teacher_store(data) - payload = { - "_comment": TEACHERS_DATA_COMMENT, - "meta": _model_dump(store.meta), - "items": [_model_dump(item) for item in store.items], - } - - path.parent.mkdir(parents=True, exist_ok=True) - temp_path = path.with_name(f"{path.name}.tmp") - temp_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", + + try: + existing_rows = { + row.id: row + for row in session.query(TeacherRecordModel).all() + } + next_ids = [record.id for record in store.items] + + if next_ids: + ( + session.query(TeacherRecordModel) + .filter(~TeacherRecordModel.id.in_(next_ids)) + .delete(synchronize_session=False) + ) + else: + session.query(TeacherRecordModel).delete(synchronize_session=False) + + for record in store.items: + row = existing_rows.get(record.id) + if row is None: + row = TeacherRecordModel(id=record.id) + session.add(row) + _populate_teacher_row(row, record) + + meta_row = session.query(TeacherMetaState).filter_by(id=DEFAULT_META_ID).first() + if meta_row is None: + meta_row = TeacherMetaState(id=DEFAULT_META_ID) + session.add(meta_row) + _populate_meta_row(meta_row, store.meta) + + session.commit() + except Exception as exc: + session.rollback() + raise TeacherCacheError(f"Teachers database write failed: {exc}") from exc + finally: + if owns_session: + session.close() + + +def load_teachers_data(db: Session | None = None) -> PublicTeacherListResponse: + return public_response_from_store(load_teacher_store(db)) + + +def _admin_record_from_model(row: TeacherRecordModel) -> TeacherAdminRecord: + published = TeacherItem( + id=row.id, + name=row.name or "", + title=row.title or "", + affiliation=row.affiliation or "", + research_areas=_json_load(row.research_areas, []), + summary=row.summary or "", + bio=row.bio or "", + email=row.email or "", + avatar_url=row.avatar_url or "", + homepage_urls=_json_load(row.homepage_urls, []), + source_url=row.source_url or "", + source_status=row.source_status or "published", + last_synced_at=row.last_synced_at or "", + sync_note=row.sync_note or "", + profile_sections=_json_load(row.profile_sections, []), ) - temp_path.replace(path) + source_config = TeacherSourceConfig( + standard_homepage_url=row.standard_homepage_url or "", + external_links=_json_load(row.external_links, []), + ) + return TeacherAdminRecord( + id=row.id, + enabled=bool(row.enabled), + sort_order=row.sort_order or 0, + published=published, + source_config=source_config, + field_sources=_json_load(row.field_sources, {}), + draft=TeacherDraft(**_json_load(row.draft, {})), + updated_at=row.updated_at or "", + ) + + +def _meta_from_model(row: TeacherMetaState | None) -> TeacherMeta: + if row is None: + return TeacherMeta() + + return TeacherMeta( + last_synced_at=row.last_synced_at or "", + source_status=row.source_status or "published", + source_url=row.source_url or "", + sync_note=row.sync_note or "", + ) + + +def _populate_teacher_row(row: TeacherRecordModel, record: TeacherAdminRecord) -> None: + published = record.published + row.id = record.id + row.enabled = record.enabled + row.sort_order = record.sort_order + row.name = published.name + row.title = published.title + row.affiliation = published.affiliation + row.research_areas = _json_dump(published.research_areas) + row.summary = published.summary + row.bio = published.bio + row.email = published.email + row.avatar_url = published.avatar_url + row.homepage_urls = _json_dump(published.homepage_urls) + row.source_url = published.source_url + row.source_status = published.source_status + row.last_synced_at = published.last_synced_at + row.sync_note = published.sync_note + row.profile_sections = _json_dump(published.profile_sections) + row.standard_homepage_url = record.source_config.standard_homepage_url + row.external_links = _json_dump(record.source_config.external_links) + row.field_sources = _json_dump(record.field_sources) + row.draft = _json_dump(record.draft) + row.updated_at = record.updated_at + + +def _populate_meta_row(row: TeacherMetaState, meta: TeacherMeta) -> None: + row.id = DEFAULT_META_ID + row.last_synced_at = meta.last_synced_at + row.source_status = meta.source_status + row.source_url = meta.source_url + row.sync_note = meta.sync_note + + +def _json_load(raw_value: str | None, fallback: Any) -> Any: + if raw_value in (None, ""): + return fallback + + try: + return json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError("teacher database contains invalid serialized JSON") from exc + + +def _json_dump(value: Any) -> str: + return json.dumps(_json_ready(value), ensure_ascii=False, separators=(",", ":")) + + +def _json_ready(value: Any) -> Any: + if isinstance(value, BaseModel): + return _model_dump(value) + if isinstance(value, list): + return [_json_ready(item) for item in value] -def load_teachers_data(path: Path = TEACHERS_DATA_PATH) -> PublicTeacherListResponse: - return public_response_from_store(load_teacher_store(path)) + if isinstance(value, dict): + return {key: _json_ready(item) for key, item in value.items()} + + return value def public_response_from_store(store: TeacherStore) -> PublicTeacherListResponse: @@ -244,72 +382,10 @@ def normalize_admin_record(record: TeacherAdminRecord, fallback_order: int = 0) return normalized -def _is_legacy_payload(raw_data: dict[str, Any]) -> bool: - items = raw_data.get("items", []) - if not items: - return False - - first_item = items[0] - return isinstance(first_item, dict) and "published" not in first_item - - -def _migrate_legacy_payload(raw_data: dict[str, Any]) -> TeacherStore: - legacy_items = raw_data.get("items", []) - legacy_meta = TeacherMeta(**raw_data.get("meta", {})) - meta = TeacherMeta( - last_synced_at=legacy_meta.last_synced_at, - source_status="published", - source_url="", - sync_note="教师资料已迁移为后台维护数据;公开页读取已发布资料。", - ) - records = [] - - for index, raw_item in enumerate(legacy_items): - item = TeacherItem(**raw_item) - item.source_status = "published" - item.sync_note = "历史资料已作为已发布内容迁移。" - source_config = TeacherSourceConfig( - standard_homepage_url=_standard_homepage_url_from_item(item), - external_links=item.homepage_urls, - ) - records.append( - TeacherAdminRecord( - id=item.id, - enabled=True, - sort_order=index + 1, - published=item, - source_config=source_config, - field_sources=_manual_field_sources(), - draft=TeacherDraft(), - updated_at=item.last_synced_at, - ) - ) - - return TeacherStore(items=records, meta=meta) - - -def _standard_homepage_url_from_item(item: TeacherItem) -> str: - urls = [item.source_url, *[link.url for link in item.homepage_urls]] - return next((url for url in urls if is_standard_homepage_url(url)), "") - - def is_standard_homepage_url(url: str) -> bool: return bool(re.match(r"^https?://person\.zju\.edu\.cn/[^/?#]+/?$", url.strip())) -def _manual_field_sources() -> dict[str, str]: - return { - "name": "manual", - "title": "manual", - "affiliation": "manual", - "research_areas": "manual", - "summary": "manual", - "bio": "manual", - "email": "manual", - "avatar_url": "manual", - } - - def _public_source_url(record: TeacherAdminRecord) -> str: if record.source_config.standard_homepage_url: return record.source_config.standard_homepage_url From 904a3e59bda9009dd751e4eae1769815f86b129f Mon Sep 17 00:00:00 2001 From: Promethus0017 <1615534978@qq.com> Date: Sun, 3 May 2026 21:26:16 +0800 Subject: [PATCH 11/11] fix(teachers): use cloud-hosted mentor avatars --- .../3a9f4c7e2d10_add_teacher_database_storage.py | 8 ++++---- scripts/verify_teachers_db.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py b/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py index 2bdd55b..adb9471 100644 --- a/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py +++ b/alembic/versions/3a9f4c7e2d10_add_teacher_database_storage.py @@ -41,7 +41,7 @@ "summary": "浙江大学百人计划研究员、博士生导师,主要研究程序分析、漏洞挖掘与应用安全,学院教师队伍页同时列出网络安全、系统安全方向。", "bio": "李松现任浙江大学百人计划研究员、博士生导师,所属计算机科学与技术学院和网络空间安全学院。其公开主页以程序分析、漏洞挖掘、应用安全为主要研究方向;学院教师队伍页补充列出网络安全、系统安全。", "email": "songli@zju.edu.cn", - "avatar_url": "/mentors/li-song/avatar.png", + "avatar_url": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/li-song-avatar.png", "homepage_urls": [ {"label": "个人主页", "url": "https://songli.io/", "type": "profile"}, { @@ -102,7 +102,7 @@ "summary": "浙江大学求是讲席教授、博士生导师,研究方向包括网络空间安全、系统安全和网络仿真。", "bio": "杜文亮现任浙江大学求是讲席教授、博士生导师,个人主页列出的研究方向为网络空间安全、系统安全、网络仿真;学院教师队伍页补充其网络系统仿真、互联网、无线移动网、车联网和工控网等方向。", "email": "wenliangdu@zju.edu.cn", - "avatar_url": "/mentors/du-wenliang/avatar.jpg", + "avatar_url": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/du-wenliang-avatar.jpg", "homepage_urls": [ {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/wenliangdu", "type": "profile"}, {"label": "SEED Labs 主页", "url": "https://seedsecuritylabs.org/wenliangdu/", "type": "profile"}, @@ -155,7 +155,7 @@ "summary": "浙江大学百人计划研究员、博士生导师,研究方向包括智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", "bio": "林峰现任浙江大学百人计划研究员、博士生导师,个人主页列出的研究方向为智能网联车安全、物联网安全、人工智能安全、无线感知攻防和生物识别身份认证。", "email": "flin@zju.edu.cn", - "avatar_url": "/mentors/lin-feng/avatar.jpg", + "avatar_url": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/lin-feng-avatar.jpg", "homepage_urls": [ {"label": "浙大个人主页", "url": "https://person.zju.edu.cn/flin", "type": "profile"}, {"label": "个人主页", "url": "https://flin.group/", "type": "profile"}, @@ -216,7 +216,7 @@ "summary": "浙江大学网络空间安全学院研究员,研究方向包括系统安全、IoT 安全和软件供应链。", "bio": "申大坤现任浙江大学网络空间安全学院研究员,学院教师队伍页列出的研究方向为系统安全、IoT 安全和软件供应链。当前公开邮箱和个人主页入口待进一步补充。", "email": "", - "avatar_url": "/mentors/shen-dakun/avatar.png", + "avatar_url": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/shen-dakun-avatar.png", "homepage_urls": [ {"label": "学院教师队伍页", "url": "https://icsr.zju.edu.cn/jsdw/list.htm", "type": "source"}, ], diff --git a/scripts/verify_teachers_db.py b/scripts/verify_teachers_db.py index caa4061..2fa97e5 100644 --- a/scripts/verify_teachers_db.py +++ b/scripts/verify_teachers_db.py @@ -33,6 +33,12 @@ EXPECTED_IDS = ["li-song", "du-wenliang", "lin-feng", "shen-dakun"] +EXPECTED_AVATAR_URLS = { + "li-song": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/li-song-avatar.png", + "du-wenliang": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/du-wenliang-avatar.jpg", + "lin-feng": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/lin-feng-avatar.jpg", + "shen-dakun": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/shen-dakun-avatar.png", +} def main(): @@ -46,6 +52,10 @@ def main(): store = load_teacher_store() assert all(record.published.id == record.id for record in store.items) assert all(record.source_config.external_links == record.published.homepage_urls for record in store.items) + assert { + record.id: record.published.avatar_url + for record in store.items + } == EXPECTED_AVATAR_URLS verify_database_round_trip(store) data = load_teachers_data()