Red Bird · 面试系统 · 技术流程

面试系统流程图(详版)

端到端运行流程、鉴权时序、数据模型与各端界面路径 —— 全部基于当前代码实现绘制(portal.db 单进程 · Flask 蓝图 · SSE 实时房间 · 内存会话)。

角色 · 学生 / 行政(完整岗·运行岗) / 教授 登录 · SSO OIDC + PKCE(S256) 实时 · SSE + live_event 重放 跨进程门禁 · itsdangerous 签名票(120s)
A

系统架构分层

单进程 Flask 应用:routes/ 蓝图 → services/portal/ 业务 → adapters/ 外部对接 → repositories/ 存储。门户数据在 portal.db(SQLite WAL),面试运行态在内存 session_store(重启即失),两者相互独立。

%%{init:{'theme':'neutral','flowchart':{'curve':'basis'},'themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
flowchart TB
  subgraph CLIENT["浏览器端 (static/portal/)"]
    STU["student.html · 学生自助"]
    ADM["admin.html · 行政端"]
    PRO["professor.html · 教授端"]
    RMU["room / console / monitor.html"]
  end
  subgraph ROUTES["routes/ · Flask 蓝图"]
    R1["portal.py<br/>鉴权·导入·学生·本组"]
    R2["portal_room.py<br/>SSE房间·控制·handoff·监控"]
    R3["live_entry.py<br/>/live-entry 验票"]
  end
  subgraph SVC["services/portal/"]
    S1["prof_auth · admin_auth"]
    S2["roster_import · assessment_import"]
    S3["student_submit · otp_service"]
    S4["my_group · export_service"]
    S5["room_service · monitor_service"]
  end
  subgraph ADP["adapters/"]
    A1["sso_oidc · OIDC+PKCE"]
    A2["handoff_token · 签名票"]
  end
  subgraph REPO["repositories/"]
    DB[("portal.db<br/>SQLite WAL")]
    MEM["session_store<br/>内存会话(重启即失)"]
  end
  CLIENT --> ROUTES
  R1 --> S1
  R1 --> S2
  R1 --> S3
  R1 --> S4
  R1 --> A1
  R2 --> S5
  R2 --> A2
  R3 --> A2
  S1 --> DB
  S2 --> DB
  S3 --> DB
  S4 --> DB
  S5 --> DB
B

数据模型 · portal.db(ER)

迁移由 PRAGMA user_version 驱动:v1 建 batch/grp/applicant/professor_assignment;v2 加 email/seq_no 列 + email_verification/uploaded_file;v3 加 live_event库中没有强制外键,关系全靠共享列值(batch_id 是枢纽,group_noapplication_nostaff_id 串联)。

%%{init:{'theme':'neutral','themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
erDiagram
  batch ||--o{ grp : "batch_id"
  batch ||--o{ applicant : "batch_id"
  batch ||--o{ professor_assignment : "batch_id"
  batch ||--o{ email_verification : "batch_id"
  batch ||--o{ uploaded_file : "batch_id"
  batch ||--o{ live_event : "batch_id"
  grp ||--o{ applicant : "group_no"
  grp ||--o{ professor_assignment : "group_no"
  applicant ||--o{ uploaded_file : "application_no"
  applicant ||--o{ email_verification : "application_no"
  batch {
    int id PK
    text name
    text status "active-closed"
    text created_at
  }
  grp {
    int id PK
    int batch_id
    text group_no
    text room
  }
  applicant {
    int id PK
    int batch_id
    text group_no
    text application_no
    text name
    text assessment
    text email "v2"
    int seq_no "v2 抽签序号"
  }
  professor_assignment {
    int id PK
    int batch_id
    text group_no
    int slot "1-3"
    text name
    text staff_id "工号"
  }
  email_verification {
    int id PK
    int batch_id
    text application_no
    text code "6位"
    text expires_at
    int attempts
    int consumed
  }
  uploaded_file {
    int id PK
    int batch_id
    text application_no
    text kind "ppt"
    text path
    text original_name
  }
  live_event {
    int id PK
    int batch_id
    text group_id
    text kind "ppt_switch-timer"
    text actor
    text applicant_no
    text payload "JSON"
    text created_at
  }
active_batch_id() = status='active' 里 id 最大的一场;导分组表会关旧场、开新场。 live_event.id 自增值即 SSE 的 Last-Event-ID,断线重放靠它。
C

端到端总流程

三段:赛前准备 → 登录鉴权 → 面试当天。身份由学校 SSO 提供,角色由我方名单判定(行政名单 env → 行政端;分组表 → 教授端;都不在 → 未分配页)。

%%{init:{'theme':'neutral','flowchart':{'curve':'basis'},'themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
flowchart TB
  subgraph P1["① 赛前准备"]
    direction TB
    A1["行政·完整岗<br/>上传分组表(花名册)"] --> DB[("portal.db")]
    A2["行政·完整岗<br/>上传综合测评表"] --> DB
    ST1["学生:申请号+姓名"] --> ST2["邮箱验证码 OTP"] --> ST3["上传PPT + 设抽签序号"] --> DB
  end
  DB --> U
  subgraph P2["② 登录与鉴权 · SSO(OIDC+PKCE)"]
    direction TB
    U["点『校园统一登录』"] --> IDP{"学校 SSO<br/>验明 emp_id"}
    IDP --> ROLE{"我方名单<br/>判角色"}
    ROLE -->|"行政名单"| RA["行政端 admin.html"]
    ROLE -->|"分组表"| RP["教授端 professor.html"]
    ROLE -->|"都不在"| RU["未分配提示页"]
  end
  subgraph P3["③ 面试当天"]
    direction TB
    RA --> OP["行政·运行台<br/>翻PPT / 控计时器"]
    OP -->|"SSE 实时同步"| ROOM["全组教授房间"]
    RP -->|"handoff 签名票 → 验票"| ROOM
    ROOM --> PV["教授看材料<br/>PPT·简历·AI追问"]
    PV --> SC["逐题评分 → 提交"]
    OP --> MON["监控大屏·全场状态"]
  end
D

SSO 登录时序(OIDC 授权码 + PKCE)

浏览器全程中转,学校只给身份;state 防跨站,code_verifier(PKCE S256)防码劫持。换 token、取 userinfo 都是我方服务器向学校的后台直连。回调按 行政名单 → 分组表 → 未分配 决定落地页。

%%{init:{'theme':'neutral','themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
sequenceDiagram
  autonumber
  participant U as 浏览器
  participant P as 我方服务 portal.py
  participant I as 学校 SSO · devsso
  U->>P: GET /api/portal/sso/login
  Note over P: make_pkce → verifier·challenge<br/>make_state → state<br/>存 session sso_state·sso_verifier
  P-->>U: 302 → /connect/authorize (client_id, redirect_uri, code_challenge S256, state)
  U->>I: 打开学校登录页, 输账号密码
  I-->>U: 302 → /signin-oidc (code, state)
  U->>P: GET /signin-oidc (code, state)
  Note over P: 校验 state 等于 session.sso_state
  P->>I: POST /connect/token (code, code_verifier, client_secret)
  I-->>P: access_token, id_token
  P->>I: POST /connect/userinfo (Bearer access_token)
  I-->>P: emp_id, name, type, email
  Note over P: admin_auth.resolve(emp_id)<br/>否则 prof_auth.verify_by_staff_id
  alt 在行政名单
    P-->>U: 302 → /portal/admin.html · registrar
  else 在分组表
    P-->>U: 302 → /portal/professor.html · professor
  else 都不在
    P-->>U: 200 未分配提示页
  end
失败分支:error 参数→400;state 不符→400;换 token/取信息异常→502。
E

学生自助提交时序

凭证门:先「申请号+姓名」核身发验证码 → 验证码换 student 会话 → 上传 PPT + 设抽签序号。验证码 10 分钟有效、最多 5 次尝试、用后作废。

%%{init:{'theme':'neutral','themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
sequenceDiagram
  autonumber
  participant S as 学生浏览器
  participant P as portal.py
  participant DB as portal.db
  participant M as 邮件
  S->>P: POST request-code (申请号 + 姓名)
  P->>DB: match_identity 查 applicant.name
  alt 不匹配 / 不在名单
    P-->>S: 400 申请号与姓名不匹配
  else 匹配
    P->>DB: INSERT email_verification (code, expires +10min)
    P->>M: 发送 6 位验证码
    P-->>S: 200 sent (email_masked)
  end
  S->>P: POST verify-code (申请号 + code)
  P->>DB: 校验 未过期·未消费·尝试未超5次
  alt 通过
    Note over P: session portal_role=student<br/>portal_application_no
    P-->>S: 200 ok
  else 失败
    P-->>S: 401 验证码错误或已失效
  end
  S->>P: POST upload-ppt (file pdf/ppt/pptx)
  P->>DB: INSERT OR REPLACE uploaded_file kind=ppt
  P-->>S: 200 ok
  S->>P: POST set-seq (抽签序号)
  P->>DB: UPDATE applicant.seq_no
  P-->>S: 200 ok
F

面试当天 · 房间实时时序

教授从门户 换一张 120s 签名票 跨进程进入 prof 直播进程;/live-entry 验签 + 组号闸门后授予会话。行政运行台翻页/计时,经 room_servicelive_event 并向本进程订阅者广播;断线用 Last-Event-ID 重放。

%%{init:{'theme':'neutral','themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
sequenceDiagram
  autonumber
  participant PR as 教授浏览器
  participant P as portal 服务
  participant LE as live-entry 进程
  participant AD as 行政运行台
  participant RS as room_service · _ROOMS
  participant DB as portal.db · live_event
  PR->>P: GET /api/portal/live-handoff
  Note over P: 签票 itsdangerous<br/>{group_no, role, name, staff_id}<br/>salt=portal-live-handoff · TTL 120s
  P-->>PR: {url: BASE/live-entry?ticket=...}
  PR->>LE: GET /live-entry?ticket=...
  Note over LE: verify(max_age=120)<br/>group_no 等于 INTERVIEW_GROUP ?
  LE-->>PR: 302 → / (授予 professor 会话)
  PR->>LE: GET /room/{batch}/{group}/stream (SSE, Last-Event-ID)
  LE->>DB: live_events_since(last_id) 重放历史
  LE-->>PR: 历史事件 + 订阅 _ROOMS 队列
  AD->>P: POST /room/{b}/{g}/ppt {deck, page}
  P->>RS: broadcast_ppt → _emit
  RS->>DB: append_live_event kind=ppt_switch → id
  RS-->>PR: SSE push {id, kind:ppt_switch, payload}
  AD->>P: POST /room/{b}/{g}/timer {action}
  P->>RS: broadcast_timer (读上一 timer 锚点)
  RS->>DB: append_live_event kind=timer → id
  RS-->>PR: SSE push {id, kind:timer, ends_at, server_time, state}
单进程不变量:_ROOMS 只在同进程内广播;多 worker 会断掉实时推送,跨进程只能靠 live_event 重放。
G

计时器状态机(服务器权威锚点)

服务器只广播锚点(ends_at / server_time / state / remaining_seconds),各端据此本地推算,天然抗抖动、断线可续。默认时长 TIMER_DEFAULT_DURATION_SECONDS=300

%%{init:{'theme':'neutral','themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
stateDiagram-v2
  [*] --> idle
  idle --> running: start · ends_at = now + duration
  running --> paused: pause · remaining = ends_at - now
  paused --> running: resume · ends_at = now + remaining
  running --> idle: reset
  paused --> idle: reset
  running --> [*]: 到点 remaining = 0
H

各端界面流程

完整岗能传表 + 当天全能;运行岗只做当天(运行台 / 监控)。教授「能看谁」由分组表把他归到某组决定,跨组访问直接 404。

%%{init:{'theme':'neutral','flowchart':{'curve':'basis'},'themeVariables':{'fontFamily':'Noto Sans SC, sans-serif'}}}%%
flowchart LR
  subgraph STU["🎓 学生端 · student.html"]
    direction TB
    U1["提交入口"] --> U2["申请号 + 姓名"] --> U3["获取验证码"] --> U4["验证通过"] --> U5["上传PPT / 设抽签序号"] --> U6["完成"]
  end
  subgraph ADM["🗂️ 行政端 · admin.html"]
    direction TB
    D1["SSO 登录"] --> D2["① 上传分组表"] --> D3["② 上传综合测评表"] --> D4["③ 导出汇总 CSV"]
    D1 --> D5["④ 当天运行台<br/>翻PPT / 计时器"]
    D1 --> D6["监控大屏"]
  end
  subgraph PRO["🎯 教授端 · professor.html"]
    direction TB
    R1["登录 SSO / 内网选组"] --> R2["本组考生列表"] --> R3["考生详情预览<br/>综合评价·PPT"] --> R4["进入当天房间<br/>handoff 验票 + 组号闸门"] --> R5["面试进行页<br/>PPT·简历·AI追问·评分"]
  end
I

API 端点表

routes/portal.py + routes/portal_room.py + routes/live_entry.py 全量端点。鉴权列标注所需角色。

方法路径作用鉴权
POST/api/portal/import/roster导入分组表 → grp / applicant / professor_assignment完整岗
POST/api/portal/import/assessment导入综合测评表 → applicant.assessment完整岗
GET/api/portal/export/applicants导出考生汇总 CSV完整岗
GET/api/portal/groups组 / 房间 / 评委列表公开
POST/api/portal/prof-login内网备用登录:组号+姓名+工号后4位公开
GET/api/portal/sso/login发起 SSO(PKCE + state)→ 跳学校公开
GET/signin-oidcSSO 回调:换 token → 取身份 → 定角色公开
GET/api/portal/sso/logout清会话 → 学校 endsession登录态
GET/api/portal/session当前角色 / 组号 / 名字 / sso_enabled公开
GET/api/portal/my-group/applicants本组考生列表教授
GET/api/portal/my-group/applicant/<no>本组某考生详情(跨组 404)教授
GET/api/portal/my-group/applicant/<no>/ppt本组某考生 PPT 文件教授
POST/api/portal/student/request-code核身(申请号+姓名)→ 发验证码公开
POST/api/portal/student/verify-code验证码 → 授予 student 会话公开
GET/api/portal/student/my-submission我的提交状态(PPT / 序号)学生
POST/api/portal/student/upload-ppt上传 PPT(pdf/ppt/pptx)学生
POST/api/portal/student/set-seq设置抽签序号学生
GET/api/portal/monitor监控大屏:各组当前页/计时状态行政
GET/api/portal/live-handoff签发 120s 跨进程 live 门票教授
GET/api/portal/room/context房间上下文(角色/组)登录态
GET/api/portal/room/{b}/{g}/streamSSE 房间流(重放 + 实时)本组成员
POST/api/portal/room/{b}/{g}/ppt翻页广播(deck, page)行政
POST/api/portal/room/{b}/{g}/timer计时广播(start/pause/resume/reset)行政
GET/live-entry?ticket=验票 + 组号闸门 → 授予 professor 会话签名票
J

角色权限矩阵

身份来自 SSO,角色来自我方名单。行政两岗为超集关系:完整岗 ⊇ 运行岗。

能力学生行政·完整岗行政·运行岗教授
上传 PPT / 设抽签序号
上传分组表 / 测评表 / 导出
当天运行台(翻PPT/计时)
监控大屏
看本组考生 / 简历 / PPT
逐题评分
身份来源验证码SSO+名单SSO+名单SSO+分组表