fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
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 <noreply@anthropic.com>
This commit is contained in:
6
apps/miniprogram/.gitignore
vendored
6
apps/miniprogram/.gitignore
vendored
@@ -1 +1,5 @@
|
||||
node_modules/\ndist/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface ArticleTagItem {
|
||||
slug?: string;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface CreateTagReq {
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface UpdateProcessDefinitionRequest {
|
||||
description?: string;
|
||||
nodes?: NodeDef[];
|
||||
edges?: EdgeDef[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listProcessDefinitions(page = 1, pageSize = 20) {
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function NotificationPanel() {
|
||||
if (!item.is_read) {
|
||||
markAsRead(item.id);
|
||||
}
|
||||
navigate('/messages');
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (item.is_read) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -85,9 +85,12 @@ function KanbanInner({
|
||||
if (!newLane) return;
|
||||
|
||||
let currentLane = '';
|
||||
let currentRecord: Record<string, any> | 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 {
|
||||
|
||||
@@ -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 {
|
||||
// 静默失败
|
||||
|
||||
@@ -99,26 +99,22 @@ export default function AppointmentList() {
|
||||
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
const items = result.data;
|
||||
// 批量解析患者和医生名称
|
||||
const missingIds = new Set<string>();
|
||||
// 批量解析患者和医生名称(分别调用对应 API)
|
||||
const missingPatientIds = new Set<string>();
|
||||
const missingDoctorIds = new Set<string>();
|
||||
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<string, string> = {};
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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 {
|
||||
|
||||
@@ -93,9 +93,9 @@ export default function PendingTasks() {
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag style={{
|
||||
background: '#eff6ff',
|
||||
background: isDark ? '#172554' : '#eff6ff',
|
||||
border: 'none',
|
||||
color: '#2563eb',
|
||||
color: isDark ? '#60a5fa' : '#2563eb',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{s}
|
||||
@@ -205,6 +205,7 @@ export default function PendingTasks() {
|
||||
<p style={{ fontWeight: 500, marginBottom: 16 }}>
|
||||
任务: {delegateModal?.node_name}
|
||||
</p>
|
||||
{/* TODO: 替换为 UserSelect 用户搜索选择组件,支持按姓名/工号搜索 */}
|
||||
<Input
|
||||
placeholder="输入目标用户 ID (UUID)"
|
||||
value={delegateTo}
|
||||
|
||||
@@ -66,7 +66,8 @@ export default function ProcessDefinitions() {
|
||||
const handleSave = async (req: CreateProcessDefinitionRequest, id?: string) => {
|
||||
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
|
||||
>
|
||||
<ProcessDesigner
|
||||
definitionId={editingId}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface MessageState {
|
||||
let unreadCountPromise: Promise<void> | null = null;
|
||||
let recentMessagesPromise: Promise<void> | null = null;
|
||||
|
||||
export const useMessageStore = create<MessageState>((set) => ({
|
||||
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
recentMessages: [],
|
||||
|
||||
@@ -55,16 +55,17 @@ export const useMessageStore = create<MessageState>((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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -425,8 +425,26 @@ async fn compute_avg_field(
|
||||
tenant_id: uuid::Uuid,
|
||||
field: &str,
|
||||
) -> AppResult<Option<f64>> {
|
||||
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())"
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@ pub struct MessageTemplateResp {
|
||||
pub language: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
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<String>,
|
||||
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
|
||||
pub title_template: Option<String>,
|
||||
#[validate(length(min = 1, message = "内容模板不能为空"))]
|
||||
pub body_template: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ============ 消息订阅偏好 DTO ============
|
||||
|
||||
/// 消息订阅偏好响应
|
||||
|
||||
@@ -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<crate::dto::MessageSubscriptionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息订阅"
|
||||
)]
|
||||
/// 获取当前用户的消息订阅偏好。
|
||||
pub async fn get_subscription<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
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",
|
||||
|
||||
@@ -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<MessageTemplateResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息模板"
|
||||
)]
|
||||
/// 更新消息模板。
|
||||
pub async fn update_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateTemplateReq>,
|
||||
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
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<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
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(())))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<MessageTemplateResp> {
|
||||
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, String>) -> String {
|
||||
@@ -110,6 +186,7 @@ impl TemplateService {
|
||||
language: m.language.clone(),
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 <token>` header
|
||||
/// or `?token=<token>` query parameter (for browser `<img>` / direct downloads).
|
||||
async fn upload_auth_middleware(
|
||||
jwt_secret: String,
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<axum::response::Response, erp_core::error::AppError> {
|
||||
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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -180,3 +180,26 @@ where
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
pub async fn deprecate_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<Vec<crate::dto::NodeDef>>(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<crate::dto::NodeDef> = serde_json::from_value(nodes_val)
|
||||
.map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = 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<ProcessDefinitionResp> {
|
||||
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,
|
||||
|
||||
382
plans/audit-report-2026-04-26.md
Normal file
382
plans/audit-report-2026-04-26.md
Normal file
@@ -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 请求/响应,发现冗余调用和错误响应
|
||||
52
plans/audit-summary-2026-04-26.md
Normal file
52
plans/audit-summary-2026-04-26.md
Normal file
@@ -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`
|
||||
261
plans/brainstorm-encapsulated-gray.md
Normal file
261
plans/brainstorm-encapsulated-gray.md
Normal file
@@ -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 | 全量回归测试 |
|
||||
Reference in New Issue
Block a user