feat(ingest): API Push 前端层 + 文档 + 端到端联通

后端(支持 api_push source 创建/调度):
- schemas/source.py:SourceIn.url 改成 str(允许 api_push 的 api-push:// 占位)
- admin.py create_source 简化 url 传递
- workers/__main__.py:_rebuild_jobs 跳过 api_push 源(它是被动接收,不抓取)
- workers/pipeline.py:run_once 也加同条件,api_push 不进抓取循环

前端:
- api/articles.ts:ArticleListItem 加 is_short_news(required)/source_ref;
  ArticleDetail 加 external_id;导出 IngestTokenOut;adminApi 加
  list/create/revoke ingest token 三个方法
- views/Feed.vue:卡片根 class 短新闻加 short-card(淡蓝底 #f6f9fc +
  左侧 3px 蓝色色条 #4f9eff);元信息栏加 📰 短讯 角标;长新闻摘要
  body_zh_text 截前 200 字,短新闻不截取保留换行(white-space: pre-wrap);
  短新闻不显示 AI 插图
- views/ArticleDetail.vue:tag 行加 📰 短讯 + source_ref 角标;短新闻
  路径下隐藏翻译状态/重译/原文链接按钮;正文区短新闻直接渲染
  body_zh_text,跳过译文/原文/AI 配图卡片;Angel + 美团双评论卡片
  都保留
- views/AdminSources.vue:kind 加 api_push 选项;api_push 源 URL 字段
  变只读占位、隐藏抓取间隔;列表操作列加 🔑 Token 按钮;
  弹窗支持生成(raw_token 一次性显示 + 复制)/列表/撤销

文档:
- docs/api-push.md:调用方契约 + 三层去重 + 限速 + lifecycle +
  owner 操作手册 + curl/Python 示例 + 重试策略 + 故障排查
- README.md:关键特性加 API Push;API 概览加 /api/v1/ingest 和
  3 个 /admin/.../ingest-tokens 端点
This commit is contained in:
xiaji
2026-06-14 16:15:21 +08:00
parent 07534eb144
commit e274246056
10 changed files with 677 additions and 51 deletions

View File

@@ -41,6 +41,10 @@ export interface ArticleListItem {
commentary_meituan_status?: string | null
commentary_engine?: string | null // angel / meituan / "angel,meituan"
image_ai_url?: string | null
// === API Push 短新闻标识 ===
// 短新闻(中文原生,由 /api/v1/ingest 推送)走差异化展示
is_short_news: boolean
source_ref?: string | null // 短新闻里再细分来源(wechat/rss-digest 等)
is_starred: boolean
is_read: boolean // 当前用户是否已读
}
@@ -79,6 +83,8 @@ export interface ArticleDetail extends ArticleListItem {
entities?: Record<string, any> | null
sentiment?: number | null
duplicate_of?: number | null
// === API Push 短新闻 ===
external_id?: string | null // 调用方幂等 key
}
export interface LlmSetting {
@@ -209,4 +215,29 @@ export const adminApi = {
`/admin/llm/enrich/${articleId}`
).then((r) => r.data)
},
// === API Push ingest token 管理 ===
listIngestTokens(sourceId: number) {
return http.get<IngestTokenOut[]>(`/admin/sources/${sourceId}/ingest-tokens`).then((r) => r.data)
},
createIngestToken(sourceId: number, body: { name?: string; expires_days?: number }) {
return http.post<IngestTokenOut>(`/admin/sources/${sourceId}/ingest-tokens`, body).then((r) => r.data)
},
revokeIngestToken(tokenId: number) {
return http.delete<{ id: number; revoked_at: string; already_revoked: boolean }>(
`/admin/ingest-tokens/${tokenId}`
).then((r) => r.data)
},
}
export interface IngestTokenOut {
id: number
source_id: number
name: string
purpose: string
created_at: string
expires_at?: string | null
revoked_at?: string | null
last_used_at?: string | null
// 仅 createIngestToken 返回时填充(raw_token 只一次性返给前端)
raw_token?: string | null
}

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed, h } from 'vue'
import {
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, useDialog,
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem,
NInput, NSelect, NInputNumber, useDialog,
} from 'naive-ui'
import { adminApi, type Source } from '@/api/articles'
import { h } from 'vue'
import { adminApi, type Source, type IngestTokenOut } from '@/api/articles'
const message = useMessage()
const dialog = useDialog()
@@ -13,7 +13,7 @@ const showCreate = ref(false)
const form = ref({
name: '',
slug: '',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel' | 'api_push',
url: '',
region: '',
language_src: 'en',
@@ -26,8 +26,18 @@ const kindOptions = [
{ label: 'RSS / Atom', value: 'rss' },
{ label: 'HTML 列表', value: 'html_list' },
{ label: 'Telegram', value: 'tg_channel' },
{ label: 'API Push(短新闻推送)', value: 'api_push' },
]
// === Ingest Token 管理(仅 api_push 源)===
const showTokenModal = ref(false)
const tokenSource = ref<Source | null>(null)
const tokenList = ref<IngestTokenOut[]>([])
const tokenLoading = ref(false)
const newTokenName = ref('default')
const newTokenExpiresDays = ref<number | null>(null)
const lastIssuedRaw = ref<string | null>(null) // 创建后弹一次性 token
async function load() {
sources.value = await adminApi.listSources()
}
@@ -58,13 +68,21 @@ async function refresh(s: Source) {
}
async function create() {
if (!form.value.name || !form.value.slug || !form.value.url) {
message.error('请填写名称 / slug / url')
if (!form.value.name || !form.value.slug) {
message.error('请填写名称 / slug')
return
}
// api_push 类型的 url 不是必填(只是合成占位);其他类型必填
if (form.value.kind !== 'api_push' && !form.value.url) {
message.error('请填写 URL')
return
}
try {
await adminApi.createSource(form.value)
message.success('已创建,等下一轮抓取')
await adminApi.createSource({
...form.value,
url: form.value.url || `api-push://${form.value.slug}`,
})
message.success('已创建,等下一轮抓取 / 推送')
showCreate.value = false
form.value = {
name: '', slug: '', kind: 'rss', url: '',
@@ -76,31 +94,166 @@ async function create() {
}
}
// === Ingest Token 弹窗 ===
async function openTokenModal(s: Source) {
tokenSource.value = s
showTokenModal.value = true
lastIssuedRaw.value = null
newTokenName.value = 'default'
newTokenExpiresDays.value = null
await loadTokens()
}
async function loadTokens() {
if (!tokenSource.value) return
tokenLoading.value = true
try {
tokenList.value = await adminApi.listIngestTokens(tokenSource.value.id)
} catch (e: any) {
message.error(e?.response?.data?.title || '加载 token 列表失败')
} finally {
tokenLoading.value = false
}
}
async function createToken() {
if (!tokenSource.value) return
if (!newTokenName.value.trim()) {
message.error('请填写 token 名称')
return
}
try {
const out = await adminApi.createIngestToken(tokenSource.value.id, {
name: newTokenName.value.trim(),
expires_days: newTokenExpiresDays.value ?? undefined,
})
if (out.raw_token) {
lastIssuedRaw.value = out.raw_token
message.success('Token 已生成 — 请复制下方 raw_token,关闭后不再显示')
} else {
message.warning('Token 生成成功,但未返回 raw_token')
}
await loadTokens()
} catch (e: any) {
message.error(e?.response?.data?.title || '生成失败')
}
}
async function revokeToken(id: number) {
dialog.warning({
title: '确认撤销',
content: '撤销后此 token 立即失效,所有正在用它的调用方会拿到 401',
positiveText: '撤销',
negativeText: '取消',
onPositiveClick: async () => {
try {
await adminApi.revokeIngestToken(id)
message.success('已撤销')
await loadTokens()
} catch (e: any) {
message.error(e?.response?.data?.title || '撤销失败')
}
},
})
}
// raw token 复制到剪贴板
async function copyRaw() {
if (!lastIssuedRaw.value) return
try {
await navigator.clipboard.writeText(lastIssuedRaw.value)
message.success('已复制到剪贴板')
} catch {
message.warning('复制失败,请手动选中复制')
}
}
const originForCurl = computed(() => {
if (typeof window !== 'undefined' && window.location) {
return window.location.origin
}
return 'https://your-domain'
})
const tokenColumns = [
{ title: 'ID', key: 'id', width: 60 },
{ title: '名称', key: 'name', width: 140 },
{
title: '状态', key: 'status', width: 100,
render: (r: IngestTokenOut) => {
if (r.revoked_at) return h(NTag, { type: 'default', size: 'small' }, () => '已撤销')
if (r.expires_at && new Date(r.expires_at) < new Date()) {
return h(NTag, { type: 'warning', size: 'small' }, () => '已过期')
}
return h(NTag, { type: 'success', size: 'small' }, () => '有效')
},
},
{ title: '创建', key: 'created_at', width: 170 },
{ title: '过期', key: 'expires_at', width: 170, render: (r: IngestTokenOut) => r.expires_at || '永不过期' },
{ title: '最后使用', key: 'last_used_at', width: 170, render: (r: IngestTokenOut) => r.last_used_at || '—' },
{
title: '操作', key: 'action', width: 100,
render: (r: IngestTokenOut) =>
r.revoked_at
? null
: h(NButton, {
size: 'tiny', type: 'error', ghost: true,
onClick: () => revokeToken(r.id),
}, () => '撤销'),
},
]
const columns = [
{ title: 'ID', key: 'id', width: 50 },
{ title: '名称', key: 'name' },
{ title: 'slug', key: 'slug' },
{ title: 'kind', key: 'kind' },
{ title: 'URL', key: 'url', render: (r: Source) => h('a', { href: r.url, target: '_blank', rel: 'noopener' }, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')) },
{ title: '地区', key: 'region' },
{
title: '优先级/间隔', key: 'meta',
title: 'kind', key: 'kind', width: 120,
render: (r: Source) => {
const map: Record<string, { label: string; type: 'success' | 'info' }> = {
rss: { label: 'RSS', type: 'success' },
html_list: { label: 'HTML', type: 'success' },
tg_channel: { label: 'Telegram', type: 'success' },
api_push: { label: 'API Push', type: 'info' },
}
const cfg = map[r.kind] || { label: r.kind, type: 'success' as const }
return h(NTag, { type: cfg.type, size: 'small' }, () => cfg.label)
},
},
{
title: 'URL', key: 'url',
render: (r: Source) => h('a', {
href: r.url.startsWith('http') ? r.url : '#',
target: '_blank', rel: 'noopener',
style: r.url.startsWith('api-push://') ? 'color:#9ca3af' : '',
}, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')),
},
{ title: '地区', key: 'region', width: 90 },
{
title: '优先级/间隔', key: 'meta', width: 110,
render: (r: Source) => `P${r.priority} / ${r.fetch_interval_min}m`,
},
{
title: '状态', key: 'enabled',
title: '状态', key: 'enabled', width: 80,
render: (r: Source) => h(NTag, { type: r.enabled ? 'success' : 'default', size: 'small' }, () => r.enabled ? '启用' : '停用'),
},
{
title: '操作', key: 'action', width: 280,
render: (row: Source) => h(NSpace, {}, () => [
h(NButton, { size: 'small', onClick: () => refresh(row) }, () => '抓取'),
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
default: () => '确认删除?',
}),
]),
title: '操作', key: 'action', width: 360,
render: (row: Source) => h(NSpace, {}, () => {
const items: any[] = [
h(NButton, { size: 'small', onClick: () => refresh(row), disabled: row.kind === 'api_push' }, () => '抓取'),
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
h(NButton, {
size: 'small', type: 'info', ghost: true,
onClick: () => openTokenModal(row),
}, () => '🔑 Token'),
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
default: () => '确认删除?',
}),
]
return items
}),
},
]
@@ -121,13 +274,25 @@ onMounted(load)
<NFormItem label="名称"><NInput v-model:value="form.name" /></NFormItem>
<NFormItem label="slug"><NInput v-model:value="form.slug" placeholder="小写字母+连字符" /></NFormItem>
<NFormItem label="类型"><NSelect v-model:value="form.kind" :options="kindOptions" /></NFormItem>
<NFormItem label="URL"><NInput v-model:value="form.url" placeholder="https://..." /></NFormItem>
<NFormItem
v-if="form.kind !== 'api_push'"
label="URL"
>
<NInput v-model:value="form.url" placeholder="https://..." />
</NFormItem>
<NFormItem v-else label="占位 URL">
<NInput
:value="`api-push://${form.slug || '<slug>'}`"
readonly
placeholder="根据 slug 自动生成"
/>
</NFormItem>
<NFormItem label="地区"><NInput v-model:value="form.region" placeholder="global / eu / asia / mena" /></NFormItem>
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en" /></NFormItem>
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en / zh" /></NFormItem>
<NFormItem label="优先级">
<NInputNumber v-model:value="form.priority" :min="1" :max="100" />
</NFormItem>
<NFormItem label="抓取间隔(分)">
<NFormItem v-if="form.kind !== 'api_push'" label="抓取间隔(分)">
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
</NFormItem>
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
@@ -139,5 +304,70 @@ onMounted(load)
</NSpace>
</template>
</NModal>
<!-- Ingest Token 管理弹窗 -->
<NModal
v-model:show="showTokenModal"
preset="card"
:title="`Ingest Tokens · ${tokenSource?.name || ''}`"
style="width: 800px"
>
<NSpace vertical :size="14">
<!-- 新建 token -->
<NCard size="small" title="生成新 token">
<NSpace align="center" :size="8" :wrap="true">
<NInput
v-model:value="newTokenName"
placeholder="名称,如 'wechat-bot'"
style="width: 200px"
/>
<NInputNumber
v-model:value="newTokenExpiresDays"
placeholder="过期天数(留空=永不过期)"
:min="1"
:max="3650"
clearable
style="width: 220px"
/>
<NButton type="primary" @click="createToken">生成</NButton>
</NSpace>
</NCard>
<!-- 新生成的 raw token(只显示一次!)-->
<NCard v-if="lastIssuedRaw" size="small" :bordered="true" content-style="background: #fef3c7;">
<NSpace vertical :size="8">
<strong style="color: #92400e">
请立即复制下方 raw_token 关闭弹窗后不再显示
</strong>
<NInput
:value="lastIssuedRaw"
readonly
type="text"
style="font-family: monospace; font-size: 12px"
/>
<NSpace>
<NButton size="small" type="primary" @click="copyRaw">复制</NButton>
<NButton size="small" @click="lastIssuedRaw = null">我已保存,关闭</NButton>
</NSpace>
<small style="color: #92400e">
用法:curl -X POST {{ originForCurl }}/api/v1/ingest
-H "X-Ingest-Token: &lt;上方的 token&gt;" -H "Content-Type: application/json"
-d '{"title":"...","body":"..."}'
</small>
</NSpace>
</NCard>
<!-- 已有 token 列表 -->
<NCard size="small" title="已有 token">
<NDataTable
:columns="tokenColumns"
:data="tokenList"
:loading="tokenLoading"
:bordered="false"
:pagination="{ pageSize: 10 }"
/>
</NCard>
</NSpace>
</NModal>
</NSpace>
</template>
</template>

View File

@@ -160,6 +160,20 @@ const originalBody = computed(() => {
return ''
})
// 短新闻(API Push)判断。短新闻的 details 视图与长新闻不同:
// - 不显示原文/译文 Tab(短新闻是中文原生,无原文)
// - 不显示 AI 配图(用户明确不要)
// - 隐藏"重译"按钮(translation_status=n/a,重译无意义)
// - 隐藏"原文链接"(合成 url,打开无意义)
const isShort = computed(() => !!article.value?.is_short_news)
// 短新闻正文:用 body_zh_text(ingest 时已 = body_text),按段落切分
const shortBody = computed(() => {
const a = article.value
if (!a) return ''
return splitIntoParagraphs(a.body_zh_text || a.body_text || '')
})
async function rerunTranslation() {
if (!article.value) return
if (!confirm('重新翻译会消耗配额,确认?')) return
@@ -213,10 +227,12 @@ onMounted(load)
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag>
<NTag v-if="article.lang_src" :bordered="false" round>{{ article.lang_src.toUpperCase() }}</NTag>
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
<NTag v-if="isShort" type="info" :bordered="false" round>📰 短讯</NTag>
<NTag v-if="article.source_ref" :bordered="false" round>{{ article.source_ref }}</NTag>
<NTag v-if="!isShort && article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
翻译:{{ article.translation_status }}
</NTag>
<NTag v-if="article.translation_engine" size="small" :bordered="false" round>
<NTag v-if="!isShort && article.translation_engine" size="small" :bordered="false" round>
{{ article.translation_engine }}
</NTag>
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round>
@@ -267,13 +283,13 @@ onMounted(load)
<NButton text @click="showTranslation = !showTranslation" round>
{{ showTranslation ? '隐藏译文' : '显示译文' }}
</NButton>
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation" round>
<NButton v-if="isOwner && !isShort" type="error" ghost @click="rerunTranslation" round>
重译
</NButton>
<NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich" round>
LLM 增强
</NButton>
<NButton tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
<NButton v-if="!isShort" tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
原文链接
</NButton>
</NSpace>
@@ -393,8 +409,17 @@ onMounted(load)
</NText>
</NCard>
<!-- 2) 译文(优先 LLM 排版版) -->
<div v-if="showTranslation" style="margin-top: 16px">
<!-- 2) 短新闻(API Push):直接显示正文,跳过译文/原文 Tab -->
<NCard v-if="isShort" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title">📝 短讯正文</span>
</template>
<div v-if="shortBody" class="article-body-fallback" v-html="shortBody" />
<NText v-else :depth="3">暂无正文</NText>
</NCard>
<!-- 2) 译文(长新闻,优先 LLM 排版版) -->
<div v-else-if="showTranslation" style="margin-top: 16px">
<NCard v-if="article.body_zh_formatted" class="detail-card">
<template #header>
<span class="card-header-title">📖 文章译文</span>
@@ -422,16 +447,16 @@ onMounted(load)
</NCard>
</div>
<!-- AI 插图 -->
<NCard v-if="article.image_ai_url" class="detail-card" style="margin-top: 16px">
<!-- AI 插图(长新闻) -->
<NCard v-if="!isShort && article.image_ai_url" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title">🎨 AI 插图</span>
</template>
<NImage :src="article.image_ai_url" object-fit="cover" class="article-image" />
</NCard>
<!-- 3) 原文 -->
<div v-if="showOriginal" style="margin-top: 16px">
<!-- 3) 原文(长新闻) -->
<div v-if="!isShort && showOriginal" style="margin-top: 16px">
<NCard class="detail-card">
<template #header>
<span class="card-header-title">📄 文章原文</span>

