feat(htykc): add course_package CRUD with pagination and org scoping

Introduce course_package table and API for selling course templates.
Supports teacher-owned and org-wide active package queries with keyword
search, sort order, and pagination.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 08:15:12 +08:00
parent 25d33e76d3
commit 542fb2461a
6 changed files with 488 additions and 20 deletions
+13
View File
@@ -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);
+214
View File
@@ -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<String> {
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<String> {
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<Arc<DbState>>,
Json(body): Json<ReqCoursePackage>,
) -> Json<HtyResponse<CoursePackage>> {
let result = (|| -> anyhow::Result<CoursePackage> {
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<Arc<DbState>>,
Json(body): Json<ReqCoursePackage>,
) -> Json<HtyResponse<CoursePackage>> {
let result = (|| -> anyhow::Result<CoursePackage> {
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<Arc<DbState>>,
Path(package_id): Path<String>,
) -> Json<HtyResponse<usize>> {
let result = (|| -> anyhow::Result<usize> {
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<Arc<DbState>>,
Path(package_id): Path<String>,
) -> Json<HtyResponse<CoursePackage>> {
let result = (|| -> anyhow::Result<CoursePackage> {
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<Arc<DbState>>,
Query(params): Query<HashMap<String, String>>,
) -> Json<HtyResponse<(Vec<CoursePackage>, i64, i64)>> {
let result = (|| -> anyhow::Result<(Vec<CoursePackage>, 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::<i64>("page", &params).unwrap_or(1);
let page_size = get_some_from_query_params::<i64>("page_size", &params).unwrap_or(20);
let keyword = get_some_from_query_params::<String>("keyword", &params);
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<Arc<DbState>>,
Query(params): Query<HashMap<String, String>>,
) -> Json<HtyResponse<(Vec<CoursePackage>, i64, i64)>> {
let result = (|| -> anyhow::Result<(Vec<CoursePackage>, i64, i64)> {
let org_id = required_org_id_from_auth(&auth)?;
let page = get_some_from_query_params::<i64>("page", &params).unwrap_or(1);
let page_size = get_some_from_query_params::<i64>("page_size", &params).unwrap_or(20);
let keyword = get_some_from_query_params::<String>("keyword", &params);
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)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS course_package;
@@ -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);
+196 -1
View File
@@ -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<String>,
}
// --- 课包(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<String>,
pub ws_course_id: Option<String>,
pub total_lessons: Option<i32>,
pub original_price: Option<i64>,
pub selling_price: Option<i64>,
pub validity_days: Option<i32>,
pub package_status: String,
pub cover_image_url: Option<String>,
pub sort_order: Option<i32>,
pub created_by: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
pub is_delete: Option<bool>,
pub org_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ReqCoursePackage {
pub id: Option<String>,
pub package_name: Option<String>,
pub description: Option<String>,
pub ws_course_id: Option<String>,
pub total_lessons: Option<i32>,
pub original_price: Option<i64>,
pub selling_price: Option<i64>,
pub validity_days: Option<i32>,
pub package_status: Option<String>,
pub cover_image_url: Option<String>,
pub sort_order: Option<i32>,
pub created_by: Option<String>,
pub created_at: Option<NaiveDateTime>,
pub updated_at: Option<NaiveDateTime>,
pub is_delete: Option<bool>,
pub org_id: Option<String>,
}
impl CoursePackage {
pub fn find_by_id(id: &String, conn: &mut PgConnection) -> anyhow::Result<CoursePackage> {
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::<CoursePackage>(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<CoursePackage> {
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<CoursePackage> {
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<usize> {
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<String>,
page: i64,
page_size: i64,
conn: &mut PgConnection,
) -> anyhow::Result<(Vec<CoursePackage>, 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::<CoursePackage>(conn)?;
Ok((items, pages, total))
}
pub fn find_all_by_teacher_id_with_page(
teacher_id: &String,
org: &String,
keyword: &Option<String>,
page: i64,
page_size: i64,
conn: &mut PgConnection,
) -> anyhow::Result<(Vec<CoursePackage>, 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::<CoursePackage>(conn)?;
Ok((items, pages, total))
}
}
// --- 消课 / 课时包(xiaoke---
#[derive(Queryable, Serialize, Deserialize, Debug, Clone)]
+41 -19
View File
@@ -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<Varchar>,
leave_type -> Varchar,
reason -> Nullable<Varchar>,
request_status -> Varchar,
created_at -> Timestamp,
created_by -> Nullable<Varchar>,
reviewed_at -> Nullable<Timestamp>,
reviewed_by -> Nullable<Varchar>,
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<Text>,
ws_course_id -> Nullable<Varchar>,
total_lessons -> Nullable<Int4>,
original_price -> Nullable<Int8>,
selling_price -> Nullable<Int8>,
validity_days -> Nullable<Int4>,
package_status -> Varchar,
cover_image_url -> Nullable<Varchar>,
sort_order -> Nullable<Int4>,
created_by -> Nullable<Varchar>,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
is_delete -> Nullable<Bool>,
org_id -> Nullable<Varchar>,
}
}
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<Varchar>,
leave_type -> Varchar,
reason -> Nullable<Varchar>,
request_status -> Varchar,
created_at -> Timestamp,
created_by -> Nullable<Varchar>,
reviewed_at -> Nullable<Timestamp>,
reviewed_by -> Nullable<Varchar>,
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,
);