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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user