Commit Graph

89 Commits

Author SHA1 Message Date
mavis
c3aa0f0cb6 feat(search): 智能搜索建议 - 固化候选词表 (search_keywords + search_title_suggestions)
后端:
- alembic 0009: 两张固化表 + GIN prefix_keys 索引 + articles trigger
- /api/v1/search/suggestions: 混合 A(高频词 ts_stat) + B(真实标题) + 冷启动 fallback
- worker 每日 03:00 + 启动时刷新 search_keywords
- 顺便填 commit 11 TODO: articles.title_zh_tsv + GIN 索引(未来 FTS 基础)

前端:
- NInput -> NAutoComplete + debounce 250ms
- 选标题 -> 跳详情;选关键词 -> 填入 + 触发搜索
- AbortController 防 race condition

性能: prefix_keys @> ARRAY[prefix] 走 GIN 亚毫秒,100w 行也稳
2026-06-15 18:26:35 +08:00
xiaji
b674fb4b22 feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。

实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
  不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
  HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
  (.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
  q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
  highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
  (..., q)):
  - 中文标题 + 原标题
  - 正文摘要
  - Angel 评论预览
  - 美团评论预览

样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
  padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题

安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS

不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
  Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)

无后端改动。
2026-06-15 07:32:39 +08:00
xiaji
8dfa302b96 fix(search): 搜索不限时间,搜全量历史
原 list_articles 默认套 24h 时间过滤(line 47-48):
    if since is None and until is None:
        since = _default_since_24h()

但用户搜索时(q 不为空)的意图是"找到含某关键字的文章",
跟时间无关;24h 默认会让搜索**找不到 24h 之前的文章**,
即使那篇文章确实包含关键字。

修法:仅在 q 为空时套 24h 默认;有 q 时不套。
显式传 since/until 仍生效(用户要限定时间就显式传)。

不影响:
- 普通 list(无 q)仍默认 24h(Feed 首页行为不变)
- 显式传 since/until 的 list 行为不变
- 搜索响应 schema 不变(前端无改动)

测试:open web -> Feed -> 搜索 "美联储" -> 看到 24h 之前的
相关文章也能命中。
2026-06-15 07:25:36 +08:00
xiaji
02d18a1157 feat(search): 搜索覆盖标题/正文/评论
原 /api/v1/articles?q=xxx 只搜 title + body_text(外文原文),
导致:
- 搜中文译文里出现的词搜不到
- 搜双 provider 评论里的"主题词"搜不到
- 搜摘要搜不到

扩展为搜索 7 个字段(命中任一即可):
- title           (原标题,外文)
- body_text       (原文,外文)
- title_zh        (中文标题)
- body_zh_text    (中文译文,纯文本)
- summary_zh      (中文摘要)
- commentary      (Angel 评论)
- commentary_meituan (美团评论)

不去搜 body_zh_formatted(HTML 包了 <p class="diary-para">),
避免 ilike 跟 HTML 标签字符串误匹配,纯文本走 body_zh_text 即可。

性能:
- ilike '%xxx%' 走不了 B-tree 索引,PG 会 seq scan
- 当前文章量级别(几千篇)下完全 OK,延迟 < 50ms
- 未来 10w+ 文章量时改 PG full-text search(to_tsvector + GIN)
  或外部 ES;现阶段不做

无 schema/migration 改动;无前端改动(后端响应 schema 没变)。
2026-06-15 07:20:10 +08:00
xiaji
6a45f84857 perf(llm): 短新闻 enrich 跳过时不再标 'n/a',避免 enrichment_loop 反复扫
问题:
  enrichment_loop 的 in-loop 过滤 (line 616) 把 'n/a' 当成
  "未 enrich" 信号:
    if any(s in ("pending", "failed", "n/a") for s in statuses):
        todo_ids.append(a.id)

  短新闻入表时 format_status='n/a'、image_ai_status='n/a'
  (commit 1 决定),而 enrich_article 在 is_short 跳过这两任务时
  保留 'n/a'。后果:enrichment_loop 永远认为"这两任务未完成",
  每 ~3.5 分钟反复把同一篇短新闻捞进队列 enrich 一次。

  实测(commit 10 修复前 1 小时数据):
  - 9 篇短新闻 × 18 次 enrich = 131 次完全 ok
  - 131 - 9 = 122 次纯浪费(占 95%)
  - 每篇 enrich 内部 Angel + 美团两个 LLM 调用是并行的
  - 浪费配额:Agnes 免费 plan 风险、Angel 月配额、美团限速窗口

修法:
  enrich 跳过某任务时,把对应 status 标 'ok' (语义 = 该任务
  "已完成" = 不需要做),而不是 'n/a' (语义 = 任务存在但跳过)。
  改动 2 处:format 跳过 + image 跳过。
  短新闻 enrich 后,所有 *_status 都是 'ok',enrichment_loop
  看到后不再扫,只跑一次。

