diff --git a/htykc/src/lib.rs b/htykc/src/lib.rs index a6dd122..70c63c4 100644 --- a/htykc/src/lib.rs +++ b/htykc/src/lib.rs @@ -18,8 +18,14 @@ use tracing::{debug, error}; mod notifications; mod ws_clazz; mod ws_clazz_repeat; +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, + find_all_course_packages_by_created_by_with_page, find_course_package_by_id, + update_course_package, +}; use crate::ws_xiaoke::{batch_save_clazz_attendance, find_clazz_attendance_by_clazz_id}; use crate::ws_xiaoke::{ approve_clazz_leave, create_clazz_leave, list_clazz_leave, supervisor_teacher_stats, @@ -97,6 +103,13 @@ pub fn clazz_router(db_url: &str) -> Router { .route("/api/v1/clazz/leave/create", post(create_clazz_leave)) .route("/api/v1/clazz/leave/approve", post(approve_clazz_leave)) .route("/api/v1/clazz/leave/list", get(list_clazz_leave)) + // 课包(course_package) + .route("/api/v1/clazz/course-package/create", post(create_course_package)) + .route("/api/v1/clazz/course-package/update", post(update_course_package)) + .route("/api/v1/clazz/course-package/delete/{id}", post(delete_course_package)) + .route("/api/v1/clazz/course-package/{id}", get(find_course_package_by_id)) + .route("/api/v1/clazz/course-package/my-packages", get(find_all_course_packages_by_created_by_with_page)) + .route("/api/v1/clazz/course-package/org-packages", get(find_all_active_course_packages_by_org_with_page)) .layer(TraceLayer::new_for_http()) .with_state(shared_db_state); diff --git a/htykc/src/ws_course_package.rs b/htykc/src/ws_course_package.rs new file mode 100644 index 0000000..2ce83d1 --- /dev/null +++ b/htykc/src/ws_course_package.rs @@ -0,0 +1,214 @@ +//! 课包(course_package)相关 API — 用于卖课的产品模板 + +use anyhow::anyhow; +use axum::extract::{Path, Query, State}; +use axum::Json; +use htycommons::common::{current_local_datetime, get_some_from_query_params, HtyErr, HtyErrCode, HtyResponse}; +use htycommons::jwt::jwt_decode_token; +use htycommons::web::{ + wrap_json_anyhow_err, wrap_json_ok_resp, AuthorizationHeader, HtyHostHeader, + HtySudoerTokenHeader, +}; +use htykc_models::models::{CoursePackage, ReqCoursePackage}; +use htycommons::uuid; +use std::collections::HashMap; +use std::ops::DerefMut; +use std::sync::Arc; +use tracing::error; + +use htycommons::db::{extract_conn, fetch_db_conn, DbState}; + +fn required_org_id_from_auth(auth: &AuthorizationHeader) -> anyhow::Result { + jwt_decode_token(&(*auth).clone())? + .current_org_id + .ok_or_else(|| anyhow!("current_org_id is required")) +} + +fn required_hty_id_from_auth(auth: &AuthorizationHeader) -> anyhow::Result { + jwt_decode_token(&(*auth).clone())? + .hty_id + .ok_or_else(|| anyhow!("hty_id is required")) +} + +pub async fn create_course_package( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + auth: AuthorizationHeader, + State(db_pool): State>, + Json(body): Json, +) -> Json> { + let result = (|| -> anyhow::Result { + let token = jwt_decode_token(&(*auth).clone())?; + let org_id = token.current_org_id.ok_or_else(|| anyhow!("current_org_id is required"))?; + let hty_id = token.hty_id.ok_or_else(|| anyhow!("hty_id is required"))?; + let now = current_local_datetime(); + let payload = CoursePackage { + id: body.id.unwrap_or_else(uuid), + package_name: body.package_name.ok_or_else(|| anyhow!("package_name is required"))?, + description: body.description, + ws_course_id: body.ws_course_id, + total_lessons: body.total_lessons, + original_price: body.original_price, + selling_price: body.selling_price, + validity_days: body.validity_days, + package_status: body.package_status.unwrap_or_else(|| "ACTIVE".to_string()), + cover_image_url: body.cover_image_url, + sort_order: body.sort_order, + created_by: Some(hty_id), + created_at: now, + updated_at: None, + is_delete: Some(false), + org_id: Some(org_id), + }; + CoursePackage::create(&payload, extract_conn(fetch_db_conn(&db_pool)?).deref_mut()) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("create_course_package -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} + +pub async fn update_course_package( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + auth: AuthorizationHeader, + State(db_pool): State>, + Json(body): Json, +) -> Json> { + let result = (|| -> anyhow::Result { + let token = jwt_decode_token(&(*auth).clone())?; + let package_id = body.id.clone().ok_or_else(|| anyhow!("id is required"))?; + 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)?; + + // 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) { + return Err(anyhow!(HtyErr { + code: HtyErrCode::AuthenticationFailed, + reason: Some("course package does not belong to current organization".to_string()), + })); + } + } + + let now = current_local_datetime(); + let payload = CoursePackage { + id: package_id, + package_name: body.package_name.unwrap_or(existing.package_name), + description: body.description.or(existing.description), + ws_course_id: body.ws_course_id.or(existing.ws_course_id), + total_lessons: body.total_lessons.or(existing.total_lessons), + original_price: body.original_price.or(existing.original_price), + selling_price: body.selling_price.or(existing.selling_price), + validity_days: body.validity_days.or(existing.validity_days), + package_status: body.package_status.unwrap_or(existing.package_status), + cover_image_url: body.cover_image_url.or(existing.cover_image_url), + sort_order: body.sort_order.or(existing.sort_order), + created_by: existing.created_by, + created_at: existing.created_at, + updated_at: Some(now), + is_delete: existing.is_delete, + org_id: existing.org_id, + }; + CoursePackage::update(&payload, conn) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("update_course_package -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} + +pub async fn delete_course_package( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + auth: AuthorizationHeader, + State(db_pool): State>, + Path(package_id): Path, +) -> Json> { + let result = (|| -> anyhow::Result { + let _token = jwt_decode_token(&(*auth).clone())?; + CoursePackage::logic_delete_by_id(&package_id, extract_conn(fetch_db_conn(&db_pool)?).deref_mut()) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("delete_course_package -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} + +pub async fn find_course_package_by_id( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + _auth: AuthorizationHeader, + State(db_pool): State>, + Path(package_id): Path, +) -> Json> { + let result = (|| -> anyhow::Result { + let mut conn_holder = extract_conn(fetch_db_conn(&db_pool)?); + CoursePackage::find_by_id(&package_id, conn_holder.deref_mut()) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("find_course_package_by_id -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} + +pub async fn find_all_course_packages_by_created_by_with_page( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + auth: AuthorizationHeader, + State(db_pool): State>, + Query(params): Query>, +) -> Json, i64, i64)>> { + let result = (|| -> anyhow::Result<(Vec, i64, i64)> { + let hty_id = required_hty_id_from_auth(&auth)?; + let org_id = required_org_id_from_auth(&auth)?; + let page = get_some_from_query_params::("page", ¶ms).unwrap_or(1); + let page_size = get_some_from_query_params::("page_size", ¶ms).unwrap_or(20); + let keyword = get_some_from_query_params::("keyword", ¶ms); + CoursePackage::find_all_by_teacher_id_with_page(&hty_id, &org_id, &keyword, page, page_size, extract_conn(fetch_db_conn(&db_pool)?).deref_mut()) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("find_all_course_packages_by_created_by_with_page -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} + +pub async fn find_all_active_course_packages_by_org_with_page( + _sudoer: HtySudoerTokenHeader, + _host: HtyHostHeader, + auth: AuthorizationHeader, + State(db_pool): State>, + Query(params): Query>, +) -> Json, i64, i64)>> { + let result = (|| -> anyhow::Result<(Vec, i64, i64)> { + let org_id = required_org_id_from_auth(&auth)?; + let page = get_some_from_query_params::("page", ¶ms).unwrap_or(1); + let page_size = get_some_from_query_params::("page_size", ¶ms).unwrap_or(20); + let keyword = get_some_from_query_params::("keyword", ¶ms); + CoursePackage::find_all_active_by_org_with_page(&org_id, &keyword, page, page_size, extract_conn(fetch_db_conn(&db_pool)?).deref_mut()) + })(); + match result { + Ok(ok) => wrap_json_ok_resp(ok), + Err(e) => { + error!("find_all_active_course_packages_by_org_with_page -> failed, e: {}", e); + wrap_json_anyhow_err(e) + } + } +} diff --git a/htykc_models/migrations/2026-04-30-100000_create_course_package/down.sql b/htykc_models/migrations/2026-04-30-100000_create_course_package/down.sql new file mode 100644 index 0000000..23d4586 --- /dev/null +++ b/htykc_models/migrations/2026-04-30-100000_create_course_package/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS course_package; diff --git a/htykc_models/migrations/2026-04-30-100000_create_course_package/up.sql b/htykc_models/migrations/2026-04-30-100000_create_course_package/up.sql new file mode 100644 index 0000000..abdd00b --- /dev/null +++ b/htykc_models/migrations/2026-04-30-100000_create_course_package/up.sql @@ -0,0 +1,23 @@ +-- 课包(用于卖课的产品模板) +CREATE TABLE course_package ( + id VARCHAR NOT NULL PRIMARY KEY, + package_name VARCHAR NOT NULL, + description TEXT, + ws_course_id VARCHAR, + total_lessons INT4 DEFAULT 0, + original_price INT8 DEFAULT 0, + selling_price INT8 DEFAULT 0, + validity_days INT4 DEFAULT 0, + package_status VARCHAR NOT NULL DEFAULT 'ACTIVE', + cover_image_url VARCHAR, + sort_order INT4 DEFAULT 0, + created_by VARCHAR, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + is_delete BOOL DEFAULT FALSE, + org_id VARCHAR +); + +CREATE INDEX idx_course_package_org_id ON course_package (org_id); +CREATE INDEX idx_course_package_created_by ON course_package (created_by); +CREATE INDEX idx_course_package_status ON course_package (package_status); diff --git a/htykc_models/src/models.rs b/htykc_models/src/models.rs index e99684c..e98049b 100644 --- a/htykc_models/src/models.rs +++ b/htykc_models/src/models.rs @@ -3,7 +3,7 @@ use chrono::NaiveDateTime; use diesel::{ delete, insert_into, sql_query, update, BoolExpressionMethods, Connection, ExpressionMethods, OptionalExtension, PgConnection, - QueryDsl, RunQueryDsl, + QueryDsl, RunQueryDsl, TextExpressionMethods, }; use log::debug; use string_builder::Builder; @@ -716,6 +716,201 @@ pub struct ReqClazzUser { pub user_type: Option, } +// --- 课包(course_package,用于卖课)--- + +#[derive( + AsChangeset, + Identifiable, + PartialEq, + Serialize, + Deserialize, + Queryable, + Debug, + Insertable, + Clone, +)] +#[diesel(table_name = course_package)] +pub struct CoursePackage { + pub id: String, + pub package_name: String, + pub description: Option, + pub ws_course_id: Option, + pub total_lessons: Option, + pub original_price: Option, + pub selling_price: Option, + pub validity_days: Option, + pub package_status: String, + pub cover_image_url: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_at: NaiveDateTime, + pub updated_at: Option, + pub is_delete: Option, + pub org_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ReqCoursePackage { + pub id: Option, + pub package_name: Option, + pub description: Option, + pub ws_course_id: Option, + pub total_lessons: Option, + pub original_price: Option, + pub selling_price: Option, + pub validity_days: Option, + pub package_status: Option, + pub cover_image_url: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_at: Option, + pub updated_at: Option, + pub is_delete: Option, + pub org_id: Option, +} + +impl CoursePackage { + pub fn find_by_id(id: &String, conn: &mut PgConnection) -> anyhow::Result { + use crate::schema::course_package::dsl::{ + course_package as packages, id as pkg_id, is_delete, + }; + match packages + .filter(pkg_id.eq(id)) + .filter(is_delete.is_null().or(is_delete.eq(false))) + .first::(conn) + { + Ok(q) => Ok(q), + Err(e) => Err(anyhow!(HtyErr { + code: HtyErrCode::DbErr, + reason: Some(e.to_string()), + })), + } + } + + pub fn create(in_package: &CoursePackage, conn: &mut PgConnection) -> anyhow::Result { + use crate::schema::course_package::dsl::course_package as packages; + match insert_into(packages).values(in_package).get_result(conn) { + Ok(res) => Ok(res), + Err(e) => Err(anyhow!(HtyErr { + code: HtyErrCode::DbErr, + reason: Some(e.to_string()), + })), + } + } + + pub fn update(in_package: &CoursePackage, conn: &mut PgConnection) -> anyhow::Result { + use crate::schema::course_package::dsl::{course_package as packages, id as pkg_id}; + let result = update(packages) + .filter(pkg_id.eq(in_package.clone().id)) + .set(in_package) + .get_result(conn) + .map_err(|e| { + anyhow!(HtyErr { + code: HtyErrCode::DbErr, + reason: Some(e.to_string()), + }) + }); + result + } + + pub fn logic_delete_by_id(id: &String, conn: &mut PgConnection) -> anyhow::Result { + use crate::schema::course_package::dsl::{ + course_package as packages, id as pkg_id, is_delete, updated_at, + }; + let now = current_local_datetime(); + update(packages) + .filter(pkg_id.eq(id)) + .set(( + is_delete.eq(true), + updated_at.eq(Some(now)), + )) + .execute(conn) + .map_err(|e| { + anyhow!(HtyErr { + code: HtyErrCode::DbErr, + reason: Some(e.to_string()), + }) + }) + } + + pub fn find_all_active_by_org_with_page( + org: &String, + keyword: &Option, + page: i64, + page_size: i64, + conn: &mut PgConnection, + ) -> anyhow::Result<(Vec, i64, i64)> { + use crate::schema::course_package::dsl::{ + course_package as packages, is_delete, org_id, package_name, package_status, + sort_order, created_at, + }; + let total: i64 = packages + .filter(org_id.eq(org)) + .filter(package_status.eq("ACTIVE")) + .filter(is_delete.is_null().or(is_delete.eq(false))) + .count() + .get_result(conn)?; + let pages = (total + page_size - 1) / page_size; + let offset = (page - 1) * page_size; + let kws = keyword.clone().unwrap_or_default(); + let mut query = packages + .filter(org_id.eq(org)) + .filter(package_status.eq("ACTIVE")) + .filter(is_delete.is_null().or(is_delete.eq(false))) + .into_boxed(); + if !kws.is_empty() { + let pattern = format!("%{}%", kws); + query = query.filter(package_name.like(pattern)); + } + let items = query + .order(sort_order.asc()) + .then_order_by(created_at.desc()) + .offset(offset) + .limit(page_size) + .load::(conn)?; + Ok((items, pages, total)) + } + + pub fn find_all_by_teacher_id_with_page( + teacher_id: &String, + org: &String, + keyword: &Option, + page: i64, + page_size: i64, + conn: &mut PgConnection, + ) -> anyhow::Result<(Vec, i64, i64)> { + use crate::schema::course_package::dsl::{ + course_package as packages, created_by, is_delete, org_id, package_name, sort_order, + created_at, + }; + let total: i64 = packages + .filter(org_id.eq(org)) + .filter(created_by.eq(teacher_id)) + .filter(is_delete.is_null().or(is_delete.eq(false))) + .count() + .get_result(conn)?; + let pages = (total + page_size - 1) / page_size; + let offset = (page - 1) * page_size; + let kws = keyword.clone().unwrap_or_default(); + let mut query = packages + .filter(org_id.eq(org)) + .filter(created_by.eq(teacher_id)) + .filter(is_delete.is_null().or(is_delete.eq(false))) + .into_boxed(); + if !kws.is_empty() { + let pattern = format!("%{}%", kws); + query = query.filter(package_name.like(pattern)); + } + let items = query + .order(sort_order.asc()) + .then_order_by(created_at.desc()) + .offset(offset) + .limit(page_size) + .load::(conn)?; + Ok((items, pages, total)) + } +} + // --- 消课 / 课时包(xiaoke)--- #[derive(Queryable, Serialize, Deserialize, Debug, Clone)] diff --git a/htykc_models/src/schema.rs b/htykc_models/src/schema.rs index 8329f43..16c61a6 100644 --- a/htykc_models/src/schema.rs +++ b/htykc_models/src/schema.rs @@ -44,6 +44,24 @@ diesel::table! { } } +diesel::table! { + clazz_leave_request (id) { + id -> Varchar, + org_id -> Varchar, + clazz_id -> Varchar, + student_id -> Varchar, + teacher_id -> Nullable, + leave_type -> Varchar, + reason -> Nullable, + request_status -> Varchar, + created_at -> Timestamp, + created_by -> Nullable, + reviewed_at -> Nullable, + reviewed_by -> Nullable, + is_delete -> Bool, + } +} + diesel::table! { clazz_repeat (id) { id -> Varchar, @@ -77,6 +95,27 @@ diesel::table! { } } +diesel::table! { + course_package (id) { + id -> Varchar, + package_name -> Varchar, + description -> Nullable, + ws_course_id -> Nullable, + total_lessons -> Nullable, + original_price -> Nullable, + selling_price -> Nullable, + validity_days -> Nullable, + package_status -> Varchar, + cover_image_url -> Nullable, + sort_order -> Nullable, + created_by -> Nullable, + created_at -> Timestamp, + updated_at -> Nullable, + is_delete -> Nullable, + org_id -> Nullable, + } +} + diesel::table! { hour_transaction (id) { id -> Varchar, @@ -92,27 +131,9 @@ diesel::table! { } } -diesel::table! { - clazz_leave_request (id) { - id -> Varchar, - org_id -> Varchar, - clazz_id -> Varchar, - student_id -> Varchar, - teacher_id -> Nullable, - leave_type -> Varchar, - reason -> Nullable, - request_status -> Varchar, - created_at -> Timestamp, - created_by -> Nullable, - reviewed_at -> Nullable, - reviewed_by -> Nullable, - is_delete -> Bool, - } -} - diesel::joinable!(clazz_attendance -> clazz (clazz_id)); -diesel::joinable!(clazz_leave_request -> clazz (clazz_id)); diesel::joinable!(clazz_attendance -> course_hour_package (course_hour_package_id)); +diesel::joinable!(clazz_leave_request -> clazz (clazz_id)); diesel::joinable!(clazz_repeat -> clazz (clazz_id)); diesel::joinable!(hour_transaction -> clazz (clazz_id)); diesel::joinable!(hour_transaction -> course_hour_package (package_id)); @@ -123,5 +144,6 @@ diesel::allow_tables_to_appear_in_same_query!( clazz_leave_request, clazz_repeat, course_hour_package, + course_package, hour_transaction, );