♻️ refactor(config): 实现配置安全存储与原子写

- 新增 `get_secure()` 和 `set_secure()` 方法,优先从环境变量或系统 keyring 读取敏感配置,`config.json` 中仅存储占位符
- 将 `save()` 方法改为使用临时文件 + `os.replace()` 的原子写入,防止进程中断导致配置文件损坏
- 在 `add_llm_provider()` 和 `get_active_llm()` 中集成安全配置读写,自动迁移旧版明文 API Key

♻️ refactor(analytics): 实现分析数据原子写

- 将 `_save_analytics()` 和 `_save_weights()` 方法改为使用临时文件 + `os.replace()` 的原子写入
- 确保在写入过程中进程被终止时,原始数据文件保持完整

♻️ refactor(main): 增强发布功能健壮性与代码模块化

- 在 `publish_to_xhs()` 中增加发布前输入校验【标题长度、图片数量、文件存在性】并在 `finally` 块中自动清理本次生成的临时图片文件
- 为全局笔记列表缓存 `_cached_proactive_entries` 和 `_cached_my_note_entries` 引入 `threading.RLock` 保护,新增 `_set_cache()` 和 `_get_cache()` 线程安全操作函数
- 将「内容创作」Tab 的 UI 构建代码拆分至 `ui/tab_create.py` 模块,主文件通过 `build_tab()` 函数调用并组装
- 将 Gradio 应用的 CSS 和主题配置提取为模块级变量,提升可维护性

📦 build(deps): 新增 keyring 依赖

- 在 `requirements.txt` 中添加 `keyring>=24.0.0` 以支持系统凭证管理

📝 docs(openspec): 新增生产就绪审计文档