注释同步更新:解释 'n/a' vs 'ok' 的语义区别,提醒后续修改者
不要把 'n/a' 写回 status 字段。

存量修复(不写入 commit,运维动作):
  修复前入库存量的短新闻 format/image_ai_status 仍是 'n/a',
  deploy 后第一次循环会把它们 enrich 一遍改成 'ok'。如果你想
  立刻修,跑:
    UPDATE articles
    SET format_status='ok', image_ai_status='ok'
    WHERE is_short_news=true
      AND (format_status='n/a' OR image_ai_status='n/a');

不改:
  - enrichment_loop 的 SQL/loop 过滤条件(不动 'n/a' 状态值,
    仍有合法场景使用:meituan_client 未配置时 commentary_meituan
    会标 'n/a'。但生产环境 meituan key 已配置,该 case 不存在)
  - ingest 路由的 *_status='n/a' 初始化(那是在入库阶段,
    enrichment_loop 不会立即扫到,有 2 秒起步延迟 +
    is_short_news/body_zh_text 过滤)
  - comment_status 等其他 status 字段(短新闻也会跑,设 'ok')

性能影响(预估):
  - 修复前:每篇短新闻每小时 18 次 enrich × 1 路串行 ~18 秒/次
    = 占 enrichment_loop 单 worker 18 * 18s = 5.4 分钟/小时
  - 修复后:每篇短新闻只 enrich 1 次,约 5 秒
  - 节省:短新闻 LLM 调用约减少 95%
  - 副作用:长新闻 enrich 不再被短新闻"挤占"semaphore 资源,
    长新闻 enrich 速度会加快(从当前 ~18s 串行,可能回到 ~5s)
2026-06-14 22:03:06 +08:00
xiaji
a81b373b8b chore(tz): 时区统一为 Asia/Shanghai
仓库里有两处时区声明写成 Asia/Hong_Kong,虽然跟 Shanghai
都是 UTC+8 在 C 库时区行为上一致(无夏令时),但:
- VPS 物理位置虽然在香港,本机 /etc/localtime 已指向 Shanghai
- 配置文件/默认值/示例 env 写成 Shanghai 更准确反映项目
  实际部署位置
- /etc/timezone 文件本机之前是 Etc/UTC 跟 timedatectl 不一致
  (本次单独在服务器上同步,不入 commit — 是运维状态)

改动:
- .env.example: TZ Asia/Hong_Kong -> Asia/Shanghai
- backend/app/config.py: settings.tz 默认值 Asia/Hong_Kong
  -> Asia/Shanghai(只有日志打了一下,不影响实际行为)

不影响:
- 生产 .env 文件没动(里面有 JWT_SECRET 等真实值,不该入库;
  新机器按 .env.example 复制出来就是 Shanghai)
- docker-compose.yml 不动 — api/worker service 用 env_file: .env
  模式,TZ 已经自动注入容器(验证过 TZ=Asia/Hong_Kong 在容器内可见)
- 应用代码所有 datetime.now() 都是 naive datetime,实际行为
  不变(数据库存的是 UTC 绝对时刻,PG TIMESTAMPTZ 转换正确)
- 文档/README 不动 — line 4 写"香港 VPS"是物理位置描述,
  时区是上海,两者并存(都是 UTC+8)不矛盾
2026-06-14 21:12:36 +08:00
xiaji
7e50e88ef9 docs(readme): 同步 commit 1-7 的功能和数据模型
README 在 commit 3 时只加过 API Push 的"关键特性"和"API 概览"两段,
但漏了以下 5 处,本次统一补上:

1. 数据模型表:
   - sources.kind 加 api_push(commit 1 alembic 0008 + commit 3 worker 跳过)
   - articles 加 is_short_news / external_id / source_ref / content_hash(commit 1)
   - articles.translation_status 加 'n/a' 状态值
   - api_tokens 加 purpose + source_id(commit 1 + commit 2)
   - ER 关系: sources 1:N api_tokens(ingest token 绑定 source)
   - 新增"短新闻(API Push)特性"子节,描述 content_hash 算法
     和 url 合成策略

2. API 概览:
   - POST /admin/refresh/{source_id} 注明对 api_push 源无效
   - POST /admin/translation/rerun 注明对短新闻/中文源返 400(commit 4)
   - 新增"端到端测试"小节,引用 scripts/smoke_ingest.py(commit 5)

3. 故障排查:
   - 新增 3 个 Q:API Push 相关 / 中文 RSS 详情页 / alembic 部署顺序
   - 最后一个 Q 引用 agent memory "FastAPI + alembic 部署 SOP"(commit 部署)

4. 路线图:
   - 新增 Phase 1.6 (API Push 短新闻)  2026-06-14
   - 新增 Phase 1.7 (中文源头 RSS 优化)  2026-06-14

