fix(health+server+mp): 审计 P0 批次修复 — 积分冲突/文章草稿泄露/商城空白/模板ID配置化
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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1697,7 +1697,6 @@ dependencies = [
|
|||||||
"erp-health",
|
"erp-health",
|
||||||
"erp-message",
|
"erp-message",
|
||||||
"erp-plugin",
|
"erp-plugin",
|
||||||
"erp-points",
|
|
||||||
"erp-server-migration",
|
"erp-server-migration",
|
||||||
"erp-workflow",
|
"erp-workflow",
|
||||||
"metrics",
|
"metrics",
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export default defineConfig(async (merge) => {
|
|||||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||||
'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'),
|
'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'),
|
||||||
'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''),
|
'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''),
|
||||||
|
'process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT || ''),
|
||||||
|
'process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP || ''),
|
||||||
|
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
|
||||||
|
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
|
||||||
|
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
|
||||||
},
|
},
|
||||||
copy: { patterns: [], options: {} },
|
copy: { patterns: [], options: {} },
|
||||||
framework: 'react',
|
framework: 'react',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const TYPE_BG: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Mall() {
|
export default function Mall() {
|
||||||
const { currentPatient } = useAuthStore();
|
const { currentPatient, loadPatients } = useAuthStore();
|
||||||
const { account, checkinStatus, refresh: refreshPoints, doCheckin } = usePointsStore();
|
const { account, checkinStatus, refresh: refreshPoints, doCheckin } = usePointsStore();
|
||||||
const [products, setProducts] = useState<PointsProduct[]>([]);
|
const [products, setProducts] = useState<PointsProduct[]>([]);
|
||||||
const [productType, setProductType] = useState('');
|
const [productType, setProductType] = useState('');
|
||||||
@@ -66,13 +66,18 @@ export default function Mall() {
|
|||||||
async (type?: string) => {
|
async (type?: string) => {
|
||||||
const t = type !== undefined ? type : productType;
|
const t = type !== undefined ? type : productType;
|
||||||
if (!currentPatient) {
|
if (!currentPatient) {
|
||||||
setNoProfile(true);
|
// 先尝试从服务端加载患者列表
|
||||||
return;
|
await loadPatients();
|
||||||
|
const updated = useAuthStore.getState().currentPatient;
|
||||||
|
if (!updated) {
|
||||||
|
setNoProfile(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setNoProfile(false);
|
setNoProfile(false);
|
||||||
await Promise.all([refreshPoints(), fetchProducts(1, t, true)]);
|
await Promise.all([refreshPoints(), fetchProducts(1, t, true)]);
|
||||||
},
|
},
|
||||||
[currentPatient, refreshPoints, fetchProducts, productType],
|
[currentPatient, loadPatients, refreshPoints, fetchProducts, productType],
|
||||||
);
|
);
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// 微信订阅消息模板 ID — 需在微信公众平台注册后填入
|
// 微信订阅消息模板 ID — 通过环境变量注入
|
||||||
// 注册路径:公众平台 → 功能 → 订阅消息 → 添加模板
|
// 注册路径:公众平台 → 功能 → 订阅消息 → 添加模板
|
||||||
// TODO: 上线前必须配置
|
// 环境变量:TARO_APP_WX_TEMPLATE_APPOINTMENT / FOLLOWUP / REPORT / CRITICAL_ALERT / HEALTH_ABNORMAL
|
||||||
export const TEMPLATE_IDS = {
|
export const TEMPLATE_IDS = {
|
||||||
APPOINTMENT_REMINDER: '',
|
APPOINTMENT_REMINDER: process.env.TARO_APP_WX_TEMPLATE_APPOINTMENT || '',
|
||||||
FOLLOWUP_REMINDER: '',
|
FOLLOWUP_REMINDER: process.env.TARO_APP_WX_TEMPLATE_FOLLOWUP || '',
|
||||||
REPORT_NOTIFICATION: '',
|
REPORT_NOTIFICATION: process.env.TARO_APP_WX_TEMPLATE_REPORT || '',
|
||||||
CRITICAL_HEALTH_ALERT: '',
|
CRITICAL_HEALTH_ALERT: process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || '',
|
||||||
HEALTH_DATA_ABNORMAL: '',
|
HEALTH_DATA_ABNORMAL: process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || '',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */
|
/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */
|
||||||
export function isTemplateConfigured(key: keyof typeof TEMPLATE_IDS): boolean {
|
export function isTemplateConfigured(key: keyof typeof TEMPLATE_IDS): boolean {
|
||||||
if (!TEMPLATE_IDS[key]) {
|
if (!TEMPLATE_IDS[key]) {
|
||||||
console.warn(`[wechat-templates] 模板 ${key} 未配置,请在微信公众平台注册并填入 ID`);
|
console.warn(`[wechat-templates] 模板 ${key} 未配置,请在微信公众平台注册并设置对应环境变量`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::Extension;
|
|||||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||||
|
|
||||||
use erp_core::error::AppError;
|
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 erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
|
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
|
||||||
@@ -21,9 +21,15 @@ where
|
|||||||
require_permission(&ctx, "health.articles.list")?;
|
require_permission(&ctx, "health.articles.list")?;
|
||||||
let page = params.page.unwrap_or(1);
|
let page = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
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(
|
let result = article_service::list_articles(
|
||||||
&state, ctx.tenant_id, page, page_size,
|
&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?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
@@ -39,7 +45,8 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.articles.list")?;
|
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)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,16 +99,21 @@ pub async fn list_articles(
|
|||||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取文章详情(管理端,不过滤发布状态)
|
/// 获取文章详情(管理端可查看任意状态,非管理端仅已发布)
|
||||||
pub async fn get_article(
|
pub async fn get_article(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
is_admin: bool,
|
||||||
) -> HealthResult<ArticleResp> {
|
) -> HealthResult<ArticleResp> {
|
||||||
let model = article::Entity::find()
|
let mut query = article::Entity::find()
|
||||||
.filter(article::Column::Id.eq(id))
|
.filter(article::Column::Id.eq(id))
|
||||||
.filter(article::Column::TenantId.eq(tenant_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)
|
.one(&state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(HealthError::ArticleNotFound)?;
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ erp-plugin.workspace = true
|
|||||||
erp-health.workspace = true
|
erp-health.workspace = true
|
||||||
erp-ai.workspace = true
|
erp-ai.workspace = true
|
||||||
erp-dialysis.workspace = true
|
erp-dialysis.workspace = true
|
||||||
erp-points.workspace = true
|
# erp-points 已禁用,积分功能统一由 erp-health 提供
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|||||||
@@ -349,13 +349,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"AI module initialized"
|
"AI module initialized"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize points module
|
// Points module 已统一到 erp-health(/health/points/* 路由)
|
||||||
let points_module = erp_points::PointsModule;
|
// erp-points 的 /points/* 路由为重复实现(大部分 501),已禁用
|
||||||
tracing::info!(
|
|
||||||
module = points_module.name(),
|
|
||||||
version = points_module.version(),
|
|
||||||
"Points module initialized"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize dialysis module
|
// Initialize dialysis module
|
||||||
let dialysis_module = erp_dialysis::DialysisModule;
|
let dialysis_module = erp_dialysis::DialysisModule;
|
||||||
@@ -373,7 +369,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.register(message_module)
|
.register(message_module)
|
||||||
.register(health_module)
|
.register(health_module)
|
||||||
.register(ai_module)
|
.register(ai_module)
|
||||||
.register(points_module)
|
// erp-points 已禁用,积分功能统一由 erp-health 提供
|
||||||
.register(dialysis_module);
|
.register(dialysis_module);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
module_count = registry.modules().len(),
|
module_count = registry.modules().len(),
|
||||||
@@ -564,7 +560,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.merge(erp_plugin::module::PluginModule::protected_routes())
|
.merge(erp_plugin::module::PluginModule::protected_routes())
|
||||||
.merge(erp_health::HealthModule::protected_routes())
|
.merge(erp_health::HealthModule::protected_routes())
|
||||||
.merge(erp_ai::AiModule::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(erp_dialysis::DialysisModule::protected_routes())
|
||||||
.merge(handlers::audit_log::audit_log_router())
|
.merge(handlers::audit_log::audit_log_router())
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -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.
|
/// Allow erp-dialysis handlers to extract their required state.
|
||||||
impl FromRef<AppState> for erp_dialysis::DialysisState {
|
impl FromRef<AppState> for erp_dialysis::DialysisState {
|
||||||
fn from_ref(state: &AppState) -> Self {
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
|||||||
Reference in New Issue
Block a user