View File

@@ -156,9 +156,13 @@ function commentaryState(status?: string | null, content?: string | null): Comme
return 'waiting'
}
// 正文摘要(取 body_zh_text 前 N 字;没有就 fallback 到 summary_zh)
function bodyExcerpt(text?: string | null, max = 200): string {
// 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
if (!text) return ''
if (keepNewlines) {
// 短新闻:不去空白,保留 \n 让前端 white-space: pre-wrap 换行
return text.length > max ? text.slice(0, max) + '…' : text
}
const trimmed = text.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
}
@@ -209,7 +213,10 @@ onMounted(async () => {
v-for="a in items"
:key="a.id"
class="article-card"
:class="{ 'article-card-read': a.is_read }"
:class="{
'article-card-read': a.is_read,
'short-card': a.is_short_news,
}"
hoverable
@click="open(a)"
>
@@ -235,6 +242,17 @@ onMounted(async () => {
>
{{ c }}
</NTag>
<!-- 短新闻(API Push)角标:固定显示 -->
<NTag
v-if="a.is_short_news"
size="tiny"
type="info"
:bordered="false"
round
class="feed-short-tag"
>
📰 短讯
</NTag>
<!-- 已读/未读小标签 -->
<NTag
v-if="a.is_read"
@@ -273,9 +291,9 @@ onMounted(async () => {
{{ a.title }}
</div>
<!-- AI 插图(若有) -->
<!-- AI 插图(若有;短新闻不显示) -->
<img
v-if="a.image_ai_url || a.image_url"
v-if="!a.is_short_news && (a.image_ai_url || a.image_url)"
:src="a.image_ai_url || a.image_url || ''"
style="
display: block;
@@ -290,9 +308,14 @@ onMounted(async () => {
loading="lazy"
/>
<!-- 翻译后正文摘要 -->
<!--
正文摘要:
- 长新闻:body_zh_text 截前 200 (去多余空白)
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
-->
<div
v-if="a.body_zh_text || a.summary_zh"
:class="{ 'short-body': a.is_short_news }"
style="
margin-top: 4px;
color: var(--color-letter);
@@ -300,7 +323,11 @@ onMounted(async () => {
line-height: 1.75;
"
>
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
{{
a.is_short_news
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200)
}}
</div>
<!-- 评论钩子( provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
@@ -590,6 +617,36 @@ onMounted(async () => {
color: var(--color-text-faint);
}
/* === 短新闻(API Push)卡片差异化 ===
长新闻:卡片色调不变
短新闻:淡蓝底 + 左侧 3px 蓝色竖线条,便于一眼区分
*/
.article-card.short-card {
background: #f6f9fc;
border-left: 3px solid #4f9eff;
}
.article-card.short-card:hover {
background: #eef4fb;
}
/* 已读 + 短新闻:已读底色优先,左边色条仍保留作为"短讯"标识 */
.article-card.short-card.article-card-read {
background: #fafafa;
border-left-color: #4f9eff;
border-left-width: 3px;
}
/* 短新闻正文:保留换行 */
.short-body {
white-space: pre-wrap;
font-size: 13.5px;
line-height: 1.65;
}
/* 短讯角标 */
.feed-short-tag {
font-size: 11px;
}
/* === 底部操作栏(浮在卡片右下角)=== */
.feed-actions {
display: flex;