无代码改动,纯文档同步。
2026-06-14 21:05:32 +08:00
xiaji
55e20e923a perf(translate): translation_loop 跳过中文源头,省 TMT 配额
中文 RSS 长新闻(原文就是中文)走 TMT 中翻中,纯粹浪费
月配额(500 万字符)且产生无意义译文。前端 commit 6
已经隐藏"译文"板块;本 commit 在后端拦截,从源头不跑翻译。

改动:
- translation_loop SQL 加 WHERE lang_src IS NULL OR NOT LIKE 'zh%'
  - lang_src 为 NULL 时仍走翻译(英文 RSS 没设 language_src 的合法场景)
  - LIKE 'zh%' 覆盖 zh / zh-CN / zh-Hans / zh-TW 等区域码
- translate_article() 函数内加防御性 guard:中文源直接返
  并把 translation_status 改 'n/a',避免反复入队
  (主路径 SQL 过滤已足够,这里是兜底,应对手动 reset status 的情况)

不影响:
- 短新闻(commit 1 已是 translation_status='n/a',根本不进队列)
- 外文 RSS(走翻译)
- 历史已被错误翻译的中文长新闻:保留 translation_status='ok'
  + body_zh_text 中文(空跑产生的) — commit 6 前端已隐藏,
  不影响用户感知;回滚存量不在本 commit 范围(独立 SQL 即可,
  风险与收益需要单独评估)
- enrichment_loop(commit 1 已经能扫到中文源头的 is_short_news,
  长新闻 lang_src=zh 仍能被 enrichment 处理,排版+插图+评论都跑)

范围:仅 backend/app/workers/pipeline.py,+20/-2 行。
2026-06-14 20:57:11 +08:00
xiaji
57784588c8 fix(detail): 中文源头 RSS 文章不显示译文板块
之前 commit 3/4 只针对 is_short_news 隐藏译文,
中文 RSS 长新闻(原文就是中文)走的是 TMT 中翻中,
详情页仍显示"文章译文"和"文章原文"两个冗余板块。

新规则:isChineseSource = isShort || lang_src.startsWith('zh')
- 短新闻 -> 显示"短讯正文"卡(原有)
- 中文 RSS 长新闻 -> 显示"文章正文"卡(新增,只一张卡,无译文)
- 其他长新闻 -> 显示"文章译文" + 可切换"文章原文"(原有)

隐藏规则(对所有中文源头):
- 翻译状态/引擎 tag
- 重译按钮(commit 4 的 guard 已拒绝;前端再次隐藏)
- 原文链接按钮
- AI 插图卡片(中文源头不需配图)
- "文章原文"卡片(在"文章正文"已经显示了)

后端不拦截(中翻中不出错,只是浪费一点配额) --
影响:腾讯 TMT 月配额可能略快用完,可下次单独优化
(在 translation_loop 加 lang_src=zh 跳过)。

范围:仅 ArticleDetail.vue 一个文件,+25/-6 行。
2026-06-14 20:50:34 +08:00
xiaji
f5fcde1153 test(ingest): 端到端 smoke 脚本
scripts/smoke_ingest.py — 验证 /api/v1/ingest 完整链路,6 步:
  1) 新建 → 201 created
  2) external_id 重复 → 200 duplicate reason=external_id_match
  3) 不带 external_id 新建 → 201 created
  4) 内容指纹重复 → 200 duplicate reason=content_hash_match
  5) 错误 token → 401
  6) body 超 5000 字 → 400 / 422

用法:python scripts/smoke_ingest.py --token <RAW_TOKEN>
默认 host=207.57.129.228 port=19717(直连服务器),
生产走 caddy 时:--scheme https --host your-domain --port 443

需要 owner 在 web 端生成 ingest token(API Push 源的 🔑 Token 按钮)。

smoke 数据会留在 Feed 里(source_ref='smoke', tags 含 'smoke'),
如果想清理:DELETE FROM articles WHERE source_ref='smoke';
或用 source_ref 过滤后手动删。
2026-06-14 20:30:35 +08:00
xiaji
6ae5dfae60 fix(ingest): rerun_translation 拒绝短新闻
短新闻(API Push)是中文原生,translation_status 固定为 n/a,
不应当走翻译链路。

前端 ArticleDetail.vue 已经在按钮上加了 v-if='isOwner && !isShort'
(commit 3),web UI 上 owner 看不到重译按钮;但 curl 直接调
POST /admin/translation/rerun/{id} 仍能绕过前端触发翻译,
把 status 改 pending 并入队 translate_article。

本 commit 加后端 guard:is_short_news=True 直接返 400,
跟前端形成双保险。

防呆:
- 修法:在 art 找到后、状态字段重置前先 raise
- 影响范围:仅 rerun_translation 路由,其他 rerun 路径不动
- 不需要 schema/迁移
2026-06-14 20:27:53 +08:00
xiaji
e274246056 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 端点
2026-06-14 16:15:21 +08:00
xiaji
07534eb144 feat(ingest): API Push 短新闻接口层
- POST /api/v1/ingest:鉴权(X-Ingest-Token) + 限速(每 token 2 篇/秒,
  Redis 滑动桶,INGEST_RATE_PER_SEC 可调) + 三层去重(L1 external_id /
  L2 content_hash / L3 DB UNIQUE 兜底,均带 reason)
