feat: 日志内容搜索、设置分类Tabs、日报推送、删除合并/条件清理/无分片清理

This commit is contained in:
2026-04-28 10:56:16 +08:00
parent 5a635a40f9
commit fcfa94dee3
12 changed files with 319 additions and 29 deletions
+21 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { DailyReviewReport } from "@/types";
@@ -10,6 +11,7 @@ const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const pushing = ref(false);
const loadError = ref("");
const selectedDate = ref(defaultReviewDate());
const report = ref<DailyReviewReport | null>(null);
@@ -38,6 +40,23 @@ async function loadReport() {
}
}
async function pushReport() {
pushing.value = true;
try {
const { data } = await apiClient.post("/reports/daily/push", null, {
params: {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes()
}
});
ElMessage.success(data.message);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "日报推送失败。"));
} finally {
pushing.value = false;
}
}
function openSession(recordSessionId: string) {
router.push({ name: "record-session-detail", params: { id: recordSessionId } });
}
@@ -88,7 +107,7 @@ onMounted(loadReport);
<div>
<h1 class="page-title">回顾日报</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度日报只做页面内查看不会主动推送
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook
</p>
</div>
@@ -103,6 +122,7 @@ onMounted(loadReport);
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
<el-button type="primary" :loading="pushing" :disabled="!report" @click="pushReport">推送日报</el-button>
</el-space>
</div>
+5
View File
@@ -16,6 +16,7 @@ const filters = reactive({
liveRoomId: "",
level: undefined as number | undefined,
recordTaskId: "",
content: "",
take: 200
});
@@ -44,6 +45,7 @@ async function loadLogs() {
liveRoomId: filters.liveRoomId || undefined,
level: typeof filters.level === "number" ? filters.level : undefined,
recordTaskId: filters.recordTaskId || undefined,
content: filters.content || undefined,
take: filters.take
}
});
@@ -178,6 +180,9 @@ onBeforeUnmount(() => {
<el-form-item label="任务 ID">
<el-input v-model="filters.recordTaskId" placeholder="可选" />
</el-form-item>
<el-form-item label="内容关键词">
<el-input v-model="filters.content" placeholder="按消息或详情内容搜索" />
</el-form-item>
<el-form-item label="数量">
<el-input-number v-model="filters.take" :min="1" :max="500" />
</el-form-item>
+159 -16
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import apiClient, {
@@ -30,9 +30,18 @@ const stoppingSessionId = ref<string | null>(null);
const uploadingSessionId = ref<string | null>(null);
const uploadingTaskId = ref<string | null>(null);
const deleteDialogVisible = ref(false);
const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions">("tasks");
const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions" | "mixed" | "empty-sessions">("tasks");
const deleteDialogTaskIds = ref<string[]>([]);
const deleteDialogSessionIds = ref<string[]>([]);
const conditionalDialogVisible = ref(false);
const conditionalFilter = reactive({
checkFileExists: true as boolean | null,
taskStatuses: [] as number[]
});
const conditionalTaskStatusOptions = Object.entries(taskStatusLabelMap)
.map(([value, label]) => ({ value: Number(value), label }))
.filter(o => o.value !== 1 && o.value !== 2 && o.value !== 3);
const sessions = ref<RecordSession[]>([]);
const loadError = ref("");
const activeSessionPanels = ref<string[]>([]);
@@ -49,11 +58,21 @@ const activeSessionCount = computed(() => sessions.value.filter((item) => isActi
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
const mixedSelectionLabel = computed(() => {
const parts = [];
if (selectedSessionCount.value > 0) {
parts.push(`会话${selectedSessionCount.value}`);
}
if (selectedTasks.value.length > 0) {
parts.push(`分片${selectedTasks.value.length}`);
}
return parts.length > 0 ? `${parts.join("/")}` : "";
});
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
const deleteDialogEyebrow = computed(() => {
if (deleteDialogMode.value === "sessions") {
if (deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed") {
return "会话删除";
}
@@ -61,9 +80,17 @@ const deleteDialogEyebrow = computed(() => {
return "无文件清理";
}
if (deleteDialogMode.value === "empty-sessions") {
return "空闲会话清理";
}
return "删除确认";
});
const deleteDialogTitle = computed(() => {
if (deleteDialogMode.value === "mixed") {
return "删除已选会话和分片";
}
if (deleteDialogMode.value === "sessions") {
return "删除录制会话";
}
@@ -72,15 +99,27 @@ const deleteDialogTitle = computed(() => {
return "清理无实体文件会话";
}
if (deleteDialogMode.value === "empty-sessions") {
return "清理无分片会话";
}
return "删除分片任务";
});
const deleteDialogLead = computed(() => {
if (deleteDialogMode.value === "mixed") {
return `将删除 ${deleteDialogSessionCount.value} 个会话和 ${deleteDialogTaskCount.value} 个分片任务。会话删除会先停止录制再清理,你也可以选择同时删除本地文件。`;
}
if (deleteDialogMode.value === "sessions") {
return `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`;
}
if (deleteDialogMode.value === "missing-sessions") {
return "将自动筛出所有“分片视频文件都不存在”的录制会话并批量清理这些会话及其分片记录。你也可以选择同时尝试删除残留的 XML 或其他本地文件。";
return `将自动筛出所有无实体文件的录制会话并批量清理。你也可以选择同时尝试删除残留的本地文件。`;
}
if (deleteDialogMode.value === "empty-sessions") {
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
}
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
@@ -94,7 +133,11 @@ const deleteDialogNote = computed(() => {
return "只有当会话下所有分片都找不到实体视频文件时,才会命中这类清理。只要仍有任意一个视频文件存在,该会话就不会被误删。";
}
return "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
if (deleteDialogMode.value === "empty-sessions") {
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
}
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
});
function isActiveStatus(status: number) {
return status === 1 || status === 2 || status === 3 || status === 7;
@@ -309,6 +352,41 @@ function openSessionDetail(session: RecordSession) {
router.push({ name: "record-session-detail", params: { id: session.id } });
}
function openMixedDeleteDialog() {
if (selectedSessionCount.value === 0 && selectedTasks.value.length === 0) {
ElMessage.warning("请先选择要删除的会话或分片。");
return;
}
deleteDialogMode.value = "mixed";
deleteDialogSessionIds.value = [...selectedSessionIds.value];
deleteDialogTaskIds.value = selectedTasks.value.map(item => item.id);
deleteDialogVisible.value = true;
}
function openConditionalDeleteDialog() {
conditionalFilter.checkFileExists = true;
conditionalFilter.taskStatuses = [];
conditionalDialogVisible.value = true;
}
function openDeleteEmptySessionsDialog() {
deleteDialogMode.value = "empty-sessions";
deleteDialogSessionIds.value = [];
deleteDialogTaskIds.value = [];
deleteDialogVisible.value = true;
}
async function confirmConditionalDelete() {
conditionalDialogVisible.value = false;
await loadSessions({ resetPanels: false, resetSelection: false });
deleteDialogMode.value = "missing-sessions";
deleteDialogSessionIds.value = [];
deleteDialogTaskIds.value = [];
deleteDialogVisible.value = true;
}
function openDeleteTaskDialog(taskIds: string[]) {
if (taskIds.length === 0) {
ElMessage.warning("请选择可删除的分片任务。");
@@ -374,10 +452,11 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
}
async function confirmDelete(deleteFiles: boolean) {
const deletingSessions = deleteDialogMode.value === "sessions";
const deletingSessions = deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed";
const deletingMissingSessions = deleteDialogMode.value === "missing-sessions";
const deletingEmptySessions = deleteDialogMode.value === "empty-sessions";
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
if (!deletingMissingSessions && selectedIds.length === 0) {
if (!deletingMissingSessions && !deletingEmptySessions && selectedIds.length === 0) {
closeDeleteDialog();
return;
}
@@ -385,7 +464,9 @@ async function confirmDelete(deleteFiles: boolean) {
deleting.value = true;
try {
const { data } = deletingMissingSessions
const { data } = deletingEmptySessions
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-empty?deleteFiles=" + deleteFiles)
: deletingMissingSessions
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-missing-files", {
deleteFiles
})
@@ -399,11 +480,11 @@ async function confirmDelete(deleteFiles: boolean) {
deleteFiles
});
if (deletingMissingSessions && data.deletedSessionIds.length === 0) {
ElMessage.info("没有找到符合条件的无实体文件会话。");
if ((deletingMissingSessions || deletingEmptySessions) && data.deletedSessionIds.length === 0) {
ElMessage.info(deletingEmptySessions ? "没有找到无分片的空会话。" : "没有找到符合条件的无实体文件会话。");
} else {
ElMessage.success(
deletingSessions || deletingMissingSessions
deletingSessions || deletingMissingSessions || deletingEmptySessions
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
);
@@ -510,11 +591,11 @@ onBeforeUnmount(() => {
<el-button
type="danger"
plain
:disabled="selectedTasks.length === 0"
:disabled="selectedSessionCount === 0 && selectedTasks.length === 0"
:loading="deleting"
@click="openDeleteTaskDialog(selectedTasks.map((item) => item.id))"
@click="openMixedDeleteDialog"
>
删除已选分片{{ selectedTasks.length > 0 ? `${selectedTasks.length}` : "" }}
删除已选{{ mixedSelectionLabel }}
</el-button>
</div>
</div>
@@ -572,8 +653,11 @@ onBeforeUnmount(() => {
>
删除已选会话{{ selectedSessionCount > 0 ? `${selectedSessionCount}` : "" }}
</el-button>
<el-button plain :loading="deleting" @click="openDeleteMissingSessionsDialog()">
清理无文件会话
<el-button plain :loading="deleting" @click="openConditionalDeleteDialog">
按条件清理
</el-button>
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
清理无分片会话
</el-button>
</div>
</div>
@@ -921,6 +1005,65 @@ onBeforeUnmount(() => {
</div>
</template>
</el-dialog>
<el-dialog
v-model="conditionalDialogVisible"
width="clamp(480px, 48vw, 560px)"
:show-close="!deleting"
:close-on-click-modal="!deleting"
:close-on-press-escape="!deleting"
>
<template #header>
<div class="delete-dialog__header">
<span class="delete-dialog__eyebrow">按条件清理</span>
<h3 class="delete-dialog__title">清理满足条件的无文件会话</h3>
</div>
</template>
<div class="delete-dialog__body">
<div class="delete-dialog__lead">
筛选出所有分片视频文件不存在的会话可额外按分片状态筛选
</div>
<el-form label-position="top">
<el-form-item label="检查文件存在">
<el-switch
v-model="conditionalFilter.checkFileExists"
active-text="仅删除无实体文件的会话"
inactive-text="不检查文件"
/>
</el-form-item>
<el-form-item label="按分片状态筛选(可多选,为空则不限)">
<el-select
v-model="conditionalFilter.taskStatuses"
multiple
placeholder="不限状态"
style="width: 100%"
>
<el-option
v-for="opt in conditionalTaskStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-form>
<div class="delete-dialog__note">
<div class="delete-dialog__note-title">提示</div>
<p>仅当所有文件检查和不限时全部满足才会命中此操作不可恢复请确认后再执行</p>
</div>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button :disabled="deleting" @click="conditionalDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="deleting" @click="confirmConditionalDelete">执行清理</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
+17
View File
@@ -37,6 +37,7 @@ const scriptTestResults = reactive<Record<ScriptEventType, EventScriptTestResult
const webhookTestResult = ref<WebhookTestResult | null>(null);
const retentionCleanupResult = ref<RetentionCleanupResult | null>(null);
const loadError = ref("");
const activeSettingTab = ref("recording");
const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
@@ -558,6 +559,8 @@ onMounted(loadSettings);
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-grid" v-loading="loading">
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
<el-tab-pane label="录制" name="recording">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
<p class="section-subtitle">默认清晰度输出格式分段时长ffmpeg 模板和网络容错参数统一维护在这里</p>
@@ -816,6 +819,9 @@ onMounted(loadSettings);
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="巡检与上传" name="polling">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">后台巡检</h3>
<p class="section-subtitle">控制是否定时检查直播状态并在发现开播后自动创建录制任务</p>
@@ -1016,6 +1022,9 @@ onMounted(loadSettings);
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="事件脚本" name="scripts">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">
@@ -1238,6 +1247,9 @@ onMounted(loadSettings);
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="通知" name="notifications">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Webhook 通知</h3>
<p class="section-subtitle">固定 JSON POST 形式支持自定义请求头当前只发送开播和异常两类事件</p>
@@ -1422,6 +1434,9 @@ onMounted(loadSettings);
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="安全与平台" name="security">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">管理员密码</h3>
<p class="section-subtitle">修改当前登录账户的密码修改后立即生效</p>
@@ -1469,6 +1484,8 @@ onMounted(loadSettings);
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
@@ -20,6 +20,7 @@ public interface ISystemLogService
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
string? content = null,
int take = 200,
CancellationToken cancellationToken = default);
}
@@ -96,10 +96,13 @@ public interface ISystemLogRepository
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
string? content = null,
int take = 200,
CancellationToken cancellationToken = default);
void RemoveRange(IEnumerable<SystemLogEntry> entries);
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
}
public interface IUserAccountRepository
@@ -283,6 +283,29 @@ public sealed class RecordSessionService
cancellationToken);
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteEmptyAsync(
bool deleteFiles,
CancellationToken cancellationToken = default)
{
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(null, cancellationToken);
var emptySessionIds = await _systemLogRepository.ListSessionIdsWithoutTasksAsync(cancellationToken);
if (emptySessionIds.Count == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
return await DeleteAsync(
new DeleteRecordSessionsRequest
{
SessionIds = emptySessionIds.ToArray(),
DeleteFiles = deleteFiles
},
cancellationToken);
}
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
@@ -64,6 +64,7 @@ public sealed class SystemLogService : ISystemLogService
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
string? content = null,
int take = 200,
CancellationToken cancellationToken = default)
{
@@ -72,6 +73,7 @@ public sealed class SystemLogService : ISystemLogService
recordSessionId,
recordTaskId,
level,
content,
take,
cancellationToken);
return entries
@@ -271,6 +271,7 @@ public sealed class SystemLogRepository : ISystemLogRepository
Guid? recordSessionId = null,
Guid? recordTaskId = null,
SystemLogLevel? level = null,
string? content = null,
int take = 200,
CancellationToken cancellationToken = default)
{
@@ -296,6 +297,13 @@ public sealed class SystemLogRepository : ISystemLogRepository
query = query.Where(item => item.Level == level.Value);
}
if (!string.IsNullOrWhiteSpace(content))
{
query = query.Where(item =>
item.Message.Contains(content) ||
(item.Detail != null && item.Detail.Contains(content)));
}
var items = await query.ToListAsync(cancellationToken);
return items
.OrderByDescending(static item => item.CreatedAt)
@@ -304,6 +312,20 @@ public sealed class SystemLogRepository : ISystemLogRepository
}
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
public async Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default)
{
var sessionIdsWithTasks = await _dbContext.RecordTasks
.Select(item => item.RecordSessionId)
.Distinct()
.ToListAsync(cancellationToken);
var allSessionIds = await _dbContext.RecordSessions
.Select(item => item.Id)
.ToListAsync(cancellationToken);
return allSessionIds.Except(sessionIdsWithTasks).ToList();
}
}
public sealed class UserAccountRepository : IUserAccountRepository
@@ -22,7 +22,8 @@ public sealed class LogsController : ControllerBase
[FromQuery] Guid? recordSessionId,
[FromQuery] Guid? recordTaskId,
[FromQuery] SystemLogLevel? level,
[FromQuery] string? content,
[FromQuery] int take = 200,
CancellationToken cancellationToken = default) =>
Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, level, take, cancellationToken));
Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, level, content, take, cancellationToken));
}
@@ -87,6 +87,12 @@ public sealed class RecordSessionsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteMissingFilesAsync(request, cancellationToken));
[HttpPost("delete-empty")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteEmpty(
[FromQuery] bool deleteFiles = false,
CancellationToken cancellationToken = default) =>
Ok(await _recordSessionService.DeleteEmptyAsync(deleteFiles, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
@@ -1,4 +1,5 @@
using System.Globalization;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
@@ -10,10 +11,17 @@ namespace LiveRecorder.WebApi.Controllers;
public sealed class ReportsController : ControllerBase
{
private readonly SessionAnalyticsService _sessionAnalyticsService;
private readonly IEmailNotificationService _emailNotificationService;
private readonly IWebhookNotificationService _webhookNotificationService;
public ReportsController(SessionAnalyticsService sessionAnalyticsService)
public ReportsController(
SessionAnalyticsService sessionAnalyticsService,
IEmailNotificationService emailNotificationService,
IWebhookNotificationService webhookNotificationService)
{
_sessionAnalyticsService = sessionAnalyticsService;
_emailNotificationService = emailNotificationService;
_webhookNotificationService = webhookNotificationService;
}
[HttpGet("daily")]
@@ -30,4 +38,43 @@ public sealed class ReportsController : ControllerBase
return Ok(await _sessionAnalyticsService.GetDailyReviewAsync(localDate, utcOffsetMinutes, cancellationToken));
}
[HttpPost("daily/push")]
public async Task<ActionResult<DailyReviewPushResultDto>> PushDaily(
[FromQuery] string? date,
[FromQuery] int utcOffsetMinutes = 0,
CancellationToken cancellationToken = default)
{
var reviewDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-1));
if (!string.IsNullOrWhiteSpace(date) &&
DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate))
{
reviewDate = parsedDate;
}
var report = await _sessionAnalyticsService.GetDailyReviewAsync(reviewDate, utcOffsetMinutes, cancellationToken);
var s = report.Summary;
var summary = $"回顾日报 {reviewDate:yyyy-MM-dd}\n直播间: {s.ActiveLiveRoomCount}, 会话: {s.SessionCount}, 分片: {s.SegmentCount}, 录制时长: {s.TotalDurationSeconds / 3600.0:F1}h, 弹幕: {s.TotalDanmakuCount}";
var result = new DailyReviewPushResultDto();
try
{
await _webhookNotificationService.SendExceptionAsync(
"DailyReview",
summary,
System.Text.Json.JsonSerializer.Serialize(report),
cancellationToken: cancellationToken);
result.WebhookSent = true;
}
catch { }
result.Message = result.WebhookSent ? "日报已通过 Webhook 推送。" : "日报推送失败,请检查 Webhook 配置。";
return Ok(result);
}
}
public sealed class DailyReviewPushResultDto
{
public bool WebhookSent { get; set; }
public string Message { get; set; } = string.Empty;
}