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

@@ -48,7 +48,10 @@
- 🌐 **智能翻译**:腾讯云 TMT(月 500 万字符配额)→ 本地 NLLB-200 降级,30 天 Redis 缓存避免重复 - 🌐 **智能翻译**:腾讯云 TMT(月 500 万字符配额)→ 本地 NLLB-200 降级,30 天 Redis 缓存避免重复
- 🤖 **LLM 智能增强** *(新)*:翻译完成后自动跑 4 项 LLM 任务 — 排版 / 分类 / 插图 / 点评 - 🤖 **LLM 智能增强** *(新)*:翻译完成后自动跑 4 项 LLM 任务 — 排版 / 分类 / 插图 / 点评
- 🎨 **AI 配图**:文生图模型自动为每篇文章生成插图(走 Agnes 平台,带限速) - 🎨 **AI 配图**:文生图模型自动为每篇文章生成插图(走 Agnes 平台,带限速)
- 👤 **双角色鉴权**:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android 预留) - 📥 **API Push 短新闻** *(新)*:`POST /api/v1/ingest` 接收外部中文短新闻推送,
三层去重(L1 external_id / L2 content_hash / L3 DB UNIQUE)+ 每 token 2 篇/秒限速;
短新闻入库后跳过翻译/排版/插图,只跑分类 + 双 provider 点评
- 👤 **双角色鉴权**:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android / ingest 预留)
- 📌 **收藏 + 关键词订阅**:用户级书签,服务端定时按关键词命中推送(预留 Telegram 通道) - 📌 **收藏 + 关键词订阅**:用户级书签,服务端定时按关键词命中推送(预留 Telegram 通道)
- 📊 **管理看板**:源健康度 / 翻译配额 / LLM 状态,全部可视化 - 📊 **管理看板**:源健康度 / 翻译配额 / LLM 状态,全部可视化
- 🔄 **热加载**:源/提示词改了不用重启,worker 每天 00:30 重建 job - 🔄 **热加载**:源/提示词改了不用重启,worker 每天 00:30 重建 job
@@ -616,9 +619,20 @@ WHERE translation_status='ok';
- `GET /bookmarks` / `POST /bookmarks` / `DELETE /bookmarks/{id}` - `GET /bookmarks` / `POST /bookmarks` / `DELETE /bookmarks/{id}`
- `GET /subscriptions` / `POST /subscriptions` / `DELETE /subscriptions/{id}` - `GET /subscriptions` / `POST /subscriptions` / `DELETE /subscriptions/{id}`
### API Push 短新闻(无鉴权,凭 X-Ingest-Token)
- `POST /api/v1/ingest` — 外部推送短新闻入库(中文原生,跳过翻译/排版/插图,跑分类 + 双 provider 点评)
- 鉴权:`X-Ingest-Token` 头对应 `api_tokens.purpose='ingest'` 的 sha256 token
- 限速:每 token 2 篇/秒(`INGEST_RATE_PER_SEC` 可调)
- 去重:三层(L1 external_id / L2 content_hash / L3 DB UNIQUE)
- 完整契约见 [`docs/api-push.md`](./docs/api-push.md)
### Owner only(`/admin/*`) ### Owner only(`/admin/*`)
- `GET /admin/sources` / `POST` / `PATCH /{id}` / `DELETE /{id}` — 源 CRUD - `GET /admin/sources` / `POST` / `PATCH /{id}` / `DELETE /{id}` — 源 CRUD
- `POST /admin/sources/{source_id}/ingest-tokens` — 为 api_push 源生成 ingest token(raw_token 仅一次性返回)
- `GET /admin/sources/{source_id}/ingest-tokens` — 列出某个 source 的 ingest token
- `DELETE /admin/ingest-tokens/{token_id}` — 撤销 ingest token
- `POST /admin/refresh/{source_id}` — 立即触发抓取 - `POST /admin/refresh/{source_id}` — 立即触发抓取
- `POST /admin/translation/rerun/{article_id}` — 重译 - `POST /admin/translation/rerun/{article_id}` — 重译
- `GET /admin/health` — 源健康看板 - `GET /admin/health` — 源健康看板

