feat: 添加平台管理员权限和优化会议状态处理逻辑

- 在 `usePermission` 钩子中添加 `isPlatformAdmin` 权限检查
- 在 `TenantMeetingPointsSettings` 页面中根据权限控制按钮显示
- 优化 `MeetingCommandServiceImpl` 和 `MeetingUnifiedStatusServiceImpl` 中的会议状态处理逻辑
- 更新 `Meetings.tsx` 中的会议状态判断逻辑
- 在 `AiTaskServiceImpl` 中添加 `AndroidMeetingPushService` 依赖,并在任务完成或失败时推送会议状态变化
dev_na
chenhao 2026-06-15 16:15:36 +08:00
parent 4572d15bea
commit 3c299aaf3b
6 changed files with 85 additions and 36 deletions

View File

@ -10,6 +10,7 @@ import com.imeeting.common.MeetingProgressStage;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusStage;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
@ -18,6 +19,7 @@ import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
@ -107,8 +109,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
.connectTimeout(Duration.ofSeconds(300))
.version(HttpClient.Version.HTTP_1_1)
.build();
@Autowired
private AndroidMeetingPushService androidMeetingPushService;
@Autowired
@Autowired
public AiTaskServiceImpl(MeetingMapper meetingMapper,
MeetingTranscriptMapper transcriptMapper,
AiModelService aiModelService,
@ -323,10 +327,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
chapterTask == null ? null : chapterTask.getId(),
chapterTask == null ? null : chapterTask.getStatus());
reconcileMeetingStatus(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.FAILED_SUMMARIZING.getCode());
return;
}
executeChapterFlow(meeting, chapterTask);
reconcileMeetingStatus(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.COMPLETED.getCode());
log.info("[CHAPTER-FLOW] 章节任务流程结束: meetingId={}, chapterTaskId={}, costMs={}",
meetingId, chapterTask.getId(), System.currentTimeMillis() - startMillis);
}
@ -365,11 +371,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
sumTask == null ? null : sumTask.getStatus());
}
reconcileMeetingStatus(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.COMPLETED.getCode());
} catch (Exception e) {
log.error("[SUMMARY-FLOW] 总结任务执行异常: meetingId={}, sumTaskId={}",
meetingId, sumTask == null ? null : sumTask.getId(), e);
failPendingSummaryTask(sumTask, e.getMessage());
reconcileMeetingStatus(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.FAILED_SUMMARIZING.getCode());
} finally {
log.info("[SUMMARY-FLOW] 总结任务流程结束: meetingId={}, costMs={}",
meetingId, System.currentTimeMillis() - startMillis);

View File

@ -1047,7 +1047,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (effectivePromptId == null) {
throw new RuntimeException("缺少 promptId无法创建总结任务");
}
meetingDomainSupport.createSummaryTask(
AiTask summaryTask = meetingDomainSupport.createSummaryTask(
meetingId,
effectiveSummaryModelId,
effectiveChapterModelId,
@ -1060,11 +1060,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meeting.setPromptId(effectivePromptId);
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode());
meeting.setLatestSummaryTaskId(summaryTask.getId());
meetingService.updateById(meeting);
if ("EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) {
updateMeetingProgress(meetingId, 95, "等待外部总结编排...", 0);
updateMeetingProgress(meetingId, summaryTask, 95, "等待外部总结编排...", 0);
} else {
updateMeetingProgress(meetingId, 90, "重新总结已提交,正在生成总结...", 0);
updateMeetingProgress(meetingId, summaryTask, 90, "重新总结已提交,正在生成总结...", 0);
}
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
@ -1457,6 +1458,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
}
private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
updateMeetingProgress(meetingId, null, percent, message, eta);
}
private void updateMeetingProgress(Long meetingId, AiTask task, int percent, String message, int eta) {
com.imeeting.common.MeetingProgressStage stage;
int meetingStatus;
if (percent < 0) {
@ -1478,7 +1483,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
stage = com.imeeting.common.MeetingProgressStage.QUEUED;
meetingStatus = MeetingStatusEnum.TRANSCRIBING.getCode();
}
meetingProgressService.markStageAfterCommitOrNow(meetingId, null, meetingStatus, stage, percent, message, eta);
meetingProgressService.markStageAfterCommitOrNow(meetingId, task, meetingStatus, stage, percent, message, eta);
}
private RealtimeMeetingResumeConfig buildRealtimeResumeConfig(CreateRealtimeMeetingCommand command,

View File

@ -133,6 +133,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
if (isTaskFailed(chapterTask)) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
if (isTaskCompleted(asrTask) || hasTranscript(meeting.getId()) || summaryTask != null || chapterTask != null) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
return UnifiedMeetingStatusStage.FAILED_INITIALIZING;
}
@ -210,6 +213,10 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
}
private boolean canViewTranscript(Long meetingId) {
return hasTranscript(meetingId);
}
private boolean hasTranscript(Long meetingId) {
return meetingId != null && meetingTranscriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)) > 0;
}

