fix(server): Phase 1.2 核心功能修复 — C1/C2/H4/H6
- 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:
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("老师点评:写得不错"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user