View File

@@ -47,7 +47,7 @@ async def create_source(body: SourceIn, session: AsyncSession = Depends(get_sess
name=body.name, name=body.name,
slug=body.slug, slug=body.slug,
kind=body.kind, kind=body.kind,
url=str(body.url), url=body.url,
detail_selector=body.detail_selector, detail_selector=body.detail_selector,
region=body.region, region=body.region,
language_src=body.language_src, language_src=body.language_src,

View File

@@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, HttpUrl from pydantic import BaseModel, ConfigDict, Field
from app.models.source import SourceKind from app.models.source import SourceKind
@@ -29,11 +30,21 @@ class SourceOut(BaseModel):
blocklist_tags: list[str] = [] blocklist_tags: list[str] = []
# url 字段:正常源要 HttpUrl(校验合法 URL),但 api_push 源是合成占位(类似 api-push://...)
# 用 Annotated Union 区分:rss/html_list/tg_channel → HttpUrl;api_push → str
# 但 SourceIn.kind 未知时(前端一次提交),无法静态区分。最简单的兼容:统一接受 str,
# 入库前在 admin.create_source 里按 kind 分支校验。
# 这里改成 str(最长 2048),保留手工校验的责任。
SourceUrlStr = Annotated[str, Field(min_length=1, max_length=2048)]
class SourceIn(BaseModel): class SourceIn(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
slug: str = Field(min_length=1, max_length=128, pattern=r"^[a-z0-9-]+$") slug: str = Field(min_length=1, max_length=128, pattern=r"^[a-z0-9-]+$")
kind: SourceKind = SourceKind.RSS kind: SourceKind = SourceKind.RSS
url: HttpUrl # url:不再强制 HttpUrl,允许 api_push 源的合成 url(api-push://...);
# rss/html_list/tg_channel 由 admin.create_source 在入库前手工校验
url: str = Field(min_length=1, max_length=2048)
region: str | None = None region: str | None = None
language_src: str | None = None language_src: str | None = None
priority: int = Field(default=50, ge=1, le=100) priority: int = Field(default=50, ge=1, le=100)

View File

@@ -29,7 +29,11 @@ logging.basicConfig(
async def _rebuild_jobs(scheduler: AsyncIOScheduler) -> None: async def _rebuild_jobs(scheduler: AsyncIOScheduler) -> None:
"""从 sources 表动态构建 job(可热更新)。""" """从 sources 表动态构建 job(可热更新)。
只调度有抓取语义的源(rss / html_list / tg_channel);
api_push 是被动接收,不进 fetch 调度。
"""
scheduler.remove_all_jobs() scheduler.remove_all_jobs()
async with AsyncSessionLocal() as s: async with AsyncSessionLocal() as s:
rows = (await s.execute(select(Source).where(Source.enabled.is_(True)))).scalars() rows = (await s.execute(select(Source).where(Source.enabled.is_(True)))).scalars()
@@ -38,6 +42,10 @@ async def _rebuild_jobs(scheduler: AsyncIOScheduler) -> None:
logger.warning("no enabled sources; scheduler idle") logger.warning("no enabled sources; scheduler idle")
return return
for src in sources: for src in sources:
# api_push 源不抓取(由 /api/v1/ingest 被动接收),跳过调度
if src.kind.value == "api_push":
logger.debug("skip scheduling api_push source: %s", src.slug)
continue
trigger = ( trigger = (
CronTrigger.from_crontab(src.fetch_cron) CronTrigger.from_crontab(src.fetch_cron)
if src.fetch_cron if src.fetch_cron

View File

@@ -293,10 +293,14 @@ def _wrap_html(text: str) -> str:
# === 全量跑(供测试 / 手动触发) === # === 全量跑(供测试 / 手动触发) ===
async def run_once() -> None: async def run_once() -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
rows = (await session.execute(select(Source).where(Source.enabled.is_(True)))).scalars() rows = (
await session.execute(
select(Source).where(Source.enabled.is_(True), Source.kind != SourceKind.API_PUSH)
)
).scalars()
sources = list(rows) sources = list(rows)
logger.info("run_once: %d enabled sources", len(sources)) logger.info("run_once: %d enabled sources (api_push excluded)", len(sources))
tasks = [fetch_one_source(s.id) for s in sources] tasks = [fetch_one_source(s.id) for s in sources]
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)

246
docs/api-push.md Normal file
View File

@@ -0,0 +1,246 @@
# API Push 短新闻
> /api/v1/ingest — 让外部系统(微信机器人、RSS-digest 脚本、自家脚本)
> 直接 POST 中文短新闻入库,跳过抓取和翻译,只跑分类 + 双 provider 点评。
跟 RSS 抓取的对比:
| 维度 | RSS | API Push(短新闻)|
|---|---|---|
| 触发 | worker 周期拉 | 外部主动 POST |
| 内容 | 长文 + 全文抽取 | 标题 + 短文(≤5000 字)|
| 语言 | 外文 → 翻译 | 中文原生,无翻译 |
| LLM 任务 | 排版 + 分类 + 插图 + 双点评 | 分类 + 双点评(无排版、无插图)|
| 入库 kind | `rss` / `html_list` / `tg_channel` | `api_push` |
| 去重 key | `url_hash` | `content_hash`(= SHA1 文本指纹) |
---
## 调用方协议
### 鉴权
每个 source 一个独立 token(绑定 source_id),通过 `X-Ingest-Token` 头传。
```http
POST /api/v1/ingest
Content-Type: application/json
X-Ingest-Token: <per-source-token>
{
"external_id": "wx-2026-06-14-001",
"title": "",
"body": "...",
"url": "https://example.com/article/123",
"source_ref": "wechat",
"author": "",
"published_at": "2026-06-14T10:00:00Z",
"tags": ["", ""]
}
```
字段:
| 字段 | 必填 | 长度 | 说明 |
|---|---|---|---|
| `title` | ✅ | 1-200 字 | 文章标题 |
| `body` | ✅ | 1-5000 字 | 正文纯文本(保留 `\n` 换行)|
| `external_id` | 推荐 | ≤128 字 | 调用方业务 ID,幂等 key |
| `url` | ❌ | ≤2048 字 | 原始链接(可选,纯展示用)|
| `source_ref` | ❌ | ≤64 字 | 短新闻里的二级来源标识,如 `"wechat"`/`"rss-digest"` |
| `author` | ❌ | ≤255 字 | 作者 |
| `published_at` | ❌ | ISO8601 | 发布时间,缺省 = now |
| `tags` | ❌ | ≤10 个,每个 ≤32 字 | 直接写入 category,跳过 LLM 分类 |
### 响应
成功新建(201):
```json
{
"article_id": 12345,
"content_hash": "a1b2c3...",
"status": "created"
}
```
命中三层去重之一(200):
```json
{
"article_id": 12340,
"content_hash": "a1b2c3...",
"status": "duplicate",
"reason": "external_id_match",
"matched_external_id": "wx-2026-06-14-001"
}
```
`reason` 取值:
- `external_id_match` —— L1,同 source 下已有相同 `external_id`
- `content_hash_match` —— L2,任意 source 下已有相同内容指纹
- `db_unique` —— L3,并发场景下 DB UNIQUE 约束兜底
错误响应:
| 状态码 | 含义 | 触发 |
|---|---|---|
| 400 | 字段缺失/超长 | pydantic 自动校验 |
| 401 | token 无效/吊销/过期 | `X-Ingest-Token` 缺失或不匹配 |
| 403 | 源 disabled 或 kind 不匹配 | source.enabled=false 或 kind≠api_push |
| 404 | source 不存在 | token 绑定的 source_id 不在 |
| 429 | 触发限速 | 同 token 1 秒内推送 >2 篇(`INGEST_RATE_PER_SEC`)|
---
## 三层去重
```
请求进来
├─ L1:SELECT WHERE external_id=? AND source_id=?
│ → 命中 → 200 duplicate, reason=external_id_match
├─ L2:SELECT WHERE content_hash=?
│ → 命中 → 200 duplicate, reason=content_hash_match
├─ INSERT (UNIQUE 约束兜底)
│ → IntegrityError → 200 duplicate, reason=db_unique
└─ 写入成功 → 201 created
```
`content_hash` 算法:
```python
if external_id:
raw = f"ext:{external_id}"
else:
raw = f"{title.strip()}|{body[:500]}" # 仅取前 500 字符,防尾部噪声
sha1(raw) 40 字符
```
- `external_id` 存在时 → 强幂等(调用方业务 ID 不变就一定去重)
- `external_id` 缺失时 → 标题 + 前 500 字符作为兜底指纹
---
## 限速
每个 token 独立计数(Redis),默认 2 篇/秒。改 `.env``INGEST_RATE_PER_SEC`
后重启 api 容器生效。
429 响应带 `Retry-After: 1` 头。
---
## 入库后会发生什么
短新闻入库时,`translation_status / format_status / image_ai_status` 均为 `'n/a'`,
跳过翻译、排版、插图。
`enrichment_loop` 看到 `is_short_news=True` + 任意 `*_status='pending'` 时:
1. **classify**(分类):带 `tags` 入库 → 直接标 `'ok'`,跳过;无 `tags` → 调 LLM 分类
2. **commentary**(Angel 评论):调 LLM 生成
3. **commentary_meituan**(美团评论):调 LLM 生成
4. **format** / **image**:跳过
完整 lifecycle:
```
T+0s POST /api/v1/ingest → 201 created
T+~15s enrichment_loop 扫到 is_short_news=True → classify + 双 commentary
T+~30s 三项 LLM 任务完成,前端拉 /articles/{id} 即可看到分类 + 双 provider 点评
```
---
## owner 操作手册
### 生成 ingest token
1. 登录 owner 账号
2. 进入 `/admin/sources`
3. 新建源(kind=API Push)或点击现有 api_push 源的 **🔑 Token** 按钮
4. 在弹窗里填名称(如 `wechat-bot`),点 "生成"
5. **raw_token 立即复制保存** —— 关闭弹窗后只显示 hash,不再显示 raw
6. 把 raw_token 配置到调用方,作为 `X-Ingest-Token`
### 撤销 token
在 Token 弹窗里点 "撤销" → 该 token 立即失效,下次推送返 401。
### 调限速
编辑 `.env``INGEST_RATE_PER_SEC=N``docker compose up -d --no-deps --force-recreate api`
(**不是 `restart`** —— `restart` 不会重新读 env;容器重建才会)
---
## 调用方示例
### curl
```bash
curl -X POST https://your-domain/api/v1/ingest \
-H "Content-Type: application/json" \
-H "X-Ingest-Token: YOUR_RAW_TOKEN" \
-d '{
"external_id": "wx-2026-06-14-001",
"title": "美联储宣布维持利率不变",
"body": "美联储在最新议息会议后宣布,将联邦基金利率目标区间维持在 4.25%-4.50%。这是连续第二次按兵不动...",
"source_ref": "wechat",
"published_at": "2026-06-14T10:00:00Z",
"tags": ["财经", "美联储"]
}'
```
### Python(httpx)
```python
import httpx
async def push_short_news(token: str, **payload):
async with httpx.AsyncClient() as client:
r = await client.post(
"https://your-domain/api/v1/ingest",
headers={"X-Ingest-Token": token},
json=payload,
timeout=10.0,
)
r.raise_for_status()
return r.json()
```
### 重试策略(应对 429)
```python
import asyncio
from httpx import HTTPStatusError
async def push_with_retry(token, **payload, max_retries=3):
for attempt in range(max_retries):
try:
return await push_short_news(token, **payload)
except HTTPStatusError as e:
if e.response.status_code == 429 and attempt < max_retries - 1:
# 429:等 Retry-After 头指定的秒数(默认 1 秒)
wait = int(e.response.headers.get("Retry-After", 1))
await asyncio.sleep(wait)
continue
raise
```
---
## 故障排查
| 现象 | 排查 | 修复 |
|---|---|---|
| 401 invalid ingest token | token 是否被撤销/过期;DB 里 `api_tokens.purpose` 是否为 `ingest` | 重新生成 |
| 413 body length invalid | body 超过 5000 字 | 截断或拆条 |
| 429 rate limit exceeded | 同 token 1 秒内推 >2 篇 | 退避 1 秒重试,或调高 `INGEST_RATE_PER_SEC` |
| 短新闻一直 `classify_status='pending'` | `enrichment_loop` 是否在跑;`/admin/health` 看 worker 状态 | 重启 worker;检查 `.env``AGNES_API_KEY` / `MEITUAN_API_KEY` |
| 短新闻在 Feed 里没出现 | 看后端日志 `docker compose logs worker \| grep ingest`;DB `SELECT * FROM articles WHERE is_short_news=true` 看是否入库 | 入库成功但 enrich 没跑就等;入库失败看 ingest 日志 |

View File

@@ -41,6 +41,10 @@ export interface ArticleListItem {
commentary_meituan_status?: string | null commentary_meituan_status?: string | null
commentary_engine?: string | null // angel / meituan / "angel,meituan" commentary_engine?: string | null // angel / meituan / "angel,meituan"
image_ai_url?: string | null 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_starred: boolean
is_read: boolean // 当前用户是否已读 is_read: boolean // 当前用户是否已读
} }
@@ -79,6 +83,8 @@ export interface ArticleDetail extends ArticleListItem {
entities?: Record<string, any> | null entities?: Record<string, any> | null
sentiment?: number | null sentiment?: number | null
duplicate_of?: number | null duplicate_of?: number | null
// === API Push 短新闻 ===
external_id?: string | null // 调用方幂等 key
} }
export interface LlmSetting { export interface LlmSetting {
@@ -209,4 +215,29 @@ export const adminApi = {
`/admin/llm/enrich/${articleId}` `/admin/llm/enrich/${articleId}`
).then((r) => r.data) ).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"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref, computed, h } from 'vue'
import { 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' } from 'naive-ui'
import { adminApi, type Source } from '@/api/articles' import { adminApi, type Source, type IngestTokenOut } from '@/api/articles'
import { h } from 'vue'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@@ -13,7 +13,7 @@ const showCreate = ref(false)
const form = ref({ const form = ref({
name: '', name: '',
slug: '', slug: '',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel', kind: 'rss' as 'rss' | 'html_list' | 'tg_channel' | 'api_push',
url: '', url: '',
region: '', region: '',
language_src: 'en', language_src: 'en',
@@ -26,8 +26,18 @@ const kindOptions = [
{ label: 'RSS / Atom', value: 'rss' }, { label: 'RSS / Atom', value: 'rss' },
{ label: 'HTML 列表', value: 'html_list' }, { label: 'HTML 列表', value: 'html_list' },
{ label: 'Telegram', value: 'tg_channel' }, { 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() { async function load() {
sources.value = await adminApi.listSources() sources.value = await adminApi.listSources()
} }
@@ -58,13 +68,21 @@ async function refresh(s: Source) {
} }
async function create() { async function create() {
if (!form.value.name || !form.value.slug || !form.value.url) { if (!form.value.name || !form.value.slug) {
message.error('请填写名称 / slug / url') message.error('请填写名称 / slug')
return
}
// api_push 类型的 url 不是必填(只是合成占位);其他类型必填
if (form.value.kind !== 'api_push' && !form.value.url) {
message.error('请填写 URL')
return return
} }
try { try {
await adminApi.createSource(form.value) await adminApi.createSource({
message.success('已创建,等下一轮抓取') ...form.value,
url: form.value.url || `api-push://${form.value.slug}`,
})
message.success('已创建,等下一轮抓取 / 推送')
showCreate.value = false showCreate.value = false
form.value = { form.value = {
name: '', slug: '', kind: 'rss', url: '', 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 = [ const columns = [
{ title: 'ID', key: 'id', width: 50 }, { title: 'ID', key: 'id', width: 50 },
{ title: '名称', key: 'name' }, { title: '名称', key: 'name' },
{ title: 'slug', key: 'slug' }, { 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`, 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 ? '启用' : '停用'), render: (r: Source) => h(NTag, { type: r.enabled ? 'success' : 'default', size: 'small' }, () => r.enabled ? '启用' : '停用'),
}, },
{ {
title: '操作', key: 'action', width: 280, title: '操作', key: 'action', width: 360,
render: (row: Source) => h(NSpace, {}, () => [ render: (row: Source) => h(NSpace, {}, () => {
h(NButton, { size: 'small', onClick: () => refresh(row) }, () => '抓取'), const items: any[] = [
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'), h(NButton, { size: 'small', onClick: () => refresh(row), disabled: row.kind === 'api_push' }, () => '抓取'),
h(NPopconfirm, { onPositiveClick: () => del(row) }, { h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'), h(NButton, {
default: () => '确认删除?', 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="名称"><NInput v-model:value="form.name" /></NFormItem>
<NFormItem label="slug"><NInput v-model:value="form.slug" placeholder="小写字母+连字符" /></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="类型"><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.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="优先级"> <NFormItem label="优先级">
<NInputNumber v-model:value="form.priority" :min="1" :max="100" /> <NInputNumber v-model:value="form.priority" :min="1" :max="100" />
</NFormItem> </NFormItem>
<NFormItem label="抓取间隔(分)"> <NFormItem v-if="form.kind !== 'api_push'" label="抓取间隔(分)">
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" /> <NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
</NFormItem> </NFormItem>
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem> <NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
@@ -139,5 +304,70 @@ onMounted(load)
</NSpace> </NSpace>
</template> </template>
</NModal> </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> </NSpace>
</template> </template>

View File

@@ -160,6 +160,20 @@ const originalBody = computed(() => {
return '' 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() { async function rerunTranslation() {
if (!article.value) return if (!article.value) return
if (!confirm('重新翻译会消耗配额,确认?')) return if (!confirm('重新翻译会消耗配额,确认?')) return
@@ -213,10 +227,12 @@ onMounted(load)
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px"> <NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag> <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.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 }} 翻译:{{ article.translation_status }}
</NTag> </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 }} {{ article.translation_engine }}
</NTag> </NTag>
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round> <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> <NButton text @click="showTranslation = !showTranslation" round>
{{ showTranslation ? '隐藏译文' : '显示译文' }} {{ showTranslation ? '隐藏译文' : '显示译文' }}
</NButton> </NButton>
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation" round> <NButton v-if="isOwner && !isShort" type="error" ghost @click="rerunTranslation" round>
重译 重译
</NButton> </NButton>
<NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich" round> <NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich" round>
LLM 增强 LLM 增强
</NButton> </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> </NButton>
</NSpace> </NSpace>
@@ -393,8 +409,17 @@ onMounted(load)
</NText> </NText>
</NCard> </NCard>
<!-- 2) 译文(优先 LLM 排版版) --> <!-- 2) 短新闻(API Push):直接显示正文,跳过译文/原文 Tab -->
<div v-if="showTranslation" style="margin-top: 16px"> <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"> <NCard v-if="article.body_zh_formatted" class="detail-card">
<template #header> <template #header>
<span class="card-header-title">📖 文章译文</span> <span class="card-header-title">📖 文章译文</span>
@@ -422,16 +447,16 @@ onMounted(load)
</NCard> </NCard>
</div> </div>
<!-- AI 插图 --> <!-- AI 插图(长新闻) -->
<NCard v-if="article.image_ai_url" class="detail-card" style="margin-top: 16px"> <NCard v-if="!isShort && article.image_ai_url" class="detail-card" style="margin-top: 16px">
<template #header> <template #header>
<span class="card-header-title">🎨 AI 插图</span> <span class="card-header-title">🎨 AI 插图</span>
</template> </template>
<NImage :src="article.image_ai_url" object-fit="cover" class="article-image" /> <NImage :src="article.image_ai_url" object-fit="cover" class="article-image" />
</NCard> </NCard>
<!-- 3) 原文 --> <!-- 3) 原文(长新闻) -->
<div v-if="showOriginal" style="margin-top: 16px"> <div v-if="!isShort && showOriginal" style="margin-top: 16px">
<NCard class="detail-card"> <NCard class="detail-card">
<template #header> <template #header>
<span class="card-header-title">📄 文章原文</span> <span class="card-header-title">📄 文章原文</span>

View File

@@ -156,9 +156,13 @@ function commentaryState(status?: string | null, content?: string | null): Comme
return 'waiting' return 'waiting'
} }
// 正文摘要(取 body_zh_text 前 N 字;没有就 fallback 到 summary_zh) // 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
function bodyExcerpt(text?: string | null, max = 200): string { function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
if (!text) return '' 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() const trimmed = text.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
} }
@@ -209,7 +213,10 @@ onMounted(async () => {
v-for="a in items" v-for="a in items"
:key="a.id" :key="a.id"
class="article-card" class="article-card"
:class="{ 'article-card-read': a.is_read }" :class="{
'article-card-read': a.is_read,
'short-card': a.is_short_news,
}"
hoverable hoverable
@click="open(a)" @click="open(a)"
> >
@@ -235,6 +242,17 @@ onMounted(async () => {
> >
{{ c }} {{ c }}
</NTag> </NTag>
<!-- 短新闻(API Push)角标:固定显示 -->
<NTag
v-if="a.is_short_news"
size="tiny"
type="info"
:bordered="false"
round
class="feed-short-tag"
>
📰 短讯
</NTag>
<!-- 已读/未读小标签 --> <!-- 已读/未读小标签 -->
<NTag <NTag
v-if="a.is_read" v-if="a.is_read"
@@ -273,9 +291,9 @@ onMounted(async () => {
{{ a.title }} {{ a.title }}
</div> </div>
<!-- AI 插图(若有) --> <!-- AI 插图(若有;短新闻不显示) -->
<img <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 || ''" :src="a.image_ai_url || a.image_url || ''"
style=" style="
display: block; display: block;
@@ -290,9 +308,14 @@ onMounted(async () => {
loading="lazy" loading="lazy"
/> />
<!-- 翻译后正文摘要 --> <!--
正文摘要:
- 长新闻:body_zh_text 截前 200 (去多余空白)
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
-->
<div <div
v-if="a.body_zh_text || a.summary_zh" v-if="a.body_zh_text || a.summary_zh"
:class="{ 'short-body': a.is_short_news }"
style=" style="
margin-top: 4px; margin-top: 4px;
color: var(--color-letter); color: var(--color-letter);
@@ -300,7 +323,11 @@ onMounted(async () => {
line-height: 1.75; 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> </div>
<!-- 评论钩子( provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) --> <!-- 评论钩子( provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
@@ -590,6 +617,36 @@ onMounted(async () => {
color: var(--color-text-faint); 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 { .feed-actions {
display: flex; display: flex;