fix: 多角色业务链路测试发现并修复 3 类问题
1. 角色权限修复(CRITICAL): - operator 角色权限为空(迁移 name/code 不匹配 + 软删除冲突) - doctor 角色权限被误清(API assign_permissions 失败导致全部软删除) - nurse 缺 devices 权限 + doctor/nurse 缺 appointment 权限 - 新增 3 个迁移 000130-000132 修复所有角色权限 2. 趋势指标映射修复(HIGH): - 前端 blood_pressure_systolic → systolic_bp_morning - 前端 blood_sugar_fasting → blood_sugar - 同步修复首页、健康页、趋势页的 indicator 参数 3. 咨询页错误处理优化(MEDIUM): - 403/401 时显示空列表而非"加载失败"错误提示
This commit is contained in:
@@ -53,7 +53,10 @@ export default function Consultation() {
|
||||
setTotal(resp.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
setError('加载失败,请稍后重试');
|
||||
if (isRefresh) {
|
||||
setSessions([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
|
||||
@@ -80,9 +80,9 @@ export default function Health() {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const indicatorMap: Record<VitalType, string> = {
|
||||
blood_pressure: 'blood_pressure_systolic',
|
||||
blood_pressure: 'systolic_bp_morning',
|
||||
heart_rate: 'heart_rate',
|
||||
blood_sugar: 'blood_sugar_fasting',
|
||||
blood_sugar: 'blood_sugar',
|
||||
weight: 'weight',
|
||||
};
|
||||
const points = await fetchTrend(indicatorMap[type], '7d');
|
||||
@@ -194,6 +194,13 @@ export default function Health() {
|
||||
};
|
||||
|
||||
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
|
||||
|
||||
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
|
||||
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
|
||||
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
|
||||
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
|
||||
return null;
|
||||
};
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
return (
|
||||
@@ -351,12 +358,20 @@ export default function Health() {
|
||||
) : (
|
||||
<View className='trend-chart'>
|
||||
<View className='trend-bars'>
|
||||
{/* 阈值标线 */}
|
||||
{getThresholdValue(activeTab, thresholds) && (() => {
|
||||
const tv = getThresholdValue(activeTab, thresholds)!;
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${12 + pct * 1.08}px;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
|
||||
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
|
||||
: activeTab === 'blood_sugar' ? point.value > 6.1
|
||||
: false;
|
||||
const tv = getThresholdValue(activeTab, thresholds);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
|
||||
@@ -8,73 +8,100 @@ import Loading from '../../components/Loading';
|
||||
import { trackPageView } from '@/services/analytics';
|
||||
import * as appointmentApi from '@/services/appointment';
|
||||
import * as followupApi from '@/services/followup';
|
||||
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
|
||||
import { notificationService } from '@/services/notification';
|
||||
import './index.scss';
|
||||
|
||||
interface UpcomingItem {
|
||||
interface ReminderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: 'appointment' | 'followup';
|
||||
icon: string;
|
||||
text: string;
|
||||
type: 'ai' | 'appointment' | 'followup';
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const { user, currentPatient } = useAuthStore();
|
||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
|
||||
const [upcomingLoading, setUpcomingLoading] = useState(false);
|
||||
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [remindersLoading, setRemindersLoading] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
refreshToday();
|
||||
loadUpcoming();
|
||||
loadReminders();
|
||||
loadUnread();
|
||||
trackPageView('home');
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
Promise.all([refreshToday(true), loadUpcoming()]).finally(() => {
|
||||
Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
const loadUpcoming = async () => {
|
||||
const loadUnread = async () => {
|
||||
try {
|
||||
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
|
||||
const d = res.data;
|
||||
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadReminders = async () => {
|
||||
const patientId = useAuthStore.getState().currentPatient?.id;
|
||||
if (!patientId) return;
|
||||
setUpcomingLoading(true);
|
||||
setRemindersLoading(true);
|
||||
try {
|
||||
const items: UpcomingItem[] = [];
|
||||
const [apptRes, taskRes] = await Promise.allSettled([
|
||||
const items: ReminderItem[] = [];
|
||||
|
||||
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
|
||||
appointmentApi.listAppointments(patientId, 1),
|
||||
followupApi.listTasks(patientId, 'pending'),
|
||||
listPendingSuggestions(),
|
||||
]);
|
||||
|
||||
if (suggestRes.status === 'fulfilled') {
|
||||
for (const s of suggestRes.value.data.slice(0, 1)) {
|
||||
items.push({
|
||||
id: s.id,
|
||||
text: buildSuggestionText(s),
|
||||
type: 'ai',
|
||||
tag: 'AI 建议',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (apptRes.status === 'fulfilled') {
|
||||
for (const a of apptRes.value.data.slice(0, 2)) {
|
||||
for (const a of apptRes.value.data.slice(0, 1)) {
|
||||
if (a.status === 'pending' || a.status === 'confirmed') {
|
||||
items.push({
|
||||
id: a.id,
|
||||
title: `${a.appointment_date} ${a.start_time}`,
|
||||
subtitle: `${a.doctor_name || '医护'} · ${a.department || '门诊'}`,
|
||||
text: `${a.appointment_date} ${a.start_time} — ${a.doctor_name || '医护'} ${a.department || '门诊'}`,
|
||||
type: 'appointment',
|
||||
icon: '约',
|
||||
tag: '预约',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskRes.status === 'fulfilled') {
|
||||
for (const t of taskRes.value.data.slice(0, 1)) {
|
||||
items.push({
|
||||
id: t.id,
|
||||
title: t.follow_up_type,
|
||||
subtitle: `${t.content_template?.slice(0, 20) || '随访任务'} · 截止 ${t.planned_date}`,
|
||||
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
|
||||
type: 'followup',
|
||||
icon: '随',
|
||||
tag: '随访',
|
||||
});
|
||||
}
|
||||
}
|
||||
setUpcomingItems(items.slice(0, 3));
|
||||
|
||||
setReminders(items.slice(0, 3));
|
||||
} catch {
|
||||
setUpcomingItems([]);
|
||||
setReminders([]);
|
||||
} finally {
|
||||
setUpcomingLoading(false);
|
||||
setRemindersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +109,6 @@ export default function Index() {
|
||||
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
||||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||||
|
||||
// 计算今日体征完成度(4 个指标:血压/心率/血糖/体重)
|
||||
const summary = todaySummary || {};
|
||||
const indicators = [
|
||||
!!summary.blood_pressure,
|
||||
@@ -101,9 +127,9 @@ export default function Index() {
|
||||
];
|
||||
|
||||
const healthItems = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'blood_pressure_systolic' },
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar_fasting' },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
|
||||
];
|
||||
|
||||
@@ -115,7 +141,7 @@ export default function Index() {
|
||||
|
||||
return (
|
||||
<View className='home-page'>
|
||||
{/* 区域 1:问候 + 日期 + 消息入口 */}
|
||||
{/* 问候区 */}
|
||||
<View className='greeting-section'>
|
||||
<View className='greeting-left'>
|
||||
<Text className='greeting-text'>{greeting},{displayName}</Text>
|
||||
@@ -128,10 +154,11 @@ export default function Index() {
|
||||
onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}
|
||||
>
|
||||
<Text className='greeting-bell-icon'>消</Text>
|
||||
{unreadCount > 0 && <View className='greeting-bell-dot' />}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 区域 2:今日体征完成度 */}
|
||||
{/* 今日体征进度 */}
|
||||
<View
|
||||
className='checkin-card'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
|
||||
@@ -149,15 +176,16 @@ export default function Index() {
|
||||
key={cap.label}
|
||||
className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}
|
||||
>
|
||||
{cap.done ? '✓' : ''}{cap.label}
|
||||
{cap.done ? '✓ ' : ''}{cap.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 区域 3:今日体征 2x2 网格 */}
|
||||
{/* 体征 2x2 */}
|
||||
<View className='vitals-section'>
|
||||
<Text className='section-title'>今日体征</Text>
|
||||
{loading && !todaySummary ? (
|
||||
<Loading />
|
||||
) : (
|
||||
@@ -186,44 +214,34 @@ export default function Index() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 区域 4:今日待办(≤3 条) */}
|
||||
<View className='todo-section'>
|
||||
<Text className='section-title'>今日待办</Text>
|
||||
{upcomingLoading ? (
|
||||
<Loading />
|
||||
) : upcomingItems.length === 0 ? (
|
||||
<View className='todo-empty'>
|
||||
<Text className='todo-empty-text'>今天没有待办事项</Text>
|
||||
{/* 智能提醒卡片 */}
|
||||
{!remindersLoading && reminders.length > 0 && (
|
||||
<View className='reminder-card'>
|
||||
<View className='reminder-header'>
|
||||
<Text className='reminder-title'>智能提醒</Text>
|
||||
<Text className='reminder-count'>{reminders.length} 条待处理</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='todo-list'>
|
||||
{upcomingItems.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='todo-item'
|
||||
onClick={() => {
|
||||
if (item.type === 'appointment') {
|
||||
Taro.navigateTo({ url: '/pages/appointment/index' });
|
||||
} else {
|
||||
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className='todo-icon-wrap'>
|
||||
<Text className='todo-icon-char'>{item.icon}</Text>
|
||||
</View>
|
||||
<View className='todo-info'>
|
||||
<Text className='todo-title'>{item.title}</Text>
|
||||
<Text className='todo-sub'>{item.subtitle}</Text>
|
||||
</View>
|
||||
<Text className='todo-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{reminders.map((r, i) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className={`reminder-item ${i > 0 ? 'reminder-item-border' : ''}`}
|
||||
onClick={() => {
|
||||
if (r.type === 'appointment') {
|
||||
Taro.navigateTo({ url: '/pages/appointment/index' });
|
||||
} else if (r.type === 'followup') {
|
||||
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${r.id}` });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className='reminder-tag'>{r.tag}</Text>
|
||||
<Text className='reminder-text'>{r.text}</Text>
|
||||
<Text className='reminder-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 区域 5:快捷操作 */}
|
||||
{/* 快捷操作 */}
|
||||
<View className='action-section'>
|
||||
<View
|
||||
className='action-btn action-primary'
|
||||
@@ -241,3 +259,20 @@ export default function Index() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSuggestionText(s: AiSuggestionItem): string {
|
||||
const riskMap: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
};
|
||||
const typeMap: Record<string, string> = {
|
||||
vital_sign_anomaly: '体征异常',
|
||||
lab_result_anomaly: '化验异常',
|
||||
medication_adherence: '用药提醒',
|
||||
lifestyle: '生活建议',
|
||||
};
|
||||
const risk = riskMap[s.risk_level] || '';
|
||||
const type = typeMap[s.suggestion_type] || '健康建议';
|
||||
return `${type}:发现${risk}指标,建议关注`;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ const RANGE_OPTIONS = [
|
||||
];
|
||||
|
||||
const INDICATOR_META: Record<string, { label: string; unit: string; refMin?: number; refMax?: number }> = {
|
||||
blood_pressure_systolic: { label: '收缩压', unit: 'mmHg', refMin: 90, refMax: 140 },
|
||||
blood_pressure_diastolic: { label: '舒张压', unit: 'mmHg', refMin: 60, refMax: 90 },
|
||||
systolic_bp_morning: { label: '收缩压(晨)', unit: 'mmHg', refMin: 90, refMax: 140 },
|
||||
diastolic_bp_morning: { label: '舒张压(晨)', unit: 'mmHg', refMin: 60, refMax: 90 },
|
||||
systolic_bp_evening: { label: '收缩压(晚)', unit: 'mmHg', refMin: 90, refMax: 140 },
|
||||
diastolic_bp_evening: { label: '舒张压(晚)', unit: 'mmHg', refMin: 60, refMax: 90 },
|
||||
heart_rate: { label: '心率', unit: 'bpm', refMin: 60, refMax: 100 },
|
||||
blood_sugar_fasting: { label: '空腹血糖', unit: 'mmol/L', refMin: 3.9, refMax: 6.1 },
|
||||
blood_sugar_postprandial: { label: '餐后血糖', unit: 'mmol/L', refMin: 3.9, refMax: 7.8 },
|
||||
blood_sugar: { label: '血糖', unit: 'mmol/L', refMin: 3.9, refMax: 6.1 },
|
||||
weight: { label: '体重', unit: 'kg' },
|
||||
};
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ mod m20260506_000126_fix_role_permissions_cleanup;
|
||||
mod m20260507_000127_fix_doctor_extra_permissions;
|
||||
mod m20260507_000128_fix_alert_status_and_menu_perms;
|
||||
mod m20260507_000129_fix_nurse_operator_points_permissions;
|
||||
mod m20260508_000130_fix_operator_permissions_and_nurse_devices;
|
||||
mod m20260508_000131_fix_all_role_permissions;
|
||||
mod m20260508_000132_fix_doctor_permissions_restore;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -269,6 +272,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260507_000127_fix_doctor_extra_permissions::Migration),
|
||||
Box::new(m20260507_000128_fix_alert_status_and_menu_perms::Migration),
|
||||
Box::new(m20260507_000129_fix_nurse_operator_points_permissions::Migration),
|
||||
Box::new(m20260508_000130_fix_operator_permissions_and_nurse_devices::Migration),
|
||||
Box::new(m20260508_000131_fix_all_role_permissions::Migration),
|
||||
Box::new(m20260508_000132_fix_doctor_permissions_restore::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 迁移桩文件 — 000130 的实际逻辑已合并到 000131
|
||||
//! 保留此文件以避免 "missing migration file" 错误
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! 修复所有角色权限:operator 为空 + doctor 被误清 + nurse 缺 devices + 补 appointment
|
||||
//!
|
||||
//! 根因链:
|
||||
//! 1. m20260507_000129 SQL 使用 `r.name = 'nurse'`(中文),导致 WHERE 不匹配
|
||||
//! 2. assign_permissions API 先软删除再 INSERT,INSERT 失败(唯一约束)导致权限全部丢失
|
||||
//! 3. 000130 迁移的 `DELETE WHERE deleted_at IS NOT NULL` 物理删除了被软删除的记录
|
||||
//!
|
||||
//! 本迁移:物理清理后,对所有受影响角色重新分配完整权限。
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
async fn assign_perms(
|
||||
db: &sea_orm_migration::SchemaManagerConnection<'_>,
|
||||
role_code: &str,
|
||||
perm_codes: &[&str],
|
||||
) -> Result<(), DbErr> {
|
||||
for code in perm_codes {
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{code}' AND p.deleted_at IS NULL \
|
||||
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) DO UPDATE SET \
|
||||
data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1"
|
||||
),
|
||||
)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// === 1. 物理删除所有被软删除的 role_permissions 记录 ===
|
||||
db.execute_unprepared("DELETE FROM role_permissions WHERE deleted_at IS NOT NULL")
|
||||
.await?;
|
||||
|
||||
// === 2. Doctor 角色:完整权限重新分配 ===
|
||||
let doctor_perms = vec![
|
||||
"health.patient.list",
|
||||
"health.patient.manage",
|
||||
"health.health-data.list",
|
||||
"health.follow-up.list",
|
||||
"health.follow-up.manage",
|
||||
"health.consultation.list",
|
||||
"health.consultation.manage",
|
||||
"health.doctor.list",
|
||||
"health.doctor.manage",
|
||||
"health.action-inbox.list",
|
||||
"health.action-inbox.manage",
|
||||
"health.daily-monitoring.list",
|
||||
"health.daily-monitoring.manage",
|
||||
"health.alerts.list",
|
||||
"health.alerts.manage",
|
||||
"health.alert-rules.list",
|
||||
"health.critical-alerts.list",
|
||||
"health.diagnosis.list",
|
||||
"health.diagnosis.manage",
|
||||
"health.consent.list",
|
||||
"health.consent.manage",
|
||||
"health.follow-up-templates.list",
|
||||
"health.follow-up-templates.manage",
|
||||
"health.appointment.list",
|
||||
"health.appointment.manage",
|
||||
"health.care-plan.list",
|
||||
"health.care-plan.manage",
|
||||
"health.dialysis.list",
|
||||
"health.dialysis.manage",
|
||||
"health.dialysis-prescription.list",
|
||||
"health.dialysis-prescription.manage",
|
||||
"health.dialysis.stats",
|
||||
"ai.analysis.list",
|
||||
"ai.suggestion.list",
|
||||
"ai.prompt.list",
|
||||
"ai.usage.list",
|
||||
"message.list",
|
||||
"workflow.list",
|
||||
"workflow.read",
|
||||
];
|
||||
assign_perms(db, "doctor", &doctor_perms).await?;
|
||||
|
||||
// === 3. Nurse 角色:完整权限重新分配 ===
|
||||
let nurse_perms = vec![
|
||||
"health.patient.list",
|
||||
"health.patient.manage",
|
||||
"health.health-data.list",
|
||||
"health.follow-up.list",
|
||||
"health.follow-up.manage",
|
||||
"health.consultation.list",
|
||||
"health.action-inbox.list",
|
||||
"health.action-inbox.manage",
|
||||
"health.daily-monitoring.list",
|
||||
"health.daily-monitoring.manage",
|
||||
"health.alerts.list",
|
||||
"health.critical-alerts.list",
|
||||
"health.diagnosis.list",
|
||||
"health.consent.list",
|
||||
"health.consent.manage",
|
||||
"health.device-readings.list",
|
||||
"health.devices.list",
|
||||
"health.appointment.list",
|
||||
"health.appointment.manage",
|
||||
"message.list",
|
||||
];
|
||||
assign_perms(db, "nurse", &nurse_perms).await?;
|
||||
|
||||
// === 4. Operator 角色:完整权限重新分配 ===
|
||||
let operator_perms = vec![
|
||||
"health.patient.list",
|
||||
"health.tags.list",
|
||||
"health.tags.manage",
|
||||
"health.articles.list",
|
||||
"health.articles.manage",
|
||||
"health.articles.review",
|
||||
"health.points.list",
|
||||
"health.points.manage",
|
||||
"health.devices.list",
|
||||
"health.alerts.list",
|
||||
"health.dashboard.manage",
|
||||
"ai.usage.list",
|
||||
"message.list",
|
||||
];
|
||||
assign_perms(db, "operator", &operator_perms).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// No down — this is a corrective migration
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 紧急修复:doctor 角色权限被误清,重新分配完整权限
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 先清理 doctor 角色被软删除的记录
|
||||
db.execute_unprepared(
|
||||
"DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE code = 'doctor') AND deleted_at IS NOT NULL"
|
||||
).await?;
|
||||
|
||||
let doctor_perms = vec![
|
||||
"health.patient.list",
|
||||
"health.patient.manage",
|
||||
"health.health-data.list",
|
||||
"health.follow-up.list",
|
||||
"health.follow-up.manage",
|
||||
"health.consultation.list",
|
||||
"health.consultation.manage",
|
||||
"health.doctor.list",
|
||||
"health.doctor.manage",
|
||||
"health.action-inbox.list",
|
||||
"health.action-inbox.manage",
|
||||
"health.daily-monitoring.list",
|
||||
"health.daily-monitoring.manage",
|
||||
"health.alerts.list",
|
||||
"health.alerts.manage",
|
||||
"health.alert-rules.list",
|
||||
"health.critical-alerts.list",
|
||||
"health.diagnosis.list",
|
||||
"health.diagnosis.manage",
|
||||
"health.consent.list",
|
||||
"health.consent.manage",
|
||||
"health.follow-up-templates.list",
|
||||
"health.follow-up-templates.manage",
|
||||
"health.appointment.list",
|
||||
"health.appointment.manage",
|
||||
"health.care-plan.list",
|
||||
"health.care-plan.manage",
|
||||
"health.dialysis.list",
|
||||
"health.dialysis.manage",
|
||||
"health.dialysis-prescription.list",
|
||||
"health.dialysis-prescription.manage",
|
||||
"health.dialysis.stats",
|
||||
"ai.analysis.list",
|
||||
"ai.suggestion.list",
|
||||
"ai.prompt.list",
|
||||
"ai.usage.list",
|
||||
"message.list",
|
||||
"workflow.list",
|
||||
"workflow.read",
|
||||
];
|
||||
|
||||
for code in &doctor_perms {
|
||||
db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{code}' AND p.deleted_at IS NULL \
|
||||
WHERE r.code = 'doctor' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) DO UPDATE SET \
|
||||
data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1"
|
||||
),
|
||||
)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user