diff --git a/analytics_service.py b/analytics_service.py index 2d0e5a3..8922fdd 100644 --- a/analytics_service.py +++ b/analytics_service.py @@ -5,6 +5,7 @@ import json import os import re +import tempfile import time import logging import math @@ -67,13 +68,35 @@ class AnalyticsService: def _save_analytics(self): os.makedirs(self.workspace_dir, exist_ok=True) - with open(self.analytics_path, "w", encoding="utf-8") as f: - json.dump(self._analytics_data, f, ensure_ascii=False, indent=2) + 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) + os.replace(tmp, target) + except Exception: + try: + os.remove(tmp) + except OSError: + pass + raise def _save_weights(self): os.makedirs(self.workspace_dir, exist_ok=True) - with open(self.weights_path, "w", encoding="utf-8") as f: - json.dump(self._weights, f, ensure_ascii=False, indent=2) + 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) + os.replace(tmp, target) + except Exception: + try: + os.remove(tmp) + except OSError: + pass + raise # ========== 数据采集 ========== diff --git a/config_manager.py b/config_manager.py index d49c7ba..f8b1ebc 100644 --- a/config_manager.py +++ b/config_manager.py @@ -4,10 +4,15 @@ """ import json import os +import tempfile import logging logger = logging.getLogger(__name__) +# 敏感字段 keyring 存储常量 +_KEYRING_PLACEHOLDER = "[keyring]" +_KEYRING_SERVICE = "autobot_xhs" + CONFIG_FILE = "config.json" OUTPUT_DIR = "xhs_workspace" @@ -88,13 +93,61 @@ class ConfigManager: return config def save(self): - """保存配置到文件""" + """原子写:临时文件 + os.replace,防止写中断导致数据损坏""" + config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) or "." try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(self._config, f, indent=4, ensure_ascii=False) + 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) + os.replace(tmp_path, CONFIG_FILE) + except Exception: + try: + os.remove(tmp_path) + except OSError: + pass + raise except IOError as e: logger.error("配置保存失败: %s", e) + def get_secure(self, key: str, default: str = "") -> str: + """读取敏感配置,优先级: 环境变量 AUTOBOT_ > 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): """获取配置项""" return self._config.get(key, default) @@ -141,14 +194,24 @@ class ConfigManager: return [p["name"] for p in self.get_llm_providers()] def get_active_llm(self) -> dict | None: - """获取当前激活的 LLM 提供商配置""" + """获取当前激活的 LLM 提供商配置(自动解析 keyring 中的 api_key)""" 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() - 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: """添加一个 LLM 提供商,返回状态消息""" @@ -161,9 +224,18 @@ class ConfigManager: for p in providers: if p["name"] == 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({ "name": name, - "api_key": api_key.strip(), + "api_key": stored_key, "base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"), }) self._config["llm_providers"] = providers diff --git a/main.py b/main.py index bc57608..4b787e4 100644 --- a/main.py +++ b/main.py @@ -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 mcp_client import MCPClient, get_mcp_client from analytics_service import AnalyticsService +from ui.tab_create import build_tab # ================= matplotlib 中文字体配置 ================= _font_candidates = ["Microsoft YaHei", "SimHei", "PingFang SC", "WenQuanYi Micro Hei"] @@ -421,45 +422,55 @@ def one_click_export(title, content, images): def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, schedule_time): - """通过 MCP 发布到小红书""" + """通过 MCP 发布到小红书(含输入校验和临时文件自动清理)""" + # === 发布前校验 === if not title: return "❌ 缺少标题" + if len(title) > 20: + return f"❌ 标题超长:当前 {len(title)} 字,小红书限制 ≤20 字,请精简后再发布" client = get_mcp_client(mcp_url) - - # 收集图片路径 - image_paths = [] - - # 先保存 AI 生成的图片到临时目录 - if images: - temp_dir = os.path.join(OUTPUT_DIR, "_temp_publish") - os.makedirs(temp_dir, exist_ok=True) - for idx, img in enumerate(images): - if isinstance(img, Image.Image): - path = os.path.abspath(os.path.join(temp_dir, f"ai_{idx}.jpg")) - if img.mode != "RGB": - img = img.convert("RGB") - img.save(path, format="JPEG", quality=95) - image_paths.append(path) - - # 添加本地上传的图片 - if 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) - if os.path.exists(img_path): - image_paths.append(os.path.abspath(img_path)) - - if not image_paths: - return "❌ 至少需要 1 张图片才能发布" - - # 解析标签 - tags = [t.strip().lstrip("#") for t in tags_str.split(",") if t.strip()] if tags_str else None - - # 定时发布 - schedule = schedule_time if schedule_time and schedule_time.strip() else None + ai_temp_files: list = [] # 追踪本次写入的临时文件,用于 finally 清理 try: + # 收集图片路径 + image_paths = [] + + # 先保存 AI 生成的图片到临时目录 + if images: + temp_dir = os.path.join(OUTPUT_DIR, "_temp_publish") + os.makedirs(temp_dir, exist_ok=True) + for idx, img in enumerate(images): + if isinstance(img, Image.Image): + path = os.path.abspath(os.path.join(temp_dir, f"ai_{idx}.jpg")) + if img.mode != "RGB": + img = img.convert("RGB") + img.save(path, format="JPEG", quality=95) + image_paths.append(path) + ai_temp_files.append(path) # 登记临时文件 + + # 添加本地上传的图片 + if local_images: + for img_file in local_images: + img_path = img_file.name if hasattr(img_file, 'name') else str(img_file) + if os.path.exists(img_path): + image_paths.append(os.path.abspath(img_path)) + + # === 图片校验 === + if not image_paths: + 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 + + # 定时发布 + schedule = schedule_time if schedule_time and schedule_time.strip() else None + result = client.publish_content( title=title, content=content, @@ -473,6 +484,14 @@ def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, sche except Exception as e: logger.error("发布失败: %s", 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: 评论管家 # ================================================== -# ---- 共用: 笔记列表缓存 ---- +# ---- 共用: 笔记列表缓存(线程安全)---- # 主动评论缓存 _cached_proactive_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"): - """通用: 获取笔记列表并缓存""" - global _cached_proactive_entries, _cached_my_note_entries + """通用: 获取笔记列表并线程安全地缓存""" try: client = get_mcp_client(mcp_url) if keyword and keyword.strip(): @@ -580,10 +618,7 @@ def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"): entries = client.list_feeds_parsed() src = "首页推荐" - if cache_name == "proactive": - _cached_proactive_entries = entries - else: - _cached_my_note_entries = entries + _set_cache(cache_name, entries) if not entries: 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)} 条笔记", ) except Exception as e: - if cache_name == "proactive": - _cached_proactive_entries = [] - else: - _cached_my_note_entries = [] + _set_cache(cache_name, []) return gr.update(choices=[], value=None), f"❌ {e}" def _pick_from_cache(selected, cache_name="proactive"): - """通用: 从缓存中提取选中条目的 feed_id / xsec_token / title""" - cache = _cached_proactive_entries if cache_name == "proactive" else _cached_my_note_entries + """通用: 从缓存中提取选中条目的 feed_id / xsec_token / title(线程安全快照)""" + cache = _get_cache(cache_name) if not selected or not cache: return "", "", "" try: @@ -699,7 +731,6 @@ def send_comment(feed_id, xsec_token, comment_content, mcp_url): def fetch_my_notes(mcp_url): """通过已保存的 userId 获取我的笔记列表""" - global _cached_my_note_entries my_uid = cfg.get("my_user_id", "") xsec = cfg.get("xsec_token", "") if not my_uid: @@ -757,7 +788,7 @@ def fetch_my_notes(mcp_url): "type": nc.get("type", ""), }) - _cached_my_note_entries = entries + _set_cache("my_notes", entries) choices = [ f"[{i+1}] {e['title'][:20]} | {e['type']} | ❤{e['likes']}" for i, e in enumerate(entries) @@ -3003,14 +3034,14 @@ def toggle_autostart(enabled: bool) -> str: config = cfg.all -with gr.Blocks( - title="小红书 AI 爆文工坊 V2.0", - theme=gr.themes.Soft(), - css=""" +_GRADIO_CSS = """ .status-ok { color: #16a34a; font-weight: bold; } .status-err { color: #dc2626; font-weight: bold; } footer { display: none !important; } - """, +""" + +with gr.Blocks( + title="小红书 AI 爆文工坊 V2.0", ) as app: gr.Markdown( "# 🍒 小红书 AI 爆文生产工坊 V2.0\n" @@ -3018,7 +3049,6 @@ with gr.Blocks( ) # 全局状态 - state_images = gr.State([]) state_search_result = gr.State("") # ============ 全局设置栏 ============ @@ -3132,71 +3162,36 @@ with gr.Blocks( # ============ Tab 页面 ============ with gr.Tabs(): - # -------- Tab 1: 内容创作 -------- - with gr.Tab("✨ 内容创作"): - with gr.Row(): - # 左栏:输入 - with gr.Column(scale=1): - gr.Markdown("### 💡 构思") - topic = gr.Textbox(label="笔记主题", placeholder="例如:优衣库早春穿搭") - style = gr.Dropdown( - DEFAULT_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="标题 (≤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 1: 内容创作(迁移至 ui/tab_create.py)-------- + _tab1 = build_tab( + config=config, + styles=DEFAULT_STYLES, + sd_preset_names=SD_PRESET_NAMES, + default_negative=DEFAULT_NEGATIVE, + llm_model=llm_model, + sd_model=sd_model, + sd_url=sd_url, + 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, + ) + res_title = _tab1["res_title"] + res_content = _tab1["res_content"] + res_prompt = _tab1["res_prompt"] + res_tags = _tab1["res_tags"] + quality_mode = _tab1["quality_mode"] + steps = _tab1["steps"] + cfg_scale = _tab1["cfg_scale"] + neg_prompt = _tab1["neg_prompt"] # -------- Tab 2: 热点探测 -------- with gr.Tab("🔥 热点探测"): @@ -3894,59 +3889,6 @@ with gr.Blocks( 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: 热点探测 ---- btn_search.click( fn=search_hotspots, @@ -4412,4 +4354,6 @@ if __name__ == "__main__": inbrowser=True, share=False, auth=(_auth_user, _auth_pass), + theme=gr.themes.Soft(), + css=_GRADIO_CSS, ) diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/.openspec.yaml b/openspec/changes/archive/2026-02-24-production-readiness-audit/.openspec.yaml new file mode 100644 index 0000000..69e221f --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-24 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/design.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/design.md new file mode 100644 index 0000000..f6a1b10 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/design.md @@ -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_` 环境变量在无 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 层单元测试?(本次不实施) diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/proposal.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/proposal.md new file mode 100644 index 0000000..eeae433 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/proposal.md @@ -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 回调签名保持不变 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/atomic-persistence/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/atomic-persistence/spec.md new file mode 100644 index 0000000..ee492bf --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/atomic-persistence/spec.md @@ -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=)` 实现) diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/publish-input-validation/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/publish-input-validation/spec.md new file mode 100644 index 0000000..30709df --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/publish-input-validation/spec.md @@ -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 接口发布,行为与改造前一致 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/secure-config/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/secure-config/spec.md new file mode 100644 index 0000000..292fcd2 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/secure-config/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 敏感字段通过系统 keyring 或环境变量存储 +ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_` > 系统 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 不显示原始值 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/temp-file-lifecycle/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/temp-file-lifecycle/spec.md new file mode 100644 index 0000000..dd160b8 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/temp-file-lifecycle/spec.md @@ -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** 每次清理只删除自己写入的文件,不影响另一次的文件 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/thread-safe-cache/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/thread-safe-cache/spec.md new file mode 100644 index 0000000..d697422 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/thread-safe-cache/spec.md @@ -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_*` 变量 diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/ui-module-split/spec.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/ui-module-split/spec.md new file mode 100644 index 0000000..615b83e --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/specs/ui-module-split/spec.md @@ -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_.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。 + +#### Scenario: 新增 Tab 模块的标准结构 +- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件 +- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接) diff --git a/openspec/changes/archive/2026-02-24-production-readiness-audit/tasks.md b/openspec/changes/archive/2026-02-24-production-readiness-audit/tasks.md new file mode 100644 index 0000000..35e5d5c --- /dev/null +++ b/openspec/changes/archive/2026-02-24-production-readiness-audit/tasks.md @@ -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_`,其次读取系统 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=)` + 写入 + `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 级别日志 + diff --git a/openspec/config.yaml b/openspec/config.yaml index 392946c..9976997 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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. diff --git a/openspec/specs/atomic-persistence/spec.md b/openspec/specs/atomic-persistence/spec.md new file mode 100644 index 0000000..ee492bf --- /dev/null +++ b/openspec/specs/atomic-persistence/spec.md @@ -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=)` 实现) diff --git a/openspec/specs/publish-input-validation/spec.md b/openspec/specs/publish-input-validation/spec.md new file mode 100644 index 0000000..30709df --- /dev/null +++ b/openspec/specs/publish-input-validation/spec.md @@ -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 接口发布,行为与改造前一致 diff --git a/openspec/specs/secure-config/spec.md b/openspec/specs/secure-config/spec.md new file mode 100644 index 0000000..292fcd2 --- /dev/null +++ b/openspec/specs/secure-config/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 敏感字段通过系统 keyring 或环境变量存储 +ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_` > 系统 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 不显示原始值 diff --git a/openspec/specs/temp-file-lifecycle/spec.md b/openspec/specs/temp-file-lifecycle/spec.md new file mode 100644 index 0000000..dd160b8 --- /dev/null +++ b/openspec/specs/temp-file-lifecycle/spec.md @@ -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** 每次清理只删除自己写入的文件,不影响另一次的文件 diff --git a/openspec/specs/thread-safe-cache/spec.md b/openspec/specs/thread-safe-cache/spec.md new file mode 100644 index 0000000..d697422 --- /dev/null +++ b/openspec/specs/thread-safe-cache/spec.md @@ -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_*` 变量 diff --git a/openspec/specs/ui-module-split/spec.md b/openspec/specs/ui-module-split/spec.md new file mode 100644 index 0000000..615b83e --- /dev/null +++ b/openspec/specs/ui-module-split/spec.md @@ -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_.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。 + +#### Scenario: 新增 Tab 模块的标准结构 +- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件 +- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接) diff --git a/requirements.txt b/requirements.txt index d926e9c..7dca301 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ gradio>=4.0.0 requests>=2.28.0 Pillow>=9.0.0 matplotlib>=3.5.0 +keyring>=24.0.0 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..9a23832 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# UI 模块包 diff --git a/ui/tab_create.py b/ui/tab_create.py new file mode 100644 index 0000000..f3014d3 --- /dev/null +++ b/ui/tab_create.py @@ -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, + }