♻️ 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:
zhoujie 2026-02-24 21:53:36 +08:00
parent 4cde2f7c67
commit d88b4e9a3b
23 changed files with 836 additions and 190 deletions

View File

@ -5,6 +5,7 @@
import json import json
import os import os
import re import re
import tempfile
import time import time
import logging import logging
import math import math
@ -67,13 +68,35 @@ class AnalyticsService:
def _save_analytics(self): def _save_analytics(self):
os.makedirs(self.workspace_dir, exist_ok=True) os.makedirs(self.workspace_dir, exist_ok=True)
with open(self.analytics_path, "w", encoding="utf-8") as f: target = self.analytics_path
target_dir = os.path.dirname(os.path.abspath(target))
fd, tmp = tempfile.mkstemp(dir=target_dir, suffix=".tmp", prefix="analytics_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(self._analytics_data, f, ensure_ascii=False, indent=2) json.dump(self._analytics_data, f, ensure_ascii=False, indent=2)
os.replace(tmp, target)
except Exception:
try:
os.remove(tmp)
except OSError:
pass
raise
def _save_weights(self): def _save_weights(self):
os.makedirs(self.workspace_dir, exist_ok=True) os.makedirs(self.workspace_dir, exist_ok=True)
with open(self.weights_path, "w", encoding="utf-8") as f: target = self.weights_path
target_dir = os.path.dirname(os.path.abspath(target))
fd, tmp = tempfile.mkstemp(dir=target_dir, suffix=".tmp", prefix="weights_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(self._weights, f, ensure_ascii=False, indent=2) json.dump(self._weights, f, ensure_ascii=False, indent=2)
os.replace(tmp, target)
except Exception:
try:
os.remove(tmp)
except OSError:
pass
raise
# ========== 数据采集 ========== # ========== 数据采集 ==========

View File

@ -4,10 +4,15 @@
""" """
import json import json
import os import os
import tempfile
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 敏感字段 keyring 存储常量
_KEYRING_PLACEHOLDER = "[keyring]"
_KEYRING_SERVICE = "autobot_xhs"
CONFIG_FILE = "config.json" CONFIG_FILE = "config.json"
OUTPUT_DIR = "xhs_workspace" OUTPUT_DIR = "xhs_workspace"
@ -88,13 +93,61 @@ class ConfigManager:
return config return config
def save(self): def save(self):
"""保存配置到文件""" """原子写:临时文件 + os.replace防止写中断导致数据损坏"""
config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) or "."
try: try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f: fd, tmp_path = tempfile.mkstemp(dir=config_dir, suffix=".tmp", prefix="config_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(self._config, f, indent=4, ensure_ascii=False) json.dump(self._config, f, indent=4, ensure_ascii=False)
os.replace(tmp_path, CONFIG_FILE)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
except IOError as e: except IOError as e:
logger.error("配置保存失败: %s", e) logger.error("配置保存失败: %s", e)
def get_secure(self, key: str, default: str = "") -> str:
"""读取敏感配置,优先级: 环境变量 AUTOBOT_<KEY> > keyring > config.json 明文(自动迁移)"""
env_val = os.environ.get(f"AUTOBOT_{key.upper()}")
if env_val:
return env_val
try:
import keyring
kr_val = keyring.get_password(_KEYRING_SERVICE, key)
if kr_val is not None:
return kr_val
except Exception as e:
logger.warning("keyring 读取失败,使用明文回退: %s", e)
current = self._config.get(key, default)
if current and current != _KEYRING_PLACEHOLDER:
# 有明文值,尝试自动迁移到 keyring
try:
import keyring
keyring.set_password(_KEYRING_SERVICE, key, current)
self._config[key] = _KEYRING_PLACEHOLDER
self.save()
logger.info("已将 '%s' 自动迁移至系统 keyring", key)
except Exception as e:
logger.warning("keyring 迁移失败,继续使用明文: %s", e)
return current
return default
def set_secure(self, key: str, value: str):
"""将敏感值存入 keyring不可用时降级为明文config.json 中写入占位符"""
try:
import keyring
keyring.set_password(_KEYRING_SERVICE, key, value)
self._config[key] = _KEYRING_PLACEHOLDER
self.save()
except Exception as e:
logger.warning("keyring 不可用,明文保存 '%s': %s", key, e)
self._config[key] = value
self.save()
def get(self, key: str, default=None): def get(self, key: str, default=None):
"""获取配置项""" """获取配置项"""
return self._config.get(key, default) return self._config.get(key, default)
@ -141,14 +194,24 @@ class ConfigManager:
return [p["name"] for p in self.get_llm_providers()] return [p["name"] for p in self.get_llm_providers()]
def get_active_llm(self) -> dict | None: def get_active_llm(self) -> dict | None:
"""获取当前激活的 LLM 提供商配置""" """获取当前激活的 LLM 提供商配置(自动解析 keyring 中的 api_key"""
active_name = self._config.get("active_llm", "") active_name = self._config.get("active_llm", "")
for p in self.get_llm_providers():
if p["name"] == active_name:
return p
# 没找到就返回第一个
providers = self.get_llm_providers() providers = self.get_llm_providers()
return providers[0] if providers else None provider = None
for p in providers:
if p["name"] == active_name:
provider = p
break
if provider is None:
provider = providers[0] if providers else None
if provider is None:
return None
# 解析 api_key占位符时从 keyring 读取真实值
api_key = provider.get("api_key", "")
if api_key == _KEYRING_PLACEHOLDER:
secure_key = f"provider_{provider['name']}_api_key"
api_key = self.get_secure(secure_key, "")
return {**provider, "api_key": api_key}
def add_llm_provider(self, name: str, api_key: str, base_url: str) -> str: def add_llm_provider(self, name: str, api_key: str, base_url: str) -> str:
"""添加一个 LLM 提供商,返回状态消息""" """添加一个 LLM 提供商,返回状态消息"""
@ -161,9 +224,18 @@ class ConfigManager:
for p in providers: for p in providers:
if p["name"] == name: if p["name"] == name:
return f"❌ 名称「{name}」已存在,请换一个" return f"❌ 名称「{name}」已存在,请换一个"
# 安全存储 api_key
stored_key = api_key.strip()
secure_key = f"provider_{name}_api_key"
try:
import keyring
keyring.set_password(_KEYRING_SERVICE, secure_key, stored_key)
stored_key = _KEYRING_PLACEHOLDER
except Exception as e:
logger.warning("keyring 不可用api_key 明文存储: %s", e)
providers.append({ providers.append({
"name": name, "name": name,
"api_key": api_key.strip(), "api_key": stored_key,
"base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"), "base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"),
}) })
self._config["llm_providers"] = providers self._config["llm_providers"] = providers

