feat(ops): add job center for cross-source job operations
Provide a unified ops console for inspecting, retrying and cancelling jobs across generation, template/kol assist, knowledge parse, desktop publish/task, compliance review and monitoring collect sources. Wires RabbitMQ for retry republish and consolidates the desktop_publish_jobs columns into the base migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ import {
|
||||
CrownOutlined,
|
||||
FileSearchOutlined,
|
||||
GlobalOutlined,
|
||||
PartitionOutlined,
|
||||
LineChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
@@ -87,6 +88,7 @@ const menuLeaves: MenuLeaf[] = [
|
||||
{ key: '/compliance/records', label: '检测记录', path: '/compliance/records' },
|
||||
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
|
||||
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
|
||||
{ key: '/jobs', label: '任务中心', path: '/jobs' },
|
||||
{ key: '/audits', label: '审计日志', path: '/audits' },
|
||||
]
|
||||
|
||||
@@ -148,6 +150,11 @@ const menuItems = computed<ItemType[]>(() => [
|
||||
label: '操作员管理',
|
||||
icon: () => h(SafetyCertificateOutlined),
|
||||
},
|
||||
{
|
||||
key: '/jobs',
|
||||
label: '任务中心',
|
||||
icon: () => h(PartitionOutlined),
|
||||
},
|
||||
{
|
||||
key: '/audits',
|
||||
label: '审计日志',
|
||||
|
||||
@@ -42,6 +42,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
meta: { title: '审计日志' },
|
||||
},
|
||||
{
|
||||
path: 'jobs',
|
||||
name: 'jobs',
|
||||
component: () => import('@/views/JobsView.vue'),
|
||||
meta: { title: '任务中心' },
|
||||
},
|
||||
{
|
||||
path: 'site-domain-mappings',
|
||||
name: 'site-domain-mappings',
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div class="jobs-page">
|
||||
<div class="jobs-header">
|
||||
<div>
|
||||
<h2 class="ops-page-title">任务中心</h2>
|
||||
<div class="jobs-header__meta">内部任务排障、追踪、重试与取消</div>
|
||||
</div>
|
||||
<a-button :loading="loading" @click="reload">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="jobs-summary">
|
||||
<div v-for="item in phaseSummary" :key="item.key" class="jobs-summary__item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-card">
|
||||
<div class="ops-toolbar jobs-toolbar">
|
||||
<a-input
|
||||
v-model:value="filter.keyword"
|
||||
placeholder="搜索 ID / 标题 / 错误 / 租户"
|
||||
style="width: 280px"
|
||||
allow-clear
|
||||
@press-enter="resetAndReload"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filter.source"
|
||||
class="jobs-select"
|
||||
:options="sourceOptions"
|
||||
allow-clear
|
||||
placeholder="任务来源"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filter.phase"
|
||||
class="jobs-select"
|
||||
:options="phaseOptions"
|
||||
allow-clear
|
||||
placeholder="阶段"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-input v-model:value="filter.kind" placeholder="类型" style="width: 160px" allow-clear @press-enter="resetAndReload" />
|
||||
<a-input v-model:value="filter.tenantId" placeholder="租户 ID" style="width: 120px" allow-clear @press-enter="resetAndReload" />
|
||||
<a-range-picker v-model:value="dateRange" show-time style="width: 360px" @change="resetAndReload" />
|
||||
<a-button @click="resetAndReload">查询</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="uid"
|
||||
:scroll="{ x: 1420 }"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'job'">
|
||||
<div class="job-main">
|
||||
<a-button type="link" class="job-link" @click="openDetail(record)">
|
||||
{{ record.title || sourceLabel(record.source) }}
|
||||
</a-button>
|
||||
<div class="job-sub">
|
||||
<code>{{ record.source }}:{{ record.id }}</code>
|
||||
<a-tag v-if="record.is_stuck" color="red">疑似卡住</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'phase'">
|
||||
<a-tag :color="phaseColor(record.phase)">
|
||||
{{ phaseLabel(record.phase) }}
|
||||
</a-tag>
|
||||
<div class="job-status">{{ record.status }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
<div>{{ record.tenant_name || '—' }}</div>
|
||||
<div class="muted">T{{ record.tenant_id || '—' }} / W{{ record.workspace_id || '—' }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'worker'">
|
||||
<div class="mono-line">{{ record.worker || record.queue || '—' }}</div>
|
||||
<div class="muted">尝试 {{ record.attempts ?? 0 }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'time'">
|
||||
<div>{{ formatDate(record.created_at) }}</div>
|
||||
<div class="muted">耗时 {{ formatDuration(record.duration_seconds) }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'error'">
|
||||
<span class="error-text">{{ shortText(record.error_message) || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip title="详情">
|
||||
<a-button type="text" class="action-btn action-edit" @click="openDetail(record)">
|
||||
<FileSearchOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="重试">
|
||||
<a-popconfirm title="确认重试该任务?" ok-text="重试" cancel-text="取消" @confirm="retryJob(record)">
|
||||
<a-button type="text" class="action-btn action-play" :disabled="!record.can_retry">
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="取消">
|
||||
<a-popconfirm title="确认取消该任务?" ok-text="取消任务" cancel-text="关闭" @confirm="cancelJob(record)">
|
||||
<a-button type="text" class="action-btn action-delete" :disabled="!record.can_cancel">
|
||||
<StopOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:open="detailOpen" width="760" :title="detailTitle" :destroy-on-close="true">
|
||||
<a-spin :spinning="detailLoading">
|
||||
<div v-if="selectedDetail" class="detail-stack">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="来源">{{ sourceLabel(selectedDetail.source) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="phaseColor(selectedDetail.phase)">{{ phaseLabel(selectedDetail.phase) }}</a-tag>
|
||||
{{ selectedDetail.status }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="任务 ID">
|
||||
<code>{{ selectedDetail.id }}</code>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ selectedDetail.kind }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">{{ selectedDetail.tenant_name || selectedDetail.tenant_id || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Workspace">{{ selectedDetail.workspace_name || selectedDetail.workspace_id || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="队列">{{ selectedDetail.queue || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Worker">{{ selectedDetail.worker || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建">{{ formatDate(selectedDetail.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新">{{ formatDate(selectedDetail.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<section v-if="selectedDetail.error_message || selectedDetail.error" class="detail-section">
|
||||
<h3>错误</h3>
|
||||
<pre>{{ formatJSON(selectedDetail.error || selectedDetail.error_message) }}</pre>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h3>Payload</h3>
|
||||
<pre>{{ formatJSON(selectedDetail.payload) }}</pre>
|
||||
</section>
|
||||
<section v-if="selectedDetail.result" class="detail-section">
|
||||
<h3>Result</h3>
|
||||
<pre>{{ formatJSON(selectedDetail.result) }}</pre>
|
||||
</section>
|
||||
<section v-if="selectedDetail.metadata" class="detail-section">
|
||||
<h3>Metadata</h3>
|
||||
<pre>{{ formatJSON(selectedDetail.metadata) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileSearchOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { OpsApiError, http } from '@/lib/http'
|
||||
|
||||
interface JobSummary {
|
||||
uid: string
|
||||
source: string
|
||||
id: string
|
||||
numeric_id?: number | null
|
||||
uuid?: string | null
|
||||
kind: string
|
||||
status: string
|
||||
phase: string
|
||||
tenant_id?: number | null
|
||||
tenant_name?: string | null
|
||||
workspace_id?: number | null
|
||||
workspace_name?: string | null
|
||||
user_id?: number | null
|
||||
user_name?: string | null
|
||||
title?: string | null
|
||||
target?: string | null
|
||||
queue?: string | null
|
||||
worker?: string | null
|
||||
attempts?: number | null
|
||||
priority?: number | null
|
||||
error_message?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
duration_seconds?: number | null
|
||||
is_stuck: boolean
|
||||
can_retry: boolean
|
||||
can_cancel: boolean
|
||||
}
|
||||
|
||||
interface JobDetail extends JobSummary {
|
||||
payload?: unknown
|
||||
result?: unknown
|
||||
error?: unknown
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface JobsResult {
|
||||
items: JobSummary[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: {
|
||||
total: number
|
||||
by_phase: Record<string, number>
|
||||
by_source: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
const sourceOptions = [
|
||||
{ label: '内容生成', value: 'generation' },
|
||||
{ label: '模板助手', value: 'template_assist' },
|
||||
{ label: 'KOL 助手', value: 'kol_assist' },
|
||||
{ label: '知识库解析', value: 'knowledge_parse' },
|
||||
{ label: '发布 Job', value: 'desktop_publish' },
|
||||
{ label: '桌面任务', value: 'desktop_task' },
|
||||
{ label: '合规复核', value: 'compliance_review' },
|
||||
{ label: '监控采集', value: 'monitoring_collect' },
|
||||
]
|
||||
|
||||
const phaseOptions = [
|
||||
{ label: '排队中', value: 'queued' },
|
||||
{ label: '运行中', value: 'running' },
|
||||
{ label: '成功', value: 'succeeded' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
{ label: '取消', value: 'canceled' },
|
||||
{ label: '阻断', value: 'blocked' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: '任务', key: 'job', width: 330, fixed: 'left' },
|
||||
{ title: '阶段', key: 'phase', width: 120 },
|
||||
{ title: '类型', dataIndex: 'kind', key: 'kind', width: 170 },
|
||||
{ title: '租户 / Workspace', key: 'tenant', width: 190 },
|
||||
{ title: '执行端', key: 'worker', width: 220 },
|
||||
{ title: '时间', key: 'time', width: 190 },
|
||||
{ title: '错误摘要', key: 'error', width: 260 },
|
||||
{ title: '操作', key: 'actions', width: 126, fixed: 'right' },
|
||||
]
|
||||
|
||||
const filter = reactive({
|
||||
keyword: '',
|
||||
source: undefined as string | undefined,
|
||||
phase: undefined as string | undefined,
|
||||
kind: '',
|
||||
tenantId: '',
|
||||
})
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||
const rows = ref<JobSummary[]>([])
|
||||
const counts = ref<JobsResult['counts']>({ total: 0, by_phase: {}, by_source: {} })
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const size = ref(50)
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const selectedDetail = ref<JobDetail | null>(null)
|
||||
|
||||
const pagination = computed<TablePaginationConfig>(() => ({
|
||||
current: page.value,
|
||||
pageSize: size.value,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
}))
|
||||
|
||||
const phaseSummary = computed(() =>
|
||||
phaseOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
value: counts.value.by_phase[option.value] ?? 0,
|
||||
})),
|
||||
)
|
||||
|
||||
const detailTitle = computed(() => {
|
||||
if (!selectedDetail.value) return '任务详情'
|
||||
return `${sourceLabel(selectedDetail.value.source)} · ${selectedDetail.value.id}`
|
||||
})
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
}
|
||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim()
|
||||
if (filter.source) params.source = filter.source
|
||||
if (filter.phase) params.phase = filter.phase
|
||||
if (filter.kind.trim()) params.kind = filter.kind.trim()
|
||||
if (filter.tenantId.trim()) {
|
||||
const id = Number(filter.tenantId.trim())
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
message.warning('租户 ID 必须是正整数')
|
||||
return
|
||||
}
|
||||
params.tenant_id = id
|
||||
}
|
||||
if (dateRange.value) {
|
||||
params.start_at = dateRange.value[0].toISOString()
|
||||
params.end_at = dateRange.value[1].toISOString()
|
||||
}
|
||||
|
||||
const result = await http.get<JobsResult>('/jobs', params)
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
counts.value = result.counts
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndReload() {
|
||||
page.value = 1
|
||||
void reload()
|
||||
}
|
||||
|
||||
function onTableChange(pag: TablePaginationConfig) {
|
||||
page.value = pag.current ?? 1
|
||||
size.value = pag.pageSize ?? 50
|
||||
void reload()
|
||||
}
|
||||
|
||||
async function openDetail(row: JobSummary) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
selectedDetail.value = null
|
||||
try {
|
||||
selectedDetail.value = await http.get<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}`)
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryJob(row: JobSummary) {
|
||||
try {
|
||||
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/retry`)
|
||||
message.success('已提交重试')
|
||||
patchRow(item)
|
||||
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelJob(row: JobSummary) {
|
||||
try {
|
||||
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/cancel`, {
|
||||
reason: 'ops console cancel',
|
||||
})
|
||||
message.success('已取消任务')
|
||||
patchRow(item)
|
||||
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function patchRow(item: JobSummary) {
|
||||
const index = rows.value.findIndex((row) => row.uid === item.uid)
|
||||
if (index >= 0) {
|
||||
rows.value[index] = item
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
return sourceOptions.find((item) => item.value === source)?.label ?? source
|
||||
}
|
||||
|
||||
function phaseLabel(phase: string): string {
|
||||
return phaseOptions.find((item) => item.value === phase)?.label ?? phase
|
||||
}
|
||||
|
||||
function phaseColor(phase: string): string {
|
||||
if (phase === 'queued') return 'blue'
|
||||
if (phase === 'running') return 'gold'
|
||||
if (phase === 'succeeded') return 'green'
|
||||
if (phase === 'failed') return 'red'
|
||||
if (phase === 'blocked') return 'volcano'
|
||||
if (phase === 'canceled') return 'default'
|
||||
return 'purple'
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '—'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${rest}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
function shortText(value?: string | null): string {
|
||||
if (!value) return ''
|
||||
return value.length > 110 ? `${value.slice(0, 110)}...` : value
|
||||
}
|
||||
|
||||
function formatJSON(value: unknown): string {
|
||||
if (value == null || value === '') return '—'
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function showError(error: unknown) {
|
||||
if (error instanceof OpsApiError) {
|
||||
message.error(error.detail || error.message)
|
||||
} else {
|
||||
message.error('请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jobs-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.jobs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.jobs-header__meta {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jobs-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jobs-summary__item {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.jobs-summary__item span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jobs-summary__item strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.jobs-select {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.job-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-weight: 650;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.job-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-status,
|
||||
.muted {
|
||||
margin-top: 2px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mono-line {
|
||||
max-width: 190px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-section h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-section pre {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.jobs-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.jobs-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedbootstrap "github.com/geo-platform/tenant-api/internal/shared/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/repository/redis"
|
||||
@@ -64,8 +65,20 @@ func main() {
|
||||
defer func() { _ = rdb.Close() }()
|
||||
appCache := cache.New(cfg.Cache.Driver, rdb)
|
||||
|
||||
var rabbitMQ *rabbitmq.Client
|
||||
if cfg.RabbitMQ.URL != "" {
|
||||
mq, mqErr := rabbitmq.New(ctx, cfg.RabbitMQ)
|
||||
if mqErr != nil {
|
||||
logger.Warn("rabbitmq unavailable during startup; job retry actions requiring queues are disabled", zap.Error(mqErr))
|
||||
} else {
|
||||
rabbitMQ = mq
|
||||
defer func() { _ = rabbitMQ.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
accountsRepo := repository.NewAccountRepository(pool)
|
||||
adminUsersRepo := repository.NewAdminUserRepository(pool)
|
||||
jobsRepo := repository.NewJobRepository(pool, monitoringPool)
|
||||
kolSubscriptionsRepo := repository.NewKolSubscriptionRepository(pool)
|
||||
auditsRepo := repository.NewAuditRepository(pool)
|
||||
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
|
||||
@@ -83,6 +96,7 @@ func main() {
|
||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).WithLoginGuard(tenantLoginGuard)
|
||||
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
|
||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||
complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
|
||||
@@ -127,6 +141,7 @@ func main() {
|
||||
Auth: authSvc,
|
||||
Accounts: accountSvc,
|
||||
AdminUsers: adminUserSvc,
|
||||
Jobs: jobSvc,
|
||||
KolSubs: kolSubscriptionSvc,
|
||||
Audits: auditSvc,
|
||||
SiteDomains: siteDomainMappingSvc,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionJobRetry = "job.retry"
|
||||
ActionJobCancel = "job.cancel"
|
||||
)
|
||||
|
||||
type JobService struct {
|
||||
jobs *repository.JobRepository
|
||||
audits *AuditService
|
||||
mq *rabbitmq.Client
|
||||
}
|
||||
|
||||
func NewJobService(jobs *repository.JobRepository, audits *AuditService, mq *rabbitmq.Client) *JobService {
|
||||
return &JobService{jobs: jobs, audits: audits, mq: mq}
|
||||
}
|
||||
|
||||
func (s *JobService) List(ctx context.Context, filter domain.JobFilter, page, size int) (*domain.JobListResult, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 500 {
|
||||
size = 50
|
||||
}
|
||||
filter.Limit = size
|
||||
filter.Offset = (page - 1) * size
|
||||
|
||||
items, total, counts, err := s.jobs.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(52101, "ops_jobs_query_failed", "failed to list jobs")
|
||||
}
|
||||
return &domain.JobListResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Size: size,
|
||||
Counts: counts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *JobService) Get(ctx context.Context, source, id string) (*domain.JobDetail, error) {
|
||||
item, err := s.jobs.Get(ctx, source, id)
|
||||
if err != nil {
|
||||
return nil, mapJobError(err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *JobService) Retry(ctx context.Context, actor *Actor, source, id string) (*domain.JobDetail, error) {
|
||||
before, err := s.jobs.Get(ctx, source, id)
|
||||
if err != nil {
|
||||
return nil, mapJobError(err)
|
||||
}
|
||||
if !before.CanRetry {
|
||||
return nil, response.ErrConflict(40970, "job_retry_unavailable", "当前任务状态不支持重试")
|
||||
}
|
||||
|
||||
item, err := s.jobs.Retry(ctx, source, id)
|
||||
if err != nil {
|
||||
return nil, mapJobError(err)
|
||||
}
|
||||
if err := s.publishRetry(ctx, item); err != nil {
|
||||
_ = s.markRetryPublishFailed(context.Background(), item, err)
|
||||
return nil, response.ErrServiceUnavailable(50370, "job_retry_queue_unavailable", "任务已回滚为失败或取消状态,队列暂不可用")
|
||||
}
|
||||
|
||||
if actor != nil && s.audits != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionJobRetry, "job", 0, jobAuditMetadata(item)))
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *JobService) Cancel(ctx context.Context, actor *Actor, source, id, reason string) (*domain.JobDetail, error) {
|
||||
before, err := s.jobs.Get(ctx, source, id)
|
||||
if err != nil {
|
||||
return nil, mapJobError(err)
|
||||
}
|
||||
if !before.CanCancel {
|
||||
return nil, response.ErrConflict(40971, "job_cancel_unavailable", "当前任务状态不支持取消")
|
||||
}
|
||||
|
||||
item, err := s.jobs.Cancel(ctx, source, id, reason)
|
||||
if err != nil {
|
||||
return nil, mapJobError(err)
|
||||
}
|
||||
if actor != nil && s.audits != nil {
|
||||
metadata := jobAuditMetadata(item)
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
metadata["reason"] = reason
|
||||
}
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionJobCancel, "job", 0, metadata))
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *JobService) publishRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
switch item.Source {
|
||||
case "generation":
|
||||
return s.publishGenerationRetry(ctx, item)
|
||||
case "template_assist":
|
||||
return s.publishTemplateAssistRetry(ctx, item)
|
||||
case "kol_assist":
|
||||
return s.publishKolAssistRetry(ctx, item)
|
||||
case "desktop_task":
|
||||
return s.publishDesktopTaskRetry(ctx, item)
|
||||
case "compliance_review":
|
||||
return s.publishComplianceReviewRetry(ctx, item)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *JobService) markRetryPublishFailed(ctx context.Context, item *domain.JobDetail, cause error) error {
|
||||
if item == nil || cause == nil {
|
||||
return nil
|
||||
}
|
||||
message := "retry publish failed: " + cause.Error()
|
||||
switch item.Source {
|
||||
case "generation", "template_assist", "kol_assist", "desktop_task", "compliance_review":
|
||||
_, err := s.jobs.Cancel(ctx, item.Source, item.ID, message)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *JobService) publishGenerationRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if s.mq == nil {
|
||||
return fmt.Errorf("rabbitmq is not configured")
|
||||
}
|
||||
if item.NumericID == nil || item.TenantID == nil {
|
||||
return fmt.Errorf("generation task is missing ids")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"task_id": *item.NumericID,
|
||||
"task_type": item.Kind,
|
||||
"tenant_id": *item.TenantID,
|
||||
"article_id": int64FromMap(item.Metadata, "article_id"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mq.PublishGenerationTask(ctx, body)
|
||||
}
|
||||
|
||||
func (s *JobService) publishTemplateAssistRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if s.mq == nil {
|
||||
return fmt.Errorf("rabbitmq is not configured")
|
||||
}
|
||||
if item.TenantID == nil {
|
||||
return fmt.Errorf("template assist task is missing tenant")
|
||||
}
|
||||
templateID := int64FromMap(item.Metadata, "template_id")
|
||||
body, err := json.Marshal(templateAssistRetryEnvelope(item, templateID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mq.PublishTemplateAssistTask(ctx, body)
|
||||
}
|
||||
|
||||
func (s *JobService) publishKolAssistRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if s.mq == nil {
|
||||
return fmt.Errorf("rabbitmq is not configured")
|
||||
}
|
||||
if item.TenantID == nil {
|
||||
return fmt.Errorf("kol assist task is missing tenant")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"task_id": item.ID,
|
||||
"tenant_id": *item.TenantID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mq.PublishKolAssistTask(ctx, body)
|
||||
}
|
||||
|
||||
func (s *JobService) publishComplianceReviewRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if s.mq == nil {
|
||||
return fmt.Errorf("rabbitmq is not configured")
|
||||
}
|
||||
if item.NumericID == nil || item.TenantID == nil {
|
||||
return fmt.Errorf("compliance review job is missing ids")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"job_id": *item.NumericID,
|
||||
"message_id": stringFromMap(item.Metadata, "message_id"),
|
||||
"tenant_id": *item.TenantID,
|
||||
"article_id": int64FromMap(item.Metadata, "article_id"),
|
||||
"article_version_id": int64FromMap(item.Metadata, "article_version_id"),
|
||||
"check_record_id": int64FromMap(item.Metadata, "check_record_id"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mq.PublishComplianceReviewTask(ctx, body)
|
||||
}
|
||||
|
||||
func (s *JobService) publishDesktopTaskRetry(ctx context.Context, item *domain.JobDetail) error {
|
||||
if s.mq == nil {
|
||||
return fmt.Errorf("rabbitmq is not configured")
|
||||
}
|
||||
if item.UUID == nil || item.WorkspaceID == nil {
|
||||
return fmt.Errorf("desktop task is missing dispatch ids")
|
||||
}
|
||||
targetClientID := stringFromMap(item.Metadata, "target_client_id")
|
||||
if targetClientID == "" {
|
||||
return fmt.Errorf("desktop task is missing target client")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"type": "task_available",
|
||||
"task_id": *item.UUID,
|
||||
"job_id": stringFromMap(item.Metadata, "job_id"),
|
||||
"workspace_id": *item.WorkspaceID,
|
||||
"target_account_id": stringFromMap(item.Metadata, "target_account_id"),
|
||||
"target_client_id": targetClientID,
|
||||
"platform": stringFromMap(item.Metadata, "platform_id"),
|
||||
"status": item.Status,
|
||||
"kind": item.Kind,
|
||||
"priority": intFromMap(item.Metadata, "priority"),
|
||||
"lane": stringFromMap(item.Metadata, "lane"),
|
||||
"interrupt_generation": intFromMap(item.Metadata, "interrupt_generation"),
|
||||
"updated_at": item.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mq.PublishDesktopTaskEvent(ctx, body); err != nil {
|
||||
return err
|
||||
}
|
||||
routingKey := desktopDispatchRoutingKey(s.mq.Config(), item.Kind, stringFromMap(item.Metadata, "lane"), targetClientID)
|
||||
dispatchBody, err := json.Marshal(stream.DesktopDispatchEvent{
|
||||
Type: "task_available",
|
||||
TaskID: *item.UUID,
|
||||
JobID: stringFromMap(item.Metadata, "job_id"),
|
||||
WorkspaceID: *item.WorkspaceID,
|
||||
TargetAccountID: stringFromMap(item.Metadata, "target_account_id"),
|
||||
TargetClientID: targetClientID,
|
||||
Platform: stringFromMap(item.Metadata, "platform_id"),
|
||||
Status: item.Status,
|
||||
Kind: item.Kind,
|
||||
Priority: intFromMap(item.Metadata, "priority"),
|
||||
Lane: stringFromMap(item.Metadata, "lane"),
|
||||
InterruptGeneration: intFromMap(item.Metadata, "interrupt_generation"),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mq.PublishDesktopDispatch(ctx, routingKey, dispatchBody)
|
||||
}
|
||||
|
||||
func templateAssistRetryEnvelope(item *domain.JobDetail, templateID int64) map[string]any {
|
||||
templateName := ""
|
||||
if item.Title != nil {
|
||||
templateName = *item.Title
|
||||
}
|
||||
envelope := map[string]any{
|
||||
"TaskID": item.ID,
|
||||
"TaskType": item.Kind,
|
||||
"TenantID": int64Value(item.TenantID),
|
||||
"TemplateID": templateID,
|
||||
"TemplateKey": stringFromMap(item.Metadata, "template_key"),
|
||||
"TemplateName": firstNonEmptyString(stringFromMap(item.Metadata, "template_name"), templateName),
|
||||
}
|
||||
switch item.Kind {
|
||||
case "analyze":
|
||||
envelope["AnalyzePrompt"] = optionalStringFromMap(item.Metadata, "analyze_prompt")
|
||||
envelope["AnalyzeRequest"] = item.Payload
|
||||
case "title":
|
||||
envelope["TitlePrompt"] = optionalStringFromMap(item.Metadata, "title_prompt")
|
||||
envelope["TitleRequest"] = item.Payload
|
||||
case "outline":
|
||||
envelope["OutlinePrompt"] = optionalStringFromMap(item.Metadata, "outline_prompt")
|
||||
envelope["OutlineRequest"] = item.Payload
|
||||
default:
|
||||
envelope["AnalyzeRequest"] = item.Payload
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func mapJobError(err error) error {
|
||||
if errors.Is(err, repository.ErrJobNotFound) {
|
||||
return response.ErrNotFound(40470, "job_not_found", "任务不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func jobAuditMetadata(item *domain.JobDetail) map[string]any {
|
||||
metadata := map[string]any{
|
||||
"job_uid": item.UID,
|
||||
"source": item.Source,
|
||||
"id": item.ID,
|
||||
"kind": item.Kind,
|
||||
"status": item.Status,
|
||||
"phase": item.Phase,
|
||||
}
|
||||
if item.TenantID != nil {
|
||||
metadata["tenant_id"] = *item.TenantID
|
||||
}
|
||||
if item.WorkspaceID != nil {
|
||||
metadata["workspace_id"] = *item.WorkspaceID
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func int64Value(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func int64FromMap(values map[string]any, key string) int64 {
|
||||
if values == nil {
|
||||
return 0
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case int64:
|
||||
return value
|
||||
case int:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
n, _ := value.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconvParseInt64(value)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func intFromMap(values map[string]any, key string) int {
|
||||
return int(int64FromMap(values, key))
|
||||
}
|
||||
|
||||
func stringFromMap(values map[string]any, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalStringFromMap(values map[string]any, key string) *string {
|
||||
value := stringFromMap(values, key)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strconvParseInt64(value string) (int64, error) {
|
||||
return strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
}
|
||||
|
||||
func desktopDispatchRoutingKey(cfg sharedconfig.RabbitMQConfig, kind, lane, targetClientID string) string {
|
||||
prefix := strings.TrimSpace(cfg.DesktopDispatchPublishPrefix)
|
||||
if strings.TrimSpace(kind) == "monitor" {
|
||||
prefix = strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix)
|
||||
}
|
||||
if prefix == "" {
|
||||
if strings.TrimSpace(kind) == "monitor" {
|
||||
prefix = "monitor"
|
||||
} else {
|
||||
prefix = "publish"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(kind) == "monitor" {
|
||||
laneSegment := "normal"
|
||||
switch strings.TrimSpace(lane) {
|
||||
case "high":
|
||||
laneSegment = "high"
|
||||
case "retry":
|
||||
laneSegment = "retry"
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s", prefix, laneSegment, targetClientID)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s", prefix, targetClientID)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type Config struct {
|
||||
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
||||
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
||||
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
@@ -232,6 +233,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if previous.Cache != current.Cache {
|
||||
add("cache", false)
|
||||
}
|
||||
if previous.RabbitMQ != current.RabbitMQ {
|
||||
add("rabbitmq", false)
|
||||
}
|
||||
if previous.Log != current.Log {
|
||||
add("log", false)
|
||||
}
|
||||
@@ -340,6 +344,7 @@ func normalizeConfig(cfg *Config) {
|
||||
cfg.Cache.Driver = "redis"
|
||||
}
|
||||
sharedconfig.NormalizeCacheConfig(&cfg.Cache)
|
||||
normalizeOpsRabbitMQConfig(&cfg.RabbitMQ)
|
||||
if strings.TrimSpace(cfg.Log.Level) == "" {
|
||||
cfg.Log.Level = "info"
|
||||
}
|
||||
@@ -374,6 +379,7 @@ func defaultSettings() map[string]any {
|
||||
"cache": map[string]any{
|
||||
"driver": "redis",
|
||||
},
|
||||
"rabbitmq": map[string]any{},
|
||||
"log": map[string]any{
|
||||
"level": "info",
|
||||
"format": "json",
|
||||
@@ -423,9 +429,57 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
|
||||
cfg.Server.TrustedProxies = strings.Split(v, ",")
|
||||
}
|
||||
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
||||
cfg.RabbitMQ.URL = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(cfg.GenerationExchange) == "" {
|
||||
cfg.GenerationExchange = "generation.task"
|
||||
}
|
||||
if strings.TrimSpace(cfg.GenerationRoutingKey) == "" {
|
||||
cfg.GenerationRoutingKey = "generation.task.run"
|
||||
}
|
||||
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
|
||||
cfg.DesktopTaskEventExchange = "desktop.task.event"
|
||||
}
|
||||
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
|
||||
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
|
||||
}
|
||||
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
|
||||
cfg.DesktopDispatchPublishPrefix = "publish"
|
||||
}
|
||||
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
|
||||
cfg.DesktopDispatchMonitorPrefix = "monitor"
|
||||
}
|
||||
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
|
||||
cfg.TemplateAssistExchange = "template.assist"
|
||||
}
|
||||
if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" {
|
||||
cfg.TemplateAssistRoutingKey = "template.assist.run"
|
||||
}
|
||||
if strings.TrimSpace(cfg.KolAssistExchange) == "" {
|
||||
cfg.KolAssistExchange = "kol.assist"
|
||||
}
|
||||
if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" {
|
||||
cfg.KolAssistRoutingKey = "kol.assist.run"
|
||||
}
|
||||
if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" {
|
||||
cfg.ComplianceReviewExchange = "compliance.review"
|
||||
}
|
||||
if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" {
|
||||
cfg.ComplianceReviewRoutingKey = "compliance.review.run"
|
||||
}
|
||||
if cfg.PublishChannelPoolSize <= 0 {
|
||||
cfg.PublishChannelPoolSize = 16
|
||||
}
|
||||
}
|
||||
|
||||
type fileSnapshot struct {
|
||||
size int64
|
||||
modTime time.Time
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
JobPhaseQueued = "queued"
|
||||
JobPhaseRunning = "running"
|
||||
JobPhaseSucceeded = "succeeded"
|
||||
JobPhaseFailed = "failed"
|
||||
JobPhaseCanceled = "canceled"
|
||||
JobPhaseBlocked = "blocked"
|
||||
JobPhaseUnknown = "unknown"
|
||||
)
|
||||
|
||||
type JobFilter struct {
|
||||
Source string
|
||||
Phase string
|
||||
Status string
|
||||
Kind string
|
||||
Keyword string
|
||||
TenantID *int64
|
||||
WorkspaceID *int64
|
||||
UserID *int64
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type JobSummary struct {
|
||||
UID string `json:"uid"`
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
NumericID *int64 `json:"numeric_id,omitempty"`
|
||||
UUID *string `json:"uuid,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
TenantName *string `json:"tenant_name,omitempty"`
|
||||
WorkspaceID *int64 `json:"workspace_id,omitempty"`
|
||||
WorkspaceName *string `json:"workspace_name,omitempty"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
UserName *string `json:"user_name,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Target *string `json:"target,omitempty"`
|
||||
Queue *string `json:"queue,omitempty"`
|
||||
Worker *string `json:"worker,omitempty"`
|
||||
Attempts *int `json:"attempts,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
|
||||
AvailableAt *time.Time `json:"available_at,omitempty"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
DurationSeconds *int64 `json:"duration_seconds,omitempty"`
|
||||
IsStuck bool `json:"is_stuck"`
|
||||
CanRetry bool `json:"can_retry"`
|
||||
CanCancel bool `json:"can_cancel"`
|
||||
}
|
||||
|
||||
type JobCounts struct {
|
||||
Total int64 `json:"total"`
|
||||
ByPhase map[string]int64 `json:"by_phase"`
|
||||
BySource map[string]int64 `json:"by_source"`
|
||||
}
|
||||
|
||||
type JobListResult struct {
|
||||
Items []JobSummary `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Counts JobCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type JobDetail struct {
|
||||
JobSummary
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
func listJobsHandler(svc *app.JobService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
|
||||
filter := domain.JobFilter{
|
||||
Source: strings.TrimSpace(c.Query("source")),
|
||||
Phase: strings.TrimSpace(c.Query("phase")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
Kind: strings.TrimSpace(c.Query("kind")),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(c.Query("tenant_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40070, "invalid_tenant_id", "tenant_id 不合法"))
|
||||
return
|
||||
}
|
||||
filter.TenantID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("workspace_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40071, "invalid_workspace_id", "workspace_id 不合法"))
|
||||
return
|
||||
}
|
||||
filter.WorkspaceID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("user_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40072, "invalid_user_id", "user_id 不合法"))
|
||||
return
|
||||
}
|
||||
filter.UserID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("start_at")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40073, "invalid_start_at", "start_at 必须是 RFC3339"))
|
||||
return
|
||||
}
|
||||
filter.StartAt = &t
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("end_at")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40074, "invalid_end_at", "end_at 必须是 RFC3339"))
|
||||
return
|
||||
}
|
||||
filter.EndAt = &t
|
||||
}
|
||||
|
||||
result, err := svc.List(c.Request.Context(), filter, page, size)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func getJobHandler(svc *app.JobService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
item, err := svc.Get(c.Request.Context(), c.Param("source"), c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
}
|
||||
|
||||
func retryJobHandler(svc *app.JobService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
item, err := svc.Retry(c.Request.Context(), actorFromGin(c), c.Param("source"), c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelJobHandler(svc *app.JobService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if c.Request.Body != nil {
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
response.Error(c, response.ErrBadRequest(40075, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
item, err := svc.Cancel(c.Request.Context(), actorFromGin(c), c.Param("source"), c.Param("id"), req.Reason)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Deps struct {
|
||||
Auth *app.AuthService
|
||||
Accounts *app.AccountService
|
||||
AdminUsers *app.AdminUserService
|
||||
Jobs *app.JobService
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
@@ -92,6 +93,11 @@ func RegisterRoutes(d Deps) {
|
||||
|
||||
authed.GET("/audits", listAuditsHandler(d.Audits))
|
||||
|
||||
authed.GET("/jobs", listJobsHandler(d.Jobs))
|
||||
authed.GET("/jobs/:source/:id", getJobHandler(d.Jobs))
|
||||
authed.POST("/jobs/:source/:id/retry", retryJobHandler(d.Jobs))
|
||||
authed.POST("/jobs/:source/:id/cancel", cancelJobHandler(d.Jobs))
|
||||
|
||||
authed.GET("/site-domain-mappings", listSiteDomainMappingsHandler(d.SiteDomains))
|
||||
authed.POST("/site-domain-mappings", createSiteDomainMappingHandler(d.SiteDomains))
|
||||
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
|
||||
|
||||
@@ -11,6 +11,12 @@ CREATE TABLE IF NOT EXISTS desktop_publish_jobs (
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
article_id BIGINT REFERENCES articles(id),
|
||||
article_version_id BIGINT REFERENCES article_versions(id),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
compliance_blocked_record_id BIGINT,
|
||||
compliance_blocked_at TIMESTAMPTZ,
|
||||
compliance_blocked_reason TEXT,
|
||||
UNIQUE (desktop_id)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_desktop_publish_jobs_status_scheduled;
|
||||
|
||||
ALTER TABLE desktop_publish_jobs
|
||||
DROP COLUMN IF EXISTS compliance_blocked_reason,
|
||||
DROP COLUMN IF EXISTS compliance_blocked_at,
|
||||
DROP COLUMN IF EXISTS compliance_blocked_record_id,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS article_version_id,
|
||||
DROP COLUMN IF EXISTS article_id;
|
||||
DROP CONSTRAINT IF EXISTS desktop_publish_jobs_compliance_blocked_record_id_fkey;
|
||||
|
||||
ALTER TABLE article_versions
|
||||
DROP CONSTRAINT IF EXISTS fk_article_versions_manual_review;
|
||||
|
||||
@@ -202,12 +202,13 @@ ALTER TABLE article_versions
|
||||
ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE desktop_publish_jobs
|
||||
ADD COLUMN IF NOT EXISTS article_id BIGINT REFERENCES articles(id),
|
||||
ADD COLUMN IF NOT EXISTS article_version_id BIGINT REFERENCES article_versions(id),
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_record_id BIGINT REFERENCES compliance_check_records(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_reason TEXT;
|
||||
DROP CONSTRAINT IF EXISTS desktop_publish_jobs_compliance_blocked_record_id_fkey;
|
||||
|
||||
ALTER TABLE desktop_publish_jobs
|
||||
ADD CONSTRAINT desktop_publish_jobs_compliance_blocked_record_id_fkey
|
||||
FOREIGN KEY (compliance_blocked_record_id)
|
||||
REFERENCES compliance_check_records(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_desktop_publish_jobs_status_scheduled
|
||||
ON desktop_publish_jobs (status, scheduled_at, created_at)
|
||||
|
||||
Reference in New Issue
Block a user