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..adb9471 --- /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": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/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": "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"}, + {"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": "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"}, + {"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": "https://pub-aec7330d95c0405db0a31a5d67a35ae6.r2.dev/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/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/__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..d5b420d --- /dev/null +++ b/routes/teachers.py @@ -0,0 +1,464 @@ +""" +职责范围:提供教师介绍公开读取接口,以及后台教师资料维护、同步草稿和审核发布接口。 +功能边界:本路由只处理 HTTP 入参、响应和权限,不直接实现远程解析规则或前端展示逻辑。 +""" + +import re +from urllib.parse import urlparse +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, + PublicTeacherDetailResponse, + PublicTeacherListResponse, + TeacherAdminRecord, + TeacherAdminResponse, + TeacherCacheError, + TeacherDraft, + TeacherItem, + TeacherMeta, + TeacherSourceConfig, + TeacherStore, + is_standard_homepage_url, + load_teacher_store, + public_response_from_store, + write_teacher_store, +) +from services.teachers_sync import ( + apply_draft_changes, + discard_draft, + now_iso, + refresh_draft_against_published, + sync_teacher_store, +) + + +router = APIRouter() + + +class TeacherAdminSaveRequest(BaseModel): + id: str = "" + enabled: bool = True + 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) + + +class TeacherAdminSyncRequest(BaseModel): + id: str = "" + + +class TeacherAdminPublishRequest(BaseModel): + id: str + fields: list[str] | None = None + + +class TeacherAdminDeleteResponse(BaseModel): + ok: bool = True + meta: TeacherMeta + + +AUTO_ID_PLACEHOLDERS = {"", "teacher-temp"} +EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + + +def load_store_or_503() -> TeacherStore: + try: + return load_teacher_store() + except TeacherCacheError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) 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()) + + +@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) + + if teacher is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Teacher not found", + ) + + return PublicTeacherDetailResponse(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, 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=sort_order, + 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) + next_store = TeacherStore(items=next_items, meta=_touch_meta(store.meta, "教师资料已保存。")) + write_store_or_503(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_store_or_503(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_store_or_503(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_store_or_503(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_store_or_503(next_store) + return load_store_or_503() + + +@router.post( + "/sync", + response_model=TeacherAdminResponse, + dependencies=[Depends(login_required_publisher)], +) +def sync_teachers(): + return sync_teacher_admin_records(TeacherAdminSyncRequest()) + + +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 _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 = 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 _unique_teacher_id(_normalize_slug(name), store) + + return _unique_teacher_id(f"teacher-{uuid4().hex[:8]}", store) + + +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 _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 = [ + 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 _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, + 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_db.py b/scripts/verify_teachers_db.py new file mode 100644 index 0000000..2fa97e5 --- /dev/null +++ b/scripts/verify_teachers_db.py @@ -0,0 +1,178 @@ +""" +职责范围:验证教师数据库记录能被后端教师 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 +sys.path.insert(0, str(PROJECT_ROOT)) + +from routes.teachers import ( + TeacherAdminSaveRequest, + _draft_after_manual_save, + _normalize_source_config, + _resolve_teacher_id, + _validate_admin_save, + get_teacher_detail, +) +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"] +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(): + 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) + assert { + record.id: record.published.avatar_url + for record in store.items + } == EXPECTED_AVATAR_URLS + verify_database_round_trip(store) + + data = load_teachers_data() + ids = [item.id for item in data.items] + + assert ids == EXPECTED_IDS, f"unexpected teacher ids: {ids}" + 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) + assert detail.item.id == teacher_id + assert detail.item.name + 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" + + 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: + assert exc.status_code == 404 + else: + raise AssertionError("missing teacher detail should return 404") + + 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__": + main() diff --git a/scripts/verify_teachers_sync.py b/scripts/verify_teachers_sync.py new file mode 100644 index 0000000..fa5c5f7 --- /dev/null +++ b/scripts/verify_teachers_sync.py @@ -0,0 +1,115 @@ +""" +职责范围:验证教师标准主页同步、草稿生成、审核发布和失败回退行为。 +功能边界:本脚本使用本地 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)) + +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, +) + + +DU_WENLIANG_FIXTURE = """ + +

Wenliang Du

博士

求是讲席教授

|

博士生导师

+

单位

计算机科学与技术学院

+

邮箱

wenliangdu-new@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(): + 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"] == ["网络空间安全", "系统安全", "网络仿真"] + summary, bio = build_objective_text("杜文亮", "求是讲席教授 | 博士生导师", extracted["research_areas"]) + assert "公开资料显示" not in summary + assert "公开资料显示" not in bio + + fixture_session = FixtureSession( + { + "https://person.zju.edu.cn/wenliangdu": DU_WENLIANG_FIXTURE, + "https://person.zju.edu.cn/flin": LIN_FENG_FIXTURE, + } + ) + 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"} + + 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" + assert failed["du-wenliang"].published.email == "wenliangdu@zju.edu.cn" + + print("teachers sync verification passed") + + +if __name__ == "__main__": + main() 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..4f87119 --- /dev/null +++ b/services/teachers_cache.py @@ -0,0 +1,401 @@ +""" +职责范围:定义教师介绍的公开数据、后台维护数据、审核草稿契约和数据库读写。 +功能边界:本模块不请求远程源站、不处理 FastAPI 路由权限,也不负责前端展示逻辑。 +""" + +import json +import re +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 + +DEFAULT_META_ID = "default" + + +class TeacherCacheError(Exception): + """Raised when teacher database records cannot be read or validated.""" + + +class HomepageUrl(BaseModel): + label: str = "" + url: str = "" + type: str = "profile" + + +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 = "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 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 = "published" + source_url: str = "" + sync_note: str = "" + + +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 PublicTeacherMeta(BaseModel): + last_synced_at: str = "" + + +class PublicTeacherListResponse(BaseModel): + items: list[PublicTeacherItem] + meta: PublicTeacherMeta + + +class PublicTeacherDetailResponse(BaseModel): + item: PublicTeacherItem + meta: PublicTeacherMeta + + +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_teacher_store(db: Session | None = None) -> TeacherStore: + owns_session = db is None + session = db or SessionLocal() + + try: + 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=[_admin_record_from_model(row) for row in rows], + meta=_meta_from_model(meta_row), + ) + ) + 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, db: Session | None = None) -> None: + owns_session = db is None + session = db or SessionLocal() + store = normalize_teacher_store(data) + + 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, []), + ) + 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] + + 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: + 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_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: + 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_standard_homepage_url(url: str) -> bool: + return bool(re.match(r"^https?://person\.zju\.edu\.cn/[^/?#]+/?$", url.strip())) + + +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]: + if hasattr(model, "model_dump"): + return model.model_dump() + + return model.dict() diff --git a/services/teachers_sync.py b/services/teachers_sync.py new file mode 100644 index 0000000..1312936 --- /dev/null +++ b/services/teachers_sync.py @@ -0,0 +1,456 @@ +""" +职责范围:从标准浙大个人主页抽取教师公开字段,并生成后台待审核同步草稿。 +功能边界:本模块不解析非标准个人主页、不直接写缓存文件,也不决定同步草稿是否发布。 +""" + +import re +from dataclasses import dataclass +from datetime import datetime +from html.parser import HTMLParser + +import requests + +from services.teachers_cache import ( + TeacherAdminRecord, + TeacherDraft, + TeacherDraftChange, + TeacherMeta, + TeacherStore, + is_standard_homepage_url, + normalize_teacher_store, +) + + +REQUEST_TIMEOUT_SECONDS = 15 +USER_AGENT = "ZJUCSA website teacher roster sync/2.0" +SYNC_FIELDS = [ + ("name", "姓名"), + ("title", "职称"), + ("affiliation", "单位"), + ("email", "邮箱"), + ("research_areas", "研究方向"), + ("summary", "简介摘要"), + ("bio", "详细简介"), +] + + +class TeacherSyncError(Exception): + """Raised when the teacher sync workflow cannot be completed.""" + + def __init__(self, message: str, attempts: list["FetchAttempt"] | None = None) -> None: + super().__init__(message) + self.attempts = attempts or [] + + +@dataclass +class FetchAttempt: + url: str + status: str + mechanism: str + evidence: str = "" + blocker_category: str = "" + + +@dataclass +class TeacherSyncResult: + data: TeacherStore + attempts: list[FetchAttempt] + + +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 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 record in normalize_teacher_store(store).items: + if record.id not in selected_ids: + synced_items.append(record) + continue + + synced_record, record_attempts = sync_teacher_record(record, session=session) + attempts.extend(record_attempts) + synced_items.append(synced_record) + + 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, [] + + 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] + + 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] + + 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] + + +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 + + if name_index is None and name: + name_index = _find_exact_line(lines, name) + + 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) + + 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 name and research_areas: + summary, bio = build_objective_text(name, title, research_areas) + extracted["summary"] = summary + extracted["bio"] = bio + + return extracted + + +def build_draft_changes(record: TeacherAdminRecord, extracted: dict[str, object]) -> list[TeacherDraftChange]: + changes = [] + + for field, label in SYNC_FIELDS: + if field not in extracted: + continue + + current = getattr(record.published, field) + proposed = extracted[field] + if _normalize_value(current) == _normalize_value(proposed): + continue + + changes.append( + TeacherDraftChange( + field=field, + label=label, + current=current, + proposed=proposed, + ) + ) + + return changes + + +def apply_draft_changes(record: TeacherAdminRecord, fields: list[str] | None = None) -> TeacherAdminRecord: + updated = record.copy(deep=True) + if updated.draft.status != "pending": + return updated + + allowed_fields = set(fields or [change.field for change in updated.draft.changes]) + accepted_fields = [] + + for change in updated.draft.changes: + if change.field not in allowed_fields: + continue + + 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 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( + 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]: + 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_text(name: str, title: str, research_areas: list[str]) -> tuple[str, str]: + areas = "、".join(research_areas) + identity = f"{name},{title}" if title else name + if not areas: + return f"{identity}。", f"{name}的公开资料暂未列出明确研究方向。" + + summary = f"{identity},主要研究方向包括{areas}。" + bio = f"{name}的个人主页列出的研究方向为{areas}。" + return summary, bio + + +def now_iso() -> 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 _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 _collect_title_after_name(lines: list[str], name_index: int | None) -> str: + if name_index is None: + return "" + + 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 + + return normalize_title(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 = re.sub(rf"^{re.escape(label)}\s*[::]?", "", line).strip() + 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 _first_value(*values: str) -> str: + return next((value for value in values if value), "") + + +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: + normalized = re.sub(r"\s+", " ", value).strip() + if len(normalized) <= limit: + return normalized + + return normalized[: limit - 3] + "..." + + +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