- 写入字段:is_short_news=True、translation/format/image_ai_status='n/a'、
  classify_status=(有 tags?'ok':'pending')、commentary_{angel,meituan}_status='pending'、
  body_zh_text=body_text(走统一路径,前端/prompt 不用改)
- services/fetchers/api_push.py:compute_content_hash + synthesize_url +
  normalize_published_at + build_initial_status 纯函数
- schemas/ingest.py:IngestPayload(title 1-200/body 1-5000/tags 去重去空) +
  IngestResponse(article_id/content_hash/status/reason/matched_external_id)
- admin.py:POST/GET/DELETE /admin/sources/{id}/ingest-tokens — owner 生成
  (raw_token 仅一次性返回)、列出、撤销
- schemas/article.py:ArticleListItem 加 is_short_news/source_ref;
  ArticleDetail 加 is_short_news/source_ref/external_id
- main.py:挂 ingest router;config.py + .env.example:ingest_rate_per_sec 默认 2

短新闻由 commit 1 enrichment_loop 自动接管 classify + 双 provider commentary,
跳过 format/image。
2026-06-14 16:04:45 +08:00
xiaji
3091f291b2 feat(ingest): API Push 短新闻数据层
- alembic 0008:articles 加 is_short_news/external_id/source_ref/content_hash
  (UNIQUE);sources.kind 加 'api_push';api_tokens 加 purpose + source_id
- SourceKind.API_PUSH enum;Article/ApiToken model 加新字段
- enrichment_article 短新闻跳过 format/image;
  enrichment_loop SQL 加 is_short_news 路径(并入'可 enrich' 条件)
- 入库侧由 commit 2(ingest 接口)负责:写 body_zh_text=body_text,
  format/image/commentary_meituan_status='n/a',
  classify/commentary_status='pending'(带 tags 时 classify='ok')

无迁移爆炸半径:articles.url 保持 NOT NULL,短新闻合成 api-push:// 占位
2026-06-14 15:51:22 +08:00
xiaji
f690f1f108 fix(fe): mark-read btn 到 Feed 卡片右下角新样式 + hide_read 模式卡片滑出动画 2026-06-14 09:35:57 +08:00
xiaji
7de018676a fix(fe): 3 个 TS 错
1. articles.ts: ArticleDetail extends ArticleListItem 重复定义 is_read
   删了(继承父类的 boolean)
2. Feed.vue: import readsApi + useMessage
3. Feed.vue: 加 const message = useMessage()
2026-06-13 21:18:06 +08:00
xiaji
7057992136 fix(alembic 0007): 改用 sa.Column 内嵌 ForeignKey 形式,避免列数不匹配
之前用独立 sa.ForeignKeyConstraint 报 ArgumentError,
改用 sa.Column(..., sa.ForeignKey(...), ...) 形式(
跟 model 里的 mapped_column 形式对齐)。
2026-06-13 21:15:22 +08:00
xiaji
4ca05b8b7d fix: 修两个 bug
1. ArticleRead.user_id 改 Integer(users.id 是 Integer,不是 BigInteger)
   alembic 0007 同样改 Integer
2. ArticleDetail.vue toggleRead 重复 catch 块导致 build 失败
   (edit 时新加的 catch 跟 toggleStar 残留的 catch 撞了)
2026-06-13 21:10:22 +08:00
xiaji
6c71ab2e79 feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。

设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段

前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
    顶部加 '隐藏已读' 开关,默认 ON
    每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
    已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
xiaji
8b3c7caf87 chore: 去掉 active_ip 的临时 debug log(已通过限流验证)
smoke_limit2 验证成功(临时 kick 真实 IP + 30 假 IP → 真实 IP 登录返 429,
限流逻辑按设计工作)。
清理掉 [CHECK] [TOUCH_DEP] stderr 调试 log,恢复代码干净。
2026-06-13 20:29:08 +08:00
xiaji
d1bf5ea2df fix(auth): Caddy 显式写入 X-Forwarded-For + uvicorn 信任 docker 网络
根因(在 smoke test 中定位):
- Caddy reverse_proxy 默认会覆盖客户端的 X-Forwarded-For 头,
  写入它自己识别的 client IP(防伪造)
- uvicorn --forwarded-allow-ips 默认只信任 127.0.0.1,
  docker 网络 172.18.0.x 不在白名单
- 结果: api 端读 X-Forwarded-For 时,看到的是 Caddy 替换后的值
  (Caddy 识别的真实 client IP),不是客户端伪造的值 — 这其实是正确的!
  但 uvicorn 不会用这个值更新 client scope

修法:
- Caddyfile: header_up X-Forwarded-For {remote_host}
  显式让 Caddy 把自己识别的 client IP 写入 X-Forwarded-For
