feat(health): 积分规则/商品 update/delete + 标签更新端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 积分规则: 添加 update/delete service + handler + 路由
- 兑换商品: 添加 update/delete service + handler + 路由
- 文章标签: 添加 update service + handler + 路由
- Web 管理端: 规则/商品列表页支持编辑/删除/启用切换
- Web 管理端: 标签管理页支持编辑、删除传 version
This commit is contained in:
iven
2026-04-26 14:07:21 +08:00
parent f0076aa240
commit 55ec57b2c0
13 changed files with 504 additions and 71 deletions

View File

@@ -218,3 +218,15 @@ impl CreateTagReq {
self.name = sanitize_string(&self.name);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateTagReq {
pub name: String,
pub version: i32,
}
impl UpdateTagReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
}
}

View File

@@ -264,6 +264,18 @@ pub struct OfflineEventResp {
// 管理端:带版本号的更新/删除包装
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateRuleWithVersion {
pub data: UpdatePointsRuleReq,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateProductWithVersion {
pub data: UpdatePointsProductReq,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateOfflineEventWithVersion {
pub data: UpdateOfflineEventReq,

View File

@@ -29,6 +29,9 @@ pub enum HealthError {
#[error("日常监测记录不存在")]
DailyMonitoringNotFound,
#[error("积分规则不存在")]
PointsRuleNotFound,
#[error("兑换商品不存在")]
PointsProductNotFound,
@@ -92,6 +95,7 @@ impl From<HealthError> for AppError {
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound
| HealthError::ArticleNotFound
| HealthError::PointsRuleNotFound
| HealthError::PointsProductNotFound
| HealthError::PointsOrderNotFound
| HealthError::OfflineEventNotFound

View File

@@ -6,7 +6,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
use crate::service::article_tag_service;
use crate::state::HealthState;
@@ -40,6 +40,24 @@ where
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_tag<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<UpdateTagReq>,
) -> Result<Json<ApiResponse<TagResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_tag_service::update_tag(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteTagReq {
pub version: i32,

View File

@@ -227,6 +227,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_rule<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>,
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data;
data.sanitize();
let result = points_service::update_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_rule<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::delete_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}
pub async fn admin_create_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -243,6 +275,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_update_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data;
data.sanitize();
let result = points_service::update_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_delete_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::delete_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}
pub async fn admin_list_orders<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -372,7 +372,8 @@ impl HealthModule {
)
.route(
"/health/article-tags/{id}",
axum::routing::delete(article_tag_handler::delete_tag),
axum::routing::put(article_tag_handler::update_tag)
.delete(article_tag_handler::delete_tag),
)
// 积分商城 — 患者端
.route(
@@ -426,10 +427,20 @@ impl HealthModule {
axum::routing::get(points_handler::list_rules)
.post(points_handler::create_rule),
)
.route(
"/health/admin/points/rules/{id}",
axum::routing::put(points_handler::update_rule)
.delete(points_handler::delete_rule),
)
.route(
"/health/admin/points/products",
axum::routing::post(points_handler::admin_create_product),
)
.route(
"/health/admin/points/products/{id}",
axum::routing::put(points_handler::admin_update_product)
.delete(points_handler::admin_delete_product),
)
.route(
"/health/admin/points/orders",
axum::routing::get(points_handler::admin_list_orders),

View File

@@ -9,7 +9,7 @@ use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
use crate::entity::article_tag;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -66,6 +66,44 @@ pub async fn create_tag(
})
}
pub async fn update_tag(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateTagReq,
) -> HealthResult<TagResp> {
let expected_version = req.version;
let model = article_tag::Entity::find()
.filter(article_tag::Column::Id.eq(id))
.filter(article_tag::Column::TenantId.eq(tenant_id))
.filter(article_tag::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::TagNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: article_tag::ActiveModel = model.into();
active.name = Set(req.name);
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article_tag.updated", "article_tag")
.with_resource_id(m.id),
&state.db,
).await;
Ok(TagResp {
id: m.id, name: m.name,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_tag(
state: &HealthState,
tenant_id: Uuid,

View File

@@ -645,6 +645,89 @@ pub async fn create_product(
})
}
pub async fn update_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsProductReq,
expected_version: i32,
) -> HealthResult<PointsProductResp> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(product_type) = req.product_type { active.product_type = Set(product_type); }
if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); }
if let Some(stock) = req.stock { active.stock = Set(stock); }
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.updated", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 兑换FIFO 消费积分)
// ---------------------------------------------------------------------------
@@ -1055,6 +1138,89 @@ pub async fn create_rule(
})
}
pub async fn update_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsRuleReq,
expected_version: i32,
) -> HealthResult<PointsRuleResp> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(points_value) = req.points_value { active.points_value = Set(points_value); }
if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); }
if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); }
if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); }
if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.updated", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsRuleResp {
id: m.id, event_type: m.event_type, name: m.name,
description: m.description, points_value: m.points_value,
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
is_active: m.is_active, created_at: m.created_at,
updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 线下活动
// ---------------------------------------------------------------------------