fix(server): Phase 1.2 核心功能修复 — C1/C2/H4/H6
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- feat(diary): 新增 list_all_classes 管理端 API (GET /diary/classes/all)
- feat(diary): 新增班级更新 API (PUT /diary/classes/{id}) — 名称/学校名编辑
- feat(diary): 新增班级停用 API (PATCH /diary/classes/{id}/deactivate)
- feat(diary): 新增班级码重置 API (POST /diary/classes/{id}/reset-code)
- fix(db): 补充权限 seed — student 获得 update/delete, teacher 获得 comment.delete
- refactor(diary): 删除 comment_service 中废弃的 contains_sensitive_words 死代码
- test(diary): 77 测试全部通过
This commit is contained in:
iven
2026-06-02 21:33:47 +08:00
parent 49d4aa36a7
commit a83909dd24
6 changed files with 366 additions and 33 deletions

View File

@@ -81,6 +81,26 @@ pub struct JoinClassReq {
pub class_code: String,
}
/// 更新班级请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateClassReq {
/// 班级名称
pub name: Option<String>,
/// 学校名称
pub school_name: Option<String>,
/// 乐观锁版本号
pub version: i32,
}
/// 重置班级码响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ResetClassCodeResp {
/// 班级 ID
pub class_id: uuid::Uuid,
/// 新的 6 位班级码
pub new_class_code: String,
}
/// 班级响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ClassResp {

View File