- 在 `openspec/changes/archive/2026-02-24-production-readiness-audit/` 下新增设计文档、提案、任务清单及各功能规格说明
- 将核心功能规格同步至 `openspec/specs/` 目录
This commit is contained in:
2026-02-24 21:53:36 +08:00
parent 4cde2f7c67
commit d88b4e9a3b
23 changed files with 836 additions and 190 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-24
@@ -0,0 +1,85 @@
## Context
当前项目是一个单机运行的小红书 AI 爆文自动化工具,使用 Gradio 作为 UI 框架,SQLite 为发布队列持久化,JSON 文件存储配置与分析数据。随着功能迭代至 V2.0,主文件 `main.py` 已超过 4400 行,并积累了多处生产风险:API Key 以明文写入 `config.json`、全局列表变量被多个回调线程无锁读写、`_temp_publish/` 目录的临时图片在发布后未清理、JSON 文件采用直接覆盖写入(电量耗尽或进程中断时会写出空文件)、发布前无标题/正文/图片数量的校验。
## Goals / Non-Goals
**Goals:**
- 消除 6 项已知的生产风险,使项目能安全、稳定地长期自动运营
- 保持所有 Gradio 回调函数签名不变(不破坏现有 UI 绑定)
- 每项改动独立可部署,不相互耦合
- 为后续功能拓展提供更清晰的代码结构基础
**Non-Goals:**
- 重写为 Web 服务或引入数据库 ORM
- 改变现有 UI 交互逻辑或视觉设计
- 实现性能优化或多账号支持
- 修改 MCP / SD / LLM 服务接口
## Decisions
### 决策 1:敏感配置存储方案选 keyring + 环境变量覆盖,放弃纯加密文件
**选项 A(选定)**: 使用 `keyring` 库将 API Key 存入系统凭证管理器(Windows Credential Manager / macOS Keychain / Linux Secret Service),`config.json` 中仅保留占位符 `"[keyring]"`;同时支持 `AUTOBOT_API_KEY_<NAME>` 环境变量在无 GUI 场景(Docker / CI)下覆盖。
**选项 B(放弃)**: 自行用 `Fernet` 加密后写入 `config.json`。缺点:密钥仍需本地存储,安全提升有限,且增加密钥管理复杂度。
**选项 C(放弃)**: 要求用户全部改用环境变量。对当前 Gradio UI 用户体验极差,用户每次重启需重新设置。
**结论**:`keyring` 方案对 Windows 单机用户最为透明,且 Docker 场景通过环境变量无缝降级。新增 `ConfigManager.get_secure(key)` / `set_secure(key, value)` 接口,内部优先读取环境变量,其次读 keyring,最后回退旧版明文(自动迁移一次)。
---
### 决策 2:全局缓存改用模块级 `threading.RLock` 保护,不引入新类
**选项 A(选定)**: 在 `main.py` 模块顶层声明一个 `_cache_lock = threading.RLock()`,所有读写 `_cached_proactive_entries` / `_cached_my_note_entries` 的函数用 `with _cache_lock:` 包裹。
**选项 B(放弃)**: 封装为 `CacheManager` 类。当前代码耦合 Gradio UI 较深,引入类会导致较大重构,收益不成比例。
**结论**:最小侵入方案,改动 5 处函数约 20 行,可在一个 PR 内完成。
---
### 决策 3:JSON 原子写使用 tempfile + os.replace
Python 标准库 `tempfile.NamedTemporaryFile` + `os.replace`(同目录)在 POSIX 和 Windows 均为原子操作(Windows Vista+ 支持)。无需引入新依赖。
适用范围:`ConfigManager.save()`、`AnalyticsService._save_analytics()`、`AnalyticsService._save_weights()`。
---
### 决策 4:临时文件清理在发布回调内同步执行
在 `publish_to_xhs()` 函数的 try/finally 块中清理 `_temp_publish/` 下以本次调用 `ai_N.jpg` 命名的文件。不使用全目录清空,以免并发发布时误删其他会话文件(Gradio 多用户虽少见,但更安全)。逐文件删除,删除失败仅打印 warning,不阻断流程。
---
### 决策 5:发布校验集中在 `publish_to_xhs()` 一处,不分散到 UI
校验逻辑写在业务函数中,而非 Gradio 的 `gr.Textbox` 校验器,保证逻辑可测试,且 UI 重构时不会遗失。
---
### 决策 6:UI 拆分采用渐进式迁移,不一次性重写
以 `ui/` 目录为目标,先提取最大的 Tab(内容创作 Tab)为 `ui/tab_create.py`,返回 `(components, callbacks)` 元组,`main.py` 调用并注册。其余 Tab 在后续迭代中逐步迁移。本次变更只迁移 `ui/tab_create.py` 作为示范,不强制完成全部拆分。
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|---------|
| `keyring` 在部分 Linux headless 环境无后端可用 | 检测到 `keyring.errors.NoKeyringError` 时降级为明文(打印警告),行为与改造前一致 |
| `os.replace` 在跨卷(不同磁盘分区)时失败 | 使用 `tempfile.mkstemp(dir=same_dir)` 确保临时文件与目标同卷 |
| 并发发布时 `ai_N.jpg` 命名冲突 | 当前 Gradio 为单进程单用户;若未来支持多用户,改用 UUID 命名 |
| UI 拆分期间双重维护 | 每次只迁移一个 Tab,迁移完成前旧代码仍有效 |
## Migration Plan
1. **无需数据迁移**:`config.json` 的旧明文 Key 在首次调用 `get_secure()` 时自动读取并写入 keyring,同时将 `config.json` 中对应字段替换为 `"[keyring]"` 占位符,单次完成。
2. **依赖安装**:`pip install keyring` 并更新 `requirements.txt`。
3. **无回滚风险**:各项改动均向后兼容,若 keyring 不可用则自动回退明文模式。
## Open Questions
- 是否需要在 UI 上增加「导出加密备份配置」功能,方便用户迁移设备?(本次范围外,记录待后续评估)
- `ui/` 拆分后是否需要引入 pytest-gradio 进行 UI 层单元测试?(本次不实施)
@@ -0,0 +1,39 @@
## Why
当前项目已具备完整的核心功能(文案生成、图片绘制、发布队列、评论管家、数据分析),但在安全性、健壮性、可维护性上存在若干生产级隐患:配置文件明文保存 API Key、全局可变状态缺乏线程锁、临时文件无清理机制、单文件超 4400 行难以维护。这些问题会在长期运营中导致数据泄露、并发竞态或内存增长,亟需在持续迭代前统一修复。
## What Changes
- **安全加固**:`config.json` 中的 API Key 等敏感字段改用操作系统 keyring / 环境变量存储,文件中只保留非敏感配置
- **线程安全**:全局笔记缓存 (`_cached_proactive_entries` / `_cached_my_note_entries`) 及 `ConfigManager` 写操作加 `threading.Lock`
- **临时文件清理**:发布完成或失败后自动清理 `_temp_publish/` 目录下的临时图片
- **原子写文件**:`analytics_service.py` 和 `config_manager.py` 的 JSON 持久化改为「写临时文件 → rename」方式,防止写中断导致数据损坏
- **发布前输入校验**:标题长度(≤20字)、正文长度、图片数量(1-18张)在提交发布前统一校验并给出明确提示
- **代码拆分**:将 `main.py` 的 Gradio UI 构建与业务逻辑分离,拆分为 `ui/` 目录下的多个 tab 模块,主文件只负责组装
## Capabilities
### New Capabilities
- `secure-config`:安全配置管理——敏感字段加密/外置存储,支持环境变量覆盖
- `thread-safe-cache`:线程安全的笔记列表缓存管理器,替换全局裸列表
- `temp-file-lifecycle`:临时发布文件的自动生命周期管理(创建→使用→清理)
- `atomic-persistence`:JSON 持久化原子写操作,防止文件损坏
- `publish-input-validation`:发布前内容合规校验(长度/图片数/必填项)
- `ui-module-split`:将 `main.py` UI 构建逻辑拆分为独立 tab 模块
### Modified Capabilities
(无现有 spec,首次建立规范)
## Impact
- **`config_manager.py`**:`save()` 方法改为原子写;新增 `get_secure()` / `set_secure()` 接口
- **`analytics_service.py`**:`_save_analytics()` / `_save_weights()` 改为原子写
- **`publish_queue.py`**:无需修改(已使用 SQLite WAL,自身较健壮)
- **`main.py`**:
- 全局缓存变量引入 `threading.Lock`
- `publish_to_xhs()` 增加校验逻辑与 temp 清理
- UI 构建代码逐步迁移至 `ui/tab_*.py`
- **`requirements.txt`**:可能新增 `keyring` 依赖
- **无破坏性 API 变更**:所有 Gradio 回调签名保持不变
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: JSON 文件写入使用原子操作
`ConfigManager.save()`、`AnalyticsService._save_analytics()` 和 `AnalyticsService._save_weights()` SHALL 使用「写临时文件 → `os.replace()` 原子重命名」的方式持久化数据。临时文件 SHALL 创建于与目标文件相同的目录(同卷),以确保 `os.replace()` 的原子性。
#### Scenario: 写入过程中进程中断不产生损坏文件
- **WHEN** JSON 写入过程中进程被强制终止
- **THEN** 目标文件保持写入前的完整状态,不出现空文件或半写入的 JSON
#### Scenario: 正常写入成功替换目标文件
- **WHEN** `ConfigManager.save()` 被调用且数据合法
- **THEN** 目标 `config.json` 被更新为最新内容,写入前存在的临时文件已被清理
#### Scenario: 临时文件与目标文件在同一目录
- **WHEN** 调用任意原子写函数
- **THEN** 临时文件的父目录与目标文件的父目录相同(通过 `tempfile.mkstemp(dir=<target_dir>)` 实现)
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: 发布前校验标题、正文和图片
`publish_to_xhs()` 函数 SHALL 在调用 MCP 发布接口前执行以下校验,任何校验失败 SHALL 立即返回包含明确说明的错误消息字符串,不发起网络请求:
| 字段 | 规则 |
|------|------|
| 标题 | 非空,长度 ≤ 20 个字符(中英文均按 1 字符计) |
| 图片数量 | 至少 1 张,至多 18 张 |
| 图片文件 | 每个路径对应的文件在磁盘上真实存在 |
#### Scenario: 标题超长时返回明确错误
- **WHEN** `publish_to_xhs()` 被调用且标题字符数超过 20
- **THEN** 返回包含「标题超长」提示及当前字符数的错误字符串,不调用 MCP 接口
#### Scenario: 无图片时返回明确错误
- **WHEN** `publish_to_xhs()` 被调用且最终收集到的图片路径列表为空
- **THEN** 返回「至少需要 1 张图片」的错误字符串
#### Scenario: 图片数量超限时返回明确错误
- **WHEN** 最终图片路径列表超过 18 张
- **THEN** 返回包含当前图片数和限制数的错误字符串,不发起发布请求
#### Scenario: 图片文件不存在时返回明确错误
- **WHEN** 图片路径列表中有路径对应的文件不存在于磁盘
- **THEN** 返回包含该文件路径的「文件不存在」错误字符串
#### Scenario: 校验通过后正常发布
- **WHEN** 所有字段均通过校验
- **THEN** 正常调用 MCP 接口发布,行为与改造前一致
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 敏感字段通过系统 keyring 或环境变量存储
ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_<KEY>` > 系统 keyring > `config.json` 明文(兼容旧版,读取后自动迁移)。`config.json` 中已迁移的字段值替换为占位符字符串 `"[keyring]"`。
#### Scenario: 首次读取明文 API Key 时自动迁移
- **WHEN** 调用 `get_secure("api_key")` 且 `config.json` 中该字段为普通字符串(非占位符)
- **THEN** 系统将该值写入 keyring,将 `config.json` 中该字段更新为 `"[keyring]"`,并返回原始值
#### Scenario: keyring 不可用时降级为明文
- **WHEN** 系统 keyring 后端不可用(抛出 `NoKeyringError`)且无对应环境变量
- **THEN** `get_secure()` 直接读取 `config.json` 中的明文值,并打印 WARNING 日志,不抛出异常
#### Scenario: 环境变量优先于 keyring
- **WHEN** 环境变量 `AUTOBOT_API_KEY` 已设置且 keyring 中也有相同 key 的值
- **THEN** `get_secure("api_key")` 返回环境变量的值
#### Scenario: 通过 UI 设置新的 API Key
- **WHEN** 用户在 Gradio UI 中输入新的 API Key 并保存
- **THEN** 调用 `set_secure("api_key", value)` 将值存入 keyring(或在降级模式下写入 `config.json`),UI 不显示原始值
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: 发布完成后清理本次生成的临时图片文件
`publish_to_xhs()` 函数 SHALL 在发布流程(无论成功或失败)结束后,删除本次调用写入 `_temp_publish/` 目录的 AI 生成临时图片文件。删除失败 SHALL 仅记录 WARNING 日志,不影响返回结果。
#### Scenario: 发布成功后临时文件被清理
- **WHEN** `publish_to_xhs()` 发布成功并返回成功消息
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
#### Scenario: 发布失败后临时文件同样被清理
- **WHEN** `publish_to_xhs()` 因网络错误等原因抛出异常或返回失败消息
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
#### Scenario: 清理失败不阻断主流程
- **WHEN** 临时文件删除时抛出 `OSError`(如文件已被其他进程占用)
- **THEN** 系统记录 WARNING 日志并继续,`publish_to_xhs()` 的返回值不受影响
### Requirement: 不清理其他会话的临时文件
发布清理逻辑 SHALL 只删除本次调用写入的文件(通过追踪写入路径列表),不执行 `_temp_publish/` 目录的全量清空。
#### Scenario: 并发发布场景下不误删其他文件
- **WHEN** 两次发布准备流程同时写入 `_temp_publish/` 目录
- **THEN** 每次清理只删除自己写入的文件,不影响另一次的文件
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 笔记列表缓存读写受互斥锁保护
模块级全局变量 `_cached_proactive_entries` 和 `_cached_my_note_entries` 的所有读写操作 SHALL 在 `threading.RLock` 的保护下执行,以防止 Gradio 回调并发调用时产生数据竞态。
#### Scenario: 并发刷新时缓存更新不出现竞态
- **WHEN** 两个 Gradio 回调线程同时调用 `_fetch_and_cache()`
- **THEN** 最终缓存状态为其中一次完整写入的结果,不出现部分更新或列表长度异常
#### Scenario: 读取缓存时不被并发写入中断
- **WHEN** `_pick_from_cache()` 正在迭代缓存列表时,另一线程触发缓存更新
- **THEN** 迭代过程不抛出 `RuntimeError: list changed size during iteration`
### Requirement: 缓存操作封装为受保护的工具函数
模块 SHALL 提供 `_set_cache(name, entries)` 和 `_get_cache(name)` 两个内部函数,统一管理缓存读写,不在业务函数中直接赋值全局列表。
#### Scenario: 缓存写入通过统一接口
- **WHEN** 任意函数需要更新笔记缓存
- **THEN** 必须调用 `_set_cache(name, entries)` 而非直接赋值 `_cached_*` 变量
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 内容创作 Tab 的 UI 代码迁移至独立模块
`ui/tab_create.py` SHALL 包含原 `main.py` 中「内容创作 Tab」的全部 Gradio 组件定义和事件绑定,并导出 `build_tab() -> None` 函数,该函数接受一个 `gr.Blocks` 上下文,在其中构建 Tab 内容。`main.py` SHALL 通过 `from ui.tab_create import build_tab` 调用该函数,不在主文件中保留重复的组件代码。
#### Scenario: main.py 正常启动并显示内容创作 Tab
- **WHEN** 运行 `python main.py` 启动 Gradio 应用
- **THEN** 内容创作 Tab 正常显示,所有组件与迁移前功能一致
#### Scenario: tab_create 模块可独立导入
- **WHEN** 在 Python 中执行 `from ui.tab_create import build_tab`
- **THEN** 不抛出任何导入错误,`build_tab` 为可调用对象
### Requirement: ui/ 目录结构规范
`ui/` 目录 SHALL 包含 `__init__.py`,每个 Tab 模块文件命名约定为 `tab_<name>.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。
#### Scenario: 新增 Tab 模块的标准结构
- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件
- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接)
@@ -0,0 +1,55 @@
## 1. 依赖与环境准备
- [x] 1.1 在 `requirements.txt` 中添加 `keyring>=24.0.0`
- [x] 1.2 运行 `pip install keyring` 并验证在当前系统(Windows)可正常使用 `keyring.get_password` / `keyring.set_password`
## 2. 安全配置(secure-config)
- [x] 2.1 在 `config_manager.py` 中新增 `get_secure(key: str) -> str` 方法:优先读取环境变量 `AUTOBOT_<KEY.upper()>`,其次读取系统 keyring,最后回退 `config.json` 明文(自动迁移一次),捕获 `keyring.errors.NoKeyringError` 并降级
- [x] 2.2 在 `config_manager.py` 中新增 `set_secure(key: str, value: str)` 方法:写入系统 keyring(降级模式下写入 `config.json`),并将 `config.json` 对应字段更新为占位符 `"[keyring]"`
- [x] 2.3 将 `main.py` 中 LLM 提供商的 `api_key` 读写全部替换为 `cfg.get_secure()` / `cfg.set_secure()`
- [ ] 2.4 手动测试:重启应用后 `config.json` 中 `api_key` 已变为 `"[keyring]"`,LLM 连接功能正常
## 3. JSON 原子写(atomic-persistence)
- [x] 3.1 在 `config_manager.py` 的 `save()` 方法中将直接 `open(CONFIG_FILE, "w")` 改为 `tempfile.mkstemp(dir=<same_dir>)` + 写入 + `os.replace()` 原子重命名
- [x] 3.2 在 `analytics_service.py` 的 `_save_analytics()` 方法中同样改为原子写
- [x] 3.3 在 `analytics_service.py` 的 `_save_weights()` 方法中同样改为原子写
- [ ] 3.4 测试:在写入过程中(`time.sleep` 模拟)验证目标文件仍完整,临时文件被清理
## 4. 线程安全缓存(thread-safe-cache)
- [x] 4.1 在 `main.py` 顶部声明 `_cache_lock = threading.RLock()`
- [x] 4.2 新增内部函数 `_set_cache(name: str, entries: list)` 和 `_get_cache(name: str) -> list`,内部使用 `with _cache_lock:` 保护
- [x] 4.3 将 `_fetch_and_cache()` 中对 `_cached_proactive_entries` / `_cached_my_note_entries` 的直接赋值改为调用 `_set_cache()`
- [x] 4.4 将 `_pick_from_cache()` 中读取缓存改为调用 `_get_cache()`(在锁内完成列表快照拷贝)
- [x] 4.5 将 `fetch_my_notes()` 中对 `_cached_my_note_entries` 的直接赋值改为调用 `_set_cache()`
## 5. 发布前输入校验(publish-input-validation)
- [x] 5.1 在 `publish_to_xhs()` 函数内、MCP 调用前添加标题长度校验(`len(title) > 20` 返回错误)
- [x] 5.2 添加图片数量下限校验(`len(image_paths) == 0` 返回「至少需要 1 张图片」)
- [x] 5.3 添加图片数量上限校验(`len(image_paths) > 18` 返回含实际数量的错误消息)
- [x] 5.4 添加图片文件存在性校验(遍历 `image_paths`,发现不存在的文件时返回含路径的错误)
- [x] 5.5 在 Gradio UI 的发布按钮标题输入框旁添加字符计数提示(`gr.Textbox` 的 `info` 参数)
## 6. 临时文件生命周期(temp-file-lifecycle)
- [x] 6.1 在 `publish_to_xhs()` 中记录本次写入的 AI 临时图片路径到局部变量 `ai_temp_files = []`
- [x] 6.2 在函数末尾添加 `finally:` 块,遍历 `ai_temp_files` 逐一调用 `os.remove()`,捕获 `OSError` 仅记录 `logger.warning`
- [ ] 6.3 验证:发布成功后 `_temp_publish/` 目录中的 `ai_*.jpg` 文件已被删除;发布失败后同样被清理
## 7. UI 模块拆分(ui-module-split)
- [x] 7.1 创建 `ui/` 目录,添加 `ui/__init__.py`(空文件)
- [x] 7.2 创建 `ui/tab_create.py`,将 `main.py` 中「内容创作 Tab」的所有 Gradio 组件定义和 `.click()` / `.change()` 事件绑定代码迁移至该文件,导出 `build_tab(cfg, mcp_url_box, ...)` 函数
- [x] 7.3 在 `main.py` 中用 `from ui.tab_create import build_tab` + 调用替换原有内容创作 Tab 代码
- [ ] 7.4 启动应用,验证内容创作 Tab 功能与迁移前完全一致(文案生成、图片生成、发布按钮均正常)
## 8. 集成验证
- [ ] 8.1 启动应用,依次测试:LLM 连接 → 文案生成 → 图片生成 → 发布(包含校验不通过和校验通过两个场景)
- [ ] 8.2 检查 `config.json` 中无明文 API Key
- [ ] 8.3 检查 `_temp_publish/` 目录在发布后为空(或只含本次以外的文件)
- [ ] 8.4 检查 `autobot.log` 中无 ERROR 级别日志
+2 -4
View File
@@ -4,10 +4,8 @@ schema: spec-driven
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
context: |
使用中文
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
+16
View File
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: JSON 文件写入使用原子操作
`ConfigManager.save()`、`AnalyticsService._save_analytics()` 和 `AnalyticsService._save_weights()` SHALL 使用「写临时文件 → `os.replace()` 原子重命名」的方式持久化数据。临时文件 SHALL 创建于与目标文件相同的目录(同卷),以确保 `os.replace()` 的原子性。
#### Scenario: 写入过程中进程中断不产生损坏文件
- **WHEN** JSON 写入过程中进程被强制终止
- **THEN** 目标文件保持写入前的完整状态,不出现空文件或半写入的 JSON
#### Scenario: 正常写入成功替换目标文件
- **WHEN** `ConfigManager.save()` 被调用且数据合法
- **THEN** 目标 `config.json` 被更新为最新内容,写入前存在的临时文件已被清理
#### Scenario: 临时文件与目标文件在同一目录
- **WHEN** 调用任意原子写函数
- **THEN** 临时文件的父目录与目标文件的父目录相同(通过 `tempfile.mkstemp(dir=<target_dir>)` 实现)
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: 发布前校验标题、正文和图片
`publish_to_xhs()` 函数 SHALL 在调用 MCP 发布接口前执行以下校验,任何校验失败 SHALL 立即返回包含明确说明的错误消息字符串,不发起网络请求:
| 字段 | 规则 |
|------|------|
| 标题 | 非空,长度 ≤ 20 个字符(中英文均按 1 字符计) |
| 图片数量 | 至少 1 张,至多 18 张 |
| 图片文件 | 每个路径对应的文件在磁盘上真实存在 |
#### Scenario: 标题超长时返回明确错误
- **WHEN** `publish_to_xhs()` 被调用且标题字符数超过 20
- **THEN** 返回包含「标题超长」提示及当前字符数的错误字符串,不调用 MCP 接口
#### Scenario: 无图片时返回明确错误
- **WHEN** `publish_to_xhs()` 被调用且最终收集到的图片路径列表为空
- **THEN** 返回「至少需要 1 张图片」的错误字符串
#### Scenario: 图片数量超限时返回明确错误
- **WHEN** 最终图片路径列表超过 18 张
- **THEN** 返回包含当前图片数和限制数的错误字符串,不发起发布请求
#### Scenario: 图片文件不存在时返回明确错误
- **WHEN** 图片路径列表中有路径对应的文件不存在于磁盘
- **THEN** 返回包含该文件路径的「文件不存在」错误字符串
#### Scenario: 校验通过后正常发布
- **WHEN** 所有字段均通过校验
- **THEN** 正常调用 MCP 接口发布,行为与改造前一致
+20
View File
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 敏感字段通过系统 keyring 或环境变量存储
ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_<KEY>` > 系统 keyring > `config.json` 明文(兼容旧版,读取后自动迁移)。`config.json` 中已迁移的字段值替换为占位符字符串 `"[keyring]"`。
#### Scenario: 首次读取明文 API Key 时自动迁移
- **WHEN** 调用 `get_secure("api_key")` 且 `config.json` 中该字段为普通字符串(非占位符)
- **THEN** 系统将该值写入 keyring,将 `config.json` 中该字段更新为 `"[keyring]"`,并返回原始值
#### Scenario: keyring 不可用时降级为明文
- **WHEN** 系统 keyring 后端不可用(抛出 `NoKeyringError`)且无对应环境变量
- **THEN** `get_secure()` 直接读取 `config.json` 中的明文值,并打印 WARNING 日志,不抛出异常
#### Scenario: 环境变量优先于 keyring
- **WHEN** 环境变量 `AUTOBOT_API_KEY` 已设置且 keyring 中也有相同 key 的值
- **THEN** `get_secure("api_key")` 返回环境变量的值
#### Scenario: 通过 UI 设置新的 API Key
- **WHEN** 用户在 Gradio UI 中输入新的 API Key 并保存
- **THEN** 调用 `set_secure("api_key", value)` 将值存入 keyring(或在降级模式下写入 `config.json`),UI 不显示原始值
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: 发布完成后清理本次生成的临时图片文件
`publish_to_xhs()` 函数 SHALL 在发布流程(无论成功或失败)结束后,删除本次调用写入 `_temp_publish/` 目录的 AI 生成临时图片文件。删除失败 SHALL 仅记录 WARNING 日志,不影响返回结果。
#### Scenario: 发布成功后临时文件被清理
- **WHEN** `publish_to_xhs()` 发布成功并返回成功消息
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
#### Scenario: 发布失败后临时文件同样被清理
- **WHEN** `publish_to_xhs()` 因网络错误等原因抛出异常或返回失败消息
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
#### Scenario: 清理失败不阻断主流程
- **WHEN** 临时文件删除时抛出 `OSError`(如文件已被其他进程占用)
- **THEN** 系统记录 WARNING 日志并继续,`publish_to_xhs()` 的返回值不受影响
### Requirement: 不清理其他会话的临时文件
发布清理逻辑 SHALL 只删除本次调用写入的文件(通过追踪写入路径列表),不执行 `_temp_publish/` 目录的全量清空。
#### Scenario: 并发发布场景下不误删其他文件
- **WHEN** 两次发布准备流程同时写入 `_temp_publish/` 目录
- **THEN** 每次清理只删除自己写入的文件,不影响另一次的文件
+19
View File
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 笔记列表缓存读写受互斥锁保护
模块级全局变量 `_cached_proactive_entries` 和 `_cached_my_note_entries` 的所有读写操作 SHALL 在 `threading.RLock` 的保护下执行,以防止 Gradio 回调并发调用时产生数据竞态。
#### Scenario: 并发刷新时缓存更新不出现竞态
- **WHEN** 两个 Gradio 回调线程同时调用 `_fetch_and_cache()`
- **THEN** 最终缓存状态为其中一次完整写入的结果,不出现部分更新或列表长度异常
#### Scenario: 读取缓存时不被并发写入中断
- **WHEN** `_pick_from_cache()` 正在迭代缓存列表时,另一线程触发缓存更新
- **THEN** 迭代过程不抛出 `RuntimeError: list changed size during iteration`
### Requirement: 缓存操作封装为受保护的工具函数
模块 SHALL 提供 `_set_cache(name, entries)` 和 `_get_cache(name)` 两个内部函数,统一管理缓存读写,不在业务函数中直接赋值全局列表。
#### Scenario: 缓存写入通过统一接口
- **WHEN** 任意函数需要更新笔记缓存
- **THEN** 必须调用 `_set_cache(name, entries)` 而非直接赋值 `_cached_*` 变量
+19
View File
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 内容创作 Tab 的 UI 代码迁移至独立模块
`ui/tab_create.py` SHALL 包含原 `main.py` 中「内容创作 Tab」的全部 Gradio 组件定义和事件绑定,并导出 `build_tab() -> None` 函数,该函数接受一个 `gr.Blocks` 上下文,在其中构建 Tab 内容。`main.py` SHALL 通过 `from ui.tab_create import build_tab` 调用该函数,不在主文件中保留重复的组件代码。
#### Scenario: main.py 正常启动并显示内容创作 Tab
- **WHEN** 运行 `python main.py` 启动 Gradio 应用
- **THEN** 内容创作 Tab 正常显示,所有组件与迁移前功能一致
#### Scenario: tab_create 模块可独立导入
- **WHEN** 在 Python 中执行 `from ui.tab_create import build_tab`
- **THEN** 不抛出任何导入错误,`build_tab` 为可调用对象
### Requirement: ui/ 目录结构规范
`ui/` 目录 SHALL 包含 `__init__.py`,每个 Tab 模块文件命名约定为 `tab_<name>.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。
#### Scenario: 新增 Tab 模块的标准结构
- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件
- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接)