fix(health+server+mp): 审计 P0 批次修复 — 积分冲突/文章草稿泄露/商城空白/模板ID配置化
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

P0-1: 微信模板 ID 从硬编码空字符串改为环境变量注入
  - wechat-templates.ts 读取 process.env.TARO_APP_WX_TEMPLATE_*
  - defineConstants 新增 5 个模板 ID 编译时注入

P0-2: 积分商城 Tab 空白降级
  - mall/index.tsx 在 currentPatient 为 null 时先调用 loadPatients()
  - 仍无档案才显示空状态引导,而非直接阻断

P0-3: 消除 erp-points 重复路由冲突
  - 从 erp-server 移除 erp-points 模块注册和路由 merge
  - 积分功能统一由 erp-health /health/points/* 提供
  - erp-points crate 保留但不参与编译

P0-4: 文章列表按角色过滤防止草稿泄露
  - list_articles handler: 非管理权限强制 status=published
  - get_article service: 新增 is_admin 参数控制状态过滤
This commit is contained in:
iven
2026-04-29 15:11:05 +08:00
parent facc8b0d24
commit dffa2dd47d
9 changed files with 46 additions and 39 deletions

View File

@@ -2,7 +2,7 @@ use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::rbac::{require_any_permission, require_permission};
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
@@ -21,9 +21,15 @@ where
require_permission(&ctx, "health.articles.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
// 非管理权限用户只能查看已发布文章,防止草稿泄露
let status = if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok() {
params.status
} else {
Some("published".to_string())
};
let result = article_service::list_articles(
&state, ctx.tenant_id, page, page_size,
params.category, params.status, params.category_id, params.tag_id, params.keyword,
params.category, status, params.category_id, params.tag_id, params.keyword,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -39,7 +45,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.list")?;
let result = article_service::get_article(&state, ctx.tenant_id, id).await?;
let is_admin = require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"]).is_ok();
let result = article_service::get_article(&state, ctx.tenant_id, id, is_admin).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -99,16 +99,21 @@ pub async fn list_articles(
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 获取文章详情(管理端,不过滤发布状态
/// 获取文章详情(管理端可查看任意状态,非管理端仅已发布
pub async fn get_article(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
is_admin: bool,
) -> HealthResult<ArticleResp> {
let model = article::Entity::find()
let mut query = article::Entity::find()
.filter(article::Column::Id.eq(id))
.filter(article::Column::TenantId.eq(tenant_id))
.filter(article::Column::DeletedAt.is_null())
.filter(article::Column::DeletedAt.is_null());
if !is_admin {
query = query.filter(article::Column::Status.eq("published"));
}
let model = query
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;

View File

@@ -31,7 +31,7 @@ erp-plugin.workspace = true
erp-health.workspace = true
erp-ai.workspace = true
erp-dialysis.workspace = true
erp-points.workspace = true
# erp-points 已禁用,积分功能统一由 erp-health 提供
anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -349,13 +349,9 @@ async fn main() -> anyhow::Result<()> {
"AI module initialized"
);
// Initialize points module
let points_module = erp_points::PointsModule;
tracing::info!(
module = points_module.name(),
version = points_module.version(),
"Points module initialized"
);
// Points module 已统一到 erp-health/health/points/* 路由)
// erp-points 的 /points/* 路由为重复实现(大部分 501已禁用
// Initialize dialysis module
let dialysis_module = erp_dialysis::DialysisModule;
@@ -373,7 +369,7 @@ async fn main() -> anyhow::Result<()> {
.register(message_module)
.register(health_module)
.register(ai_module)
.register(points_module)
// erp-points 已禁用,积分功能统一由 erp-health 提供
.register(dialysis_module);
tracing::info!(
module_count = registry.modules().len(),
@@ -564,7 +560,7 @@ async fn main() -> anyhow::Result<()> {
.merge(erp_plugin::module::PluginModule::protected_routes())
.merge(erp_health::HealthModule::protected_routes())
.merge(erp_ai::AiModule::protected_routes())
.merge(erp_points::PointsModule::protected_routes())
// erp-points 已禁用,积分路由统一由 erp-health /health/points/* 提供
.merge(erp_dialysis::DialysisModule::protected_routes())
.merge(handlers::audit_log::audit_log_router())
.route(

View File

@@ -123,16 +123,6 @@ impl FromRef<AppState> for erp_ai::AiState {
}
}
/// Allow erp-points handlers to extract their required state.
impl FromRef<AppState> for erp_points::PointsState {
fn from_ref(state: &AppState) -> Self {
Self {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
}
}
}
/// Allow erp-dialysis handlers to extract their required state.
impl FromRef<AppState> for erp_dialysis::DialysisState {
fn from_ref(state: &AppState) -> Self {