feat: add WeChat session authentication
This commit is contained in:
@@ -5,6 +5,15 @@ HOST=127.0.0.1
|
||||
PORT=8080
|
||||
DATABASE_MAX_CONNECTIONS=5
|
||||
|
||||
# WeChat login uses the free wx.login/code2Session basic capability. Keep the
|
||||
# AppSecret on the server and configure both values together.
|
||||
# WECHAT_APP_ID=wx3fe11e262a4b4885
|
||||
# WECHAT_APP_SECRET=your-app-secret
|
||||
AUTH_SESSION_TTL_DAYS=30
|
||||
|
||||
# Local compatibility only. Production must keep this false.
|
||||
ALLOW_DEVELOPMENT_USER_HEADER=false
|
||||
|
||||
# A single development origin. Leave unset to disable CORS middleware.
|
||||
# CORS_ALLOWED_ORIGIN=https://servicewechat.com
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- OpenAPI JSON:`GET /openapi.json`
|
||||
- 完整调用流程:[API_GUIDE.md](./API_GUIDE.md)
|
||||
|
||||
Scalar 中的业务接口已预填开发用户 ID 和请求体示例。创建、列表、预设和健康检查可以直接发起请求;按 ID 操作时,应使用创建或列表接口返回的真实 ID。
|
||||
Scalar 中的业务接口需要填写微信登录换取的 Bearer 令牌,请求体接口提供了示例。按 ID 操作时,应使用创建或列表接口返回的真实 ID。
|
||||
|
||||
## 启动和数据库策略
|
||||
|
||||
@@ -22,6 +22,7 @@ Scalar 中的业务接口已预填开发用户 ID 和请求体示例。创建、
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# 在 .env 中填写 DATABASE_URL
|
||||
# 同时填写 WECHAT_APP_ID 和 WECHAT_APP_SECRET
|
||||
cargo run --bin migrate
|
||||
cargo run
|
||||
```
|
||||
@@ -39,7 +40,7 @@ ASR_WORKER_CONCURRENCY=1
|
||||
ASR_JOB_LEASE_SECONDS=3600
|
||||
```
|
||||
|
||||
在项目根目录执行 `docker compose -f compose.asr.yml up --build -d` 启动中文模型。`GET /health` 中 `speech_available=true` 表示模型已下载并可以接收任务。
|
||||
在项目根目录执行 `docker compose -f compose.asr.yml up --build -d` 启动中文模型。`GET /health` 中 `speech_available=true` 表示模型已下载并可以接收任务;`wechat_auth_configured=true` 且 `development_auth_enabled=false` 表示生产微信鉴权已就绪。
|
||||
|
||||
腾讯云仅作为可选提供方保留。需要切换时配置:
|
||||
|
||||
@@ -59,21 +60,30 @@ FEEDBACK_SUMMARY_API_KEY=your-api-key
|
||||
FEEDBACK_SUMMARY_MODEL=your-model
|
||||
```
|
||||
|
||||
## 临时身份边界
|
||||
## 微信登录和会话
|
||||
|
||||
目前所有业务接口都要求 `X-User-Id` 请求头,值为 UUID。例如:
|
||||
小程序调用 `wx.login()`,再把一次性 code 发送到:
|
||||
|
||||
```text
|
||||
X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
|
||||
POST /api/v1/auth/wechat/login
|
||||
```
|
||||
|
||||
这是微信登录接入完成前的开发身份边界,用于确保每位教师只能访问自己的记录。接入微信登录后,后端会从登录凭据解析用户身份,客户端不再直接提供此请求头。
|
||||
Rust API 使用服务端 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET` 调用微信 `code2Session`,把 `openid` 映射为内部教师 UUID,并返回 30 天有效的随机会话令牌。业务接口统一使用:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
数据库仅保存令牌的 SHA-256 哈希,AppSecret 和微信 session_key 不会返回客户端。本地兼容头只有设置 `ALLOW_DEVELOPMENT_USER_HEADER=true` 时才启用,生产 Compose 强制为 `false`。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/health` | 服务、数据库、语音提供方及模型可用状态。 |
|
||||
| `POST` | `/api/v1/auth/wechat/login` | 用 `wx.login` code 换取后端会话。 |
|
||||
| `GET` | `/api/v1/auth/me` | 读取当前教师身份和会话状态。 |
|
||||
| `POST` | `/api/v1/auth/logout` | 撤销当前会话。 |
|
||||
| `GET` | `/api/v1/profiles` | 列出学生档案;可选 `query`、`grade`、`subject` 参数。 |
|
||||
| `POST` | `/api/v1/profiles` | 创建学生档案。 |
|
||||
| `GET` | `/api/v1/profiles/{profile_id}` | 读取单个学生档案。 |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
docker compose -f compose.asr.yml up --build -d
|
||||
```
|
||||
|
||||
在 `server/.env` 中配置 `DATABASE_URL` 后执行:
|
||||
在 `server/.env` 中配置 `DATABASE_URL`、`WECHAT_APP_ID` 和 `WECHAT_APP_SECRET` 后执行:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
@@ -26,12 +26,14 @@ cargo run
|
||||
|
||||
## 2. 在 Scalar 中直接测试
|
||||
|
||||
打开 `/scalar`,选择接口后点击 **Test Request**。业务接口已经预填以下开发用户 ID:
|
||||
打开 `/scalar`,选择接口后点击 **Test Request**。除登录和健康检查外,业务接口需要填写:
|
||||
|
||||
```text
|
||||
X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
access token 由小程序 `wx.login` code 调用 `POST /api/v1/auth/wechat/login` 获得。code 一次性且有效时间短,AppSecret 不得放入 Scalar、小程序或命令行历史。
|
||||
|
||||
请求体接口也已提供可发送的默认 JSON。建议按以下顺序测试:
|
||||
|
||||
1. 执行 `GET /health`,确认 `database_configured` 和 `speech_available` 均为 `true`。首次模型下载期间后者为 `false`。
|
||||
@@ -58,7 +60,7 @@ curl http://127.0.0.1:8080/health
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/api/v1/profiles \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0' \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
@@ -76,7 +78,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/profiles \
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/api/v1/feedback-records \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0' \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{
|
||||
"profile_id": null,
|
||||
"feedback_date": "2026-07-15",
|
||||
@@ -89,16 +91,16 @@ curl -X POST http://127.0.0.1:8080/api/v1/feedback-records \
|
||||
|
||||
```bash
|
||||
curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' \
|
||||
-H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0'
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
## 4. 身份和数据隔离
|
||||
|
||||
当前尚未接入微信登录,所有业务接口临时使用 `X-User-Id` 标识用户。相同 UUID 可以访问自己创建的数据,不同 UUID 之间的数据互相不可见。
|
||||
微信 `openid` 只用于后端映射内部教师 UUID,客户端不能直接提交 `openid` 或 `user_id`。随机 access token 只在登录响应中返回,数据库仅保存哈希。
|
||||
|
||||
`X-User-Id` 缺失时返回 `401`,格式不是 UUID 时返回 `400`。接入微信登录后,应由后端根据登录凭据确定用户身份,客户端不再直接提交该请求头。
|
||||
Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新调用 `wx.login` 并重试原请求一次。本地接口测试可显式设置 `ALLOW_DEVELOPMENT_USER_HEADER=true` 使用旧开发头;生产必须保持 `false`。
|
||||
|
||||
小程序开发客户端当前使用同一个测试 UUID,并默认连接 `https://feedback.shay7sev.site`。可在小程序“我的”页面修改 API 地址并执行健康检查;本地 API 调试时使用 `http://127.0.0.1:8080`。微信公众平台需将生产域名同时配置为 `request` 和 `uploadFile` 合法域名。
|
||||
小程序默认连接 `https://feedback.shay7sev.site`,并在“我的”页面显示微信登录状态和内部教师 ID。本地 API 调试时使用 `http://127.0.0.1:8080`。微信公众平台需将生产域名同时配置为 `request` 和 `uploadFile` 合法域名。
|
||||
|
||||
## 5. 响应状态码
|
||||
|
||||
@@ -108,7 +110,8 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>'
|
||||
| `201` | 创建成功 |
|
||||
| `204` | 删除成功,无响应体 |
|
||||
| `400` | 参数格式或业务校验失败 |
|
||||
| `401` | 缺少 `X-User-Id` |
|
||||
| `401` | 登录 code 或 Bearer 会话无效 |
|
||||
| `502` | 微信登录上游暂时不可用 |
|
||||
| `404` | 记录不存在或不属于当前用户 |
|
||||
| `500` | 数据库查询或服务内部错误 |
|
||||
| `503` | 未配置数据库连接 |
|
||||
@@ -125,4 +128,4 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>'
|
||||
|
||||
## 6. OpenAPI 维护方式
|
||||
|
||||
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和默认身份参数。
|
||||
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。
|
||||
|
||||
2
server/Cargo.lock
generated
2
server/Cargo.lock
generated
@@ -1897,10 +1897,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"hmac",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
||||
@@ -11,10 +11,12 @@ base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
hmac = "0.12"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "net", "signal"] }
|
||||
|
||||
@@ -8,7 +8,8 @@ COPY src ./src
|
||||
|
||||
RUN cargo build --locked --release \
|
||||
--bin teaching-feedback-api \
|
||||
--bin migrate
|
||||
--bin migrate \
|
||||
--bin migrate-user-owner
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
@@ -24,6 +25,7 @@ RUN sed -i "s|http://deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/
|
||||
|
||||
COPY --from=builder /build/target/release/teaching-feedback-api /usr/local/bin/teaching-feedback-api
|
||||
COPY --from=builder /build/target/release/migrate /usr/local/bin/migrate
|
||||
COPY --from=builder /build/target/release/migrate-user-owner /usr/local/bin/migrate-user-owner
|
||||
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
|
||||
64
server/migrations/0004_wechat_auth.sql
Normal file
64
server/migrations/0004_wechat_auth.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'disabled')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO users (id)
|
||||
SELECT owner_id FROM student_profiles
|
||||
UNION
|
||||
SELECT owner_id FROM student_profile_defaults
|
||||
UNION
|
||||
SELECT owner_id FROM feedback_records
|
||||
UNION
|
||||
SELECT owner_id FROM feedback_sessions
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE wechat_identities (
|
||||
app_id VARCHAR(64) NOT NULL,
|
||||
openid VARCHAR(128) NOT NULL,
|
||||
unionid VARCHAR(128),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (app_id, openid)
|
||||
);
|
||||
|
||||
CREATE INDEX wechat_identities_user_idx
|
||||
ON wechat_identities (user_id);
|
||||
|
||||
CREATE UNIQUE INDEX wechat_identities_app_unionid_idx
|
||||
ON wechat_identities (app_id, unionid)
|
||||
WHERE unionid IS NOT NULL;
|
||||
|
||||
CREATE TABLE auth_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX auth_sessions_active_user_idx
|
||||
ON auth_sessions (user_id, expires_at DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
ALTER TABLE student_profiles
|
||||
ADD CONSTRAINT student_profiles_owner_fk
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE student_profile_defaults
|
||||
ADD CONSTRAINT student_profile_defaults_owner_fk
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE feedback_records
|
||||
ADD CONSTRAINT feedback_records_owner_fk
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE feedback_sessions
|
||||
ADD CONSTRAINT feedback_sessions_owner_fk
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
485
server/src/auth.rs
Normal file
485
server/src/auth.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
use std::{sync::Arc, time::Duration as StdDuration};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use tokio::sync::Semaphore;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
config::AuthConfig,
|
||||
error::{ApiError, ErrorBody},
|
||||
};
|
||||
|
||||
type SharedState = Arc<AppState>;
|
||||
|
||||
const BEARER_PREFIX: &str = "Bearer ";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
config: AuthConfig,
|
||||
client: reqwest::Client,
|
||||
login_slots: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: Uuid,
|
||||
pub session_id: Option<Uuid>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({"code": "wx.login 返回的一次性 code"}))]
|
||||
struct WechatLoginInput {
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct WechatLoginResponse {
|
||||
access_token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct CurrentUserResponse {
|
||||
user_id: Uuid,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
auth_mode: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Code2SessionResponse {
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> OpenApiRouter<SharedState> {
|
||||
OpenApiRouter::new()
|
||||
.routes(routes!(wechat_login))
|
||||
.routes(routes!(current_user))
|
||||
.routes(routes!(logout))
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
login_slots: Arc::new(Semaphore::new(8)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wechat_configured(&self) -> bool {
|
||||
self.config.wechat.is_some()
|
||||
}
|
||||
|
||||
pub fn development_auth_enabled(&self) -> bool {
|
||||
self.config.allow_development_user_header
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
&self,
|
||||
pool: Option<&PgPool>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedUser, ApiError> {
|
||||
if let Some(token) = bearer_token(headers)? {
|
||||
let pool = pool.ok_or(ApiError::DatabaseUnavailable)?;
|
||||
return authenticate_token(pool, token).await;
|
||||
}
|
||||
if self.config.allow_development_user_header {
|
||||
return development_user(pool, headers).await;
|
||||
}
|
||||
Err(ApiError::Unauthorized)
|
||||
}
|
||||
|
||||
async fn exchange_code(&self, code: &str) -> Result<WechatIdentity, ApiError> {
|
||||
let config = self.config.wechat.as_ref().ok_or_else(|| {
|
||||
ApiError::ServiceUnavailable("WeChat login has not been configured".to_owned())
|
||||
})?;
|
||||
let _permit = self
|
||||
.login_slots
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(&config.code2session_url)
|
||||
.query(&[
|
||||
("appid", config.app_id.as_str()),
|
||||
("secret", config.app_secret.as_str()),
|
||||
("js_code", code),
|
||||
("grant_type", "authorization_code"),
|
||||
])
|
||||
.timeout(StdDuration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
is_timeout = error.is_timeout(),
|
||||
is_connect = error.is_connect(),
|
||||
"WeChat code2Session request failed"
|
||||
);
|
||||
ApiError::BadGateway("WeChat login service is unavailable".to_owned())
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(status = %response.status(), "WeChat code2Session returned HTTP error");
|
||||
return Err(ApiError::BadGateway(
|
||||
"WeChat login service is unavailable".to_owned(),
|
||||
));
|
||||
}
|
||||
let response = response.json::<Code2SessionResponse>().await.map_err(|_| {
|
||||
tracing::warn!("WeChat code2Session returned invalid JSON");
|
||||
ApiError::BadGateway("WeChat login service returned an invalid response".to_owned())
|
||||
})?;
|
||||
let Some(openid) = response.openid.filter(|value| !value.trim().is_empty()) else {
|
||||
tracing::warn!(
|
||||
errcode = response.errcode,
|
||||
errmsg = response.errmsg.as_deref().unwrap_or("unknown"),
|
||||
"WeChat login code was rejected"
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
};
|
||||
Ok(WechatIdentity {
|
||||
app_id: config.app_id.clone(),
|
||||
openid,
|
||||
unionid: response.unionid.filter(|value| !value.trim().is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WechatIdentity {
|
||||
app_id: String,
|
||||
openid: String,
|
||||
unionid: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/wechat/login",
|
||||
tag = "身份认证",
|
||||
summary = "微信登录",
|
||||
description = "用 wx.login 的一次性 code 换取本服务的 Bearer 会话令牌。AppSecret 只保存在服务端。",
|
||||
request_body(content = WechatLoginInput, content_type = "application/json"),
|
||||
responses(
|
||||
(status = 200, description = "登录成功", body = WechatLoginResponse),
|
||||
(status = 400, description = "code 格式无效", body = ErrorBody),
|
||||
(status = 401, description = "微信拒绝了 code", body = ErrorBody),
|
||||
(status = 502, description = "微信登录服务不可用", body = ErrorBody),
|
||||
(status = 503, description = "数据库或微信登录未配置", body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
async fn wechat_login(
|
||||
State(state): State<SharedState>,
|
||||
Json(input): Json<WechatLoginInput>,
|
||||
) -> Result<Json<WechatLoginResponse>, ApiError> {
|
||||
let code = input.code.trim();
|
||||
if code.is_empty() || code.len() > 256 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"code must contain 1 to 256 characters".to_owned(),
|
||||
));
|
||||
}
|
||||
let pool = state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)?;
|
||||
let identity = state.auth.exchange_code(code).await?;
|
||||
let user_id = upsert_identity(pool, &identity).await?;
|
||||
let user_active = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND status = 'active')",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if !user_active {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let (access_token, token_hash) = create_token();
|
||||
let expires_at = Utc::now() + Duration::days(state.auth.config.session_ttl_days);
|
||||
sqlx::query(
|
||||
"INSERT INTO auth_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(user_id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM auth_sessions WHERE expires_at < now() - interval '30 days' OR revoked_at < now() - interval '30 days'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
Ok(Json(WechatLoginResponse {
|
||||
access_token,
|
||||
expires_at,
|
||||
user_id,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/auth/me",
|
||||
tag = "身份认证",
|
||||
summary = "读取当前身份",
|
||||
description = "校验 Bearer 会话并返回内部教师 ID。开发模式可返回开发身份。",
|
||||
params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")),
|
||||
responses(
|
||||
(status = 200, description = "当前身份", body = CurrentUserResponse),
|
||||
(status = 401, description = "会话无效或已过期", body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
async fn current_user(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<CurrentUserResponse>, ApiError> {
|
||||
let identity = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?;
|
||||
Ok(Json(CurrentUserResponse {
|
||||
user_id: identity.user_id,
|
||||
expires_at: identity.expires_at,
|
||||
auth_mode: if identity.session_id.is_some() {
|
||||
"wechat"
|
||||
} else {
|
||||
"development"
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/logout",
|
||||
tag = "身份认证",
|
||||
summary = "退出登录",
|
||||
description = "撤销当前 Bearer 会话。",
|
||||
params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")),
|
||||
responses(
|
||||
(status = 204, description = "会话已撤销"),
|
||||
(status = 401, description = "会话无效或已过期", body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
async fn logout(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let identity = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?;
|
||||
if let Some(session_id) = identity.session_id {
|
||||
sqlx::query("UPDATE auth_sessions SET revoked_at = now() WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.execute(state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)?)
|
||||
.await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn authenticate_token(pool: &PgPool, token: &str) -> Result<AuthenticatedUser, ApiError> {
|
||||
let token_hash = hash_token(token.as_bytes());
|
||||
let result = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>)>(
|
||||
"SELECT session.user_id, session.id, session.expires_at \
|
||||
FROM auth_sessions session JOIN users ON users.id = session.user_id \
|
||||
WHERE session.token_hash = $1 AND session.revoked_at IS NULL \
|
||||
AND session.expires_at > now() AND users.status = 'active'",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some((user_id, session_id, expires_at)) = result else {
|
||||
return Err(ApiError::Unauthorized);
|
||||
};
|
||||
Ok(AuthenticatedUser {
|
||||
user_id,
|
||||
session_id: Some(session_id),
|
||||
expires_at: Some(expires_at),
|
||||
})
|
||||
}
|
||||
|
||||
async fn upsert_identity(pool: &PgPool, identity: &WechatIdentity) -> Result<Uuid, ApiError> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let existing = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT user_id FROM wechat_identities WHERE app_id = $1 AND openid = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(&identity.app_id)
|
||||
.bind(&identity.openid)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
let user_id = match existing {
|
||||
Some(user_id) => user_id,
|
||||
None => insert_identity(&mut transaction, identity).await?,
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE wechat_identities SET unionid = COALESCE($3, unionid), last_login_at = now() \
|
||||
WHERE app_id = $1 AND openid = $2",
|
||||
)
|
||||
.bind(&identity.app_id)
|
||||
.bind(&identity.openid)
|
||||
.bind(&identity.unionid)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
async fn insert_identity(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
identity: &WechatIdentity,
|
||||
) -> Result<Uuid, ApiError> {
|
||||
let proposed_user_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO users (id) VALUES ($1)")
|
||||
.bind(proposed_user_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
let user_id = sqlx::query_scalar::<_, Uuid>(
|
||||
"INSERT INTO wechat_identities (app_id, openid, unionid, user_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, openid) DO UPDATE SET last_login_at = now() \
|
||||
RETURNING user_id",
|
||||
)
|
||||
.bind(&identity.app_id)
|
||||
.bind(&identity.openid)
|
||||
.bind(&identity.unionid)
|
||||
.bind(proposed_user_id)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
if user_id != proposed_user_id {
|
||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(proposed_user_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
}
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
fn bearer_token(headers: &HeaderMap) -> Result<Option<&str>, ApiError> {
|
||||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.to_str().map_err(|_| ApiError::Unauthorized)?;
|
||||
let token = value
|
||||
.strip_prefix(BEARER_PREFIX)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
if token.is_empty() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
async fn development_user(
|
||||
pool: Option<&PgPool>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedUser, ApiError> {
|
||||
let value = headers
|
||||
.get("x-user-id")
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let user_id = Uuid::parse_str(value)
|
||||
.map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned()))?;
|
||||
if let Some(pool) = pool {
|
||||
sqlx::query("INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(AuthenticatedUser {
|
||||
user_id,
|
||||
session_id: None,
|
||||
expires_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_token() -> (String, Vec<u8>) {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let token = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let token_hash = hash_token(token.as_bytes());
|
||||
(token, token_hash)
|
||||
}
|
||||
|
||||
fn hash_token(token: &[u8]) -> Vec<u8> {
|
||||
Sha256::digest(token).to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::Query,
|
||||
http::{HeaderMap, HeaderValue, header::AUTHORIZATION},
|
||||
routing::get,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::{AuthConfig, WechatAuthConfig};
|
||||
|
||||
use super::{AuthService, bearer_token, create_token, hash_token};
|
||||
|
||||
#[test]
|
||||
fn bearer_token_requires_exact_scheme_and_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(bearer_token(&headers).unwrap(), None);
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer test-token"));
|
||||
assert_eq!(bearer_token(&headers).unwrap(), Some("test-token"));
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic test-token"));
|
||||
assert!(bearer_token(&headers).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_tokens_are_unique_and_only_hashes_are_stored() {
|
||||
let (first, first_hash) = create_token();
|
||||
let (second, second_hash) = create_token();
|
||||
assert_ne!(first, second);
|
||||
assert_ne!(first_hash, second_hash);
|
||||
assert_eq!(first_hash.len(), 32);
|
||||
assert_eq!(first_hash, hash_token(first.as_bytes()));
|
||||
assert_ne!(first_hash.as_slice(), first.as_bytes());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_code_uses_server_credentials_and_returns_wechat_identity() {
|
||||
let app = Router::new().route(
|
||||
"/jscode2session",
|
||||
get(|Query(query): Query<HashMap<String, String>>| async move {
|
||||
assert_eq!(query.get("appid").map(String::as_str), Some("test-app"));
|
||||
assert_eq!(query.get("secret").map(String::as_str), Some("test-secret"));
|
||||
assert_eq!(query.get("js_code").map(String::as_str), Some("test-code"));
|
||||
Json(json!({"openid": "openid-1", "unionid": "unionid-1"}))
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let service = AuthService::new(AuthConfig {
|
||||
wechat: Some(WechatAuthConfig {
|
||||
app_id: "test-app".to_owned(),
|
||||
app_secret: "test-secret".to_owned(),
|
||||
code2session_url: format!("http://{address}/jscode2session"),
|
||||
}),
|
||||
session_ttl_days: 30,
|
||||
allow_development_user_header: false,
|
||||
});
|
||||
|
||||
let identity = service.exchange_code("test-code").await.unwrap();
|
||||
assert_eq!(identity.app_id, "test-app");
|
||||
assert_eq!(identity.openid, "openid-1");
|
||||
assert_eq!(identity.unionid.as_deref(), Some("unionid-1"));
|
||||
}
|
||||
}
|
||||
160
server/src/bin/migrate-user-owner.rs
Normal file
160
server/src/bin/migrate-user-owner.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::{env, io};
|
||||
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
let (source_user_id, target_user_id) = parse_arguments()?;
|
||||
if source_user_id == target_user_id {
|
||||
return Err(invalid_input("source and target user IDs must differ"));
|
||||
}
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.map_err(|_| invalid_input("DATABASE_URL is required before migrating user ownership"))?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
migrate_ownership(&pool, source_user_id, target_user_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_ownership(
|
||||
pool: &PgPool,
|
||||
source_user_id: Uuid,
|
||||
target_user_id: Uuid,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
for (label, user_id) in [("source", source_user_id), ("target", target_user_id)] {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND status = 'active')",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(invalid_input(format!(
|
||||
"{label} user {user_id} does not exist or is disabled"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let defaults_conflict = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM student_profile_defaults WHERE owner_id = $1) \
|
||||
AND EXISTS(SELECT 1 FROM student_profile_defaults WHERE owner_id = $2)",
|
||||
)
|
||||
.bind(source_user_id)
|
||||
.bind(target_user_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
if defaults_conflict {
|
||||
return Err(invalid_input(
|
||||
"both users have profile defaults; resolve the conflict before migrating",
|
||||
));
|
||||
}
|
||||
|
||||
let active_session_conflict = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(\
|
||||
SELECT 1 FROM feedback_sessions source \
|
||||
JOIN feedback_sessions target \
|
||||
ON source.profile_id IS NOT DISTINCT FROM target.profile_id \
|
||||
AND target.owner_id = $2 AND target.status = 'active' \
|
||||
WHERE source.owner_id = $1 AND source.status = 'active'\
|
||||
)",
|
||||
)
|
||||
.bind(source_user_id)
|
||||
.bind(target_user_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
if active_session_conflict {
|
||||
return Err(invalid_input(
|
||||
"both users have an active feedback session for the same profile",
|
||||
));
|
||||
}
|
||||
|
||||
let profiles = update_owner(
|
||||
&mut transaction,
|
||||
"student_profiles",
|
||||
source_user_id,
|
||||
target_user_id,
|
||||
)
|
||||
.await?;
|
||||
let defaults = update_owner(
|
||||
&mut transaction,
|
||||
"student_profile_defaults",
|
||||
source_user_id,
|
||||
target_user_id,
|
||||
)
|
||||
.await?;
|
||||
let records = update_owner(
|
||||
&mut transaction,
|
||||
"feedback_records",
|
||||
source_user_id,
|
||||
target_user_id,
|
||||
)
|
||||
.await?;
|
||||
let sessions = update_owner(
|
||||
&mut transaction,
|
||||
"feedback_sessions",
|
||||
source_user_id,
|
||||
target_user_id,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query("UPDATE users SET updated_at = now() WHERE id = $1")
|
||||
.bind(target_user_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
println!(
|
||||
"User ownership migrated from {source_user_id} to {target_user_id}: \
|
||||
{profiles} profiles, {defaults} defaults, {records} feedback records, \
|
||||
{sessions} feedback sessions"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_owner(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
table: &str,
|
||||
source_user_id: Uuid,
|
||||
target_user_id: Uuid,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let statement = format!("UPDATE {table} SET owner_id = $2 WHERE owner_id = $1");
|
||||
Ok(sqlx::query(&statement)
|
||||
.bind(source_user_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?
|
||||
.rows_affected())
|
||||
}
|
||||
|
||||
fn parse_arguments() -> Result<(Uuid, Uuid), Box<dyn std::error::Error>> {
|
||||
let mut source = None;
|
||||
let mut target = None;
|
||||
let mut arguments = env::args().skip(1);
|
||||
while let Some(argument) = arguments.next() {
|
||||
let value = arguments
|
||||
.next()
|
||||
.ok_or_else(|| invalid_input(format!("missing value after {argument}")))?;
|
||||
match argument.as_str() {
|
||||
"--from" => source = Some(parse_uuid(&value, "--from")?),
|
||||
"--to" => target = Some(parse_uuid(&value, "--to")?),
|
||||
_ => return Err(invalid_input(format!("unknown argument: {argument}"))),
|
||||
}
|
||||
}
|
||||
let source = source
|
||||
.ok_or_else(|| invalid_input("usage: migrate-user-owner --from <UUID> --to <UUID>"))?;
|
||||
let target = target
|
||||
.ok_or_else(|| invalid_input("usage: migrate-user-owner --from <UUID> --to <UUID>"))?;
|
||||
Ok((source, target))
|
||||
}
|
||||
|
||||
fn parse_uuid(value: &str, argument: &str) -> Result<Uuid, Box<dyn std::error::Error>> {
|
||||
Uuid::parse_str(value).map_err(|_| invalid_input(format!("{argument} must be a valid UUID")))
|
||||
}
|
||||
|
||||
fn invalid_input(message: impl Into<String>) -> Box<dyn std::error::Error> {
|
||||
Box::new(io::Error::new(io::ErrorKind::InvalidInput, message.into()))
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::{env, net::IpAddr, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub enum SpeechProviderConfig {
|
||||
Local { api_url: String },
|
||||
Tencent(TencentSpeechConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct TencentSpeechConfig {
|
||||
pub app_id: u64,
|
||||
pub secret_id: String,
|
||||
@@ -14,7 +14,7 @@ pub struct TencentSpeechConfig {
|
||||
pub engine_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct SpeechConfig {
|
||||
pub provider: SpeechProviderConfig,
|
||||
pub request_timeout_seconds: u64,
|
||||
@@ -22,14 +22,28 @@ pub struct SpeechConfig {
|
||||
pub job_lease_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct SummaryConfig {
|
||||
pub api_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct WechatAuthConfig {
|
||||
pub app_id: String,
|
||||
pub app_secret: String,
|
||||
pub code2session_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub wechat: Option<WechatAuthConfig>,
|
||||
pub session_ttl_days: i64,
|
||||
pub allow_development_user_header: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub host: IpAddr,
|
||||
pub port: u16,
|
||||
@@ -39,6 +53,7 @@ pub struct Config {
|
||||
pub audio_storage_dir: PathBuf,
|
||||
pub speech: Option<SpeechConfig>,
|
||||
pub summary: Option<SummaryConfig>,
|
||||
pub auth: AuthConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -58,6 +73,7 @@ impl Config {
|
||||
|
||||
let speech = speech_config()?;
|
||||
let summary = summary_config()?;
|
||||
let auth = auth_config()?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
@@ -74,10 +90,41 @@ impl Config {
|
||||
.unwrap_or_else(|_| PathBuf::from("./data/audio")),
|
||||
speech,
|
||||
summary,
|
||||
auth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_config() -> Result<AuthConfig, String> {
|
||||
let app_id = optional_env("WECHAT_APP_ID");
|
||||
let app_secret = optional_env("WECHAT_APP_SECRET");
|
||||
let wechat = match (app_id, app_secret) {
|
||||
(Some(app_id), Some(app_secret)) => Some(WechatAuthConfig {
|
||||
app_id,
|
||||
app_secret,
|
||||
code2session_url: optional_env("WECHAT_CODE2SESSION_URL")
|
||||
.unwrap_or_else(|| "https://api.weixin.qq.com/sns/jscode2session".to_owned()),
|
||||
}),
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(
|
||||
"WECHAT_APP_ID and WECHAT_APP_SECRET must be configured together".to_owned(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let session_ttl_days = parse_env("AUTH_SESSION_TTL_DAYS", 30_i64)?;
|
||||
if !(1..=365).contains(&session_ttl_days) {
|
||||
return Err("AUTH_SESSION_TTL_DAYS must be between 1 and 365".to_owned());
|
||||
}
|
||||
let allow_development_user_header = parse_bool_env("ALLOW_DEVELOPMENT_USER_HEADER", false)?;
|
||||
|
||||
Ok(AuthConfig {
|
||||
wechat,
|
||||
session_ttl_days,
|
||||
allow_development_user_header,
|
||||
})
|
||||
}
|
||||
|
||||
fn speech_config() -> Result<Option<SpeechConfig>, String> {
|
||||
let provider = optional_env("ASR_PROVIDER").unwrap_or_else(|| "local".to_owned());
|
||||
if matches!(provider.as_str(), "disabled" | "none") {
|
||||
@@ -165,3 +212,14 @@ where
|
||||
|value| value.parse().map_err(|_| format!("{name} is invalid")),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_bool_env(name: &str, default: bool) -> Result<bool, String> {
|
||||
let Some(value) = optional_env(name) else {
|
||||
return Ok(default);
|
||||
};
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
_ => Err(format!("{name} must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub enum ApiError {
|
||||
DatabaseUnavailable,
|
||||
#[error("{0}")]
|
||||
ServiceUnavailable(String),
|
||||
#[error("{0}")]
|
||||
BadGateway(String),
|
||||
#[error("internal server error")]
|
||||
Internal,
|
||||
}
|
||||
@@ -30,7 +32,7 @@ pub struct ErrorBody {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// 所有需要数据库和临时用户身份的业务接口都可能返回的公共错误。
|
||||
/// 所有需要数据库和登录身份的业务接口都可能返回的公共错误。
|
||||
#[allow(dead_code)]
|
||||
#[derive(IntoResponses)]
|
||||
pub enum CommonApiErrors {
|
||||
@@ -38,13 +40,13 @@ pub enum CommonApiErrors {
|
||||
#[response(
|
||||
status = 400,
|
||||
description = "请求参数无效",
|
||||
example = json!({"error": "X-User-Id must be a UUID"})
|
||||
example = json!({"error": "request parameters are invalid"})
|
||||
)]
|
||||
BadRequest(ErrorBody),
|
||||
/// 缺少 `X-User-Id` 请求头。
|
||||
/// 缺少 Bearer 令牌,或会话无效、过期、已撤销。
|
||||
#[response(
|
||||
status = 401,
|
||||
description = "缺少开发阶段身份请求头",
|
||||
description = "登录会话无效",
|
||||
example = json!({"error": "authentication is required"})
|
||||
)]
|
||||
Unauthorized(ErrorBody),
|
||||
@@ -78,6 +80,7 @@ impl IntoResponse for ApiError {
|
||||
"database has not been configured".to_owned(),
|
||||
),
|
||||
Self::ServiceUnavailable(message) => (StatusCode::SERVICE_UNAVAILABLE, message),
|
||||
Self::BadGateway(message) => (StatusCode::BAD_GATEWAY, message),
|
||||
Self::Internal => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".to_owned(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod error;
|
||||
mod recording_routes;
|
||||
@@ -21,6 +22,7 @@ pub struct AppState {
|
||||
pub audio_storage_dir: PathBuf,
|
||||
pub speech: speech::SpeechService,
|
||||
pub summary: summary::SummaryService,
|
||||
pub auth: auth::AuthService,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -33,11 +35,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Config::from_env().map_err(std::io::Error::other)?;
|
||||
let pool = connect_database(&config).await?;
|
||||
let speech = speech::SpeechService::new(config.speech.clone());
|
||||
let auth = auth::AuthService::new(config.auth.clone());
|
||||
let state = AppState {
|
||||
pool,
|
||||
audio_storage_dir: config.audio_storage_dir.clone(),
|
||||
speech: speech.clone(),
|
||||
summary: summary::SummaryService::new(config.summary.clone()),
|
||||
auth,
|
||||
};
|
||||
if let Some(pool) = state.pool.clone() {
|
||||
if speech.configured() {
|
||||
|
||||
@@ -183,7 +183,7 @@ struct AudioSegmentUpload {
|
||||
summary = "读取进行中的反馈批次",
|
||||
description = "读取当前用户指定学生下尚未保存的反馈批次;profile_id 留空时读取不关联学生的批次。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("profile_id" = Option<Uuid>, Query, description = "学生档案 ID;留空表示不关联学生")
|
||||
),
|
||||
responses(
|
||||
@@ -196,7 +196,11 @@ async fn get_active_feedback_session(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ActiveSessionQuery>,
|
||||
) -> Result<Json<Option<FeedbackSessionResponse>>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let session = sqlx::query_as::<_, FeedbackSessionRow>(
|
||||
"SELECT id, profile_id, feedback_date, content, status, created_at, updated_at \
|
||||
FROM feedback_sessions \
|
||||
@@ -222,7 +226,7 @@ async fn get_active_feedback_session(
|
||||
tag = "语音反馈",
|
||||
summary = "创建或恢复反馈批次",
|
||||
description = "为学生创建一个反馈批次;已有未保存批次时直接恢复,并同步最新反馈日期。",
|
||||
params(("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))),
|
||||
params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")),
|
||||
request_body(content = FeedbackSessionInput, content_type = "application/json"),
|
||||
responses(
|
||||
(status = 200, description = "创建或恢复后的反馈批次", body = FeedbackSessionResponse),
|
||||
@@ -235,7 +239,11 @@ async fn ensure_feedback_session(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FeedbackSessionInput>,
|
||||
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
validate_draft_content(&input.content)?;
|
||||
if let Some(profile_id) = input.profile_id {
|
||||
@@ -284,7 +292,7 @@ async fn ensure_feedback_session(
|
||||
summary = "自动保存反馈草稿",
|
||||
description = "在用户编辑反馈正文或日期时自动保存进行中的反馈批次。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("session_id" = Uuid, Path, description = "反馈批次 ID")
|
||||
),
|
||||
request_body(content = FeedbackSessionUpdateInput, content_type = "application/json"),
|
||||
@@ -300,7 +308,11 @@ async fn update_feedback_session(
|
||||
Path(session_id): Path<Uuid>,
|
||||
Json(input): Json<FeedbackSessionUpdateInput>,
|
||||
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
validate_draft_content(&input.content)?;
|
||||
let session = sqlx::query_as::<_, FeedbackSessionRow>(
|
||||
"UPDATE feedback_sessions SET feedback_date = $3, content = $4, updated_at = now() \
|
||||
@@ -324,7 +336,7 @@ async fn update_feedback_session(
|
||||
summary = "开始一节课堂录音",
|
||||
description = "在反馈批次中自动创建下一节课堂录音。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("session_id" = Uuid, Path, description = "反馈批次 ID")
|
||||
),
|
||||
responses(
|
||||
@@ -338,7 +350,11 @@ async fn create_lesson_session(
|
||||
headers: HeaderMap,
|
||||
Path(session_id): Path<Uuid>,
|
||||
) -> Result<(axum::http::StatusCode, Json<LessonCreatedResponse>), ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
assert_session_owner(pool(&state)?, owner_id, session_id).await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM lesson_sessions \
|
||||
@@ -377,7 +393,7 @@ async fn create_lesson_session(
|
||||
summary = "上传并转录录音片段",
|
||||
description = "上传单个录音片段并加入后端转录队列。接口保存音频后立即返回,页面通过反馈批次状态无感获取转录结果。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
|
||||
),
|
||||
request_body(content = AudioSegmentUpload, content_type = "multipart/form-data"),
|
||||
@@ -393,7 +409,11 @@ async fn upload_audio_segment(
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(axum::http::StatusCode, Json<AudioSegmentResponse>), ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let session_id = assert_lesson_owner(pool(&state)?, owner_id, lesson_id).await?;
|
||||
let mut sequence = None;
|
||||
let mut duration_ms = None;
|
||||
@@ -506,7 +526,7 @@ async fn upload_audio_segment(
|
||||
summary = "结束一节课堂录音",
|
||||
description = "汇总该节课的片段状态和转录文本;短录音会标记为可直接加入反馈。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
|
||||
),
|
||||
responses(
|
||||
@@ -520,7 +540,11 @@ async fn finish_lesson_session(
|
||||
headers: HeaderMap,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
) -> Result<Json<FinishedLessonResponse>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let session_id = assert_lesson_owner(pool(&state)?, owner_id, lesson_id).await?;
|
||||
let segments = sqlx::query_as::<_, (String, i32, Option<String>)>(
|
||||
"SELECT status, duration_ms, transcript FROM audio_segments \
|
||||
@@ -571,7 +595,7 @@ async fn finish_lesson_session(
|
||||
summary = "标记短录音已加入反馈",
|
||||
description = "短录音文字直接加入反馈内容后,标记该节课无需再次汇总。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
|
||||
),
|
||||
request_body(content = MarkLessonAppliedInput, content_type = "application/json"),
|
||||
@@ -587,7 +611,11 @@ async fn mark_lesson_applied(
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
Json(input): Json<MarkLessonAppliedInput>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
validate_draft_content(&input.content)?;
|
||||
let pool = pool(&state)?;
|
||||
let session_id = assert_lesson_owner(pool, owner_id, lesson_id).await?;
|
||||
@@ -618,7 +646,7 @@ async fn mark_lesson_applied(
|
||||
summary = "重试失败的录音转录",
|
||||
description = "将后端保留的失败音频重新加入转录队列,接口立即返回。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("segment_id" = Uuid, Path, description = "录音片段 ID")
|
||||
),
|
||||
responses(
|
||||
@@ -632,7 +660,11 @@ async fn retry_audio_segment(
|
||||
headers: HeaderMap,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<Json<AudioSegmentResponse>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>(
|
||||
"SELECT s.sequence, s.duration_ms, s.audio_path, l.id \
|
||||
FROM audio_segments s \
|
||||
@@ -684,7 +716,7 @@ async fn retry_audio_segment(
|
||||
summary = "生成汇总反馈",
|
||||
description = "将尚未处理的长录音或多节课堂转录与教师已有内容合并。未配置大模型时使用本地抽取式汇总。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("session_id" = Uuid, Path, description = "反馈批次 ID")
|
||||
),
|
||||
request_body(content = GenerateFeedbackInput, content_type = "application/json"),
|
||||
@@ -700,7 +732,11 @@ async fn generate_feedback_content(
|
||||
Path(session_id): Path<Uuid>,
|
||||
Json(input): Json<GenerateFeedbackInput>,
|
||||
) -> Result<Json<GenerateFeedbackResponse>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
validate_draft_content(&input.existing_content)?;
|
||||
assert_session_owner(pool(&state)?, owner_id, session_id).await?;
|
||||
let pending_status = sqlx::query_as::<_, (bool, bool)>(
|
||||
@@ -867,14 +903,6 @@ async fn parse_multipart_i32(
|
||||
.map_err(|_| ApiError::BadRequest(format!("{name} must be an integer")))
|
||||
}
|
||||
|
||||
fn current_user(headers: &HeaderMap) -> Result<Uuid, ApiError> {
|
||||
let value = headers
|
||||
.get("x-user-id")
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
Uuid::parse_str(value).map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned()))
|
||||
}
|
||||
|
||||
fn pool(state: &AppState) -> Result<&PgPool, ApiError> {
|
||||
state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
AppState, auth,
|
||||
error::{ApiError, CommonApiErrors, ErrorBody},
|
||||
recording_routes,
|
||||
};
|
||||
@@ -30,6 +30,7 @@ const DEFAULT_DURATION_MINUTES: i16 = 60;
|
||||
pub fn router() -> (Router<SharedState>, OpenApi) {
|
||||
let (router, mut openapi) = OpenApiRouter::new()
|
||||
.routes(routes!(health))
|
||||
.merge(auth::router())
|
||||
.routes(routes!(list_profiles, create_profile))
|
||||
.routes(routes!(get_profile, update_profile, delete_profile))
|
||||
.routes(routes!(get_defaults, update_defaults))
|
||||
@@ -41,13 +42,14 @@ pub fn router() -> (Router<SharedState>, OpenApi) {
|
||||
let mut info = Info::new("教学反馈助手 API", env!("CARGO_PKG_VERSION"));
|
||||
info.description = Some(
|
||||
"学生档案、档案预设和教学反馈记录 API。\n\n\
|
||||
业务接口使用 `X-User-Id` 作为开发阶段身份边界;Scalar 已预填测试 UUID 和请求体示例。"
|
||||
业务接口使用微信登录换取的 Bearer 会话令牌;只有显式开启时才兼容开发身份头。"
|
||||
.to_owned(),
|
||||
);
|
||||
openapi.info = info;
|
||||
openapi.servers = Some(vec![Server::new("/")]);
|
||||
openapi.tags = Some(vec![
|
||||
api_tag("健康检查", "检查 HTTP 服务状态和数据库是否已配置。"),
|
||||
api_tag("身份认证", "通过微信登录建立、读取和撤销后端会话。"),
|
||||
api_tag("学生档案", "创建、查询、更新和删除当前用户的学生档案。"),
|
||||
api_tag("档案预设", "读取或保存当前用户新建档案时使用的默认值。"),
|
||||
api_tag("反馈记录", "创建、查询和删除当前用户的教学反馈记录。"),
|
||||
@@ -71,7 +73,9 @@ fn api_tag(name: &str, description: &str) -> Tag {
|
||||
"speech_configured": true,
|
||||
"speech_available": true,
|
||||
"speech_provider": "local-funasr",
|
||||
"summary_configured": false
|
||||
"summary_configured": false,
|
||||
"wechat_auth_configured": true,
|
||||
"development_auth_enabled": false
|
||||
}))]
|
||||
struct HealthResponse {
|
||||
/// HTTP 服务状态;正常时固定为 `ok`。
|
||||
@@ -86,6 +90,10 @@ struct HealthResponse {
|
||||
speech_provider: Option<&'static str>,
|
||||
/// 是否已经配置生成式反馈汇总服务;未配置时使用本地抽取式汇总。
|
||||
summary_configured: bool,
|
||||
/// 是否已经配置微信 AppID 和 AppSecret。
|
||||
wechat_auth_configured: bool,
|
||||
/// 是否允许使用仅供本地调试的 `X-User-Id`。
|
||||
development_auth_enabled: bool,
|
||||
}
|
||||
|
||||
/// 检查服务状态。
|
||||
@@ -107,6 +115,8 @@ async fn health(State(state): State<SharedState>) -> Json<HealthResponse> {
|
||||
speech_available: state.speech.available().await,
|
||||
speech_provider: state.speech.provider_name(),
|
||||
summary_configured: state.summary.configured(),
|
||||
wechat_auth_configured: state.auth.wechat_configured(),
|
||||
development_auth_enabled: state.auth.development_auth_enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,7 +206,7 @@ struct ProfileQuery {
|
||||
summary = "列出学生档案",
|
||||
description = "按最近更新时间倒序返回当前用户的档案。三个查询条件均可省略,也可以组合使用。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("query" = Option<String>, Query, description = "模糊匹配姓名、年级、学科或家长称呼", example = "小谢"),
|
||||
("grade" = Option<String>, Query, description = "精确匹配年级或学生类别", example = "艺术类"),
|
||||
("subject" = Option<String>, Query, description = "精确匹配学科", example = "美术")
|
||||
@@ -211,7 +221,11 @@ async fn list_profiles(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ProfileQuery>,
|
||||
) -> Result<Json<Vec<StudentProfile>>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new(
|
||||
"SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \
|
||||
@@ -254,9 +268,9 @@ async fn list_profiles(
|
||||
path = "/api/v1/profiles/{profile_id}",
|
||||
tag = "学生档案",
|
||||
summary = "读取学生档案",
|
||||
description = "只允许读取属于当前 `X-User-Id` 的档案。请先从创建或列表接口复制真实的档案 ID。",
|
||||
description = "只允许读取属于当前登录教师的档案。请先从创建或列表接口复制真实的档案 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
@@ -270,7 +284,11 @@ async fn get_profile(
|
||||
headers: HeaderMap,
|
||||
Path(profile_id): Path<Uuid>,
|
||||
) -> Result<Json<StudentProfile>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let profile = sqlx::query_as::<_, StudentProfile>(
|
||||
"SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \
|
||||
FROM student_profiles WHERE id = $1 AND owner_id = $2",
|
||||
@@ -289,9 +307,9 @@ async fn get_profile(
|
||||
path = "/api/v1/profiles",
|
||||
tag = "学生档案",
|
||||
summary = "创建学生档案",
|
||||
description = "使用请求头中的用户 ID 创建档案。Scalar 已预填完整请求体,可以直接执行测试。",
|
||||
description = "为当前登录教师创建档案。Scalar 已预填完整请求体,填写 Bearer 令牌后可直接测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
|
||||
),
|
||||
request_body(
|
||||
content = ProfileInput,
|
||||
@@ -319,7 +337,11 @@ async fn create_profile(
|
||||
Json(input): Json<ProfileInput>,
|
||||
) -> Result<(axum::http::StatusCode, Json<StudentProfile>), ApiError> {
|
||||
validate_profile(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let profile = sqlx::query_as::<_, StudentProfile>(
|
||||
"INSERT INTO student_profiles \
|
||||
(id, owner_id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time) \
|
||||
@@ -349,7 +371,7 @@ async fn create_profile(
|
||||
summary = "更新学生档案",
|
||||
description = "完整替换指定档案的可编辑字段。请先从创建或列表接口复制真实的档案 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
request_body(
|
||||
@@ -380,7 +402,11 @@ async fn update_profile(
|
||||
Json(input): Json<ProfileInput>,
|
||||
) -> Result<Json<StudentProfile>, ApiError> {
|
||||
validate_profile(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let profile = sqlx::query_as::<_, StudentProfile>(
|
||||
"UPDATE student_profiles \
|
||||
SET name = $1, grade = $2, subject = $3, academic_year = $4, term = $5, guardian_title = $6, \
|
||||
@@ -412,7 +438,7 @@ async fn update_profile(
|
||||
summary = "删除学生档案",
|
||||
description = "删除属于当前用户的档案。关联反馈记录不会被删除,其 `profile_id` 会被置空。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
@@ -426,7 +452,11 @@ async fn delete_profile(
|
||||
headers: HeaderMap,
|
||||
Path(profile_id): Path<Uuid>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let result = sqlx::query("DELETE FROM student_profiles WHERE id = $1 AND owner_id = $2")
|
||||
.bind(profile_id)
|
||||
.bind(owner_id)
|
||||
@@ -479,7 +509,7 @@ struct ProfileDefaultsInput {
|
||||
summary = "读取档案预设",
|
||||
description = "读取当前用户的预设;尚未保存时直接返回艺术类、美术、60 分钟的系统默认值。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "当前档案预设", body = ProfileDefaults),
|
||||
@@ -490,7 +520,11 @@ async fn get_defaults(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ProfileDefaults>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let defaults = sqlx::query_as::<_, ProfileDefaults>(
|
||||
"SELECT grade, subject, lesson_duration_minutes, updated_at \
|
||||
FROM student_profile_defaults WHERE owner_id = $1",
|
||||
@@ -510,7 +544,7 @@ async fn get_defaults(
|
||||
summary = "保存档案预设",
|
||||
description = "新增或覆盖当前用户的档案预设。Scalar 已预填完整请求体,可以直接执行测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
|
||||
),
|
||||
request_body(
|
||||
content = ProfileDefaultsInput,
|
||||
@@ -533,7 +567,11 @@ async fn update_defaults(
|
||||
Json(input): Json<ProfileDefaultsInput>,
|
||||
) -> Result<Json<ProfileDefaults>, ApiError> {
|
||||
validate_defaults(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let defaults = sqlx::query_as::<_, ProfileDefaults>(
|
||||
"INSERT INTO student_profile_defaults (owner_id, grade, subject, lesson_duration_minutes) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
@@ -610,7 +648,7 @@ struct FeedbackRecordQuery {
|
||||
summary = "列出反馈记录",
|
||||
description = "按反馈日期和更新时间倒序返回当前用户的记录;可按学生档案 ID 筛选。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("profile_id" = Option<Uuid>, Query, description = "只返回关联到指定档案的记录;直接测试时可留空", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
@@ -623,7 +661,11 @@ async fn list_feedback_records(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<FeedbackRecordQuery>,
|
||||
) -> Result<Json<Vec<FeedbackRecord>>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new(
|
||||
"SELECT id, profile_id, feedback_date, content, created_at, updated_at \
|
||||
@@ -650,7 +692,7 @@ async fn list_feedback_records(
|
||||
summary = "创建反馈记录",
|
||||
description = "创建一条教学反馈。Scalar 默认将 `profile_id` 设为 null,因此无需先创建档案即可直接测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
|
||||
),
|
||||
request_body(
|
||||
content = FeedbackRecordInput,
|
||||
@@ -679,7 +721,11 @@ async fn create_feedback_record(
|
||||
"content must contain 1 to 2000 characters".to_owned(),
|
||||
));
|
||||
}
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let pool = pool(&state)?;
|
||||
if let Some(profile_id) = input.profile_id {
|
||||
assert_profile_owner(pool, owner_id, profile_id).await?;
|
||||
@@ -735,7 +781,7 @@ async fn create_feedback_record(
|
||||
summary = "删除反馈记录",
|
||||
description = "删除属于当前用户的反馈记录。请先从创建或列表接口复制真实的记录 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与记录所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||
("record_id" = Uuid, Path, description = "反馈记录 ID;将示例值替换为创建接口返回的 ID", example = json!("22222222-2222-4222-8222-222222222222"))
|
||||
),
|
||||
responses(
|
||||
@@ -749,7 +795,11 @@ async fn delete_feedback_record(
|
||||
headers: HeaderMap,
|
||||
Path(record_id): Path<Uuid>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let owner_id = state
|
||||
.auth
|
||||
.authenticate(state.pool.as_ref(), &headers)
|
||||
.await?
|
||||
.user_id;
|
||||
let result = sqlx::query("DELETE FROM feedback_records WHERE id = $1 AND owner_id = $2")
|
||||
.bind(record_id)
|
||||
.bind(owner_id)
|
||||
@@ -759,14 +809,6 @@ async fn delete_feedback_record(
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn current_user(headers: &HeaderMap) -> Result<Uuid, ApiError> {
|
||||
let value = headers
|
||||
.get("x-user-id")
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
Uuid::parse_str(value).map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned()))
|
||||
}
|
||||
|
||||
fn pool(state: &AppState) -> Result<&PgPool, ApiError> {
|
||||
state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)
|
||||
}
|
||||
@@ -864,8 +906,6 @@ mod tests {
|
||||
|
||||
use super::router;
|
||||
|
||||
const TEST_USER_ID: &str = "5a4da7f0-d70c-465f-bcab-124c504aa9f0";
|
||||
|
||||
#[test]
|
||||
fn openapi_documents_all_routes_and_default_test_data() {
|
||||
let (_, openapi) = router();
|
||||
@@ -884,7 +924,7 @@ mod tests {
|
||||
.filter(|operation| operation.get("responses").is_some())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(operations.len(), 20);
|
||||
assert_eq!(operations.len(), 23);
|
||||
for operation in &operations {
|
||||
assert_non_empty(operation, "summary");
|
||||
assert_non_empty(operation, "description");
|
||||
@@ -901,7 +941,9 @@ mod tests {
|
||||
|
||||
let business_operations = paths
|
||||
.iter()
|
||||
.filter(|(path, _)| path.starts_with("/api/v1/"))
|
||||
.filter(|(path, _)| {
|
||||
path.starts_with("/api/v1/") && *path != "/api/v1/auth/wechat/login"
|
||||
})
|
||||
.flat_map(|(_, path)| {
|
||||
path.as_object()
|
||||
.expect("path item should be an object")
|
||||
@@ -910,13 +952,15 @@ mod tests {
|
||||
.filter(|operation| operation.get("responses").is_some());
|
||||
|
||||
for operation in business_operations {
|
||||
let user_header = operation["parameters"]
|
||||
let authorization = operation["parameters"]
|
||||
.as_array()
|
||||
.expect("business operation should have parameters")
|
||||
.iter()
|
||||
.find(|parameter| parameter["name"] == "X-User-Id" && parameter["in"] == "header")
|
||||
.expect("business operation should document X-User-Id");
|
||||
assert_eq!(user_header["example"], TEST_USER_ID);
|
||||
.find(|parameter| {
|
||||
parameter["name"] == "Authorization" && parameter["in"] == "header"
|
||||
})
|
||||
.expect("business operation should document Bearer authentication");
|
||||
assert_eq!(authorization["example"], "Bearer <access-token>");
|
||||
}
|
||||
|
||||
assert_eq!(document["servers"][0]["url"], "/");
|
||||
|
||||
Reference in New Issue
Block a user