- docker-compose api command: 加 --forwarded-allow-ips 172.18.0.0/16
  信任 docker 网络(让 uvicorn 采用 X-Forwarded-For 的值)
- api 端 get_client_ip 不变,读 X-Forwarded-For 拿真实 client IP

效果: X-Forwarded-For 在代理链中始终代表真实 client IP,
不再被任何中间件覆盖或丢弃
2026-06-13 19:47:11 +08:00
xiaji
0d5f29fd37 fix(active_ip): 修 debug log 引用未定义变量导致 500
bug: 之前 smoke 加 print 时把 print 放到了 'limit = ...' 之前,
Python 看到函数内有 'limit =' 就把整个 limit 视为 local,
print 在赋值前引用 → UnboundLocalError → 整个 login 返 500。

修: 把 print 挪到 limit 赋值之后。
2026-06-13 19:16:58 +08:00
xiaji
7c5aff6345 debug: 临时加 stderr log 看 IP 提取 + touch_ip 调用 2026-06-13 19:12:23 +08:00
xiaji
b500613d22 feat(auth): 限制同时在线 IP 数 (默认 30, 第 31 拒绝)
背景: 防 token 泄漏被滥用 + 限共享账号人数。

- 新增 app/services/active_ip.py:
    Redis ZSET 'active_ips' 存 IP,score=last_seen_unix
    登录/refresh 时 check_or_register_login_ip():
        IP 已在 set → 刷新 score,放行(老用户重连)
        IP 不在 set + ZCARD < 30 → 加入,放行
        IP 不在 set + ZCARD >= 30 → raise 429
    每个已认证请求 _resolve_user() 调 touch_ip_dependency()
        滑动 TTL,30 天没活动自动从 set 剔除
- get_client_ip() 取真实 IP,优先级 X-Forwarded-For > X-Real-IP > client.host
    trust_x_forwarded_for 默认 True(生产 Caddy/Nginx 后面)
- config 加 3 个开关:
    site_max_active_ips: int = 30
    site_active_ip_idle_days: int = 30
    trust_x_forwarded_for: bool = True
- admin.py 加 3 个端点:
    GET  /admin/active-ips       — 看当前活跃 IP 列表 + last_seen
    POST /admin/active-ips/kick  — 强制踢出指定 IP(body={ip})
    DELETE /admin/active-ips/{ip}— 简写踢出
- 注: refresh 也算 IP 占用(拿到 access token 就能用)
    但已存在的 IP 直接放行,不会踢自己
2026-06-13 18:22:40 +08:00
xiaji
0df6e79e56 style(ui): 移除 ok 状态的'已生成'绿色徽章
- 已生成的评论不再显示徽章,回归简洁(只剩标题和内容)
- 失败/生成中徽章保留(红/灰,辨识度需要)
- .commentary-badge-ok 样式移除
2026-06-12 23:48:52 +08:00
xiaji
ddf2bc98e0 style(ui): 评论三态视觉化 — 等待/失败卡片增强辨识度
旧版问题:等待中卡片视觉太低调(灰小斜体一行),失败也不显眼,
用户截图反馈:看不出状态,无法判断是 bug 还是没跑。

新版:
- 等待中: 浅蓝底 + 1.5px 虚线边框 + 旋转 spinner + '生成中' 徽章
- 失败: 浅红底 + 1.5px 实线红边框 + '失败' 徽章(详情页显示原因)
- 成功: 保留原木色左边框样式
- 加 commentary-badge(带 spinner / 状态文字) 替代原 NTag,辨识度更高
- 列表/详情 min-height 60px,避免占位卡片太矮导致视觉跳
- 失败时详情页显示 commentary_meituan_error(给 owner 排查用)
2026-06-12 23:37:48 +08:00
xiaji
66e57c6e07 feat(ui): 评论三态显式 — 有内容 / 等待中(灰斜体) / 失败(红)
- 之前: 只有 commentary 内容才显示 v-if 块,否则空白
- 现在: 评论卡永远显示,三态语义明确:
    ok + 有内容 → 评论文本(正常)
    pending / n/a / null → 等待中(灰斜体 + 提示 enrichment_loop 会跑)
    failed → 评论生成失败(红斜体 + 失败原因)