View File

@ -8,8 +8,10 @@ const PROFILE_KEY = "userProfile";
interface PermissionState {
codes: string[];
isAdmin: boolean;
isPlatformAdmin: boolean;
setCodes: (codes: string[]) => void;
setIsAdmin: (isAdmin: boolean) => void;
setIsPlatformAdmin: (isAdmin: boolean) => void;
load: () => Promise<void>;
}
@ -32,6 +34,16 @@ export const usePermissionStore = create<PermissionState>((set) => ({
return false;
}
})(),
isPlatformAdmin: (() => {
try {
const raw = sessionStorage.getItem(PROFILE_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
return !!parsed.isPlatformAdmin;
} catch (e) {
return false;
}
})(),
setCodes: (codes) => {
set({ codes });
localStorage.setItem(STORAGE_KEY, JSON.stringify(codes));
@ -39,20 +51,27 @@ export const usePermissionStore = create<PermissionState>((set) => ({
setIsAdmin: (isAdmin) => {
set({ isAdmin });
},
setIsPlatformAdmin: (isPlatformAdmin) => {
set({isPlatformAdmin});
},
load: async () => {
try {
let isAdmin = false;
let isPlatformAdmin = false;
const cachedProfile = sessionStorage.getItem(PROFILE_KEY);
if (cachedProfile) {
const parsed = JSON.parse(cachedProfile);
isAdmin = !!parsed.isAdmin;
isPlatformAdmin = !!parsed.isPlatformAdmin;
} else {
const profile = await getCurrentUser();
isAdmin = !!profile.isAdmin;
isPlatformAdmin = !!profile.isPlatformAdmin;
sessionStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
}
set({isPlatformAdmin});
set({ isAdmin });
if (isAdmin) {
if (isPlatformAdmin) {
return;
}
const perms = await listMyPermissions();
@ -66,27 +85,23 @@ export const usePermissionStore = create<PermissionState>((set) => ({
}));
export function usePermission() {
const { codes, load, isAdmin } = usePermissionStore();
const {codes, load, isAdmin, isPlatformAdmin} = usePermissionStore();
const can = (perm?: string) => {
if (!perm) return true;
if (isAdmin) return true;
if (isPlatformAdmin) return true;
if (!codes || codes.length === 0) {
return true;
return perm.startsWith("menu:");
}
const hasMenuCodes = codes.some((c) => c.startsWith("menu:"));
const hasButtonCodes = codes.some((c) => !c.startsWith("menu:") && c.includes(":"));
let result = false;
if (perm.startsWith("menu:")) {
result = !hasMenuCodes || codes.includes(perm);
} else {
result = !hasButtonCodes || codes.includes(perm);
return !hasMenuCodes || codes.includes(perm);
}
return result;
return codes.includes(perm);
};
return { codes, load, can, isAdmin };

View File

@ -112,7 +112,7 @@ const hasLatestGenerationFailure = (item: MeetingVO) =>
item.latestChapterAttemptStatus === 3 || item.latestSummaryAttemptStatus === 3;
const shouldTrackGenerationProgress = (item: MeetingVO) =>
!hasLatestGenerationFailure(item) && (item.status === 0 || item.status === 1 || item.status === 2);
item.status === 0 || item.status === 1 || item.status === 2;
const isTerminalMeetingProgress = (progress?: MeetingProgress | null) =>
!!progress && (
@ -156,14 +156,17 @@ const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) =
if (unifiedStatusCode?.startsWith("FAILED_")) {
return 4;
}
if (hasLatestGenerationFailure(item)) {
return 4;
}
const status = item.displayStatus ?? item.status;
// 如果处于初始化中但已经有进度,则视为转译音频中
if (status === 0 && progress && progress.percent > 0) {
return 1;
}
if (status === 0 || status === 1 || status === 2) {
return status;
}
if (hasLatestGenerationFailure(item)) {
return 4;
}
return status;
};

View File

@ -3,6 +3,7 @@ import { getCurrentUser } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import ListTable from "@/components/shared/ListTable/ListTable";
import PageContainer from "@/components/shared/PageContainer";
import {usePermission} from "@/hooks/usePermission";
import {
getCurrentTenantMeetingPointsSetting,
pageTenantMeetingPointsSettings,
@ -14,6 +15,7 @@ import { Button, Card, Input, message, Modal, Select, Space, Statistic, Tag, Typ
import { useEffect, useState } from "react";
const { Text } = Typography;
const BALANCE_CHECK_UPDATE_PERMISSION = "biz:tenant-meeting-points:balance-check:update";
function formatDateTime(value?: string) {
return value ? value.replace("T", " ").substring(0, 19) : "-";
@ -26,6 +28,7 @@ function renderStatusTag(enabled?: boolean) {
}
export default function TenantMeetingPointsSettings() {
const {can} = usePermission();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
@ -42,6 +45,7 @@ export default function TenantMeetingPointsSettings() {
const isPlatformAdmin = Boolean(profile?.isPlatformAdmin);
const isTenantAdmin = Boolean(profile?.isTenantAdmin);
const canUpdateBalanceCheck = isPlatformAdmin || (isTenantAdmin && can(BALANCE_CHECK_UPDATE_PERMISSION));
const loadPlatformPage = async (nextParams = params) => {
setLoading(true);
@ -179,15 +183,20 @@ export default function TenantMeetingPointsSettings() {
key: "action",
width: 140,
fixed: "right" as const,
render: (_: unknown, record: TenantMeetingPointsSettingVO) => (
<Button
type="link"
loading={switchingTenantId === record.tenantId}
onClick={() => confirmSwitch(record, !record.balanceCheckEnabled)}
>
{record.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
),
render: (_: unknown, record: TenantMeetingPointsSettingVO) => {
if (!canUpdateBalanceCheck) {
return null;
}
return (
<Button
type="link"
loading={switchingTenantId === record.tenantId}
onClick={() => confirmSwitch(record, !record.balanceCheckEnabled)}
>
{record.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
);
},
},
];
@ -231,13 +240,15 @@ export default function TenantMeetingPointsSettings() {
</Space>
<Space>
<Button
type="primary"
loading={switchingTenantId === currentTenantSetting.tenantId}
onClick={() => confirmSwitch(currentTenantSetting, !currentTenantSetting.balanceCheckEnabled)}
>
{currentTenantSetting.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
{canUpdateBalanceCheck ? (
<Button
type="primary"
loading={switchingTenantId === currentTenantSetting.tenantId}
onClick={() => confirmSwitch(currentTenantSetting, !currentTenantSetting.balanceCheckEnabled)}
>
{currentTenantSetting.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>