@@ -8,7 +8,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::{ClassMemberResp, ClassResp, CreateClassReq, JoinClassReq};
use crate::dto::{ClassMemberResp, ClassResp, CreateClassReq, JoinClassReq, ResetClassCodeResp, UpdateClassReq};
use crate::service::class_service::ClassService;
use crate::state::DiaryState;
@@ -190,3 +190,152 @@ where
let resp = ClassService::my_classes(ctx.tenant_id, ctx.user_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/classes/all",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<ClassResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "班级管理"
)]
/// GET /api/v1/diary/classes/all
///
/// 获取租户下所有班级(管理端用)。需要 `diary.class.manage` 权限。
pub async fn list_all_classes<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<ClassResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.class.manage")?;
let resp = ClassService::list_all_classes(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/diary/classes/{id}",
params(("id" = Uuid, Path, description = "班级ID")),
request_body = UpdateClassReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<ClassResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "班级不存在"),
(status = 409, description = "版本冲突"),
),
security(("bearer_auth" = [])),
tag = "班级管理"
)]
/// PUT /api/v1/diary/classes/:id
///
/// 更新班级信息。需要 `diary.class.manage` 权限(仅班级创建者可编辑)。
pub async fn update_class<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateClassReq>,
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.class.manage")?;
if let Some(ref name) = req.name {
if name.trim().is_empty() {
return Err(AppError::Validation("班级名称不能为空".to_string()));
}
}
let resp = ClassService::update_class(
ctx.tenant_id,
ctx.user_id,
id,
req.name,
req.school_name,
req.version,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
patch,
path = "/api/v1/diary/classes/{id}/deactivate",
params(("id" = Uuid, Path, description = "班级ID")),
responses(
(status = 200, description = "停用成功", body = ApiResponse<ClassResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "班级不存在"),
),
security(("bearer_auth" = [])),
tag = "班级管理"
)]
/// PATCH /api/v1/diary/classes/:id/deactivate
///
/// 停用班级。需要 `diary.class.manage` 权限(仅班级创建者可停用)。
pub async fn deactivate_class<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.class.manage")?;
let resp = ClassService::deactivate_class(
ctx.tenant_id,
ctx.user_id,
id,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/diary/classes/{id}/reset-code",
params(("id" = Uuid, Path, description = "班级ID")),
responses(
(status = 200, description = "重置成功", body = ApiResponse<ResetClassCodeResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "班级不存在"),
),
security(("bearer_auth" = [])),
tag = "班级管理"
)]
/// POST /api/v1/diary/classes/:id/reset-code
///
/// 重置班级码。需要 `diary.class.manage` 权限(仅班级创建者可重置)。
pub async fn reset_class_code<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<ResetClassCodeResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.class.manage")?;
let resp = ClassService::reset_class_code(ctx.tenant_id, ctx.user_id, id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -142,18 +142,31 @@ impl DiaryModule {
axum::routing::post(class_handler::create_class)
.get(class_handler::my_classes),
)
.route(
"/diary/classes/all",
axum::routing::get(class_handler::list_all_classes),
)
.route(
"/diary/classes/join",
axum::routing::post(class_handler::join_class),
)
.route(
"/diary/classes/{id}",
axum::routing::get(class_handler::get_class),
axum::routing::get(class_handler::get_class)
.put(class_handler::update_class),
)
.route(
"/diary/classes/{id}/members",
axum::routing::get(class_handler::list_members),
)
.route(
"/diary/classes/{id}/deactivate",
axum::routing::patch(class_handler::deactivate_class),
)
.route(
"/diary/classes/{id}/reset-code",
axum::routing::post(class_handler::reset_class_code),
)
// 主题布置
.route(
"/diary/classes/{class_id}/topics",

View File

@@ -7,7 +7,7 @@ use sea_orm::{
};
use uuid::Uuid;
use crate::dto::{ClassMemberResp, ClassResp};
use crate::dto::{ClassMemberResp, ClassResp, ResetClassCodeResp};
use crate::entity::{class_member, school_class};
use crate::error::{DiaryError, DiaryResult};
use erp_core::events::{DomainEvent, EventBus};
@@ -321,6 +321,172 @@ impl ClassService {
Ok(classes.into_iter().map(class_model_to_resp).collect())
}
/// 获取租户下所有班级(管理端用)
///
/// 仅限管理员/老师角色调用,返回租户内所有未删除的班级。
pub async fn list_all_classes(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<ClassResp>> {
let classes = school_class::Entity::find()
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(classes.into_iter().map(class_model_to_resp).collect())
}
/// 更新班级信息(老师)
///
/// 仅班级创建者teacher_id可修改班级名称和学校名称。
/// 使用乐观锁防止并发冲突。
pub async fn update_class(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
name: Option<String>,
school_name: Option<String>,
version: i32,
db: &DatabaseConnection,
) -> DiaryResult<ClassResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可编辑
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
// 乐观锁校验
if model.version != version {
return Err(DiaryError::VersionConflict {
local: version,
server: model.version,
});
}
let now = Utc::now();
let mut active: school_class::ActiveModel = model.into();
if let Some(n) = name {
if n.trim().is_empty() {
return Err(DiaryError::Validation("班级名称不能为空".to_string()));
}
active.name = Set(n);
}
if let Some(s) = school_name {
active.school_name = Set(Some(s));
}
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(version + 1);
let updated = active.update(db).await?;
Ok(class_model_to_resp(updated))
}
/// 停用班级(老师)
///
/// 将班级设为停用状态,学生将无法通过班级码加入。
/// 已在班内的学生仍可查看班级内容。
pub async fn deactivate_class(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<ClassResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可停用
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
if !model.is_active {
return Err(DiaryError::BadRequest("班级已处于停用状态".to_string()));
}
let now = Utc::now();
let current_version = model.version;
let mut active: school_class::ActiveModel = model.into();
active.is_active = Set(false);
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(current_version + 1);
let updated = active.update(db).await?;
// 发布 ClassDeactivated 事件
event_bus
.publish(
DomainEvent::new(
"diary.class.deactivated",
tenant_id,
serde_json::json!({
"class_id": class_id,
"teacher_id": user_id,
}),
),
db,
)
.await;
Ok(class_model_to_resp(updated))
}
/// 重置班级码(老师)
///
/// 生成新的 6 位班级码,旧码立即失效。
/// CLAUDE.md 要求:"老师可随时重置"。
pub async fn reset_class_code(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<ResetClassCodeResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可重置班级码
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
let new_code = Self::generate_unique_code(db).await?;
let now = Utc::now();
let current_version = model.version;
let mut active: school_class::ActiveModel = model.into();
active.class_code = Set(new_code.clone());
// 重置过期时间为 6 个月
active.expires_at = Set(now.checked_add_months(Months::new(6)));
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(current_version + 1);
active.update(db).await?;
Ok(ResetClassCodeResp {
class_id,
new_class_code: new_code,
})
}
/// 生成唯一班级码(重试最多 10 次)
async fn generate_unique_code(db: &DatabaseConnection) -> DiaryResult<String> {
for _ in 0..10 {

View File

@@ -200,41 +200,20 @@ fn comment_model_to_resp(model: comment::Model) -> CommentResp {
}
}
/// 基础敏感词检查
///
/// Phase 1 使用简单字符串匹配B6 阶段替换为 ContentSafetyService。
fn contains_sensitive_words(content: &str) -> bool {
const SENSITIVE_WORDS: &[&str] = &[
// 占位 — Phase 1 仅检查是否为空或过短
// 完整词库将在 B6 ContentSafetyService 中添加
];
if content.trim().is_empty() {
return true;
}
for word in SENSITIVE_WORDS {
if content.contains(word) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_content_is_sensitive() {
assert!(contains_sensitive_words(""));
assert!(contains_sensitive_words(" "));
fn content_safety_phase1_empty_is_safe() {
// Phase 1 词库为空,所有内容(包括空串)返回 Safe
// 空内容检查由 handler 层的 Validation 守卫处理
assert!(ContentSafetyService::is_safe(""));
}
#[test]
fn normal_content_is_not_sensitive() {
assert!(!contains_sensitive_words("今天天气真好!"));
assert!(!contains_sensitive_words("老师点评:写得不错"));
fn normal_content_is_safe() {
assert!(ContentSafetyService::is_safe("今天天气真好!"));
assert!(ContentSafetyService::is_safe("老师点评:写得不错"));
}
}

View File

@@ -33,17 +33,23 @@ impl MigrationTrait for Migration {
.map_err(|e| DbErr::Custom(e.to_string()))?;
}
// student 权限: diary.journal.create, diary.journal.read
// teacher 权限: diary.journal.create, diary.journal.read, diary.class.manage, diary.topic.assign, diary.comment.write
// student 权限: diary.journal.create, diary.journal.read, diary.journal.update, diary.journal.delete
// teacher 权限: diary.journal.create, diary.journal.read, diary.journal.update, diary.journal.delete,
// diary.class.manage, diary.topic.assign, diary.comment.write, diary.comment.delete
// parent 权限: diary.journal.read, diary.parent.bind
let role_permissions = [
("student", "diary.journal.create"),
("student", "diary.journal.read"),
("student", "diary.journal.update"),
("student", "diary.journal.delete"),
("teacher", "diary.journal.create"),
("teacher", "diary.journal.read"),
("teacher", "diary.journal.update"),
("teacher", "diary.journal.delete"),
("teacher", "diary.class.manage"),
("teacher", "diary.topic.assign"),
("teacher", "diary.comment.write"),
("teacher", "diary.comment.delete"),
("parent", "diary.journal.read"),
("parent", "diary.parent.bind"),
];