- 列表 Feed + 详情 ArticleDetail 同步改造
- 加 commentaryState() 辅助函数:status+content → 'ok'|'waiting'|'failed'
2026-06-12 23:24:30 +08:00
xiaji
16536fe3a0 feat(meituan): 政治类文章拦截 + 写'无可奉告' + Angel 并发 3→1
- llm_settings 加 meituan_blocked_topics / blocked_keywords / no_comment_text
- alembic 0006 迁移,默认 topics=[时政/国际/军事/政治/战争/冲突/制裁/选举], 默认文案='无可奉告'
- enrichment._is_meituan_blocked 预检:category 命中 topic 或 关键词 → 直接写'无可奉告',不调美团 API
- 命中后 commentary_meituan_model='policy-block' 标识非真实生成
- enrichment_loop Semaphore(3)→(1),Agnes 免费 plan 不再 429
- 前端 AdminLlmSettings 美团卡片加 3 字段 UI(主题/关键词/固定文案)
2026-06-12 22:44:00 +08:00
xiaji
aaf728f3f4 feat(admin): Angel(Agnes) provider 凭据 DB 化 + 安全 key_set 字段
- llm_settings.agnes_api_key           TEXT   (DB key 优先,.env 兜底)
- llm_settings.agnes_base_url_override VARCHAR (留空 = 用 .env)
- alembic 0005_agnes_key 迁移
- LlmSettingOut.agnes_api_key_set (bool) 替代直接回传 key
- LlmSettingUpdate 加 agnes_api_key / agnes_base_url_override(可空可清空)
- providers.get_angel_client 改用 DB key 优先
- enrichment.py 改为 get_angel_client() 工厂调用(热改 key 不需重启)
- /admin/llm/settings/test 走 get_angel_client(测的是 DB 里的 key)
- 前端 AdminLlmSettings 在'总开关 + 模型'卡里加 Angel api_key 输入框 +
  base_url 覆盖 + 已配置/未配置指示灯 + 清空按钮
- 顶部'测连接'按钮复用(测的就是 Angel)
2026-06-12 20:43:54 +08:00
xiaji
785b63cfed fix(ci): docker-compose 加 alembic/alembic.ini 挂载,让容器看到新迁移 2026-06-12 19:08:40 +08:00
xiaji
bc36a1fc38 feat(commentary): 双 provider 评论 — Angel(Agnes) + 美团大模型(LongCat)
- 新增 articles.commentary_meituan{_status,_model,_error} 4 列 + commentary_engine
- LlmSetting 加 meituan_api_key/base_url/chat_model/interval_sec/enabled/commentary_prompt
- 新 app/services/llm/providers.py 工厂,支持多 provider 客户端
- enrichment 流程改为 commentary_angel + commentary_meituan 并行(asyncio.gather),
  任一 provider 失败不影响另一个
- enrichment_loop 状态判定:任一 provider 状态不是 ok 都视为待 enrich
- alembic 0004_dual_commentary 迁移
- 前端 Feed 卡片 + ArticleDetail 详情页各加一条'美团评论'卡
- AdminLlmSettings 加美团 provider 配置卡(独立 api_key 编辑器,不回显明文)
- LlmSettingOut.meituan_api_key_set (bool) 替代直接回传 key
- 默认 URL https://api.longcat.chat/openai/v1 / 默认模型 LongCat-2.0-Preview
2026-06-12 19:00:00 +08:00
Mavis
3ab6e4c7d0 fix(detail): 原始译文/原文标签字号 16→19px,加段落切分+行高 1.95
问题:'文章译文(原始)' 和 '文章原文' 标签下,内容是 body_zh_text / body_text 一坨没分段,
走 .article-body-fallback 16px,排版难看。

修复:
1) .article-body-fallback 字号 16→19,行高 1.85→1.95,加 max-width 720px 居中 + 0.02em letter-spacing
2) 加 splitIntoParagraphs: 已经是 HTML 标签的不动;否则按 \n 切;一坨纯文字按 [。!?!?] 切
3) 段首 text-indent 2em(中文书报排版)
4) translationBody / originalBody computed 替代内联三元

LLM 排版过的 (body_zh_formatted) 已经 <p> 段标签,不受影响。
2026-06-11 23:54:32 +08:00
Mavis
e4733ab495 style(ai): 插图默认尺寸 768x512 → 512x384(更省 CDN 流量)
512x384 是 4:3 经典比例,后端 DEFAULT_IMAGE_SIZE 常量值。
老文章(已 enrich)的 image_ai_url 仍是 768x512 不会自动变;
新 enrich 走 512x384。Setting.image_size 字段保留(用户在 UI 改生效),
但默认行为不依赖它,避免被改大。
2026-06-11 23:25:05 +08:00
Mavis
e96e1cf1ff style(article): 正文 p 字号 17→19px,行高 1.7→1.75(阅读更舒服)
- 后端 enrichment.ARTICLE_BODY_FONT_SIZE: 17 → 19(新写库的 LLM 排版版)
- 前端 style.css .article-body / .diary-para: 17 → 19,line-height 1.7 → 1.75
- 768px 媒体查询: 16 → 18,line-height 1.8 → 1.85
- 480px 媒体查询: 15.5 → 17

老文章也立即变大(前端 .article-body 兜底);新排版文章用新 inline style。
后端要等下一次 enrich 才落新行(原 inline style 还是 17px),
如果想老排版版立即也变,在 /admin/llm 手动 trigger 几篇 enrich 即可。
2026-06-11 22:53:38 +08:00
Mavis
847af6c104 docs(readme): 重写'单篇文章的一生'数据流章节
旧版只画了一个简单流程图,信息过时(只提腾讯 TMT + Agnes,没提 spark/zhipu/normalize)。

