端到端运行流程、鉴权时序、数据模型与各端界面路径 —— 全部基于当前代码实现绘制(portal.db 单进程 · Flask 蓝图 · SSE 实时房间 · 内存会话)。
单进程 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
迁移由 PRAGMA user_version 驱动:v1 建 batch/grp/applicant/professor_assignment;v2 加 email/seq_no 列 + email_verification/uploaded_file;v3 加 live_event。库中没有强制外键,关系全靠共享列值(batch_id 是枢纽,group_no、application_no、staff_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
}
live_event.id 自增值即 SSE 的 Last-Event-ID,断线重放靠它。
三段:赛前准备 → 登录鉴权 → 面试当天。身份由学校 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
浏览器全程中转,学校只给身份;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。凭证门:先「申请号+姓名」核身发验证码 → 验证码换 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
教授从门户 换一张 120s 签名票 跨进程进入 prof 直播进程;/live-entry 验签 + 组号闸门后授予会话。行政运行台翻页/计时,经 room_service 写 live_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 重放。
服务器只广播锚点(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
完整岗能传表 + 当天全能;运行岗只做当天(运行台 / 监控)。教授「能看谁」由分组表把他归到某组决定,跨组访问直接 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
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-oidc | SSO 回调:换 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}/stream | SSE 房间流(重放 + 实时) | 本组成员 |
| 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 会话 | 签名票 |
身份来自 SSO,角色来自我方名单。行政两岗为超集关系:完整岗 ⊇ 运行岗。
| 能力 | 学生 | 行政·完整岗 | 行政·运行岗 | 教授 |
|---|---|---|---|---|
| 上传 PPT / 设抽签序号 | ✓ | — | — | — |
| 上传分组表 / 测评表 / 导出 | — | ✓ | — | — |
| 当天运行台(翻PPT/计时) | — | ✓ | ✓ | — |
| 监控大屏 | — | ✓ | ✓ | — |
| 看本组考生 / 简历 / PPT | — | — | — | ✓ |
| 逐题评分 | — | — | — | ✓ |
| 身份来源 | 验证码 | SSO+名单 | SSO+名单 | SSO+分组表 |