From 83fe89cbcde47e5df332bb93e5038efc9e9f2468 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 19:16:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E7=B3=BB=E7=BB=9F=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8/=E6=95=B0=E6=8D=AE=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=80=A7/=E5=8A=9F=E8=83=BD=E7=BC=BA=E9=99=B7/UX=20(Phase=201-?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 安全热修复: - P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param) - P0-2: analytics/batch 路由从 public 移到 protected_routes - P0-3: plugin engine SQL 注入修复(format! → 参数化查询) - P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换 Phase 2 数据完整性: - P0-4: 组织删除级联检查(添加部门存在性校验) - P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验) - P0-8: workflow on_tenant_deleted 实现 5 实体批量删除 - P0-7: 并行网关 race condition 修复(consumed → completed 原子转换) Phase 3 P1 后端 Bug: - P1-12: plugin host 表名消毒(使用 sanitize_identifier) - P1-10: workflow deprecated 状态转换(published → deprecated) - P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证) - P0-9: 小程序 .gitignore 添加 .env/.env.*/日志 - P1-19: 小程序加密密钥替换为 64 字符强密钥 Phase 4 消息模块: - P1-5: 通知偏好 GET 路由 + handler - P1-4: 消息模板 update/delete CRUD + version - P2-8: mark_all_read SQL 添加 version + 1 - P2-7: markAsRead 改为乐观更新 + 失败回滚 Phase 5 前端修复: - P2-9: 通知面板点击导航到 /messages - P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示) - P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API - P2-17: PluginMarket installed 字段修正(name → id) - P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径) - P2-15: workflow updateDefinition 添加 version 字段 - P3-9: Kanban 版本使用记录实际 version - P2-21: secure-storage 生产环境无密钥时阻止存储 - P3-11: destroyOnHidden → destroyOnClose - P3-13: PendingTasks 深色模式 Tag 颜色适配 Co-Authored-By: Claude Opus 4.7 --- apps/miniprogram/.gitignore | 6 +- apps/miniprogram/src/utils/secure-storage.ts | 14 +- apps/web/src/api/health/articles.ts | 1 + apps/web/src/api/workflowDefinitions.ts | 1 + apps/web/src/components/NotificationPanel.tsx | 1 + apps/web/src/layouts/MainLayout.tsx | 14 +- apps/web/src/pages/PluginKanbanPage.tsx | 7 +- apps/web/src/pages/PluginMarket.tsx | 2 +- apps/web/src/pages/health/AppointmentList.tsx | 28 +- .../web/src/pages/health/ArticleTagManage.tsx | 4 +- .../web/src/pages/health/FollowUpTaskList.tsx | 16 + apps/web/src/pages/workflow/PendingTasks.tsx | 5 +- .../src/pages/workflow/ProcessDefinitions.tsx | 5 +- apps/web/src/stores/message.ts | 17 +- crates/erp-auth/src/service/dept_service.rs | 30 ++ crates/erp-auth/src/service/org_service.rs | 15 + .../erp-health/src/service/stats_service.rs | 20 +- crates/erp-message/src/dto.rs | 15 + .../src/handler/subscription_handler.rs | 23 ++ .../src/handler/template_handler.rs | 54 ++- crates/erp-message/src/module.rs | 7 +- .../src/service/message_service.rs | 2 +- .../src/service/template_service.rs | 79 +++- crates/erp-plugin/src/engine.rs | 15 +- crates/erp-plugin/src/host.rs | 7 +- crates/erp-server/src/main.rs | 61 ++- crates/erp-workflow/src/engine/executor.rs | 22 +- .../src/handler/definition_handler.rs | 23 ++ crates/erp-workflow/src/module.rs | 42 +- .../src/service/definition_service.rs | 77 +++- plans/audit-report-2026-04-26.md | 382 ++++++++++++++++++ plans/audit-summary-2026-04-26.md | 52 +++ plans/brainstorm-encapsulated-gray.md | 261 ++++++++++++ 33 files changed, 1238 insertions(+), 70 deletions(-) create mode 100644 plans/audit-report-2026-04-26.md create mode 100644 plans/audit-summary-2026-04-26.md create mode 100644 plans/brainstorm-encapsulated-gray.md diff --git a/apps/miniprogram/.gitignore b/apps/miniprogram/.gitignore index 07fb98f..932be48 100644 --- a/apps/miniprogram/.gitignore +++ b/apps/miniprogram/.gitignore @@ -1 +1,5 @@ -node_modules/\ndist/ +node_modules/ +dist/ +.env +.env.* +*.log diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index bb1dcf0..4c7bc58 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -9,12 +9,22 @@ if (!ENCRYPTION_KEY && IS_DEV) { } function encrypt(plaintext: string): string { - if (!ENCRYPTION_KEY) return plaintext; + if (!ENCRYPTION_KEY) { + if (process.env.NODE_ENV === 'production') { + throw new Error('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,生产环境禁止明文存储'); + } + return plaintext; + } return CryptoJS.AES.encrypt(plaintext, ENCRYPTION_KEY).toString(); } function decrypt(ciphertext: string): string { - if (!ENCRYPTION_KEY) return ciphertext; + if (!ENCRYPTION_KEY) { + if (process.env.NODE_ENV === 'production') { + throw new Error('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,生产环境禁止明文读取'); + } + return ciphertext; + } try { const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY); return bytes.toString(CryptoJS.enc.Utf8); diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts index 12eaed0..27ef507 100644 --- a/apps/web/src/api/health/articles.ts +++ b/apps/web/src/api/health/articles.ts @@ -105,6 +105,7 @@ export interface ArticleTagItem { slug?: string; color?: string; created_at: string; + version?: number; } export interface CreateTagReq { diff --git a/apps/web/src/api/workflowDefinitions.ts b/apps/web/src/api/workflowDefinitions.ts index 81628dd..4490fd3 100644 --- a/apps/web/src/api/workflowDefinitions.ts +++ b/apps/web/src/api/workflowDefinitions.ts @@ -48,6 +48,7 @@ export interface UpdateProcessDefinitionRequest { description?: string; nodes?: NodeDef[]; edges?: EdgeDef[]; + version: number; } export async function listProcessDefinitions(page = 1, pageSize = 20) { diff --git a/apps/web/src/components/NotificationPanel.tsx b/apps/web/src/components/NotificationPanel.tsx index 670c93d..c7064ec 100644 --- a/apps/web/src/components/NotificationPanel.tsx +++ b/apps/web/src/components/NotificationPanel.tsx @@ -82,6 +82,7 @@ export default function NotificationPanel() { if (!item.is_read) { markAsRead(item.id); } + navigate('/messages'); }} onMouseEnter={(e) => { if (item.is_read) { diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 972b5d8..9be0f4e 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -381,12 +381,16 @@ export default function MainLayout({ children }: { children: React.ReactNode }) navigate('/login'); }, [logout, navigate]); - // 标题查找:先从动态菜单查找,再 fallback + // 标题查找:先从动态菜单查找,再 fallback(支持动态路径参数匹配) const headerTitle = useMemo(() => { - return getTitleFromMenus(currentPath, dynamicMenus) - || routeTitleFallback[currentPath] - || pluginMenuItems.find((p) => p.key === currentPath)?.label - || '页面'; + const fromMenus = getTitleFromMenus(currentPath, dynamicMenus); + if (fromMenus) return fromMenus; + // 尝试模式匹配 routeTitleFallback 的 key(如 /health/patients/:id) + for (const [pattern, title] of Object.entries(routeTitleFallback)) { + const regex = new RegExp('^' + pattern.replace(/:[^/]+/g, '[^/]+') + '$'); + if (regex.test(currentPath)) return title; + } + return pluginMenuItems.find((p) => p.key === currentPath)?.label || '页面'; }, [currentPath, dynamicMenus, pluginMenuItems]); const userMenuItems = [ diff --git a/apps/web/src/pages/PluginKanbanPage.tsx b/apps/web/src/pages/PluginKanbanPage.tsx index 0b7ec64..d7f1c01 100644 --- a/apps/web/src/pages/PluginKanbanPage.tsx +++ b/apps/web/src/pages/PluginKanbanPage.tsx @@ -85,9 +85,12 @@ function KanbanInner({ if (!newLane) return; let currentLane = ''; + let currentRecord: Record | null = null; for (const [lane, items] of Object.entries(lanes)) { - if (items.some((item) => item.id === recordId)) { + const found = items.find((item) => item.id === recordId); + if (found) { currentLane = lane; + currentRecord = found; break; } } @@ -112,7 +115,7 @@ function KanbanInner({ try { await patchPluginData(pluginId, entity, recordId, { data: { [laneField]: newLane }, - version: 0, + version: currentRecord?.version ?? 0, }); message.success('移动成功'); } catch { diff --git a/apps/web/src/pages/PluginMarket.tsx b/apps/web/src/pages/PluginMarket.tsx index 5ee5064..14fcfbd 100644 --- a/apps/web/src/pages/PluginMarket.tsx +++ b/apps/web/src/pages/PluginMarket.tsx @@ -65,7 +65,7 @@ export default function PluginMarket() { const fetchInstalled = useCallback(async () => { try { const result = await listPlugins(1); - const ids = new Set(result.data.map((p) => p.name)); + const ids = new Set(result.data.map((p) => p.id)); setInstalledIds(ids); } catch { // 静默失败 diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index be81e3d..09bd614 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -99,26 +99,22 @@ export default function AppointmentList() { date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined, }); const items = result.data; - // 批量解析患者和医生名称 - const missingIds = new Set(); + // 批量解析患者和医生名称(分别调用对应 API) + const missingPatientIds = new Set(); + const missingDoctorIds = new Set(); items.forEach((a) => { - if (a.patient_id && !nameCache[a.patient_id]) missingIds.add(a.patient_id); - if (a.doctor_id && !nameCache[a.doctor_id]) missingIds.add(a.doctor_id); + if (a.patient_id && !nameCache[a.patient_id]) missingPatientIds.add(a.patient_id); + if (a.doctor_id && !nameCache[a.doctor_id]) missingDoctorIds.add(a.doctor_id); }); const newCache: Record = {}; - await Promise.all( - Array.from(missingIds).map(async (id) => { - try { - const p = await patientApi.get(id); - newCache[id] = p.name; - } catch { - try { - const d = await doctorApi.get(id); - newCache[id] = d.name; - } catch { /* ignore */ } - } + await Promise.allSettled([ + ...Array.from(missingPatientIds).map(async (id) => { + try { const p = await patientApi.get(id); newCache[id] = p.name; } catch { /* skip */ } }), - ); + ...Array.from(missingDoctorIds).map(async (id) => { + try { const d = await doctorApi.get(id); newCache[id] = d.name; } catch { /* skip */ } + }), + ]); if (Object.keys(newCache).length > 0) { setNameCache((prev) => ({ ...prev, ...newCache })); } diff --git a/apps/web/src/pages/health/ArticleTagManage.tsx b/apps/web/src/pages/health/ArticleTagManage.tsx index a1dad19..828ed22 100644 --- a/apps/web/src/pages/health/ArticleTagManage.tsx +++ b/apps/web/src/pages/health/ArticleTagManage.tsx @@ -63,7 +63,7 @@ export default function ArticleTagManage() { const handleSubmit = async (values: { name: string; slug?: string; color?: string }) => { try { if (editing) { - await articleTagApi.update(editing.id, { name: values.name, version: editing.version }); + await articleTagApi.update(editing.id, { name: values.name, version: editing.version ?? 0 }); message.success('标签更新成功'); } else { const req: CreateTagReq = { @@ -86,7 +86,7 @@ export default function ArticleTagManage() { const handleDelete = async (record: ArticleTagItem) => { try { - await articleTagApi.delete(record.id, record.version); + await articleTagApi.delete(record.id, record.version ?? 0); message.success('标签已删除'); fetchTags(); } catch { diff --git a/apps/web/src/pages/health/FollowUpTaskList.tsx b/apps/web/src/pages/health/FollowUpTaskList.tsx index f135db5..bb17439 100644 --- a/apps/web/src/pages/health/FollowUpTaskList.tsx +++ b/apps/web/src/pages/health/FollowUpTaskList.tsx @@ -15,6 +15,7 @@ import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-d import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import dayjs from 'dayjs'; import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp'; +import { patientApi } from '../../api/health/patients'; import { StatusTag } from './components/StatusTag'; import { PatientSelect } from './components/PatientSelect'; import { DoctorSelect } from './components/DoctorSelect'; @@ -104,6 +105,21 @@ export default function FollowUpTaskList() { const result = await followUpApi.listTasks(params); setTasks(result.data); setTotal(result.total); + + // Batch resolve patient names + const patientIds = [...new Set(result.data.map((t: FollowUpTask) => t.patient_id).filter(Boolean))]; + const newLabels: Record = {}; + await Promise.allSettled( + patientIds.map(async (id: string) => { + try { + const p = await patientApi.get(id); + newLabels[id] = p.name; + } catch { /* skip */ } + }), + ); + if (Object.keys(newLabels).length > 0) { + setPatientLabels((prev) => ({ ...prev, ...newLabels })); + } } catch { message.error('加载随访任务失败'); } finally { diff --git a/apps/web/src/pages/workflow/PendingTasks.tsx b/apps/web/src/pages/workflow/PendingTasks.tsx index 3475346..5a6e562 100644 --- a/apps/web/src/pages/workflow/PendingTasks.tsx +++ b/apps/web/src/pages/workflow/PendingTasks.tsx @@ -93,9 +93,9 @@ export default function PendingTasks() { width: 100, render: (s: string) => ( {s} @@ -205,6 +205,7 @@ export default function PendingTasks() {

任务: {delegateModal?.node_name}

+ {/* TODO: 替换为 UserSelect 用户搜索选择组件,支持按姓名/工号搜索 */} { try { if (id) { - await updateProcessDefinition(id, req); + const current = data.find((d) => d.id === id); + await updateProcessDefinition(id, { ...req, version: current?.version ?? 0 }); message.success('更新成功'); } else { await createProcessDefinition(req); @@ -187,7 +188,7 @@ export default function ProcessDefinitions() { onCancel={() => setDesignerOpen(false)} footer={null} width={1200} - destroyOnHidden + destroyOnClose > | null = null; let recentMessagesPromise: Promise | null = null; -export const useMessageStore = create((set) => ({ +export const useMessageStore = create((set, get) => ({ unreadCount: 0, recentMessages: [], @@ -55,16 +55,17 @@ export const useMessageStore = create((set) => ({ }, markAsRead: async (id: string) => { + const prev = { unreadCount: get().unreadCount, recentMessages: get().recentMessages }; + set((state) => ({ + unreadCount: Math.max(0, state.unreadCount - 1), + recentMessages: state.recentMessages.map((m) => + m.id === id ? { ...m, is_read: true } : m, + ), + })); try { await markRead(id); - set((state) => ({ - unreadCount: Math.max(0, state.unreadCount - 1), - recentMessages: state.recentMessages.map((m) => - m.id === id ? { ...m, is_read: true } : m, - ), - })); } catch { - // 静默失败 + set({ unreadCount: prev.unreadCount, recentMessages: prev.recentMessages }); } }, })); diff --git a/crates/erp-auth/src/service/dept_service.rs b/crates/erp-auth/src/service/dept_service.rs index cff352d..793ea35 100644 --- a/crates/erp-auth/src/service/dept_service.rs +++ b/crates/erp-auth/src/service/dept_service.rs @@ -7,6 +7,8 @@ use uuid::Uuid; use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq}; use crate::entity::department; use crate::entity::organization; +use crate::entity::position; +use crate::entity::user_department; use crate::error::{AuthError, AuthResult}; use erp_core::audit::AuditLog; use erp_core::audit_service; @@ -274,6 +276,34 @@ impl DeptService { )); } + // Check for positions under this department + let positions = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::DeptId.eq(id)) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if positions.is_some() { + return Err(AuthError::Validation( + "该部门下存在岗位,无法删除".to_string(), + )); + } + + // Check for users assigned to this department + let users = user_department::Entity::find() + .filter(user_department::Column::TenantId.eq(tenant_id)) + .filter(user_department::Column::DepartmentId.eq(id)) + .filter(user_department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if users.is_some() { + return Err(AuthError::Validation( + "该部门下存在用户,无法删除".to_string(), + )); + } + let current_version = model.version; let mut active: department::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs index cd983dc..8c82d90 100644 --- a/crates/erp-auth/src/service/org_service.rs +++ b/crates/erp-auth/src/service/org_service.rs @@ -5,6 +5,7 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq}; +use crate::entity::department; use crate::entity::organization; use crate::error::{AuthError, AuthResult}; use erp_core::audit::AuditLog; @@ -251,6 +252,20 @@ impl OrgService { )); } + // Check for departments under this organization + let depts = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(id)) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if depts.is_some() { + return Err(AuthError::Validation( + "该组织下存在部门,无法删除".to_string(), + )); + } + let current_version = model.version; let mut active: organization::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index 2623978..eae3ea6 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -425,8 +425,26 @@ async fn compute_avg_field( tenant_id: uuid::Uuid, field: &str, ) -> AppResult> { + const ALLOWED_FIELDS: &[&str] = &[ + "uf_volume", + "uf_rate", + "blood_flow_rate", + "dialysate_flow_rate", + "pre_weight", + "post_weight", + "pre_bp_systolic", + "pre_bp_diastolic", + "post_bp_systolic", + "post_bp_diastolic", + ]; + if !ALLOWED_FIELDS.contains(&field) { + return Err(erp_core::error::AppError::Validation(format!( + "不允许的字段名: {field}" + ))); + } + // field is whitelist-validated, safe to interpolate let sql = format!( - "SELECT AVG({field}) AS avg_val FROM dialysis_record \ + "SELECT AVG({field})::FLOAT8 AS avg_val FROM dialysis_record \ WHERE tenant_id = $1 AND deleted_at IS NULL AND {field} IS NOT NULL \ AND created_at >= date_trunc('month', NOW())" ); diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs index 782e5b4..167c033 100644 --- a/crates/erp-message/src/dto.rs +++ b/crates/erp-message/src/dto.rs @@ -113,6 +113,7 @@ pub struct MessageTemplateResp { pub language: String, pub created_at: DateTime, pub updated_at: DateTime, + pub version: i32, } /// 创建消息模板请求 @@ -148,6 +149,20 @@ fn default_language() -> String { "zh-CN".to_string() } +/// 更新消息模板请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateTemplateReq { + #[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))] + pub name: Option, + #[validate(length(min = 1, max = 200, message = "标题模板不能为空"))] + pub title_template: Option, + #[validate(length(min = 1, message = "内容模板不能为空"))] + pub body_template: Option, + pub language: Option, + pub channel: Option, + pub version: i32, +} + // ============ 消息订阅偏好 DTO ============ /// 消息订阅偏好响应 diff --git a/crates/erp-message/src/handler/subscription_handler.rs b/crates/erp-message/src/handler/subscription_handler.rs index 284ee19..73157b1 100644 --- a/crates/erp-message/src/handler/subscription_handler.rs +++ b/crates/erp-message/src/handler/subscription_handler.rs @@ -9,6 +9,29 @@ use crate::dto::UpdateSubscriptionReq; use crate::message_state::MessageState; use crate::service::subscription_service::SubscriptionService; +#[utoipa::path( + get, + path = "/api/v1/message-subscriptions", + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "消息订阅" +)] +/// 获取当前用户的消息订阅偏好。 +pub async fn get_subscription( + State(_state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let resp = SubscriptionService::get(ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + #[utoipa::path( put, path = "/api/v1/message-subscriptions", diff --git a/crates/erp-message/src/handler/template_handler.rs b/crates/erp-message/src/handler/template_handler.rs index a0ddf26..233e31b 100644 --- a/crates/erp-message/src/handler/template_handler.rs +++ b/crates/erp-message/src/handler/template_handler.rs @@ -1,14 +1,15 @@ use axum::Json; use axum::extract::FromRef; -use axum::extract::{Extension, Query, State}; +use axum::extract::{Extension, Path, Query, State}; use serde::Deserialize; +use uuid::Uuid; use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use validator::Validate; -use crate::dto::{CreateTemplateReq, MessageTemplateResp}; +use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq}; use crate::message_state::MessageState; use crate::service::template_service::TemplateService; @@ -88,3 +89,52 @@ where let resp = TemplateService::create(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?; Ok(Json(ApiResponse::ok(resp))) } + +#[utoipa::path( + put, + path = "/api/v1/message-templates/{id}", + request_body = UpdateTemplateReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息模板" +)] +/// 更新消息模板。 +pub async fn update_template( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.manage")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = TemplateService::update(id, ctx.tenant_id, ctx.user_id, &req, &_state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +/// 删除消息模板。 +#[allow(clippy::type_complexity)] +pub async fn delete_template( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.manage")?; + + TemplateService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index 7701f1e..6e4b146 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -41,10 +41,15 @@ impl MessageModule { "/message-templates", get(template_handler::list_templates).post(template_handler::create_template), ) + .route( + "/message-templates/{id}", + put(template_handler::update_template).delete(template_handler::delete_template), + ) // 订阅偏好路由 .route( "/message-subscriptions", - put(subscription_handler::update_subscription), + get(subscription_handler::get_subscription) + .put(subscription_handler::update_subscription), ) } diff --git a/crates/erp-message/src/service/message_service.rs b/crates/erp-message/src/service/message_service.rs index c3212e7..5814e05 100644 --- a/crates/erp-message/src/service/message_service.rs +++ b/crates/erp-message/src/service/message_service.rs @@ -304,7 +304,7 @@ impl MessageService { let now = Utc::now(); db.execute(Statement::from_sql_and_values( DatabaseBackend::Postgres, - "UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL", + "UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3, version = version + 1 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL", [ now.into(), now.into(), diff --git a/crates/erp-message/src/service/template_service.rs b/crates/erp-message/src/service/template_service.rs index 06039fc..58b0abf 100644 --- a/crates/erp-message/src/service/template_service.rs +++ b/crates/erp-message/src/service/template_service.rs @@ -2,7 +2,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::dto::{CreateTemplateReq, MessageTemplateResp}; +use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq}; use crate::entity::message_template; use crate::error::{MessageError, MessageResult}; @@ -88,6 +88,82 @@ impl TemplateService { Ok(Self::model_to_resp(&inserted)) } + /// 更新消息模板。 + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateTemplateReq, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + let model = message_template::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?; + + let current_version = model.version; + let next_ver = erp_core::error::check_version(req.version, current_version) + .map_err(|_| MessageError::VersionMismatch)?; + + let mut active: message_template::ActiveModel = model.into(); + if let Some(name) = &req.name { + active.name = Set(name.clone()); + } + if let Some(title) = &req.title_template { + active.title_template = Set(title.clone()); + } + if let Some(body) = &req.body_template { + active.body_template = Set(body.clone()); + } + if let Some(lang) = &req.language { + active.language = Set(lang.clone()); + } + if let Some(channel) = &req.channel { + active.channel = Set(channel.clone()); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(Self::model_to_resp(&updated)) + } + + /// 软删除消息模板。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<()> { + let model = message_template::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?; + + let current_version = model.version; + let mut active: message_template::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + + active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(()) + } + /// 使用模板渲染消息内容。 /// 支持 {{variable}} 格式的变量插值。 pub fn render(template: &str, variables: &std::collections::HashMap) -> String { @@ -110,6 +186,7 @@ impl TemplateService { language: m.language.clone(), created_at: m.created_at, updated_at: m.updated_at, + version: m.version, } } } diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs index 7b009e1..ee1b65a 100644 --- a/crates/erp-plugin/src/engine.rs +++ b/crates/erp-plugin/src/engine.rs @@ -627,16 +627,13 @@ impl PluginEngine { use sea_orm::FromQueryResult; #[derive(Debug, FromQueryResult)] struct ConfigRow { config_json: serde_json::Value } - let sql = format!( - "SELECT config_json FROM plugins WHERE tenant_id = '{}'\n\ - AND deleted_at IS NULL\n\ - AND manifest_json->'metadata'->>'id' = '{}'\n\ - LIMIT 1", - tenant_id, pid.replace('\'', "''") - ); - ConfigRow::find_by_statement(Statement::from_string( + ConfigRow::find_by_statement(Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, - sql, + "SELECT config_json FROM plugins WHERE tenant_id = $1\n\ + AND deleted_at IS NULL\n\ + AND manifest_json->'metadata'->>'id' = $2\n\ + LIMIT 1", + [tenant_id.into(), pid.into()], )) .one(&db) .await diff --git a/crates/erp-plugin/src/host.rs b/crates/erp-plugin/src/host.rs index 4227fed..8a3cf43 100644 --- a/crates/erp-plugin/src/host.rs +++ b/crates/erp-plugin/src/host.rs @@ -335,8 +335,11 @@ impl host_api::Host for HostState { _ => String::new(), // "never" — 不需要周期 key }; - // 序列表名 - let table_name = format!("plugin_numbering_seq_{}", plugin_id.replace('-', "_")); + // 序列表名(使用 sanitize_identifier 防注入) + let table_name = format!( + "plugin_numbering_seq_{}", + crate::dynamic_table::sanitize_identifier(&plugin_id) + ); // 确保序列表存在 let create_sql = format!( diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 9796ae7..4c357c3 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -494,16 +494,15 @@ async fn main() -> anyhow::Result<()> { "/docs/openapi.json", axum::routing::get(handlers::openapi::openapi_spec), ) - .route( - "/analytics/batch", - axum::routing::post(handlers::analytics::batch), - ) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_ip, )) .with_state(state.clone()); + // Clone jwt_secret for upload auth before protected_routes closure moves it + let secret_for_uploads = jwt_secret.clone(); + // Protected routes (JWT authentication required) // User-based rate limiting (100 req/min) applied after JWT auth let protected_routes = erp_auth::AuthModule::protected_routes() @@ -522,6 +521,10 @@ async fn main() -> anyhow::Result<()> { "/admin/tenants/{id}/rotate-key", axum::routing::post(handlers::crypto_admin::rotate_tenant_key), ) + .route( + "/analytics/batch", + axum::routing::post(handlers::analytics::batch), + ) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_user, @@ -540,9 +543,15 @@ async fn main() -> anyhow::Result<()> { // All API routes are nested under /api/v1 let cors = build_cors_layer(&state.config.cors.allowed_origins); let upload_dir = state.config.storage.upload_dir.clone(); + let uploads_router = Router::new() + .fallback_service(ServeDir::new(&upload_dir)) + .layer(axum_middleware::from_fn(move |req, next| { + let secret = secret_for_uploads.clone(); + async move { upload_auth_middleware(secret, req, next).await } + })); let app = Router::new() .nest("/api/v1", public_routes.merge(protected_routes)) - .nest_service("/uploads", ServeDir::new(&upload_dir)) + .nest("/uploads", uploads_router) .layer(cors); let addr = format!("{}:{}", host, port); @@ -560,6 +569,48 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// JWT auth middleware for `/uploads` file serving. +/// +/// Accepts token from either `Authorization: Bearer ` header +/// or `?token=` query parameter (for browser `` / direct downloads). +async fn upload_auth_middleware( + jwt_secret: String, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + use erp_auth::service::token_service::TokenService; + + let token = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) + .or_else(|| { + req.uri().query().and_then(|q| { + q.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + if k == "token" && !v.is_empty() { + Some(v.to_string()) + } else { + None + } + }) + }) + }); + + let token = token.ok_or(erp_core::error::AppError::Unauthorized)?; + + let claims = TokenService::decode_token(&token, &jwt_secret) + .map_err(|_| erp_core::error::AppError::Unauthorized)?; + + if claims.token_type != "access" { + return Err(erp_core::error::AppError::Unauthorized); + } + + Ok(next.run(req).await) +} + /// Build a CORS layer from the comma-separated allowed origins config. /// /// If the config is "*", allows all origins (development mode). diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs index c85231b..d33f9db 100644 --- a/crates/erp-workflow/src/engine/executor.rs +++ b/crates/erp-workflow/src/engine/executor.rs @@ -406,7 +406,27 @@ impl FlowExecutor { } } - // 所有分支都完成了,沿出边继续 + // 所有分支都完成了,先将 consumed tokens 标记为 completed 防止并发重复触发 + for edge in &incoming { + let consumed_tokens = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::NodeId.eq(&edge.source)) + .filter(token::Column::Status.eq("consumed")) + .all(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + for mut t in consumed_tokens { + let ver = t.version; + let mut active: token::ActiveModel = t.into(); + active.status = Set("completed".to_string()); + active.version = Set(ver + 1); + active.updated_at = Set(chrono::Utc::now()); + active.update(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?; + } + } + + // 沿出边继续创建新 token let outgoing = graph.get_outgoing_edges(node_id); let mut new_tokens = Vec::new(); for edge in &outgoing { diff --git a/crates/erp-workflow/src/handler/definition_handler.rs b/crates/erp-workflow/src/handler/definition_handler.rs index 1652185..0871893 100644 --- a/crates/erp-workflow/src/handler/definition_handler.rs +++ b/crates/erp-workflow/src/handler/definition_handler.rs @@ -180,3 +180,26 @@ where Ok(Json(ApiResponse::ok(resp))) } + +pub async fn deprecate_definition( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.publish")?; + + let resp = DefinitionService::deprecate( + id, + ctx.tenant_id, + ctx.user_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-workflow/src/module.rs b/crates/erp-workflow/src/module.rs index d84a301..4441811 100644 --- a/crates/erp-workflow/src/module.rs +++ b/crates/erp-workflow/src/module.rs @@ -41,6 +41,10 @@ impl WorkflowModule { "/workflow/definitions/{id}/publish", post(definition_handler::publish_definition), ) + .route( + "/workflow/definitions/{id}/deprecate", + post(definition_handler::deprecate_definition), + ) // Instance routes .route( "/workflow/instances", @@ -147,9 +151,43 @@ impl ErpModule for WorkflowModule { async fn on_tenant_deleted( &self, - _tenant_id: Uuid, - _db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, ) -> AppResult<()> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // Delete in dependency order: variables → tasks → tokens → instances → definitions + // process_variables + crate::entity::process_variable::Entity::delete_many() + .filter(crate::entity::process_variable::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // tasks + crate::entity::task::Entity::delete_many() + .filter(crate::entity::task::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // tokens + crate::entity::token::Entity::delete_many() + .filter(crate::entity::token::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // process_instances + crate::entity::process_instance::Entity::delete_many() + .filter(crate::entity::process_instance::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // process_definitions + crate::entity::process_definition::Entity::delete_many() + .filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + tracing::info!(%tenant_id, "Workflow data cleaned up for deleted tenant"); Ok(()) } diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs index a63fd95..ac2d386 100644 --- a/crates/erp-workflow/src/service/definition_service.rs +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -171,11 +171,21 @@ impl DefinitionService { if let Some(description) = &req.description { active.description = Set(Some(description.clone())); } + // 当 nodes 或 edges 任一存在时,取最终值验证流程图完整性 + let final_nodes = req.nodes.as_ref().or_else(|| { + serde_json::from_value::>(active.nodes.as_ref().clone()).ok().as_ref().map(|_| unreachable!()) + }); + // 简化:如果提供了 nodes 或 edges,将两者合并后验证 + if req.nodes.is_some() || req.edges.is_some() { + let nodes_val = req.nodes.as_ref().map(|n| serde_json::to_value(n).unwrap()).unwrap_or(active.nodes.as_ref().clone()); + let edges_val = req.edges.as_ref().map(|e| serde_json::to_value(e).unwrap()).unwrap_or(active.edges.as_ref().clone()); + let nodes: Vec = serde_json::from_value(nodes_val) + .map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?; + let edges: Vec = serde_json::from_value(edges_val) + .map_err(|e| WorkflowError::Validation(format!("连线数据无效: {e}")))?; + parser::parse_and_validate(&nodes, &edges)?; + } if let Some(nodes) = &req.nodes { - // 验证新流程图 - if let Some(edges) = &req.edges { - parser::parse_and_validate(nodes, edges)?; - } let nodes_json = serde_json::to_value(nodes) .map_err(|e| WorkflowError::Validation(e.to_string()))?; active.nodes = Set(nodes_json); @@ -278,6 +288,65 @@ impl DefinitionService { Ok(Self::model_to_resp(&updated)) } + /// 将已发布的流程定义标记为 deprecated。 + pub async fn deprecate( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + if model.status != "published" { + return Err(WorkflowError::InvalidState( + "只有 published 状态的流程定义可以废弃".to_string(), + )); + } + + let current_version = model.version_field; + let mut active: process_definition::ActiveModel = model.into(); + active.status = Set("deprecated".to_string()); + active.version_field = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "process_definition.deprecated", + tenant_id, + serde_json::json!({ "definition_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.deprecate", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + /// 软删除流程定义。 pub async fn delete( id: Uuid, diff --git a/plans/audit-report-2026-04-26.md b/plans/audit-report-2026-04-26.md new file mode 100644 index 0000000..6c2d99a --- /dev/null +++ b/plans/audit-report-2026-04-26.md @@ -0,0 +1,382 @@ +# HMS 全系统前端审计报告 + +> 日期: 2026-04-26 | 审计范围: 全系统(基础模块 + 健康模块 + 插件 + 小程序) +> 方法: 代码扫描 + 浏览器操作测试 + 后端日志分析 +> 审计人: Claude Code 自动化审计 + +--- + +## 执行摘要 + +| 指标 | 值 | +|------|-----| +| 审计覆盖模块 | 12 个(auth/config/workflow/message/plugin/health/ai/settings/小程序) | +| 浏览器验证页面 | 18 个 Web 页面 + 通知面板 | +| 代码级审计模块 | 10 个(4 个后台 agent + 6 个手动/浏览器) | +| **总问题数** | **72 个** | +| P0 阻断 | **9 个** | +| P1 严重 | **20 个** | +| P2 中等 | **22 个** | +| P3 轻微 | **15 个** | +| P4 优化 | **6 个** | + +### 风险矩阵 + +| 风险域 | 状态 | 关键发现 | +|--------|------|---------| +| 安全 | 🔴 高风险 | 2 个 P0(文件无认证访问 + SQL 注入)| +| 数据完整性 | 🔴 高风险 | 级联删除缺失 × 3,并行网关死锁 | +| 功能完整性 | 🟡 中等 | 多个模块 CRUD 不完整(模板/偏好/组织) | +| 实时性 | 🟡 中等 | 消息 60 秒轮询,无 WebSocket | +| 代码质量 | 🟢 良好 | 动态表 SQL 注入防护好,租户隔离一致 | + +--- + +## P0 阻断问题(8 个) + +### P0-1: 上传文件无认证访问 +- **模块**: erp-server 静态文件服务 +- **类型**: 安全漏洞 +- **复现步骤**: 直接访问 `http://localhost:3000/uploads/{任意文件名}` +- **预期行为**: 需 JWT 认证才能访问医疗文档 +- **实际行为**: 所有上传文件公开可访问,包括医疗文档 +- **影响**: 医疗隐私数据泄露,违反 HIPAA 合规 +- **相关文件**: `crates/erp-server/src/main.rs:545-546` +- **建议修复**: 在 ServeDir 前添加 JWT 认证中间件,或使用签名 URL + +### P0-2: analytics/batch 端点公开 +- **模块**: erp-server API +- **类型**: 安全漏洞 +- **复现步骤**: 无需认证 POST `/api/v1/analytics/batch` +- **影响**: 任意 JSON 事件注入,可伪造分析数据 +- **相关文件**: `crates/erp-server/src/main.rs` 路由注册 +- **建议修复**: 添加 JWT 认证守卫 + +### P0-3: load_plugin_config SQL 注入 +- **模块**: erp-plugin 引擎 +- **类型**: 安全漏洞 +- **复现步骤**: 恶意 WASM 插件 manifest 中 craft `plugin_id` 包含 SQL 注入 payload +- **影响**: 通过 `format!()` 拼接 SQL,`plugin_id` 仅用 `replace('\'', "''")` 转义,不安全 +- **相关文件**: `crates/erp-plugin/src/engine.rs:630-637` +- **建议修复**: 改用参数化查询 `$N` 占位符 + +### P0-4: 组织删除无级联检查 +- **模块**: erp-auth 组织管理 +- **类型**: 数据完整性 +- **复现步骤**: 删除包含子部门/岗位/用户关联的组织 +- **影响**: 软删除组织后,子部门、岗位、用户关联成为孤儿数据 +- **建议修复**: 删除前检查并阻止存在子记录的删除操作 + +### P0-5: 部门删除无级联检查 +- **模块**: erp-auth 部门管理 +- **类型**: 数据完整性 +- **复现步骤**: 删除包含子部门或关联用户的部门 +- **影响**: 同 P0-4,部门树结构断裂 +- **建议修复**: 递归检查子部门和用户关联 + +### P0-6: 岗位删除无级联检查 +- **模块**: erp-auth 岗位管理 +- **类型**: 数据完整性 +- **影响**: 删除岗位后用户-岗位关联悬空 +- **建议修复**: 检查用户关联后阻止或级联更新 + +### P0-7: 并行网关 Join 逻辑可能导致死锁 +- **模块**: erp-workflow 执行引擎 +- **类型**: 功能缺陷 +- **相关文件**: `crates/erp-workflow/src/engine/executor.rs:369-425` +- **影响**: 并行分支汇聚时,token 查询可能匹配到历史迭代的 stale token,导致提前/错误完成或永久等待 +- **建议修复**: 添加 token lineage/correlation 机制,区分不同 fork 产生的 token + +### P0-8: workflow on_tenant_deleted 是空操作 +- **模块**: erp-workflow 租户清理 +- **类型**: 数据完整性 +- **相关文件**: `crates/erp-workflow/src/module.rs:149-154` +- **影响**: 删除租户后,流程定义/实例/任务/Token 残留,跨租户数据泄漏风险 +- **建议修复**: 实现级联软删除所有租户相关数据 + +--- + +## P1 严重问题(18 个) + +### P1-1: health-data 统计端点 500 错误 +- **模块**: erp-health 统计 +- **类型**: 功能缺陷 +- **根因**: PostgreSQL AVG() 返回 NUMERIC 类型,Rust 代码期望 f64(FLOAT8) +- **相关文件**: `crates/erp-health/src/service/stats_service.rs` +- **浏览器验证**: 统计报表页面显示"加载统计数据失败 500" +- **建议修复**: 使用 `Decimal` 类型或 SQL 中显式 `CAST(AVG(...) AS FLOAT8)` + +### P1-2: 行级数据权限未生效 +- **模块**: erp-auth 权限 +- **类型**: 功能缺陷 +- **影响**: `department_ids` 已填充但未用于数据过滤查询 +- **建议修复**: 在查询构建器中加入 department_ids 过滤条件 + +### P1-3: 消息无实时推送 +- **模块**: erp-message +- **类型**: 功能缺失 +- **影响**: 医疗告警最多延迟 60 秒才能到达医护人员,临床不可接受 +- **建议修复**: 实现 WebSocket 或 SSE 推送 + +### P1-4: 消息模板 CRUD 不完整 +- **模块**: erp-message +- **类型**: 功能缺失 +- **影响**: 模板只能创建和列表,无法编辑、删除,且 `render()` 方法未接入发送管道 +- **相关文件**: `crates/erp-message/src/template_service.rs` + +### P1-5: 通知偏好设置无法加载已有配置 +- **模块**: erp-message + 前端 +- **类型**: 功能缺陷 +- **影响**: 后端无 GET `/message-subscriptions` 端点,前端无法加载用户已保存的偏好;第二次保存因缺少 version 字段必然失败 +- **相关文件**: `crates/erp-message/src/module.rs`, `NotificationPreferences.tsx` + +### P1-6: 工作流 ServiceTask 是空操作 +- **模块**: erp-workflow +- **类型**: 功能缺失 +- **相关文件**: `crates/erp-workflow/src/engine/executor.rs:277` +- **影响**: 所有 ServiceTask 被自动跳过,`service_type` 字段无效 + +### P1-7: 工作流未注册任何事件处理器 +- **模块**: erp-workflow +- **类型**: 功能缺失 +- **影响**: `register_event_handlers` 为空函数,工作流模块不响应任何外部事件 +- **相关文件**: `crates/erp-workflow/src/module.rs:137` + +### P1-8: candidate_groups(角色/部门分配)存储但未使用 +- **模块**: erp-workflow +- **类型**: 功能缺失 +- **影响**: 配置了 candidate_groups 但无 assignee 的 UserTask 对所有用户不可见,任务成为孤儿 +- **相关文件**: `crates/erp-workflow/src/service/task_service.rs:25-36` + +### P1-9: 工作流超时检查仅记录日志 +- **模块**: erp-workflow +- **类型**: 功能缺失 +- **影响**: 超时任务无升级/自动完成/通知,永久停留在 pending 状态 +- **相关文件**: `crates/erp-workflow/src/engine/timeout.rs` + +### P1-10: 工作流 deprecated 状态不可达 +- **模块**: erp-workflow + 前端 +- **类型**: 功能缺陷 +- **影响**: 前端定义了 `deprecated` 状态样式,但后端无转换路径 +- **相关文件**: `ProcessDefinitions.tsx:19` + +### P1-11: 工作流定义更新跳过校验 +- **模块**: erp-workflow +- **类型**: 功能缺陷 +- **影响**: 只更新 nodes 而不提供 edges 时,不做图结构验证 +- **相关文件**: `crates/erp-workflow/src/service/definition_service.rs:174-181` + +### P1-12: 编号序列表名未充分消毒 +- **模块**: erp-plugin +- **类型**: 安全隐患 +- **影响**: `plugin_id` 格式化到 DDL/DML 语句时仅 `replace('-', "_")`,不处理引号/分号 +- **相关文件**: `crates/erp-plugin/src/host.rs:339` + +### P1-13 ~ P1-18: 组织模块 P1 问题 +- **P1-13**: 组织/部门/岗位缺少列表 API 中的树形结构返回 +- **P1-14**: 部门树重组操作(拖拽移动父节点)未实现 +- **P1-15**: 组织/部门名称唯一性校验缺失 +- **P1-16**: 部门详情 GET 端点缺失 +- **P1-17**: 岗位分配/取消分配 API 缺失 +- **P1-18**: 消息群发(角色/部门/全员)fan-out 未实现 + +--- + +## P2 中等问题(20 个) + +| ID | 模块 | 问题 | 文件 | +|----|------|------|------| +| P2-1 | health | 随访任务患者名显示为 UUID 片段 | `FollowUpTaskList.tsx` | +| P2-2 | health | 前端测试覆盖率极低(3 个文件) | `apps/web/src/` | +| P2-3 | health | 深色模式样式重复 ~19 页内联 isDark | 各健康页面 | +| P2-4 | health | useDarkMode 和 useThemeMode 重叠 | hooks/ | +| P2-5 | health | AppointmentList 冗余 404 请求 | `AppointmentList.tsx` | +| P2-6 | message | 未读计数不即时刷新(等 60s 轮询) | `stores/message.ts` | +| P2-7 | message | markAsRead 失败不回滚乐观更新 | `stores/message.ts:57-69` | +| P2-8 | message | mark_all_read 不更新 version 字段 | `message_service.rs:298-326` | +| P2-9 | message | 通知面板点击不导航到详情 | `NotificationPanel.tsx:81-85` | +| P2-10 | message | 轮询间隔 60s 对医疗告警不可接受 | `NotificationPanel.tsx:28-31` | +| P2-11 | workflow | N+1 查询问题(实例/任务列表) | `instance_service.rs`, `task_service.rs` | +| P2-12 | workflow | 排他网关表达式错误被静默吞掉 | `executor.rs:176` | +| P2-13 | workflow | ProcessDesigner 不支持边条件配置 | `ProcessDesigner.tsx` | +| P2-14 | workflow | 委派 API 要求手动输入 UUID | `PendingTasks.tsx:207-210` | +| P2-15 | workflow | 前端 UpdateDefinitionRequest 缺少 version | `workflowDefinitions.ts:45-51` | +| P2-16 | plugin | reconcile_references 表名注入模式 | `data_service.rs:1204-1206` | +| P2-17 | plugin | PluginMarket installed 判断字段不匹配 | `PluginMarket.tsx:68` | +| P2-18 | plugin | 模板渲染未接入发送管道 | `template_service.rs:92-99` | +| P2-19 | plugin | 偏好设置 version 字段未发送导致更新失败 | `NotificationPreferences.tsx:26-37` | +| P2-20 | plugin | 偏好设置仅暴露 DND,channel_preferences 隐藏 | `NotificationPreferences.tsx:60` | + +--- + +## P3 轻微问题(14 个) + +| ID | 模块 | 问题 | +|----|------|------| +| P3-1 | health | 已完成任务仍显示操作按钮 | +| P3-2 | health | ArticleEditor 图片上传未实现 (TODO) | +| P3-3 | health | PatientDetail 头部标题显示"页面" | +| P3-4 | health | 4 处 any 类型使用 | +| P3-5 | health | 登录硬编码默认 tenant_id | +| P3-6 | settings | 审计日志操作用户列显示原始 UUID | +| P3-7 | settings | 审计日志资源类型过滤列表硬编码 | +| P3-8 | settings | 系统参数无列表 API,需手动输入 key | +| P3-9 | plugin | Kanban 拖拽 version 硬编码 0 导致锁冲突 | +| P3-10 | plugin | CRUD 排序使用 JSONB 文本提取导致字典序 | +| P3-11 | workflow | ProcessDesigner 用 destroyOnHidden 而非 destroyOnClose | +| P3-12 | workflow | InstanceMonitor 显示 raw node_id 而非名称 | +| P3-13 | workflow | 待办任务状态 Tag 不适配深色模式 | +| P3-14 | message | 偏好设置 DND 启用但未填时间范围时无效 | + +--- + +## P4 优化建议(6 个) + +| ID | 模块 | 建议 | +|----|------|------| +| P4-1 | plugin | PluginAdmin purge 按钮状态与后端不一致 | +| P4-2 | plugin | WASM init() 使用 nil UUID | +| P4-3 | plugin | recover_plugins 不按 tenant_id 过滤 | +| P4-4 | settings | LanguageManager 编辑弹窗无可编辑字段 | +| P4-5 | settings | ChangePassword 最小长度仅前端校验 | +| P4-6 | settings | Settings API delete/update URL 编码不一致 | + +--- + +## 模块审计摘要 + +### 基础模块 + +| 模块 | 页面数 | 浏览器验证 | 代码审计 | 关键发现 | +|------|--------|-----------|---------|---------| +| 用户/权限 (B1) | 2 | ✅ | ✅ | email 验证宽松 | +| 组织架构 (B2) | 1 | ❌ | ✅ | 3×P0 级联删除缺失 | +| 工作流 (B3) | 6 | ✅ 3 页 | ✅ | 3×P0 + 6×P1 功能大量缺失 | +| 消息 (B4) | 3+面板 | ✅ 面板 | ✅ | 5×P1 无实时推送 | + +### 健康模块 + +| 模块 | 页面数 | 浏览器验证 | 关键发现 | +|------|--------|-----------|---------| +| 患者管理 (B5) | 2 | ✅ | 标题"页面"bug | +| 医生/排班/预约 (B6) | 3 | ✅ | 冗余 404 请求 | +| 随访/咨询 (B7) | 4 | ✅ 部分 | 患者 UUID 片段 | +| 积分/文章/AI (B8) | 9 | ✅ 部分 | 统计报表 500 | + +### 系统/插件 + +| 模块 | 页面数 | 浏览器验证 | 代码审计 | 关键发现 | +|------|--------|-----------|---------|---------| +| 插件 (B9) | 8 | ❌ | ✅ | P1 SQL 注入 | +| 统计/仪表盘 (B9) | 2 | ✅ | ✅ | 500 错误 | +| 系统设置 (B10) | 8 标签 | ✅ 3 标签 | ✅ | 审计日志 UUID | + +### 小程序(B11-B14) + +| 模块 | 审计方式 | 关键发现 | +|------|---------|---------| +| 登录/首页 (B11) | 代码审计 | P0 .env 泄露风险 | +| 健康管理 (B12) | 代码审计 | P2 错误处理缺失 | +| 医患交互 (B13) | 代码审计 | P1 咨询消息轮询无错误恢复 | +| 内容/商城 (B14) | 代码审计 | P2 空状态处理缺失 | + +#### 小程序专项发现 + +### P0-9: .env 未加入 .gitignore +- **模块**: miniprogram 配置 +- **类型**: 安全漏洞 +- **相关文件**: `apps/miniprogram/.gitignore`(仅含 node_modules/ 和 dist/) +- **影响**: `.env` 文件含 `TARO_APP_ENCRYPTION_KEY=hms_miniprogram_encryption_key_2026`,可能意外提交到 git +- **当前状态**: 未被 git 追踪(git ls-files 为空),但风险存在 +- **建议修复**: 在 `.gitignore` 中添加 `.env` 和 `.env.*` + +### P1-19: 小程序加密密钥为弱密钥 +- **模块**: miniprogram secure-storage +- **类型**: 安全隐患 +- **相关文件**: `apps/miniprogram/.env:2`, `apps/miniprogram/src/utils/secure-storage.ts` +- **影响**: `hms_miniprogram_encryption_key_2026` 为可预测字符串,AES 加密形同虚设 +- **建议修复**: 使用 `python -c "import secrets; print(secrets.token_hex(32))"` 生成强密钥 + +### P1-20: project.config.json urlCheck: false +- **模块**: miniprogram 配置 +- **类型**: 安全隐患 +- **相关文件**: `apps/miniprogram/project.config.json:6` +- **影响**: 生产环境允许请求任意域名,应限制为后端 API 域名 +- **建议修复**: 生产版本设为 `true` 并配置合法域名白名单 + +### P2-21: secure-storage 未设密钥时明文存储 +- **模块**: miniprogram secure-storage +- **类型**: 安全隐患 +- **相关文件**: `apps/miniprogram/src/utils/secure-storage.ts:12` +- **影响**: `ENCRYPTION_KEY` 为空时 `encrypt()` 直接返回明文,token 和敏感数据不加密存储 +- **建议修复**: 强制要求密钥,未设置时阻止存储敏感数据 + +### P2-22: 小程序各页面缺少统一错误边界 +- **模块**: miniprogram 全局 +- **类型**: 功能缺陷 +- **影响**: API 请求失败时页面无友好错误提示,可能白屏 +- **建议修复**: 添加全局错误边界组件和网络错误拦截器 + +### P3-15: 小程序部分页面缺少空状态处理 +- **模块**: miniprogram 健康管理 +- **类型**: UX 问题 +- **影响**: 健康数据为空时无引导提示 + +--- + +## 浏览器验证发现汇总 + +以下问题通过实际浏览器操作验证: + +| 页面 | 操作 | 结果 | 问题 | +|------|------|------|------| +| Users | 创建用户(无效邮箱) | 成功 | email 验证宽松 | +| Users | 删除用户 | 成功 | — | +| Users | 分配角色 | 成功 | — | +| Patient List | 搜索 | 成功 | — | +| Patient Detail | 切换标签 | 成功 | 标题显示"页面" | +| Statistics | 加载 | 失败 500 | SQL 类型不匹配 | +| Settings-字典 | 新建/添加项/删除 | 全部成功 | — | +| Settings-密码 | 错误旧密码 | 正确提示 | — | +| Settings-审计日志 | 查看 | 成功 | UUID 而非用户名 | +| Settings-菜单 | 查看 | 成功 | — | +| Workflow-定义 | 列表 | 成功 | — | +| Workflow-设计器 | 编辑草稿 | 成功 | 缺节点属性编辑器 | +| Workflow-待办 | 查看 | 成功 | — | +| 通知面板 | 打开 | 成功 | 60s 轮询延迟 | + +--- + +## 优先修复建议 + +### 第一优先级(P0,必须立即修复) + +1. **上传文件认证**(P0-1)— 医疗隐私合规要求 +2. **SQL 注入修复**(P0-3)— 安全风险,改用参数化查询 +3. **analytics/batch 认证**(P0-2)— 防止数据伪造 +4. **级联删除检查**(P0-4/5/6)— 防止数据完整性破坏 +5. **并行网关修复**(P0-7)— 核心工作流引擎可靠性 +6. **租户清理实现**(P0-8)— 多租户数据隔离 + +### 第二优先级(P1,2 周内修复) + +1. 统计报表 SQL 类型修复(P1-1)— 面向用户的功能 +2. 行级数据权限实现(P1-2)— 安全要求 +3. 消息实时推送(P1-3)— 医疗告警时效性 +4. 工作流功能补全(P1-6~11)— 模块可用性 +5. 消息模板/偏好完善(P1-4/5)— 功能闭环 + +### 第三优先级(P2,1 月内修复) + +1. 前端测试覆盖率提升(P2-2) +2. 工作流 N+1 查询优化(P2-11) +3. 各模块 UX 修复(UUID 显示、标题、按钮状态等) + +--- + +## 附录:审计方法 + +- **代码扫描**: Explore agent 深度遍历源码,逐模块检查功能完整性 +- **浏览器测试**: Chrome DevTools MCP 实际操作 18 个页面,验证 CRUD 链路 +- **后端日志分析**: 解析 Axum tracing 日志定位 500 错误根因 +- **网络面板**: 监控 API 请求/响应,发现冗余调用和错误响应 diff --git a/plans/audit-summary-2026-04-26.md b/plans/audit-summary-2026-04-26.md new file mode 100644 index 0000000..31e5b5a --- /dev/null +++ b/plans/audit-summary-2026-04-26.md @@ -0,0 +1,52 @@ +# HMS 审计摘要 — 一页纸 + +> 2026-04-26 | 全系统审计(Web + 小程序) + +## 总览 + +| | P0 | P1 | P2 | P3 | P4 | 合计 | +|---|---|---|---|---|---|---| +| 安全 | 4 | 2 | 2 | | | 8 | +| 数据完整性 | 5 | 2 | 1 | | | 8 | +| 功能缺失/缺陷 | | 12 | 8 | 5 | | 25 | +| UX/代码质量 | | | 9 | 10 | 6 | 25 | +| 配置/运维 | | 2 | 2 | | | 4 | +| **合计** | **9** | **18** | **22** | **15** | **6** | **72** | + +## 必须立即修复(Top 5) + +1. **上传文件无认证** — 医疗文档公开可访问,合规风险极高 +2. **SQL 注入**(plugin engine `load_plugin_config`)— 改用参数化查询 +3. **小程序 .env 未加入 .gitignore** — 含弱加密密钥,可能意外泄露 +4. **组织/部门/岗位级联删除缺失** — 软删除后数据完整性破坏 +5. **统计报表 500** — PostgreSQL NUMERIC vs Rust f64 类型不匹配 + +## 模块健康度 + +| 模块 | 评级 | 核心问题 | +|------|------|---------| +| 用户/权限 | 🟢 | 基本可用,email 验证偏松 | +| 组织架构 | 🔴 | 级联删除全部缺失 | +| 工作流 | 🔴 | 大量功能未实现(ServiceTask/claim/deprecate/timeout) | +| 消息 | 🟡 | 无实时推送,模板/偏好功能半成品 | +| 患者管理 | 🟢 | 基本可用,细节问题 | +| 预约排班 | 🟢 | 正常 | +| 随访咨询 | 🟡 | 患者 UUID 显示 | +| 积分文章 | 🟢 | 正常 | +| 统计报表 | 🔴 | 500 错误 | +| 插件系统 | 🟡 | SQL 注入需修,其他防护好 | +| 系统设置 | 🟢 | 基本可用 | +| **小程序** | 🟡 | .env 安全风险,加密密钥弱 | + +## 下一步 + +1. **修复 9 个 P0**(预计 3-5 天)— 安全和数据完整性 +2. **修复高优 P1**(预计 1 周)— 统计报表/实时推送/加密密钥 +3. **补全工作流和消息模块**(预计 2-3 周)— 功能闭环 +4. **小程序安全加固**(预计 3 天)— .gitignore/密钥/urlCheck +5. **提升前端测试覆盖率**(持续) + +## 报告文件 + +- 详细报告: `plans/audit-report-2026-04-26.md` +- 本摘要: `plans/audit-summary-2026-04-26.md` diff --git a/plans/brainstorm-encapsulated-gray.md b/plans/brainstorm-encapsulated-gray.md new file mode 100644 index 0000000..475ad8a --- /dev/null +++ b/plans/brainstorm-encapsulated-gray.md @@ -0,0 +1,261 @@ +# HMS 审计问题全量修复计划 + +> 日期: 2026-04-26 | 基于 audit-report-2026-04-26.md 的 72 个问题 +> 分 7 个 Phase 按优先级执行,每个 Phase 完成后提交验证 + +## Context + +全系统审计发现 72 个问题(9 P0 / 18 P1 / 22 P2 / 15 P3 / 6 P4 + 2 额外发现)。本计划将所有问题按修复复杂度和依赖关系分 7 个阶段执行。 + +## 修正后的审计计数 + +经代码验证,部分 P0 发现需要修正: +- **P0-4/5 部分存在**:组织删除已检查子组织(`org_service.rs:241-252`),部门删除已检查子部门(`dept_service.rs:264-275`),但均未检查关联的岗位/用户 +- **新增 P0**:`stats_service.rs:423-444` 的 `compute_avg_field` 函数也有 SQL 注入(`format!` 拼接 `field` 参数) +- **P3-5 误报**:登录默认 tenant_id 是开发环境行为,非 bug + +--- + +## Phase 1: 安全热修复(P0 安全,4 个问题) + +### 1.1 P0-1: 上传文件认证 +- **文件**: `crates/erp-server/src/main.rs:543-546` +- **现状**: `ServeDir` 在 auth middleware 之外 +- **修复**: 将 `/uploads` 移到 protected_routes,或添加 `axum_middleware::from_fn` JWT 检查 +- **方案**: 创建 `serve_file_with_auth` 中间件,从 query param 或 header 取 token 验证 + +### 1.2 P0-2: analytics/batch 认证 +- **文件**: `crates/erp-server/src/main.rs:497-500` +- **现状**: 在 `public_routes` 中 +- **修复**: 移到 `protected_routes`(加 `.merge(...)` 到 line 514 的 protected_routes) + +### 1.3 P0-3: plugin engine SQL 注入 +- **文件**: `crates/erp-plugin/src/engine.rs:630-637` +- **现状**: `format!()` 拼接 `pid` +- **修复**: 改用 `Statement::from_sql_and_values` + `$1`, `$2` 参数化 + +### 1.4 P0-new: stats_service compute_avg_field SQL 注入 +- **文件**: `crates/erp-health/src/service/stats_service.rs:423-444` +- **现状**: `format!("SELECT AVG({field}) AS avg_val...")` 直接拼接字段名 +- **修复**: 添加字段名白名单验证 + 使用 `CAST(AVG(...) AS FLOAT8)` 同时解决类型问题 + +--- + +## Phase 2: 数据完整性修复(P0 数据,4 个问题) + +### 2.1 P0-4: 组织删除级联(补充检查) +- **文件**: `crates/erp-auth/src/service/org_service.rs:240-252` +- **现状**: 已检查子组织,未检查部门 +- **修复**: 在 line 252 后添加部门存在性检查 +```rust +// Check for departments under this org +let depts = department::Entity::find() + .filter(department::Column::OrganizationId.eq(id)) + .filter(department::Column::DeletedAt.is_null()) + .one(db).await?; +if depts.is_some() { + return Err(AuthError::Validation("该组织下存在部门,无法删除".into())); +} +``` + +### 2.2 P0-5: 部门删除级联(补充检查) +- **文件**: `crates/erp-auth/src/service/dept_service.rs:264-275` +- **现状**: 已检查子部门,未检查岗位 +- **修复**: 添加岗位存在性检查 + +### 2.3 P0-6: 岗位删除级联 +- **文件**: `crates/erp-auth/src/service/position_service.rs:214-249` +- **现状**: 无关联检查 +- **修复**: 检查 user_position 关联表中是否有用户分配到此岗位 + +### 2.4 P0-8: workflow on_tenant_deleted +- **文件**: `crates/erp-workflow/src/module.rs:148-154` +- **现状**: 空操作 `Ok(())` +- **修复**: 实现 5 个实体的批量软删除(process_definition → instance → task/token/variable) +- **实体**: `process_definitions`, `process_instances`, `tasks`, `tokens`, `process_variables` + +--- + +## Phase 3: 并行网关 + P1 后端 Bug(7 个问题) + +### 3.1 P0-7: 并行网关 token 关联 +- **文件**: `crates/erp-workflow/src/engine/executor.rs:369-425` +- **修复**: 在 token 表添加 `fork_id` 字段(或使用 token 创建时间窗口),区分不同 fork 产生的 token +- **轻量方案**: 使用 `SELECT ... FOR UPDATE` 加行锁 + 检查 token 的 `consumed_at` 时间窗口 + +### 3.2 P1-1: 统计报表 SQL 类型修复 +- **文件**: `crates/erp-health/src/service/stats_service.rs` +- **修复**: SQL 中使用 `CAST(AVG(...) AS FLOAT8)` 或 `AVG(...)::FLOAT8` +- **同时修复**: `compute_avg_field` 的字段名白名单 + +### 3.3 P1-12: plugin host 表名消毒 +- **文件**: `crates/erp-plugin/src/host.rs:339` +- **修复**: 使用已有的 `sanitize_identifier()` 函数 + +### 3.4 P1-10: workflow deprecated 状态 +- **文件**: `crates/erp-workflow/src/service/definition_service.rs` +- **修复**: 添加 `deprecate` 方法,实现 `published → deprecated` 转换 + +### 3.5 P1-11: workflow 更新验证 +- **文件**: `crates/erp-workflow/src/service/definition_service.rs:174-181` +- **修复**: nodes 或 edges 任一存在即执行验证 + +### 3.6 P0-9: 小程序 .gitignore +- **文件**: `apps/miniprogram/.gitignore` +- **修复**: 添加 `.env`, `.env.*`, `*.log` + +### 3.7 P1-19: 小程序加密密钥 +- **文件**: `apps/miniprogram/.env` +- **修复**: 生成 64 字符 hex 强密钥替换 + +--- + +## Phase 4: 消息模块修复(P1,5 个问题) + +### 4.1 P1-5: 通知偏好 GET + version +- **后端**: `crates/erp-message/src/module.rs` 添加 `GET /message-subscriptions` 路由 +- **后端**: `crates/erp-message/src/handler/subscription_handler.rs` 添加 `get_subscription` handler +- **前端**: `apps/web/src/pages/messages/NotificationPreferences.tsx` + - useEffect 中调用 GET API 加载已有配置 + - 保存时发送 version 字段 + +### 4.2 P1-4: 消息模板 CRUD +- **后端**: `template_service.rs` 添加 `update`, `delete` 方法 +- **后端**: `module.rs` 添加 `PUT /message-templates/{id}`, `DELETE /message-templates/{id}` 路由 +- **前端**: `MessageTemplates.tsx` 添加编辑/删除按钮 + +### 4.3 P2-6/7/8: 消息 store + mark_all_read 修复 +- `stores/message.ts`: markAsRead 改为乐观更新 + 失败回滚 +- `stores/message.ts`: 添加 markAllRead action,重置 unreadCount 为 0 +- `message_service.rs:298-326`: mark_all_read SQL 中添加 `version = version + 1` + +### 4.4 P2-9: 通知面板点击导航 +- `NotificationPanel.tsx:81-85`: 添加 `navigate('/messages')` 跳转 + +### 4.5 P1-20: urlCheck 配置 +- **文件**: `apps/miniprogram/project.config.json:6` +- **修复**: 添加注释说明仅开发环境使用 false,或改用条件配置 + +--- + +## Phase 5: 前端 P2-P3 Bug 修复(15 个问题) + +### 5.1 P2-1: 随访任务患者名 UUID +- **文件**: `apps/web/src/pages/health/FollowUpTaskList.tsx` +- **修复**: 在 `fetchTasks` 后添加 `AppointmentList` 风格的批量 ID 解析循环 + +### 5.2 P2-5: AppointmentList 冗余请求 +- **文件**: `apps/web/src/pages/health/AppointmentList.tsx:103-121` +- **修复**: 分离 patient_id 和 doctor_id,分别调用对应 API + +### 5.3 P3-3: PatientDetail 标题"页面" +- **文件**: `apps/web/src/layouts/MainLayout.tsx:84-95, 387` +- **修复**: 将 `routeTitleFallback` 查找改为路径模式匹配(用 `startsWith` + 动态段替换) + +### 5.4 P3-1: 已完成任务显示操作按钮 +- **文件**: 健康模块各列表页 +- **修复**: 根据状态条件渲染按钮 + +### 5.5 P2-17: PluginMarket installed 字段 +- **文件**: `apps/web/src/pages/PluginMarket.tsx:68` +- **修复**: `Set` 改为 `result.data.map(p => p.id)` 而非 `p.name` + +### 5.6 P3-6: 审计日志 UUID +- **文件**: `apps/web/src/pages/settings/AuditLogViewer.tsx:123-135` +- **修复**: 添加用户 ID → 用户名解析(批量查询或缓存) + +### 5.7 P3-7: 审计日志资源类型过滤 +- **文件**: `AuditLogViewer.tsx:7-18` +- **修复**: 添加 plugin 相关类型到 RESOURCE_TYPE_OPTIONS + +### 5.8 P3-9: Kanban version 硬编码 +- **文件**: `apps/web/src/pages/PluginKanbanPage.tsx:113` +- **修复**: 使用 record 的实际 version 字段 + +### 5.9 P3-11: destroyOnHidden → destroyOnClose +- **文件**: `ProcessDefinitions.tsx:192` +- **修复**: 替换 prop 名 + +### 5.10 P3-13: 深色模式 Tag +- **文件**: `PendingTasks.tsx:95-101` +- **修复**: 使用 `isDark` 条件判断颜色 + +### 5.11 P2-21: secure-storage 明文回退 +- **文件**: `apps/miniprogram/src/utils/secure-storage.ts:12` +- **修复**: 生产环境下密钥为空时阻止存储,抛出错误 + +### 5.12 P3-12: InstanceMonitor node_id +- **文件**: `InstanceMonitor.tsx:149` +- **修复**: 从 definition 的 nodes 中查找 node_name + +### 5.13 P2-14: 委派 UUID 输入 +- **文件**: `PendingTasks.tsx:207-213` +- **修复**: 替换为用户搜索选择组件 + +### 5.14 P2-15: UpdateDefinition version +- **文件**: `apps/web/src/api/workflowDefinitions.ts:45-51` +- **修复**: 添加 version 字段 + +### 5.15 P3-4: any 类型替换 +- 搜索 `apps/web/src/` 中 4 处 any,替换为具体类型 + +--- + +## Phase 6: 功能补全(P1 功能缺失,8 个问题) + +### 6.1 P1-3: 消息 SSE 推送(最小可行方案) +- **后端**: 添加 `GET /api/v1/messages/stream` SSE 端点 +- **后端**: EventBus 订阅 `message.sent` 事件推送给对应用户 +- **前端**: NotificationPanel 中连接 SSE,收到事件立即更新 +- **注意**: 先实现 SSE(比 WebSocket 简单),后续可升级 + +### 6.2 P1-6/7/8/9: 工作流引擎功能补全 +- **P1-6 ServiceTask**: 添加 HTTP 调用能力(基础版:支持 GET/POST URL) +- **P1-7 事件注册**: 实现 `register_event_handlers`,监听 `user.deleted` 等事件 +- **P1-8 任务认领**: 添加 `claim` 方法,支持 candidate_groups 过滤列表 +- **P1-9 超时升级**: timeout checker 中添加自动通知发布 + +### 6.3 P1-15: 名称唯一性 +- **文件**: `org_service.rs`, `dept_service.rs` +- **修复**: create/update 时检查同 tenant 下名称唯一性 + +### 6.4 P1-18: 消息群发 fan-out +- **文件**: `message_service.rs` +- **修复**: 当 recipient_type 为 role/dept/all 时,查询对应用户列表,批量创建消息 + +--- + +## Phase 7: P3-P4 收尾 + 优化(6 个问题) + +- P4-1: PluginAdmin purge 按钮状态 +- P4-3: recover_plugins tenant 过滤 +- P4-4: LanguageManager 编辑弹窗 +- P4-5: ChangePassword 后端验证 +- P4-6: Settings API URL 编码 +- P3-2: ArticleEditor 图片上传(标记为未来任务) +- P3-14: 偏好设置 DND 时间范围验证 +- P3-15: 小程序空状态处理 + +--- + +## 验证计划 + +每个 Phase 完成后执行: + +1. `cargo check` — 编译通过 +2. `cargo test --workspace` — 所有测试通过 +3. 浏览器验证 — 启动服务,操作对应页面确认修复生效 +4. `git commit` — 提交修复 +5. `git push` — 推送到远程 + +### 关键验证点 + +| Phase | 验证方式 | +|-------|---------| +| Phase 1 | curl 无 token 访问 /uploads 应 401 | +| Phase 2 | 尝试删除含部门的组织应返回错误 | +| Phase 3 | 统计报表页面应正常加载数据 | +| Phase 4 | 偏好设置保存后重载应显示已有配置 | +| Phase 5 | FollowUpTaskList 患者名应显示而非 UUID | +| Phase 6 | 发送角色消息,该角色用户应收到 | +| Phase 7 | 全量回归测试 |