新章节:
- ASCII 流程图:APScheduler 启动 3 个常驻 loop(translation_loop / enrichment_loop),
  全部经 articles 表 → /api/v1/articles/{id} → 前端
- T+0s~T+任意 时间线:每个阶段约几秒,清晰标出 spark/zhipu/tencent 引擎选择点
- 字段生命周期表:7 阶段 × 7 字段(翻译/4 LLM 状态),列 NULL/n/a/ok 真实状态
- 翻译引擎优先级表:spark → zhipu → tencent TMT → tencent_maas → agnes → local,
  6 个引擎 + 配置/默认 model/配额/失败处理
- LLM 智能增强流程图:4 任务(classify/format/image/commentary)独立 try/except
  + 限速(LlmClient 内部 sem + enrichment_loop 外层 Semaphore(3))
- 故障模式速查表:6 个常见 case(pending 停滞 / 单项 failed / 401 / worker loop 没起
  / RSS 抓不到),给排查命令和修复方向

目的:让看 README 的人能一眼看出当前 6 翻译引擎链路 + 4 LLM 任务是不是真在工作。
便于在交接 / 出问题时,新人按文档直接对线。
2026-06-11 22:09:01 +08:00
Mavis
24478604b1 bfddfdfdfewe 2026-06-11 17:24:46 +08:00
Mavis
fd7817b881 fix(translate): 拦截引擎错误 marker + pipeline 严格 status 判定,避免 TMT AuthFailure 伪装 ok 2026-06-11 10:01:19 +08:00
Mavis
6293f82a3a chore(translate): 降频 2秒/次 + 改 spark 为 wss WebSocket 鉴权(智谱/zhipu=第一) 2026-06-11 09:34:01 +08:00
Mavis
4b8d776aac feat(scripts): 添加 retranslate_history 脚本,支持软/硬重译 + dry-run + 按源/数量过滤 2026-06-11 09:28:26 +08:00
Mavis
d90c5955f5 feat(web): 手机端排版适配 — 媒体查询 + 抽屉式侧栏 + 过滤区 wrap 2026-06-11 09:28:14 +08:00
Mavis
2e0e5ea80c fix(translate): 调整优先级为 zhipu→spark(智谱第一,星火第二) 2026-06-11 09:28:03 +08:00
Mavis
3f183d14db feat(translate): service.py 接 zhipu / config 加 zhipu_* / .env.example 加 ZHIPU 配置
配 zhipu.py(zhipu 模块)一起使用:
- service.py: 新增 _zhipu_translator(),引擎选择链路 spark → zhipu → tencent → maas → agnes → local
- config.py: 新增 zhipu_api_key / zhipu_base_url / zhipu_model / zhipu_interval_sec
- .env.example: 补 ZHIPU_* 字段说明

留空 zhipu_api_key = spark 不可用时直接降级 tencent(向后兼容)
2026-06-10 23:48:14 +08:00
Mavis
b6fc1b322f feat(translate): 加智谱 GLM 作为第二序位翻译引擎(spark → zhipu → tencent)
- 新增 app/services/translation/zhipu.py: OpenAI 兼容协议客户端,
  URL = https://open.bigmodel.cn/api/paas/v4/chat/completions,
  鉴权 = Bearer <api_key>, model = glm-4-flash(默认,免费)/glm-4-air/glm-4.5 等
- service.py 引擎链路调整为: spark → zhipu → tencent(配额)→ maas → agnes → local
- 配置: 新增 ZHIPU_API_KEY / ZHIPU_BASE_URL / ZHIPU_MODEL / ZHIPU_INTERVAL_SEC
  (留空 ZHIPU_API_KEY = spark 不可用时直接降级 tencent,向后兼容)
- 实测 GLM-4-Flash 2.3s 返回 OK;GLM-4.7-Flash 当前限流 1305
2026-06-10 23:47:55 +08:00
Mavis
a8e93cf7c7 fix(translate): pipeline 写库用 service 返回的 engine(之前写死 tencent)
pipeline.translate_article 调 translation_service.translate() 后,
自己写 engine_label='tencent',忽略 service 实际返回的 engine。
效果:即使 service 内部按 spark 优先链路跑成功了,
DB 里 translation_engine 也只显示 tencent。

修法: 改用 tr_title.engine(or 默认 tencent 兜底)。

附带: deploy_pull.py 加 sys.stdout.reconfigure(encoding='utf-8'),
Windows GBK 终端下 Unicode 字符不会再 UnicodeEncodeError。
2026-06-10 23:31:03 +08:00
Mavis
b27643123e feat(translate): 加星火 Spark(Lite)作为优先翻译引擎
- 新增 app/services/translation/spark.py: OpenAI 兼容协议客户端,
  URL = https://spark-api-open.xf-yun.com/v1/chat/completions,
  鉴权 = Bearer <APIPassword>, model = lite(默认)/generalv3.5/4.0Ultra 可切换
