feat(course-package): add publish/unpublish with snapshot locking
- Add published_snapshot (jsonb) and published_at (timestamp) columns
- New POST publish/{id} endpoint: stores snapshot JSON, locks editing
- New POST unpublish/{id} endpoint: clears published_at for editing
- Block update and sync_items when published_at is set
- Frontend detail page with conditional edit/publish/unpublish buttons
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+6
-2
@@ -22,9 +22,11 @@ mod ws_course_package;
|
||||
mod ws_xiaoke;
|
||||
|
||||
use crate::ws_course_package::{
|
||||
create_course_package, delete_course_package, find_all_active_course_packages_by_org_with_page,
|
||||
create_course_package, delete_course_package,
|
||||
find_all_active_course_packages_by_org_with_page,
|
||||
find_all_course_packages_by_created_by_with_page, find_course_package_by_id,
|
||||
list_course_package_items, sync_course_package_items, update_course_package,
|
||||
list_course_package_items, publish_course_package, sync_course_package_items,
|
||||
unpublish_course_package, update_course_package,
|
||||
};
|
||||
use crate::ws_xiaoke::{batch_save_clazz_attendance, find_clazz_attendance_by_clazz_id};
|
||||
use crate::ws_xiaoke::{
|
||||
@@ -112,6 +114,8 @@ pub fn clazz_router(db_url: &str) -> Router {
|
||||
.route("/api/v1/clazz/course-package/org-packages", get(find_all_active_course_packages_by_org_with_page))
|
||||
.route("/api/v1/clazz/course-package/item/sync", post(sync_course_package_items))
|
||||
.route("/api/v1/clazz/course-package/item/list/{package_id}", get(list_course_package_items))
|
||||
.route("/api/v1/clazz/course-package/publish/{id}", post(publish_course_package))
|
||||
.route("/api/v1/clazz/course-package/unpublish/{id}", post(unpublish_course_package))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(shared_db_state);
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ pub async fn create_course_package(
|
||||
updated_at: None,
|
||||
is_delete: Some(false),
|
||||
org_id: Some(org_id),
|
||||
published_snapshot: None,
|
||||
published_at: None,
|
||||
};
|
||||
CoursePackage::create(&payload, extract_conn(fetch_db_conn(&db_pool)?).deref_mut())
|
||||
})();
|
||||
@@ -86,6 +88,14 @@ pub async fn update_course_package(
|
||||
|
||||
let existing = CoursePackage::find_by_id(&package_id, conn)?;
|
||||
|
||||
// Reject editing if package is published (has snapshot)
|
||||
if existing.published_at.is_some() {
|
||||
return Err(anyhow!(HtyErr {
|
||||
code: HtyErrCode::CommonError,
|
||||
reason: Some("已发布的课包不可编辑,请先下架再修改".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Only creator or same org can update
|
||||
if let Some(org_id) = &token.current_org_id {
|
||||
if existing.org_id.as_ref() != Some(org_id) {
|
||||
@@ -114,6 +124,8 @@ pub async fn update_course_package(
|
||||
updated_at: Some(now),
|
||||
is_delete: existing.is_delete,
|
||||
org_id: existing.org_id,
|
||||
published_snapshot: existing.published_snapshot,
|
||||
published_at: existing.published_at,
|
||||
};
|
||||
CoursePackage::update(&payload, conn)
|
||||
})();
|
||||
@@ -246,6 +258,15 @@ pub async fn sync_course_package_items(
|
||||
let mut conn_holder = extract_conn(fetch_db_conn(&db_pool)?);
|
||||
let conn = conn_holder.deref_mut();
|
||||
|
||||
// Reject if package is published
|
||||
let existing = CoursePackage::find_by_id(&package_id, conn)?;
|
||||
if existing.published_at.is_some() {
|
||||
return Err(anyhow!(HtyErr {
|
||||
code: HtyErrCode::CommonError,
|
||||
reason: Some("已发布的课包不可修改内容,请先下架再操作".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// 先软删除现有项目
|
||||
CoursePackageItem::logic_delete_by_package_id(&package_id, conn)?;
|
||||
|
||||
@@ -295,3 +316,74 @@ pub async fn list_course_package_items(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish_course_package(
|
||||
_sudoer: HtySudoerTokenHeader,
|
||||
auth: AuthorizationHeader,
|
||||
State(db_pool): State<Arc<DbState>>,
|
||||
Path(package_id): Path<String>,
|
||||
Json(snapshot): Json<serde_json::Value>,
|
||||
) -> Json<HtyResponse<CoursePackage>> {
|
||||
let result = (|| -> anyhow::Result<CoursePackage> {
|
||||
require_supervisor_role(&auth)?;
|
||||
let mut conn_holder = extract_conn(fetch_db_conn(&db_pool)?);
|
||||
let conn = conn_holder.deref_mut();
|
||||
|
||||
let existing = CoursePackage::find_by_id(&package_id, conn)?;
|
||||
if existing.published_at.is_some() {
|
||||
return Err(anyhow!(HtyErr {
|
||||
code: HtyErrCode::CommonError,
|
||||
reason: Some("课包已发布,不可重复发布".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
let now = current_local_datetime();
|
||||
let payload = CoursePackage {
|
||||
published_snapshot: Some(snapshot),
|
||||
published_at: Some(now),
|
||||
..existing
|
||||
};
|
||||
CoursePackage::update(&payload, conn)
|
||||
})();
|
||||
match result {
|
||||
Ok(ok) => wrap_json_ok_resp(ok),
|
||||
Err(e) => {
|
||||
error!("publish_course_package -> failed, e: {}", e);
|
||||
wrap_json_anyhow_err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unpublish_course_package(
|
||||
_sudoer: HtySudoerTokenHeader,
|
||||
auth: AuthorizationHeader,
|
||||
State(db_pool): State<Arc<DbState>>,
|
||||
Path(package_id): Path<String>,
|
||||
) -> Json<HtyResponse<CoursePackage>> {
|
||||
let result = (|| -> anyhow::Result<CoursePackage> {
|
||||
require_supervisor_role(&auth)?;
|
||||
let mut conn_holder = extract_conn(fetch_db_conn(&db_pool)?);
|
||||
let conn = conn_holder.deref_mut();
|
||||
|
||||
let existing = CoursePackage::find_by_id(&package_id, conn)?;
|
||||
if existing.published_at.is_none() {
|
||||
return Err(anyhow!(HtyErr {
|
||||
code: HtyErrCode::CommonError,
|
||||
reason: Some("课包未发布,无法下架".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
let payload = CoursePackage {
|
||||
published_at: None,
|
||||
..existing
|
||||
};
|
||||
CoursePackage::update(&payload, conn)
|
||||
})();
|
||||
match result {
|
||||
Ok(ok) => wrap_json_ok_resp(ok),
|
||||
Err(e) => {
|
||||
error!("unpublish_course_package -> failed, e: {}", e);
|
||||
wrap_json_anyhow_err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE course_package DROP COLUMN IF EXISTS published_snapshot;
|
||||
ALTER TABLE course_package DROP COLUMN IF EXISTS published_at;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE course_package ADD COLUMN published_snapshot jsonb;
|
||||
ALTER TABLE course_package ADD COLUMN published_at timestamp;
|
||||
@@ -747,6 +747,8 @@ pub struct CoursePackage {
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
pub is_delete: Option<bool>,
|
||||
pub org_id: Option<String>,
|
||||
pub published_snapshot: Option<serde_json::Value>,
|
||||
pub published_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -767,6 +769,8 @@ pub struct ReqCoursePackage {
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
pub is_delete: Option<bool>,
|
||||
pub org_id: Option<String>,
|
||||
pub published_snapshot: Option<serde_json::Value>,
|
||||
pub published_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl CoursePackage {
|
||||
|
||||
@@ -113,6 +113,8 @@ diesel::table! {
|
||||
updated_at -> Nullable<Timestamp>,
|
||||
is_delete -> Nullable<Bool>,
|
||||
org_id -> Nullable<Varchar>,
|
||||
published_snapshot -> Nullable<Jsonb>,
|
||||
published_at -> Nullable<Timestamp>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user