226
main.py
View File

@ -22,6 +22,7 @@ from llm_service import LLMService
from sd_service import SDService, DEFAULT_NEGATIVE, FACE_IMAGE_PATH, SD_PRESET_NAMES, get_sd_preset, get_model_profile, get_model_profile_info, detect_model_profile, SD_MODEL_PROFILES from sd_service import SDService, DEFAULT_NEGATIVE, FACE_IMAGE_PATH, SD_PRESET_NAMES, get_sd_preset, get_model_profile, get_model_profile_info, detect_model_profile, SD_MODEL_PROFILES
from mcp_client import MCPClient, get_mcp_client from mcp_client import MCPClient, get_mcp_client
from analytics_service import AnalyticsService from analytics_service import AnalyticsService
from ui.tab_create import build_tab
# ================= matplotlib 中文字体配置 ================= # ================= matplotlib 中文字体配置 =================
_font_candidates = ["Microsoft YaHei", "SimHei", "PingFang SC", "WenQuanYi Micro Hei"] _font_candidates = ["Microsoft YaHei", "SimHei", "PingFang SC", "WenQuanYi Micro Hei"]
@ -421,12 +422,17 @@ def one_click_export(title, content, images):
def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, schedule_time): def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, schedule_time):
"""通过 MCP 发布到小红书""" """通过 MCP 发布到小红书(含输入校验和临时文件自动清理)"""
# === 发布前校验 ===
if not title: if not title:
return "❌ 缺少标题" return "❌ 缺少标题"
if len(title) > 20:
return f"❌ 标题超长:当前 {len(title)} 字,小红书限制 ≤20 字,请精简后再发布"
client = get_mcp_client(mcp_url) client = get_mcp_client(mcp_url)
ai_temp_files: list = [] # 追踪本次写入的临时文件,用于 finally 清理
try:
# 收集图片路径 # 收集图片路径
image_paths = [] image_paths = []
@ -441,17 +447,23 @@ def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, sche
img = img.convert("RGB") img = img.convert("RGB")
img.save(path, format="JPEG", quality=95) img.save(path, format="JPEG", quality=95)
image_paths.append(path) image_paths.append(path)
ai_temp_files.append(path) # 登记临时文件
# 添加本地上传的图片 # 添加本地上传的图片
if local_images: if local_images:
for img_file in local_images: for img_file in local_images:
# Gradio File 组件返回的是 NamedString 或 tempfile path
img_path = img_file.name if hasattr(img_file, 'name') else str(img_file) img_path = img_file.name if hasattr(img_file, 'name') else str(img_file)
if os.path.exists(img_path): if os.path.exists(img_path):
image_paths.append(os.path.abspath(img_path)) image_paths.append(os.path.abspath(img_path))
# === 图片校验 ===
if not image_paths: if not image_paths:
return "❌ 至少需要 1 张图片才能发布" return "❌ 至少需要 1 张图片才能发布"
if len(image_paths) > 18:
return f"❌ 图片数量超限:当前 {len(image_paths)} 张,小红书限制 ≤18 张,请减少图片"
for p in image_paths:
if not os.path.exists(p):
return f"❌ 图片文件不存在:{p}"
# 解析标签 # 解析标签
tags = [t.strip().lstrip("#") for t in tags_str.split(",") if t.strip()] if tags_str else None tags = [t.strip().lstrip("#") for t in tags_str.split(",") if t.strip()] if tags_str else None
@ -459,7 +471,6 @@ def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, sche
# 定时发布 # 定时发布
schedule = schedule_time if schedule_time and schedule_time.strip() else None schedule = schedule_time if schedule_time and schedule_time.strip() else None
try:
result = client.publish_content( result = client.publish_content(
title=title, title=title,
content=content, content=content,
@ -473,6 +484,14 @@ def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, sche
except Exception as e: except Exception as e:
logger.error("发布失败: %s", e) logger.error("发布失败: %s", e)
return f"❌ 发布异常: {e}" return f"❌ 发布异常: {e}"
finally:
# 清理本次写入的 AI 临时图片(无论成功/失败)
for tmp_path in ai_temp_files:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except OSError as cleanup_err:
logger.warning("临时文件清理失败 %s: %s", tmp_path, cleanup_err)
# ================================================== # ==================================================
@ -560,17 +579,36 @@ def generate_from_hotspot(model, topic_from_hotspot, style, search_result, sd_mo
# Tab 3: 评论管家 # Tab 3: 评论管家
# ================================================== # ==================================================
# ---- 共用: 笔记列表缓存 ---- # ---- 共用: 笔记列表缓存(线程安全)----
# 主动评论缓存 # 主动评论缓存
_cached_proactive_entries: list[dict] = [] _cached_proactive_entries: list[dict] = []
# 我的笔记评论缓存 # 我的笔记评论缓存
_cached_my_note_entries: list[dict] = [] _cached_my_note_entries: list[dict] = []
# 缓存互斥锁,防止并发回调产生竞态
_cache_lock = threading.RLock()
def _set_cache(name: str, entries: list):
"""线程安全地更新笔记列表缓存"""
global _cached_proactive_entries, _cached_my_note_entries
with _cache_lock:
if name == "proactive":
_cached_proactive_entries = list(entries)
else:
_cached_my_note_entries = list(entries)
def _get_cache(name: str) -> list:
"""线程安全地获取笔记列表缓存快照(返回副本)"""
with _cache_lock:
if name == "proactive":
return list(_cached_proactive_entries)
return list(_cached_my_note_entries)
def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"): def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
"""通用: 获取笔记列表并缓存""" """通用: 获取笔记列表并线程安全地缓存"""
global _cached_proactive_entries, _cached_my_note_entries
try: try:
client = get_mcp_client(mcp_url) client = get_mcp_client(mcp_url)
if keyword and keyword.strip(): if keyword and keyword.strip():
@ -580,10 +618,7 @@ def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
entries = client.list_feeds_parsed() entries = client.list_feeds_parsed()
src = "首页推荐" src = "首页推荐"
if cache_name == "proactive": _set_cache(cache_name, entries)
_cached_proactive_entries = entries
else:
_cached_my_note_entries = entries
if not entries: if not entries:
return gr.update(choices=[], value=None), f"⚠️ 从{src}未找到笔记" return gr.update(choices=[], value=None), f"⚠️ 从{src}未找到笔记"
@ -599,16 +634,13 @@ def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
f"✅ 从{src}获取 {len(entries)} 条笔记", f"✅ 从{src}获取 {len(entries)} 条笔记",
) )
except Exception as e: except Exception as e:
if cache_name == "proactive": _set_cache(cache_name, [])
_cached_proactive_entries = []
else:
_cached_my_note_entries = []
return gr.update(choices=[], value=None), f"{e}" return gr.update(choices=[], value=None), f"{e}"
def _pick_from_cache(selected, cache_name="proactive"): def _pick_from_cache(selected, cache_name="proactive"):
"""通用: 从缓存中提取选中条目的 feed_id / xsec_token / title""" """通用: 从缓存中提取选中条目的 feed_id / xsec_token / title(线程安全快照)"""
cache = _cached_proactive_entries if cache_name == "proactive" else _cached_my_note_entries cache = _get_cache(cache_name)
if not selected or not cache: if not selected or not cache:
return "", "", "" return "", "", ""
try: try:
@ -699,7 +731,6 @@ def send_comment(feed_id, xsec_token, comment_content, mcp_url):
def fetch_my_notes(mcp_url): def fetch_my_notes(mcp_url):
"""通过已保存的 userId 获取我的笔记列表""" """通过已保存的 userId 获取我的笔记列表"""
global _cached_my_note_entries
my_uid = cfg.get("my_user_id", "") my_uid = cfg.get("my_user_id", "")
xsec = cfg.get("xsec_token", "") xsec = cfg.get("xsec_token", "")
if not my_uid: if not my_uid:
@ -757,7 +788,7 @@ def fetch_my_notes(mcp_url):
"type": nc.get("type", ""), "type": nc.get("type", ""),
}) })
_cached_my_note_entries = entries _set_cache("my_notes", entries)
choices = [ choices = [
f"[{i+1}] {e['title'][:20]} | {e['type']} | ❤{e['likes']}" f"[{i+1}] {e['title'][:20]} | {e['type']} | ❤{e['likes']}"
for i, e in enumerate(entries) for i, e in enumerate(entries)
@ -3003,14 +3034,14 @@ def toggle_autostart(enabled: bool) -> str:
config = cfg.all config = cfg.all
with gr.Blocks( _GRADIO_CSS = """
title="小红书 AI 爆文工坊 V2.0",
theme=gr.themes.Soft(),
css="""
.status-ok { color: #16a34a; font-weight: bold; } .status-ok { color: #16a34a; font-weight: bold; }
.status-err { color: #dc2626; font-weight: bold; } .status-err { color: #dc2626; font-weight: bold; }
footer { display: none !important; } footer { display: none !important; }
""", """
with gr.Blocks(
title="小红书 AI 爆文工坊 V2.0",
) as app: ) as app:
gr.Markdown( gr.Markdown(
"# 🍒 小红书 AI 爆文生产工坊 V2.0\n" "# 🍒 小红书 AI 爆文生产工坊 V2.0\n"
@ -3018,7 +3049,6 @@ with gr.Blocks(
) )
# 全局状态 # 全局状态
state_images = gr.State([])
state_search_result = gr.State("") state_search_result = gr.State("")
# ============ 全局设置栏 ============ # ============ 全局设置栏 ============
@ -3132,71 +3162,36 @@ with gr.Blocks(
# ============ Tab 页面 ============ # ============ Tab 页面 ============
with gr.Tabs(): with gr.Tabs():
# -------- Tab 1: 内容创作 -------- # -------- Tab 1: 内容创作(迁移至 ui/tab_create.py--------
with gr.Tab("✨ 内容创作"): _tab1 = build_tab(
with gr.Row(): config=config,
# 左栏:输入 styles=DEFAULT_STYLES,
with gr.Column(scale=1): sd_preset_names=SD_PRESET_NAMES,
gr.Markdown("### 💡 构思") default_negative=DEFAULT_NEGATIVE,
topic = gr.Textbox(label="笔记主题", placeholder="例如:优衣库早春穿搭") llm_model=llm_model,
style = gr.Dropdown( sd_model=sd_model,
DEFAULT_STYLES, sd_url=sd_url,
label="风格", value="好物种草", persona=persona,
status_bar=status_bar,
face_swap_toggle=face_swap_toggle,
face_image_preview=face_image_preview,
mcp_url=mcp_url,
fn_gen_copy=generate_copy,
fn_gen_img=generate_images,
fn_export=one_click_export,
fn_publish=publish_to_xhs,
fn_get_sd_preset=get_sd_preset,
fn_cfg_set=cfg.set,
fn_cfg_update=cfg.update,
) )
btn_gen_copy = gr.Button("✨ 第一步:生成文案", variant="primary") res_title = _tab1["res_title"]
res_content = _tab1["res_content"]
gr.Markdown("---") res_prompt = _tab1["res_prompt"]
gr.Markdown("### 🎨 绘图参数") res_tags = _tab1["res_tags"]
quality_mode = gr.Radio( quality_mode = _tab1["quality_mode"]
SD_PRESET_NAMES, steps = _tab1["steps"]
label="生成模式", cfg_scale = _tab1["cfg_scale"]
value=config.get("quality_mode", "标准 (约1分钟)"), neg_prompt = _tab1["neg_prompt"]
info="快速≈30s 标准≈1min 精细≈2-3min (SDXL)",
)
with gr.Accordion("高级设置 (覆盖预设)", open=False):
neg_prompt = gr.Textbox(
label="反向提示词",
value=config.get("sd_negative_prompt", DEFAULT_NEGATIVE),
lines=2,
)
steps = gr.Slider(8, 50, value=config.get("sd_steps", 20), step=1, label="步数")
cfg_scale = gr.Slider(1, 15, value=config.get("sd_cfg_scale", 5.5), step=0.5, label="CFG Scale")
btn_gen_img = gr.Button("🎨 第二步:生成图片", variant="primary")
# 中栏:文案编辑
with gr.Column(scale=1):
gr.Markdown("### 📝 文案编辑")
res_title = gr.Textbox(label="标题 (≤20字)", interactive=True)
res_content = gr.TextArea(
label="正文 (可手动修改)", lines=12, interactive=True,
)
res_prompt = gr.TextArea(
label="绘图提示词", lines=3, interactive=True,
)
res_tags = gr.Textbox(
label="话题标签 (逗号分隔)", interactive=True,
placeholder="穿搭, 春季, 好物种草",
)
# 右栏:预览 & 发布
with gr.Column(scale=1):
gr.Markdown("### 🖼️ 视觉预览")
gallery = gr.Gallery(label="AI 生成图片", columns=2, height=300)
local_images = gr.File(
label="📁 上传本地图片(可混排)",
file_count="multiple",
file_types=["image"],
)
gr.Markdown("### 🚀 发布")
schedule_time = gr.Textbox(
label="定时发布 (可选, ISO8601格式)",
placeholder="如 2026-02-08T18:00:00+08:00留空=立即发布",
)
with gr.Row():
btn_export = gr.Button("📂 导出本地", variant="secondary")
btn_publish = gr.Button("🚀 发布到小红书", variant="primary")
publish_msg = gr.Markdown("")
# -------- Tab 2: 热点探测 -------- # -------- Tab 2: 热点探测 --------
with gr.Tab("🔥 热点探测"): with gr.Tab("🔥 热点探测"):
@ -3894,59 +3889,6 @@ with gr.Blocks(
outputs=[face_image_preview, face_status], outputs=[face_image_preview, face_status],
) )
# ---- Tab 1: 内容创作 ----
btn_gen_copy.click(
fn=generate_copy,
inputs=[llm_model, topic, style, sd_model, persona],
outputs=[res_title, res_content, res_prompt, res_tags, status_bar],
)
# 生成模式切换 → 同步更新步数/CFG预览
def on_quality_mode_change(mode, sd_model_val):
p = get_sd_preset(mode, sd_model_val)
return p["steps"], p["cfg_scale"]
# 保存图片生成参数到配置
def save_quality_mode(mode):
cfg.set("quality_mode", mode)
p = get_sd_preset(mode)
return p["steps"], p["cfg_scale"]
def save_sd_params(s, c, n):
cfg.update({"sd_steps": s, "sd_cfg_scale": c, "sd_negative_prompt": n})
return None
quality_mode.change(
fn=save_quality_mode,
inputs=[quality_mode],
outputs=[steps, cfg_scale],
)
# 保存高级参数
steps.change(fn=lambda s: cfg.set("sd_steps", s), inputs=[steps], outputs=[])
cfg_scale.change(fn=lambda c: cfg.set("sd_cfg_scale", c), inputs=[cfg_scale], outputs=[])
neg_prompt.change(fn=lambda n: cfg.set("sd_negative_prompt", n), inputs=[neg_prompt], outputs=[])
btn_gen_img.click(
fn=generate_images,
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale,
face_swap_toggle, face_image_preview, quality_mode, persona],
outputs=[gallery, state_images, status_bar],
)
btn_export.click(
fn=one_click_export,
inputs=[res_title, res_content, state_images],
outputs=[publish_msg],
)
btn_publish.click(
fn=publish_to_xhs,
inputs=[res_title, res_content, res_tags, state_images,
local_images, mcp_url, schedule_time],
outputs=[publish_msg],
)
# ---- Tab 2: 热点探测 ---- # ---- Tab 2: 热点探测 ----
btn_search.click( btn_search.click(
fn=search_hotspots, fn=search_hotspots,
@ -4412,4 +4354,6 @@ if __name__ == "__main__":
inbrowser=True, inbrowser=True,
share=False, share=False,
auth=(_auth_user, _auth_pass), auth=(_auth_user, _auth_pass),
theme=gr.themes.Soft(),
css=_GRADIO_CSS,
) )

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-24

View File

@ -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 内完成。
---
### 决策 3JSON 原子写使用 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 重构时不会遗失。
---
### 决策 6UI 拆分采用渐进式迁移,不一次性重写
`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 层单元测试?(本次不实施)

View File

@ -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 回调签名保持不变

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>)` 实现)

View File

@ -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 接口发布,行为与改造前一致

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 不显示原始值

View File

@ -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** 每次清理只删除自己写入的文件,不影响另一次的文件

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_*` 变量

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 时触发服务连接)

View File

@ -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 级别日志

View File

@ -4,10 +4,8 @@ schema: spec-driven
# This is shown to AI when creating artifacts. # This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc. # Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example: # Example:
# context: | context: |
# Tech stack: TypeScript, React, Node.js 使用中文
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional) # Per-artifact rules (optional)
# Add custom rules for specific artifacts. # Add custom rules for specific artifacts.

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>)` 实现)

View File

@ -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 接口发布,行为与改造前一致

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 不显示原始值

View File

@ -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** 每次清理只删除自己写入的文件,不影响另一次的文件

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_*` 变量

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 时触发服务连接)

View File

@ -2,3 +2,4 @@ gradio>=4.0.0
requests>=2.28.0 requests>=2.28.0
Pillow>=9.0.0 Pillow>=9.0.0
matplotlib>=3.5.0 matplotlib>=3.5.0
keyring>=24.0.0

1
ui/__init__.py Normal file
View File

@ -0,0 +1 @@
# UI 模块包

172
ui/tab_create.py Normal file
View File

@ -0,0 +1,172 @@
"""
内容创作 Tab UI 模块
包含 Tab 1 内容创作的所有 Gradio 组件定义和事件绑定
"""
import gradio as gr
def build_tab(
config: dict,
styles: list,
sd_preset_names: list,
default_negative: str,
# 共享的 Gradio 组件(由 main.py 创建并传入)
llm_model,
sd_model,
sd_url,
persona,
status_bar,
face_swap_toggle,
face_image_preview,
mcp_url,
# 业务处理函数(由 main.py 传入,避免循环导入)
fn_gen_copy,
fn_gen_img,
fn_export,
fn_publish,
fn_get_sd_preset,
fn_cfg_set,
fn_cfg_update,
):
"""
构建 内容创作Tab注册所有事件绑定
Parameters
----------
config : 当前配置字典用于读取初始值
styles : 文案风格列表
sd_preset_names : SD 生成模式名称列表
default_negative: 默认反向提示词
llm_model / sd_model / ... : 由全局设置栏创建的共享组件引用
fn_* : 业务逻辑回调函数
Returns
-------
None 所有事件绑定均在此函数内完成无需外部再次绑定
"""
with gr.Tab("✨ 内容创作"):
# 本 Tab 私有的图片状态(不被其他 Tab 使用)
state_images = gr.State([])
with gr.Row():
# ---- 左栏:输入 ----
with gr.Column(scale=1):
gr.Markdown("### 💡 构思")
topic = gr.Textbox(label="笔记主题", placeholder="例如:优衣库早春穿搭")
style = gr.Dropdown(
styles,
label="风格", value="好物种草",
)
btn_gen_copy = gr.Button("✨ 第一步:生成文案", variant="primary")
gr.Markdown("---")
gr.Markdown("### 🎨 绘图参数")
quality_mode = gr.Radio(
sd_preset_names,
label="生成模式",
value=config.get("quality_mode", "标准 (约1分钟)"),
info="快速≈30s 标准≈1min 精细≈2-3min (SDXL)",
)
with gr.Accordion("高级设置 (覆盖预设)", open=False):
neg_prompt = gr.Textbox(
label="反向提示词",
value=config.get("sd_negative_prompt", default_negative),
lines=2,
)
steps = gr.Slider(8, 50, value=config.get("sd_steps", 20), step=1, label="步数")
cfg_scale = gr.Slider(1, 15, value=config.get("sd_cfg_scale", 5.5), step=0.5, label="CFG Scale")
btn_gen_img = gr.Button("🎨 第二步:生成图片", variant="primary")
# ---- 中栏:文案编辑 ----
with gr.Column(scale=1):
gr.Markdown("### 📝 文案编辑")
res_title = gr.Textbox(
label="标题",
interactive=True,
info="小红书限制 ≤20 字,超出将无法发布",
)
res_content = gr.TextArea(
label="正文 (可手动修改)", lines=12, interactive=True,
)
res_prompt = gr.TextArea(
label="绘图提示词", lines=3, interactive=True,
)
res_tags = gr.Textbox(
label="话题标签 (逗号分隔)", interactive=True,
placeholder="穿搭, 春季, 好物种草",
)
# ---- 右栏:预览 & 发布 ----
with gr.Column(scale=1):
gr.Markdown("### 🖼️ 视觉预览")
gallery = gr.Gallery(label="AI 生成图片", columns=2, height=300)
local_images = gr.File(
label="📁 上传本地图片(可混排)",
file_count="multiple",
file_types=["image"],
)
gr.Markdown("### 🚀 发布")
schedule_time = gr.Textbox(
label="定时发布 (可选, ISO8601格式)",
placeholder="如 2026-02-08T18:00:00+08:00留空=立即发布",
)
with gr.Row():
btn_export = gr.Button("📂 导出本地", variant="secondary")
btn_publish = gr.Button("🚀 发布到小红书", variant="primary")
publish_msg = gr.Markdown("")
# ---- 事件绑定 ----
btn_gen_copy.click(
fn=fn_gen_copy,
inputs=[llm_model, topic, style, sd_model, persona],
outputs=[res_title, res_content, res_prompt, res_tags, status_bar],
)
def save_quality_mode(mode):
fn_cfg_set("quality_mode", mode)
p = fn_get_sd_preset(mode)
return p["steps"], p["cfg_scale"]
quality_mode.change(
fn=save_quality_mode,
inputs=[quality_mode],
outputs=[steps, cfg_scale],
)
steps.change(fn=lambda s: fn_cfg_set("sd_steps", s), inputs=[steps], outputs=[])
cfg_scale.change(fn=lambda c: fn_cfg_set("sd_cfg_scale", c), inputs=[cfg_scale], outputs=[])
neg_prompt.change(fn=lambda n: fn_cfg_set("sd_negative_prompt", n), inputs=[neg_prompt], outputs=[])
btn_gen_img.click(
fn=fn_gen_img,
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale,
face_swap_toggle, face_image_preview, quality_mode, persona],
outputs=[gallery, state_images, status_bar],
)
btn_export.click(
fn=fn_export,
inputs=[res_title, res_content, state_images],
outputs=[publish_msg],
)
btn_publish.click(
fn=fn_publish,
inputs=[res_title, res_content, res_tags, state_images,
local_images, mcp_url, schedule_time],
outputs=[publish_msg],
)
# 返回可能被其他 Tab 引用的组件
return {
"res_title": res_title,
"res_content": res_content,
"res_prompt": res_prompt,
"res_tags": res_tags,
"quality_mode": quality_mode,
"steps": steps,
"cfg_scale": cfg_scale,
"neg_prompt": neg_prompt,
}