- service.py 引擎链路调整为: spark → tencent(配额)→ maas → agnes → local。
  优先级降序: spark 配了 key 就用它,失败再走 tencent(继续吃配额,不绕过)。
  要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
- 配置: 新增 SPARK_API_PASSWORD / SPARK_BASE_URL / SPARK_MODEL / SPARK_INTERVAL_SEC
  (留空 SPARK_API_PASSWORD = 走原 tencent 主链路,向后兼容)
- 缓存白名单 / 配额计数逻辑保持原行为,只把 spark 加入允许缓存的引擎集合
2026-06-10 23:14:20 +08:00
Mavis
759eefabc3 style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。

统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):

style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一

Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round

ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif

Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round

未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
Mavis
921e674a30 feat(translate): 加 Agnes 翻译 fallback,buffer 改 0.5
腾讯 TMT 月度配额快满时(腾讯后台口径已用 2M/5M),翻译降级链:

1. tencent TMT(主,按月配额)
2. tencent MaaS u2(第二级,翻译专用,无配额)
3. agnes 通用 LLM(第三级,质量次之但够用)
4. local NLLB(最后兜底)

新增 backend/app/services/translation/agnes.py: AgnesTranslator
复用 LlmClient 做限速 + 重试,系统 prompt 强约束只输出译文,
去除 "以下是翻译" 等常见 LLM 翻译前缀。

service.py 改动:
- fallback 链 maas -> agnes -> local
- cache 接受 agnes 结果(30天)
- add_usage 只算 tencent TMT

buffer 调整: TENCENT_TMT_QUOTA_BUFFER 0.05 -> 0.5
腾讯云后台按请求字节计费,与我们 redis 字符累加口径差约 2.5x;
按腾讯后台口径 redis 累加到 1M 字符即触发降级(对应腾讯约 2.5M 字节 =50% 用量),
留足 buffer,避免月底真爆。
2026-06-10 17:44:47 +08:00
Mavis
764de4e85c fix(worker): enrichment_loop 改并发 + 加大 batch
之前每轮只跑 3 篇串行,587 篇待 enrich 队列要 4.7h 才清完。

改动:
- ENRICHMENT_BATCH_SIZE: 3 -> 8
- ENRICHMENT_INTERVAL_SEC: 5 -> 2
- 处理 todo_ids 改 asyncio.gather 并发 3 篇
- LlmClient 内部 interval_sec 限速不变,这里只加并发上限

效果:每分钟 ~7 篇 -> 587 篇约 84 分钟清完。

排查过程中还发现根因: llm_settings 表空行导致 enrichment 静默跳过,
已手动 INSERT 默认 LlmSetting(id=1, enabled=true) 触发循环。
2026-06-10 17:20:53 +08:00
Mavis
02f0260dfc docs(android): 完整方案 + logo 资源 + 启动屏
新增 docs/android/ 目录:
- README.md 总入口(快速上手 + 决策摘要 + 数据流)
- 01-architecture.md 模块划分 + 数据流 + 选型理由
- 02-api-contract.md 每个接口的请求/响应 + DTO 字段映射
- 03-build-run.md Gradle/SDK/网络安全白名单/真机调试
- 04-milestones.md 7 天里程碑 + DoD + E2E 测试场景

新增 assets/:
- logo/: 主图标 master + adaptive icon + 5 DPI launcher (方/圆)
- splash/: 启动屏 logo + 完整背景预览 + 5 DPI 资源
- android_resources/: 集成所需的 XML(adaptive icon/主题/颜色/字符串/drawable/layout)
- INTEGRATION.md 集成指南
- logo.svg + _make_logo.py 设计源

设计风格:参考用户提供的木质方块字母积木图,米色木纹底 +
深棕色字母 D,代表 'Diary',温暖私人日记感。

服务器体检:所有容器/API/DB/翻译主链路正常,TMT 本月已用 0.37%。
MaaS 备用通道上次已验证可用。
2026-06-10 14:11:43 +08:00
Mavis
81c83ced8d feat(feed): 列表展示翻译正文摘要 + 页码分页
首页 Feed.vue 改造:
- 卡片在中文标题下直接展示 body_zh_text(前 220 字)
  用户不进详情就能看到译文正文,提升阅读效率
- 配图(image_ai_url 或 image_url)也直接显示在卡片中
- 把原标题作为副标题(灰色,辅助参考)

分页从 cursor 无限滚动换成 page + page_size:
- 后端 /articles 加 page/page_size 参数,返回 total/total_pages
- 干掉 _encode_cursor/_decode_cursor
- 前端用 n-pagination,显示 1,2,3,4,5 + 快速跳转
- 筛选/搜索变化自动回到第 1 页
- 切页自动滚到顶部
2026-06-10 12:07:04 +08:00