From cf29e2decf5ae74e5a7037531edb0218dc4bdcea Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 10:38:54 +0800 Subject: [PATCH 01/42] =?UTF-8?q?feat:=20=E6=89=A9=E5=85=85=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E9=A3=8E=E6=A0=BC=E5=BA=93=E5=B9=B6=E8=A1=A5=E5=BC=BA?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/image_themes.py | 329 ++++++++++++++---- app/ai/prompt_builder.py | 43 ++- app/ai/prompt_templates.py | 18 +- ...24-image-style-library-prompt-hardening.md | 72 ++++ frontend/src/api.ts | 7 +- frontend/src/components/ImageThemePicker.tsx | 155 +++++++++ frontend/src/pages/v2/AIImages.tsx | 102 ++++-- frontend/src/styles.css | 300 +++++++++++++++- templates/image_prompt/default.md | 18 +- tests/test_daily_random_theme.py | 143 +++++++- tests/test_ui_api.py | 24 +- tests/test_v2_group_prompt_api.py | 21 ++ tests/test_v2_prompt_builder.py | 59 +++- tests/test_v2_prompt_editing.py | 7 +- 14 files changed, 1158 insertions(+), 140 deletions(-) create mode 100644 docs/tasks/2026-08-24-image-style-library-prompt-hardening.md create mode 100644 frontend/src/components/ImageThemePicker.tsx diff --git a/app/ai/image_themes.py b/app/ai/image_themes.py index 200f7f7..64af977 100644 --- a/app/ai/image_themes.py +++ b/app/ai/image_themes.py @@ -4,21 +4,48 @@ import hashlib import random +import re from dataclasses import dataclass from datetime import date -from typing import Any +from typing import Any, Literal class ImageThemeError(ValueError): """生图主题配置不合法。""" +ThemeKind = Literal["mode", "preset"] + + @dataclass(frozen=True) class ImageThemeDefinition: key: str label: str description: str prompt: str + kind: ThemeKind = "preset" + category: str = "" + swatches: tuple[str, ...] = () + variation_count: int = 1 + + +@dataclass(frozen=True) +class StyleFamilyDefinition: + """一个可公开选择、每天在家族内细微变化的美术风格。""" + + key: str + label: str + category: str + description: str + swatches: tuple[str, str, str] + media: tuple[str, str] + palette: tuple[str, str] + texture: tuple[str, str] + light: tuple[str, str] + + @property + def variation_count(self) -> int: + return len(self.media) * len(self.palette) * len(self.texture) * len(self.light) @dataclass(frozen=True) @@ -52,7 +79,9 @@ def to_meta(self) -> dict[str, str]: RANDOM_PRESET_THEME = "random_preset" AI_FREE_THEME = "ai_free" CUSTOM_THEME = "custom" -STYLE_CATALOG_VERSION = "daily-style-v2" +STYLE_CATALOG_VERSION = "daily-style-v3" +STYLE_VARIATIONS_PER_FAMILY = 16 +STYLE_SAFETY_SUFFIX = "只控制美术语言和视觉质感,不得新增、删除或改写聊天事实、人物、数字和指定文字。" # 旧主题继续接受和解析,供历史配置与已保存 Prompt 使用;主界面不再展示。 CONCRETE_THEME_KEYS: tuple[str, ...] = ( @@ -78,25 +107,166 @@ def to_meta(self) -> dict[str, str]: } IMAGE_THEME_MODE_DEFINITIONS: dict[str, ImageThemeDefinition] = { - RANDOM_PRESET_THEME: ImageThemeDefinition(RANDOM_PRESET_THEME, "每日随机", "每群每天独立生成一套可复现风格", "按日期与群聊稳定生成兼容的画材、配色、纹理和光影组合;页面几何由漫画分镜单独决定。"), - AI_FREE_THEME: ImageThemeDefinition(AI_FREE_THEME, "AI 自由发挥(兼容)", "历史配置兼容模式", "根据当天真实聊天选择一个统一视觉主题;不得新增或改变聊天事实。"), - CUSTOM_THEME: ImageThemeDefinition(CUSTOM_THEME, "指定风格", "输入 1~80 字的群专属风格词", "严格使用用户指定的视觉主题,完全替代每日随机;只能影响视觉表现。"), + RANDOM_PRESET_THEME: ImageThemeDefinition( + RANDOM_PRESET_THEME, "每日随机", "每群每天从完整风格库生成一套可复现风格", + "按日期与群聊稳定生成兼容的画材、配色、纹理和光影组合;页面几何由漫画分镜单独决定。", + kind="mode", category="模式", variation_count=352, + ), + AI_FREE_THEME: ImageThemeDefinition( + AI_FREE_THEME, "AI 自由发挥(兼容)", "历史配置兼容模式", + "根据当天真实聊天选择一个统一视觉主题;不得新增或改变聊天事实。", + kind="mode", category="模式", + ), + CUSTOM_THEME: ImageThemeDefinition( + CUSTOM_THEME, "指定风格", "输入 1~80 字的群专属风格词", + "严格使用用户指定的视觉主题,完全替代每日随机;只能影响视觉表现。", + kind="mode", category="模式", + ), } -IMAGE_THEME_KEYS = frozenset((*CONCRETE_THEME_KEYS, *IMAGE_THEME_MODE_DEFINITIONS)) - -# 每个家族内部的选择经过人工配伍,不会把所有词库做无约束笛卡尔积。 -_STYLE_FAMILIES: tuple[dict[str, Any], ...] = ( - {"label": "丝网印刷社论漫画", "media": ("颗粒丝网印刷", "双色孔版印刷"), "palette": ("群青、奶油白与珊瑚红", "墨绿、米白与亮橙"), "texture": ("纸张颗粒和套色轻微错位", "半调网点与撕纸边缘"), "light": ("平面高对比光影", "清晰块面明暗")}, - {"label": "纸艺立体插画", "media": ("分层剪纸", "立体纸雕"), "palette": ("天蓝、暖黄与番茄红", "鼠尾草绿、杏色与靛蓝"), "texture": ("纤维纸纹与圆润切边", "折纸阴影与手工拼贴边缘"), "light": ("柔和棚拍侧光", "浅景深纸艺投影")}, - {"label": "水彩旅行手账", "media": ("透明水彩线稿", "水彩与彩铅混合"), "palette": ("湖蓝、浅赭与豆沙红", "薄荷绿、柠檬黄与灰紫"), "texture": ("水渍晕染和纸胶带", "彩铅笔触与手写箭头"), "light": ("通透自然光", "柔和晨光氛围")}, - {"label": "复古未来主义", "media": ("复古科幻杂志插画", "几何矢量科幻海报"), "palette": ("深靛蓝、铜橙与象牙白", "紫黑、青绿与暖金"), "texture": ("老印刷网点与金属刻度", "扫描线和细密星尘"), "light": ("边缘霓虹与局部辉光", "戏剧化逆光")}, - {"label": "黏土定格剧场", "media": ("手工黏土定格动画", "软陶微缩场景"), "palette": ("奶油黄、天空蓝与草莓红", "陶土橙、青绿与乳白"), "texture": ("可见手捏纹理与圆角道具", "软陶颗粒和纸板布景"), "light": ("温暖摄影棚柔光", "微缩场景侧逆光")}, - {"label": "木刻新闻画", "media": ("现代木刻版画", "粗线条凸版印刷"), "palette": ("黑、米白与朱红", "藏青、牛皮纸色与橘黄"), "texture": ("刀刻线纹和粗粝纸面", "油墨不均与印章点缀"), "light": ("高反差明暗", "版画式硬边阴影")}, - {"label": "玻璃拟态科技刊", "media": ("半透明玻璃拟态", "清洁三维插画"), "palette": ("冰蓝、白色与荧光青", "深灰蓝、薰衣草紫与亮粉"), "texture": ("磨砂玻璃和细线纹理", "透明折射与柔和渐变"), "light": ("冷色体积光", "柔亮边缘光")}, - {"label": "儿童科普绘本", "media": ("蜡笔与彩铅绘本", "不透明水粉童书插画"), "palette": ("明黄、草绿与天空蓝", "珊瑚红、奶油白与海军蓝"), "texture": ("蜡笔颗粒和手绘图标", "水粉笔触与圆润贴纸"), "light": ("明快均匀光线", "温暖午后光")}, - {"label": "建筑蓝图漫画", "media": ("工程蓝图线稿", "等距轴测技术插画"), "palette": ("蓝底白线与安全橙", "石墨灰、亮蓝与荧光黄"), "texture": ("网格纸、尺寸线和编号章", "铅笔辅助线与半透明线稿"), "light": ("理性均匀照明", "细微高光强化结构")}, - {"label": "织物刺绣拼布", "media": ("刺绣与布艺拼贴", "羊毛毡立体插画"), "palette": ("靛蓝、米白与砖红", "橄榄绿、芥末黄与莓果紫"), "texture": ("织物纹理、针脚和毛毡边缘", "绒线轮廓与布贴层次"), "light": ("柔和室内散射光", "温暖侧光突出纤维")}, + +STYLE_FAMILIES: tuple[StyleFamilyDefinition, ...] = ( + StyleFamilyDefinition( + "silkscreen_editorial", "丝网印刷社论漫画", "印刷与编辑", "粗线、套色与纸张颗粒形成醒目的社论漫画感。", + ("#21409A", "#F6E8C9", "#F25F5C"), + ("颗粒丝网印刷", "双色孔版印刷"), ("群青、奶油白与珊瑚红", "墨绿、米白与亮橙"), + ("纸张颗粒和轻微套色错位", "半调网点与撕纸边缘"), ("平面高对比光影", "清晰块面明暗"), + ), + StyleFamilyDefinition( + "paper_cut_layered", "分层纸艺插画", "立体与手作", "纤维纸、手工切边与柔和投影带来轻巧层次。", + ("#63B3ED", "#F6C453", "#E34D3B"), + ("分层剪纸", "立体纸雕"), ("天蓝、暖黄与番茄红", "鼠尾草绿、杏色与靛蓝"), + ("纤维纸纹与圆润切边", "折纸阴影与手工拼贴边缘"), ("柔和棚拍侧光", "浅景深纸艺投影"), + ), + StyleFamilyDefinition( + "watercolor_journal", "水彩旅行手账", "绘画与纸本", "透明水彩、彩铅和自然纸纹营造轻松手账感。", + ("#4FA3B7", "#B8D8BA", "#C97B84"), + ("透明水彩细墨线", "水彩与彩铅混合"), ("湖蓝、浅赭与豆沙红", "薄荷绿、柠檬黄与灰紫"), + ("水渍晕染与纸胶带质感", "彩铅笔触与棉纸纹理"), ("通透自然光", "柔和晨光氛围"), + ), + StyleFamilyDefinition( + "retro_futurism", "复古未来主义", "科技与结构", "旧科幻印刷语言与克制辉光结合的未来想象。", + ("#1E2A5E", "#C56E33", "#F2E9D8"), + ("复古科幻杂志插画", "几何矢量科幻海报"), ("深靛蓝、铜橙与象牙白", "紫黑、青绿与暖金"), + ("老印刷网点与金属刻度", "扫描线与细密星尘"), ("边缘霓虹与局部辉光", "戏剧化逆光"), + ), + StyleFamilyDefinition( + "clay_stopmotion", "黏土定格剧场", "立体与手作", "手捏质感与微缩摄影让群聊像一场定格短片。", + ("#F2C94C", "#5DADE2", "#E96B6B"), + ("手工黏土定格动画", "软陶微缩场景"), ("奶油黄、天空蓝与草莓红", "陶土橙、青绿与乳白"), + ("可见手捏纹理与圆角道具", "软陶颗粒与纸板布景"), ("温暖摄影棚柔光", "微缩场景侧逆光"), + ), + StyleFamilyDefinition( + "woodcut_editorial", "木刻新闻画", "印刷与编辑", "粗线刀刻、套印墨色与强反差形成有力叙事。", + ("#171717", "#F3E6C8", "#B52A2A"), + ("现代木刻版画", "粗线凸版印刷"), ("黑、米白与朱红", "藏青、牛皮纸色与橘黄"), + ("刀刻线纹与粗粝纸面", "油墨不均与印章色点"), ("高反差明暗", "版画式硬边阴影"), + ), + StyleFamilyDefinition( + "glassmorphism_tech", "玻璃拟态科技刊", "科技与结构", "透明折射、磨砂表面与冷色辉光呈现清洁科技感。", + ("#25304A", "#67E8F9", "#A78BFA"), + ("半透明玻璃拟态插画", "清洁三维科技插画"), ("冰蓝、白色与荧光青", "深灰蓝、薰衣草紫与亮粉"), + ("磨砂玻璃与细线纹理", "透明折射与柔和渐变"), ("冷色体积光", "柔亮边缘光"), + ), + StyleFamilyDefinition( + "children_science_picturebook", "儿童科普绘本", "绘画与纸本", "蜡笔、水粉和圆润造型带来明快、亲切的科普绘本感。", + ("#F5C542", "#67B76F", "#5AA7E8"), + ("蜡笔与彩铅绘本", "不透明水粉童书插画"), ("明黄、草绿与天空蓝", "珊瑚红、奶油白与海军蓝"), + ("蜡笔颗粒与手绘符号", "水粉笔触与圆润纸贴质感"), ("明快均匀光线", "温暖午后光"), + ), + StyleFamilyDefinition( + "architectural_blueprint", "建筑蓝图漫画", "科技与结构", "工程线稿、轴测绘制与克制高光形成理性视觉语言。", + ("#165DFF", "#F8FAFC", "#FF8A34"), + ("工程蓝图线稿", "等距轴测技术插画"), ("蓝底白线与安全橙", "石墨灰、亮蓝与荧光黄"), + ("工程网格与细线刻痕", "铅笔辅助线与半透明线稿"), ("理性均匀照明", "细微高光强化形体"), + ), + StyleFamilyDefinition( + "textile_embroidery", "织物刺绣拼布", "立体与手作", "针脚、毛毡和布贴层次形成温暖的手作触感。", + ("#344E7A", "#F4ECD8", "#A64B3C"), + ("刺绣与布艺拼贴", "羊毛毡立体插画"), ("靛蓝、米白与砖红", "橄榄绿、芥末黄与莓果紫"), + ("织物纹理、针脚与毛毡边缘", "绒线轮廓与布贴层次"), ("柔和室内散射光", "温暖侧光突出纤维"), + ), + StyleFamilyDefinition( + "ink_wash_editorial", "水墨留白漫画", "传统与复古", "干湿笔、宣纸留白与克制设色呈现代水墨叙事。", + ("#1B1D1F", "#264653", "#C43D2F"), + ("现代水墨细线漫画", "水墨设色干湿笔"), ("墨黑、黛青、朱砂与宣纸白", "松烟黑、石青、赭石与暖白"), + ("宣纸纤维、飞白与墨晕", "枯笔皴擦、水痕与印章色点"), ("雾化散射与局部硬墨", "柔亮留白与深墨对比"), + ), + StyleFamilyDefinition( + "art_deco_night", "装饰艺术夜刊", "传统与复古", "对称几何、金属质感与深色调形成精致夜刊气氛。", + ("#0D3B2E", "#D4AF37", "#F5E6C8"), + ("几何装饰插画", "金属箔复古印刷"), ("深祖母绿、黑金与奶油色", "午夜蓝、酒红与香槟金"), + ("压纹纸与细金线", "天鹅绒颗粒与金箔"), ("琥珀聚光", "高对比金属边缘光"), + ), + StyleFamilyDefinition( + "isometric_miniature", "等距微缩模型", "立体与手作", "俯视微缩物件与短投影构成清爽的立体小世界。", + ("#8EC5FC", "#F9C74F", "#90BE6D"), + ("等距微缩模型", "低多边形立体插画"), ("天空蓝、暖黄与草绿", "鼠尾草绿、陶土橙与乳白"), + ("磨砂模型树脂", "微缩木材与颗粒地形"), ("柔和俯视棚拍", "清晰日光与短投影"), + ), + StyleFamilyDefinition( + "pixel_arcade", "像素街机小志", "动漫与数字", "清晰像素簇、街机色彩与屏幕辉光形成活跃数字质感。", + ("#2B174A", "#00D4FF", "#FF4D8D"), + ("16-bit 像素画", "32-bit 像素插画"), ("紫黑、荧光青与亮粉", "深蓝、青柠绿与亮橙"), + ("抖动网点与像素簇", "扫描线与精细精灵边缘"), ("屏幕霓虹辉光", "街机式高对比光"), + ), + StyleFamilyDefinition( + "cel_animation", "赛璐璐动画", "动漫与数字", "平涂色块、手绘墨线和戏剧轮廓光带来动画张力。", + ("#243B6B", "#F2C14E", "#E85D75"), + ("手绘赛璐璐动画", "复古电视动画"), ("深蓝、明黄与珊瑚红", "青绿、奶油白与绯红"), + ("平涂色块、墨线与胶片尘点", "赛璐璐颜料边缘与轻微套色"), ("戏剧性轮廓光", "暖色夕照补光"), + ), + StyleFamilyDefinition( + "chibi_sticker", "Q版贴纸剧场", "动漫与数字", "圆润人物、白边贴纸和糖果色让群聊轻松可爱。", + ("#F8BBD0", "#B39DDB", "#81D4FA"), + ("光泽 Q 版贴纸", "圆润萌系插画"), ("粉红、薰衣草紫与天空蓝", "柠檬黄、薄荷绿与珊瑚红"), + ("白色贴纸边与高光", "柔软塑料颗粒与纸标签质感"), ("柔和正面光", "糖果色环境光"), + ), + StyleFamilyDefinition( + "pencil_storyboard", "铅笔分镜手稿", "绘画与纸本", "石墨线、纸纹和速写痕迹保留鲜活的创作现场感。", + ("#4A4A4A", "#D9CBB6", "#B76E79"), + ("石墨铅笔手稿", "彩铅速写"), ("石墨灰、米纸色与红铅色", "炭黑、象牙白与靛蓝"), + ("纸纹、橡皮痕与辅助线", "交叉排线与纸胶带质感"), ("桌面散射光", "窗边柔和侧光"), + ), + StyleFamilyDefinition( + "natural_history_engraving", "复古博物图鉴", "传统与复古", "精密排线、旧纸斑点与博物馆光线呈现古典图鉴气质。", + ("#5B4636", "#C9B27C", "#6B7D4E"), + ("铜版蚀刻插画", "钢笔线描图鉴"), ("棕褐、橄榄绿与砖红", "墨黑、旧纸色与深蓝"), + ("铜版排线与旧纸斑点", "细密交叉线与纤维纸"), ("博物馆柔光", "轻微暗角侧光"), + ), + StyleFamilyDefinition( + "minimal_vector", "极简几何插画", "印刷与编辑", "大色块、清晰轮廓与少量错位投影保持利落现代。", + ("#111827", "#F9FAFB", "#FF6B35"), + ("扁平矢量插画", "几何拼贴插画"), ("黑、白与亮橙", "深蓝、奶油白与草绿"), + ("纯色色块与清晰轮廓", "切割几何表面"), ("均匀平面光", "轻微错位投影"), + ), + StyleFamilyDefinition( + "gouache_editorial", "不透明水粉社论", "绘画与纸本", "厚实哑光笔触与纸张颗粒形成温暖的社论插画感。", + ("#D95D39", "#E9C46A", "#2A9D8F"), + ("不透明水粉插画", "水粉与彩铅混合"), ("赭黄、珊瑚红与青绿", "灰紫、芥末黄与海军蓝"), + ("厚实笔触与纸张颗粒", "叠色水粉与干刷边缘"), ("柔和哑光", "温暖散射光"), + ), + StyleFamilyDefinition( + "stained_glass", "彩色玻璃拼画", "立体与手作", "铅条接缝、宝石色玻璃与透射光形成璀璨手作质感。", + ("#2E1A47", "#1F7A8C", "#C99700"), + ("铅条彩色玻璃", "手工玻璃马赛克"), ("紫晶、青绿与金黄", "宝石红、钴蓝与琥珀色"), + ("玻璃气泡与铅条接缝", "不规则马赛克边缘"), ("透射辉光", "高对比彩色折射光"), + ), + StyleFamilyDefinition( + "mineral_pigment", "矿物岩彩插画", "传统与复古", "矿物颗粒、石面底色与细碎金箔呈现沉稳华丽的岩彩效果。", + ("#B33A3A", "#235789", "#C6A15B"), + ("矿物岩彩插画", "石色设色绘画"), ("朱砂、石青、石绿与金色", "赭石、靛蓝、玉绿与象牙白"), + ("矿物颗粒与灰泥底", "石面纹理与细碎金箔"), ("掠射侧光", "柔和宝石反射光"), + ), +) + +STYLE_FAMILY_BY_KEY = {family.key: family for family in STYLE_FAMILIES} +STYLE_FAMILY_KEYS = tuple(family.key for family in STYLE_FAMILIES) +IMAGE_THEME_KEYS = frozenset((*CONCRETE_THEME_KEYS, *IMAGE_THEME_MODE_DEFINITIONS, *STYLE_FAMILY_KEYS)) + +_HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$") +_PERSISTED_STYLE_FORBIDDEN = ( + "版式使用", "卡片", "数据面板", "分栏", "跨格", "路线式阅读", "信息节点", "中心主视觉", "REFERENCE_0", ) @@ -119,7 +289,7 @@ def validate_image_theme_config(theme: Any, custom: Any = "") -> tuple[str, str] custom = "" if not isinstance(custom, str): raise ImageThemeError("自定义主题必须是文本") - custom_text = normalize_custom_theme(custom) if custom.strip() or key == CUSTOM_THEME else "" + custom_text = normalize_custom_theme(custom) if key == CUSTOM_THEME else "" return key, custom_text @@ -130,32 +300,48 @@ def _signature(prompt: str) -> str: return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16] -def _daily_style(seed_text: str, previous_signature: str = "") -> ResolvedImageTheme: +def _style_prompt(family: StyleFamilyDefinition, picker: random.Random) -> str: + media = picker.choice(family.media) + palette = picker.choice(family.palette) + texture = picker.choice(family.texture) + light = picker.choice(family.light) + return f"统一采用{media};配色为{palette};质感为{texture};光影为{light}。{STYLE_SAFETY_SUFFIX}" + + +def _daily_style(requested_key: str, group_key: str, run_date: str, previous_signature: str = "") -> ResolvedImageTheme: for attempt in range(20): - material = f"{STYLE_CATALOG_VERSION}|{seed_text}|{attempt}" + material = f"{STYLE_CATALOG_VERSION}|{requested_key}|{group_key}|{run_date}|{attempt}" seed = int(hashlib.sha256(material.encode("utf-8")).hexdigest()[:16], 16) picker = random.Random(seed) - family = picker.choice(_STYLE_FAMILIES) - media = picker.choice(family["media"]) - palette = picker.choice(family["palette"]) - texture = picker.choice(family["texture"]) - light = picker.choice(family["light"]) - prompt = ( - f"统一采用{media}形式;配色为{palette};加入{texture};光影为{light}。" - "保持中文清晰;所有风格元素只作为视觉表现,不得新增或改变聊天事实。" - ) + family = picker.choice(STYLE_FAMILIES) if requested_key == RANDOM_PRESET_THEME else STYLE_FAMILY_BY_KEY[requested_key] + prompt = _style_prompt(family, picker) signature = _signature(prompt) if signature != previous_signature or attempt == 19: return ResolvedImageTheme( - RANDOM_PRESET_THEME, - "daily_random", - family["label"], - prompt, - style_signature=signature, - style_seed=f"{seed:016x}", - catalog_version=STYLE_CATALOG_VERSION, + requested_key, family.key, family.label, prompt, + style_signature=signature, style_seed=f"{seed:016x}", catalog_version=STYLE_CATALOG_VERSION, ) - raise AssertionError("每日随机风格解析失败") + raise AssertionError("每日风格解析失败") + + +def _restored_style(key: str, persisted_meta: dict[str, Any] | None) -> ResolvedImageTheme | None: + if not persisted_meta or persisted_meta.get("requested_theme") != key: + return None + version = str(persisted_meta.get("style_catalog_version") or "") + prompt = str(persisted_meta.get("theme_prompt") or "").strip() + if version not in {"daily-style-v2", STYLE_CATALOG_VERSION} or not prompt: + return None + if any(term in prompt for term in _PERSISTED_STYLE_FORBIDDEN): + return None + return ResolvedImageTheme( + key, + str(persisted_meta.get("resolved_theme") or ("daily_random" if key == RANDOM_PRESET_THEME else key)), + str(persisted_meta.get("theme_display_name") or "每日随机"), + prompt, + style_signature=str(persisted_meta.get("style_signature") or _signature(prompt)), + style_seed=str(persisted_meta.get("style_seed") or ""), + catalog_version=version, + ) def resolve_image_theme( @@ -169,29 +355,17 @@ def resolve_image_theme( persisted_meta: dict[str, Any] | None = None, ) -> ResolvedImageTheme: key, custom_text = validate_image_theme_config(theme, custom) - if key == RANDOM_PRESET_THEME: - if ( - persisted_meta - and persisted_meta.get("requested_theme") == RANDOM_PRESET_THEME - and persisted_meta.get("theme_prompt") - and persisted_meta.get("style_catalog_version") == STYLE_CATALOG_VERSION - ): - return ResolvedImageTheme( - RANDOM_PRESET_THEME, - str(persisted_meta.get("resolved_theme") or "daily_random"), - str(persisted_meta.get("theme_display_name") or "每日随机"), - str(persisted_meta["theme_prompt"]), - style_signature=str(persisted_meta.get("style_signature") or ""), - style_seed=str(persisted_meta.get("style_seed") or ""), - catalog_version=str(persisted_meta.get("style_catalog_version") or STYLE_CATALOG_VERSION), - ) + if key == RANDOM_PRESET_THEME or key in STYLE_FAMILY_BY_KEY: + restored = _restored_style(key, persisted_meta) + if restored is not None: + return restored # 兼容旧测试/调用方显式注入的 choice 选择器。 - if rng is not None: + if key == RANDOM_PRESET_THEME and rng is not None: actual_key = rng.choice(CONCRETE_THEME_KEYS) definition = IMAGE_THEME_DEFINITIONS[actual_key] return ResolvedImageTheme(key, actual_key, definition.label, definition.prompt, style_signature=_signature(definition.prompt)) effective_date = run_date or date.today().isoformat() - return _daily_style(f"{group_key or 'preview'}|{effective_date}", previous_signature) + return _daily_style(key, group_key or "preview", effective_date, previous_signature) if key == CUSTOM_THEME: prompt = ( f"自定义大主题「{custom_text}」:严格使用该指定风格并完全替代随机风格;" @@ -208,12 +382,37 @@ def resolve_image_theme( resolve_theme = resolve_image_theme -def public_image_theme_options() -> list[dict[str, str]]: - """主界面只展示每日随机与指定风格两种模式。""" - return [ - {"key": definition.key, "label": definition.label, "description": definition.description} - for definition in ( - IMAGE_THEME_MODE_DEFINITIONS[RANDOM_PRESET_THEME], - IMAGE_THEME_MODE_DEFINITIONS[CUSTOM_THEME], +def _public_option(definition: ImageThemeDefinition) -> dict[str, object]: + return { + "key": definition.key, + "label": definition.label, + "description": definition.description, + "kind": definition.kind, + "category": definition.category, + "swatches": list(definition.swatches), + "variation_count": definition.variation_count, + } + + +def public_image_theme_options() -> list[dict[str, object]]: + """返回两种选择模式和稳定排序的 22 个公开风格家族。""" + modes = (IMAGE_THEME_MODE_DEFINITIONS[RANDOM_PRESET_THEME], IMAGE_THEME_MODE_DEFINITIONS[CUSTOM_THEME]) + presets = ( + ImageThemeDefinition( + family.key, family.label, family.description, "", kind="preset", category=family.category, + swatches=family.swatches, variation_count=family.variation_count, ) - ] + for family in STYLE_FAMILIES + ) + return [_public_option(definition) for definition in (*modes, *presets)] + + +def validate_style_catalog() -> None: + """启动测试可调用的目录自检;生产解析不在请求热路径重复运行。""" + if len(STYLE_FAMILIES) != 22 or len(STYLE_FAMILY_BY_KEY) != len(STYLE_FAMILIES): + raise ImageThemeError("公开风格家族必须恰好为 22 个且键唯一") + for family in STYLE_FAMILIES: + if family.variation_count != STYLE_VARIATIONS_PER_FAMILY: + raise ImageThemeError(f"风格 {family.key} 的变化数量不是 16") + if len(family.swatches) != 3 or any(not _HEX_COLOR_RE.fullmatch(color) for color in family.swatches): + raise ImageThemeError(f"风格 {family.key} 的色板不合法") diff --git a/app/ai/prompt_builder.py b/app/ai/prompt_builder.py index 6a8f655..741d0ad 100644 --- a/app/ai/prompt_builder.py +++ b/app/ai/prompt_builder.py @@ -91,8 +91,8 @@ _FINAL_PROMPT_RETRY_INSTRUCTION = """\ 上一次最终 Prompt 暴露了内部字段名、主题 ID,或退化成等大模块列表。请完整重写: -只用自然短标题、事实旁白、人物姓名和逐字气泡;保留全部已选话题、指定画风、统计日期与漫画分镜; -不要输出任何数据字段式栏目名,不要输出 topic ID,不要把一个话题机械装进一个等大的矩形区域。""" +按“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”写每个话题;保留全部已选话题、指定画风、统计日期与漫画分镜; +每段指定文字只出现一次,不要输出任何数据字段式栏目名、英文装饰词、Logo、网址或 topic ID,也不要把一个话题机械装进一个等大的矩形区域。""" SYSTEM_BASE = """你是「群报 GroupBrief」的漫画日报海报 Prompt 设计师。 你的唯一任务:根据给定的微信群聊内容,生成一份可以直接复制给 GPT 图片生成能力的完整中文 Prompt, @@ -111,11 +111,17 @@ 9. 【大主题】是全图最高视觉约束,控制配色、画材、服装、造型、装饰、纹理、光影和画风; 漫画分镜只控制格子几何、阅读路径和镜头节拍,不得替换或削弱【大主题】。 10. 一个话题不等于一个矩形模块;5~7 个话题可以展开为 7~12 个镜头,至少一个话题使用连续镜头。 -11. 每段内容用自然短标题、一句事实旁白、真实人物姓名和至少一句逐字气泡呈现; - 禁止输出内部字段名、topic ID、表格栏目或说明性标签。 -12. 格子必须有明显的大、中、小三级尺寸差,并按计划使用嵌套特写、连续动作或跨格主体; +11. 每段内容必须写成“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”,不得只给抽象总结。 +12. 每个话题只显示一个不超过 12 个汉字的自然短标题、一个完整真实姓名、一句不超过 24 个汉字的事实短句, + 以及默认一条不超过 22 个汉字的真实主气泡;只有连续镜头确有需要时才允许第二条短气泡。 +13. 所有指定文字必须逐字且恰好出现一次;禁止输出内部字段名、topic ID、表格栏目、说明性标签、 + 自动创造的栏目名、英文装饰词、Logo 或网址。 +14. 海报用于微信手机端,画布固定为 1024×1536 竖版;关键文字避开四周安全边距,缩略图优先看清主标题、日期和两项统计。 +15. 空间不足时严格依次减少装饰、底部总结、副标题、次要气泡;日期、两项统计、全部话题、完整姓名、事实短句和主气泡不可删除。 +16. 重新生图只允许改变所选美术家族及当天解析出的视觉细节;聊天事实、日期、数字、人物、气泡、话题覆盖和既定分镜不得改变。 +17. 格子必须有明显的大、中、小三级尺寸差,并按计划使用嵌套特写、连续动作或跨格主体; 禁止整齐两列等高矩形和“每个话题一块”的列表式构图。 -13. 必须把给定的“统计日期:YYYY-MM-DD”作为清晰可见的画面文字,放在海报顶部或底部,不得省略或改写。""" +18. 必须把给定的“统计日期:YYYY-MM-DD”作为清晰可见的画面文字,放在海报顶部或底部,不得省略或改写。""" CHUNK_ANALYZE_SYSTEM = """你是群聊事件分析助手。只提取聊天中真实存在的事件/人物/原话, 输出严格 JSON(不输出其他内容),没有事件就返回空数组。""" @@ -191,21 +197,27 @@ def build_grounded_story_material(selection: dict, topic_order: tuple[str, ...]) raise ValueError("漫画阅读顺序没有覆盖全部入选主题") lines = [ - f"整页按阅读顺序讲清以下 {len(ordered)} 段真实群聊剧情。序号仅表示阅读次序,不得画进图片:" + f"整页按阅读顺序讲清以下 {len(ordered)} 段真实群聊剧情。序号仅表示阅读次序,不得画进图片。" + "每段都要给出具体景别、人物动作、群友反应或道具特写,并使用下列逐字文字:" ] for index, item in enumerate(ordered, start=1): - title = _compact_visible_text(item.get("title"), 24) or "群聊话题" - participant_label = str(item.get("participant_label") or "群友(昵称未识别)").strip() - fact = _compact_visible_text(item.get("summary"), 72) or "(仅按该话题的真实消息证据绘制)" + title = _compact_visible_text(item.get("title"), 12) or "群聊话题" + visible_people = item.get("visible_participants") if isinstance(item.get("visible_participants"), list) else [] + participant_label = next((str(value).strip() for value in visible_people if str(value).strip()), "") + if not participant_label: + participant_label = str(item.get("participant_label") or "群友(昵称未识别)").strip() + fact = _compact_visible_text(item.get("summary"), 24) or "仅按真实消息证据绘制" quotes = item.get("quotes") if isinstance(item.get("quotes"), list) else [] - quote = next((_compact_visible_text(value, 48) for value in quotes if str(value or "").strip()), "") + quote = next((_compact_visible_text(value, 22) for value in quotes if str(value or "").strip()), "") + bubble = quote or _compact_visible_text(fact, 22) lines.append( - f"{index}. 小标题写《{title}》。画面讲清“{fact}”。让 {participant_label} 出现在对应场景附近," - f"其中一个气泡逐字写“{quote or fact}”。" + f"{index}. 短标题逐字写《{title}》;完整姓名逐字写“{participant_label}”;" + f"事实短句逐字写“{fact}”;主气泡逐字写“{bubble}”。" + "画面指令必须补全景别、该人物的具体动作,以及群友反应或对应道具特写。" ) lines.append( - "这些剧情句只用于指导绘画;画面只显示小标题、短旁白、人物姓名和气泡正文," - "不要显示序号、说明文字、字段名称或程序标识。" + "以上每段指定文字在整张图中恰好出现一次。画面只显示允许的短标题、事实短句、完整姓名和气泡正文;" + "不要显示序号、说明文字、字段名称、程序标识、英文装饰词、Logo、网址或额外标签。" ) return "\n".join(lines) @@ -486,6 +498,7 @@ def _theme_constraint(theme_prompt: str) -> str: + theme_prompt + "\n大主题控制全图配色、画材、服装、造型、装饰、纹理、光影和画风;" "不得创造、补充或改写聊天事实,也不得被整体版式替换或削弱。" + "重新生图只允许改变所选美术家族及当天已解析的视觉细节,日期、数字、人物、逐字气泡、话题覆盖和既定分镜都是不变量。" ) @staticmethod diff --git a/app/ai/prompt_templates.py b/app/ai/prompt_templates.py index b8e7cb1..09bd56a 100644 --- a/app/ai/prompt_templates.py +++ b/app/ai/prompt_templates.py @@ -17,6 +17,9 @@ DEFAULT_IMAGE_PROMPT_TEMPLATE = """【任务】 生成一张竖版微信群日报漫画信息图。 +【使用场景与画布】 +用于微信手机端阅读,画布固定为 1024×1536 竖版。关键文字避开四周安全边距;缩略图状态优先看清主标题、统计日期和两项统计数据。 + 【创作优先级】 事实真实性是准入门槛;通过真实性校验后,好玩程度、群内识别度和视觉笑点是第一优化目标。 正常保留 5~7 个独立话题的密度,用漫画镜头表现“谁做了什么、别人怎样接话”,不要画成栏目列表。 @@ -51,10 +54,18 @@ 按给定阅读顺序使用全部入选话题,不得遗漏、重复、增删或改选。 一个话题不等于一个矩形模块;同一话题可以用连续的环境、动作、对白、反应或特写镜头展开。 正常 5~7 个话题应形成 7~12 个视觉格,至少一个话题使用两个以上连续镜头。 +每个话题必须写成可绘制的“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”,不得只写抽象总结。 + +【逐话题可见文字合同】 +每个话题只使用:一个不超过 12 个汉字的自然短标题、一个完整真实姓名、一句不超过 24 个汉字的事实短句,以及默认一条不超过 22 个汉字的真实主气泡。 +只有连续镜头确有需要时,才允许增加第二条不超过 22 个汉字的短气泡;完整真实姓名不得缩写、替换或省略。 【画面文字白名单】 只清晰绘制:主标题、统计日期、给定数据、自然的话题短标题、短事实旁白、真实姓名和精选群聊气泡。 -不得绘制程序字段、主题编号、说明性栏目名或 JSON;空间不足时先减少装饰和副标题,保留事实与气泡。 +每段指定文字逐字、恰好出现一次,不得重复;不得绘制程序字段、主题编号、说明性栏目名、JSON、自动创造的栏目名、英文装饰词、Logo、网址或额外标签。 + +【空间不足时的降级顺序】 +严格依次减少:装饰 → 底部总结 → 副标题 → 次要气泡。不得删除主标题、统计日期、两项统计数据、任何入选话题、完整真实姓名、事实短句或主气泡。 【分镜表现】 整页至少有大、中、小三级格子尺寸差;使用嵌套反应小格、连续动作、局部特写或一次跨格主体建立节奏。 @@ -63,6 +74,9 @@ 【底部总结】 可用一句短文案回收当天讨论;不使用“信息量拉满”“一天顶一周”“比过山车还刺激”等通用套话。 +【重新生图不变量】 +重新生图时只允许改变所选美术家族和当天已解析的画材、配色、纹理、光影细节;聊天事实、统计日期、数字、人物、逐字气泡、话题覆盖和既定漫画分镜不得改变。 + 【硬性要求】 1. 只使用聊天内容中真实存在的事件、人物、对话,禁止编造。 2. 不得凭空补充金额、时间、地点、身份关系。 @@ -77,6 +91,8 @@ 11. 每个入选话题至少显示一个真实姓名、一句事实短句和一句给定气泡,不得用泛化头像替代人物。 12. 漫画主体与对话必须和对应聊天事实直接相关,视觉比喻只能放大已有笑点,不能另写故事。 13. 必须把“统计日期:{{report_date}}”逐字作为清晰可见的画面文字,放在海报顶部或底部。 +14. 指定文字必须逐字且恰好出现一次;不得自行创造栏目名、英文装饰词、Logo、网址或说明性标签。 +15. 空间不足时只能按既定降级顺序缩减,日期、两项统计、全部话题、真实姓名、事实短句和主气泡是不可删除项。 """ # 生图 Prompt 模板支持的变量 diff --git a/docs/tasks/2026-08-24-image-style-library-prompt-hardening.md b/docs/tasks/2026-08-24-image-style-library-prompt-hardening.md new file mode 100644 index 0000000..b83a44f --- /dev/null +++ b/docs/tasks/2026-08-24-image-style-library-prompt-hardening.md @@ -0,0 +1,72 @@ +# 生图风格库与 Prompt 补强执行任务 + +## 背景 + +GroupBrief 已支持每日可复现随机风格、自定义风格、动态漫画分镜与运行级 Prompt 编辑,但随机风格家族尚未作为可搜索的正式目录开放,群级配置也只能在每日随机和自定义文本之间切换。现有 Prompt 对真实聊天事实已有约束,仍需进一步明确手机端画布、逐话题文字配额、可绘制镜头、空间不足时的降级顺序及重新生图不变量。 + +本任务只抽象借鉴外部风格分类与 OpenAI 官方图像提示词指南,不复制外部仓库的 Prompt、图片、品牌、角色、艺术家姓名或参考图依赖。 + +## 目标 + +- 把现有 10 个随机风格家族整理为带稳定键、分类、说明和色板的正式目录,并新增 12 个家族。 +- 每个命名预设固定家族,但在家族内提供 16 种可复现的每日微变化;每日随机覆盖 22 个家族的 352 种组合。 +- 群级默认风格和运行级风格共用紧凑、可搜索、可筛选且支持键盘操作的选择器。 +- 强化微信手机端竖版画布、逐字文字、动作镜头、空间降级和重新生图不变量,不降低正常 5~7 个真实话题的信息密度。 +- 修复 AI 图片页选题评分字段与后端不一致导致的潜在运行时错误。 + +## 允许修改范围 + +- `app/ai/image_themes.py` 风格目录、解析、兼容和公开元数据。 +- V2 风格目录与解析 API 的响应元数据,不改变现有请求体。 +- `templates/image_prompt/default.md`、`app/ai/prompt_templates.py` 与 `app/ai/prompt_builder.py` 的图片 Prompt 约束。 +- `frontend/src/pages/v2/AIImages.tsx`、必要的前端类型和 `frontend/src/styles.css`。 +- 与风格、Prompt、群 API、运行级编辑和 UI API 直接相关的测试。 +- 本任务说明文件。 + +## 禁止修改范围 + +- 不新增或迁移数据库列,不改变调度、归档、微信发送、邮件发送或生图调用链路。 +- 不读取真实聊天,不修改 `data/groupbrief.db`,不写入 `output/`。 +- 不调用真实 ImageGen,不发送微信或邮件,不修改真实群配置。 +- 不引入外部图片、预览图、品牌、角色 IP、艺术家姓名、`REFERENCE_0` 或参考图依赖。 +- 不读取、输出或提交 `.env`、API Key、Token、Cookie、浏览器数据或其他 secrets。 +- 不修改 Codex 模型提供商、登录方式或认证配置。 + +## 已确定实现要求 + +1. 公开目录固定包含 `random_preset`、`custom` 两个模式和 22 个命名预设;每项提供稳定键、名称、说明、类型、分类、三色色板和变化数量。 +2. 每个家族提供两组画材、配色、纹理、光影候选,共 16 种组合;统一安全尾句只控制美术语言和视觉质感,不得删改事实、人物、数字或指定文字。 +3. 目录版本升级为 `daily-style-v3`。种子包含版本、主题键、群键和运行日期;同群同日一致,跨日尽量避免与上一签名完全相同。 +4. 安全的 `daily-style-v2` 已保存 `theme_prompt` 原样复用;旧版中夹带版式结构的 Prompt 拒绝复用。历史具体主题键继续解析但不在公开目录展示。 +5. 群级保存命名预设时写入预设键并清空自定义文本;未保存草稿跨目标群切换保留。运行级切换只替换 Prompt 的【大主题】段。 +6. Prompt 明确微信手机端 `1024×1536` 竖版、安全边距、逐话题短标题/真实姓名/事实句/主气泡、可绘制镜头、逐字文字恰好一次、空间降级顺序和重新生图不变量。 +7. 前端评分字段使用后端真实的 `comedy`、`group_recognition`、`visual`、`discussion`、`participation`、`continuity`。 + +## 验收标准 + +- 后端目录测试覆盖 22 个公开家族、每个 16 种变化、唯一键、合法色板及禁用词/IP/参考图依赖。 +- 确定性和兼容测试覆盖同日一致、跨日微变化、上一签名排除、v2 复用、旧版污染拒绝与历史主题键解析。 +- API 测试覆盖稳定顺序、新字段、群配置预设保存及运行级只替换主题段。 +- Prompt 测试确认文件模板与内置模板一致,新画布、文字配额、镜头动作、降级顺序和不变量存在,2~7 个真实话题校验不回退。 +- 前端构建、后端定向测试、完整测试、`git diff --check` 通过。 +- 隔离数据库浏览器检查覆盖搜索、分类、预设/自定义/随机、保存重载、运行级替换、评分卡、Esc 和 1280×720 横向布局。 +- 最终检查实际 diff、依赖、硬编码、TODO/debug、临时文件和范围外修改。 + +## 测试命令 + +```powershell +$env:DATABASE_URL='sqlite:///data/style-library-smoke.db' +.\.venv\Scripts\python.exe -m pytest -q tests/test_daily_random_theme.py tests/test_v2_group_prompt_api.py tests/test_v2_prompt_builder.py tests/test_image_layouts.py tests/test_v2_prompt_editing.py tests/test_ui_api.py +.\.venv\Scripts\python.exe -m pytest -q +npm --prefix frontend run build +git diff --check +``` + +浏览器检查使用隔离数据库和本地测试服务;不得连接真实生产数据库或触发任何外部发送/生图动作。 + +## 返回格式 + +- 风格目录、选择器、Prompt 补强和兼容性说明。 +- 修改文件、定向/全量测试、前端构建和隔离 UI 检查结果。 +- 明确声明未执行真实生图、未读取真实聊天、未发送微信或邮件、未修改生产数据库。 +- Git 分支、提交哈希、远端仓库地址和推送结果。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9b89d1..946a18c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -210,6 +210,10 @@ export interface ImageThemeOption { key: string; label: string; description: string; + kind: "mode" | "preset"; + category: string; + swatches: string[]; + variation_count: number; } export interface ResolvedImageTheme { @@ -249,7 +253,8 @@ export interface RunPromptConfig { export interface TopicScores { discussion: number; participation: number; - interestingness: number; + comedy: number; + group_recognition: number; visual: number; continuity: number; total: number; diff --git a/frontend/src/components/ImageThemePicker.tsx b/frontend/src/components/ImageThemePicker.tsx new file mode 100644 index 0000000..729ab44 --- /dev/null +++ b/frontend/src/components/ImageThemePicker.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ImageThemeOption } from "../api"; + +const THEME_CATEGORIES = [ + "印刷与编辑", + "绘画与纸本", + "立体与手作", + "动漫与数字", + "科技与结构", + "传统与复古", +] as const; + +interface ImageThemePickerProps { + themes: ImageThemeOption[]; + value: string; + onChange: (key: string) => void; + label: string; + loading?: boolean; + error?: string; + disabled?: boolean; +} + +function ThemeSwatches({ colors }: { colors: string[] }) { + if (!colors.length) return null; + return ( + + ); +} + +export function ImageThemePicker({ + themes, + value, + onChange, + label, + loading = false, + error = "", + disabled = false, +}: ImageThemePickerProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(""); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const searchRef = useRef(null); + const current = themes.find((theme) => theme.key === value); + const modes = themes.filter((theme) => theme.kind === "mode"); + const presets = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + return themes.filter((theme) => { + if (theme.kind !== "preset") return false; + if (category && theme.category !== category) return false; + if (!normalized) return true; + return `${theme.label} ${theme.description} ${theme.category}`.toLocaleLowerCase().includes(normalized); + }); + }, [category, query, themes]); + + useEffect(() => { + if (!open) return; + const handlePointer = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handlePointer); + window.setTimeout(() => searchRef.current?.focus(), 0); + return () => document.removeEventListener("mousedown", handlePointer); + }, [open]); + + const close = () => { + setOpen(false); + window.setTimeout(() => triggerRef.current?.focus(), 0); + }; + + const choose = (key: string) => { + onChange(key); + close(); + }; + + const status = current?.key === "random_preset" + ? `每日随机 · ${current.variation_count} 种组合` + : current?.key === "custom" + ? "自定义描述" + : current ? `${current.variation_count} 种微变化` : "兼容主题"; + + return ( +
{ + if (event.key === "Escape" && open) { + event.preventDefault(); + close(); + } + }}> + {label} + + {open && ( +
+
+
选择生图风格预设家族每天产生可复现的细微变化
+ +
+ {loading ?
正在读取风格目录…
: error ?
{error}
: ( + <> +
+ {modes.map((theme) => ( + + ))} +
+ +
+ + {THEME_CATEGORIES.map((item) => )} +
+ {presets.length ?
+ {presets.map((theme) => ( + + ))} +
:
没有匹配的风格,换个关键词或分类试试。
} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/v2/AIImages.tsx b/frontend/src/pages/v2/AIImages.tsx index 8ba241d..066e0ee 100644 --- a/frontend/src/pages/v2/AIImages.tsx +++ b/frontend/src/pages/v2/AIImages.tsx @@ -41,6 +41,7 @@ import { StatusBadge, Toast, } from "../../components/common"; +import { ImageThemePicker } from "../../components/ImageThemePicker"; import { copyText, useToast } from "../../components/ui"; const STATUS_LABELS: Record = { @@ -145,6 +146,7 @@ export default function AIImages() { const [defaultConfig, setDefaultConfig] = useState(null); const [globalDefaultPrompt, setGlobalDefaultPrompt] = useState(""); const [defaultTemplateError, setDefaultTemplateError] = useState(""); + const [defaultTheme, setDefaultTheme] = useState("random_preset"); const [defaultCustom, setDefaultCustom] = useState(""); const [defaultThemeText, setDefaultThemeText] = useState(""); const [defaultThemeError, setDefaultThemeError] = useState(""); @@ -254,9 +256,10 @@ export default function AIImages() { if (cancelled) return; setDefaultConfig(config); if (!defaultStyleTouchedRef.current) { + setDefaultTheme(config.image_theme || "random_preset"); const savedCustom = config.image_theme === "custom" ? config.image_theme_custom || "" : ""; setDefaultCustom(savedCustom); - setDefaultThemeText(savedCustom ? config.resolved_theme?.theme_text || "" : ""); + setDefaultThemeText(config.resolved_theme?.theme_text || ""); } }) .catch((reason: unknown) => { @@ -274,6 +277,7 @@ export default function AIImages() { }, [defaultGroupId, defaultReloadVersion, toast]); useEffect(() => { + if (defaultTheme !== "custom") return; const custom = defaultCustom.trim(); if (!custom) { setDefaultThemeText(""); @@ -300,7 +304,7 @@ export default function AIImages() { cancelled = true; window.clearTimeout(timer); }; - }, [defaultCustom, defaultGroupId]); + }, [defaultCustom, defaultGroupId, defaultTheme]); const filteredRuns = runs.filter((run) => { const query = groupFilter.trim().toLocaleLowerCase(); @@ -388,37 +392,64 @@ export default function AIImages() { }, [detail?.run.group_name, detail?.run.run_date, regenStatus]); const selectedDefaultGroup = groups.find((group) => group.id === defaultGroupId); + const savedDefaultTheme = defaultConfig?.image_theme || "random_preset"; const savedDefaultCustom = defaultConfig?.image_theme === "custom" ? defaultConfig.image_theme_custom.trim() : ""; - const defaultDirty = Boolean(defaultConfig) && defaultCustom.trim() !== savedDefaultCustom; - const defaultPreviewTheme = defaultCustom.trim() - ? defaultThemeText || `指定风格「${defaultCustom.trim()}」(正在生成完整约束)` - : ""; + const currentDefaultCustom = defaultTheme === "custom" ? defaultCustom.trim() : ""; + const defaultDirty = Boolean(defaultConfig) + && (defaultTheme !== savedDefaultTheme || currentDefaultCustom !== savedDefaultCustom); + const defaultPreviewTheme = defaultTheme === "custom" && currentDefaultCustom + ? defaultThemeText || `指定风格「${currentDefaultCustom}」(正在生成完整约束)` + : defaultThemeText; const defaultPreview = useMemo( () => renderGroupPreview(defaultConfig?.content || globalDefaultPrompt, selectedDefaultGroup, defaultPreviewTheme), [defaultConfig?.content, defaultPreviewTheme, globalDefaultPrompt, selectedDefaultGroup], ); const runDirty = Boolean(runPrompt) && runDraft !== runPrompt?.content; + const applyDefaultTheme = async (key: string) => { + defaultStyleTouchedRef.current = true; + setDefaultStyleTouched(true); + setDefaultTheme(key); + setDefaultThemeError(""); + if (key === "custom") { + setDefaultThemeText(""); + return; + } + setDefaultCustom(""); + try { + const resolved = await resolveImageTheme({ + image_theme: key, + group_id: defaultGroupId ?? undefined, + }); + setDefaultThemeText(resolved.theme_text); + } catch (reason) { + setDefaultThemeText(""); + setDefaultThemeError(`风格预览失败:${String(reason)}`); + } + }; + const saveDefaultStyle = async () => { if (!defaultConfig || defaultGroupId === null) return; setDefaultSaving(true); try { - const custom = defaultCustom.trim(); + const custom = defaultTheme === "custom" ? defaultCustom.trim() : ""; await updateGroup(defaultGroupId, { - image_theme: custom ? "custom" : "random_preset", + image_theme: defaultTheme, image_theme_custom: custom, }); const refreshed = await getGroupImagePrompt(defaultGroupId); setDefaultConfig(refreshed); + setDefaultTheme(refreshed.image_theme || "random_preset"); setDefaultCustom(refreshed.image_theme === "custom" ? refreshed.image_theme_custom || "" : ""); - setDefaultThemeText(refreshed.image_theme === "custom" ? refreshed.resolved_theme?.theme_text || "" : ""); + setDefaultThemeText(refreshed.resolved_theme?.theme_text || ""); defaultStyleTouchedRef.current = false; setDefaultStyleTouched(false); - toast(custom ? `已把指定风格保存到「${selectedDefaultGroup?.display_name || selectedDefaultGroup?.wechat_group_name || `群 ${defaultGroupId}`}」` : "已清除该群的指定风格"); + const selectedThemeLabel = themes.find((theme) => theme.key === defaultTheme)?.label || "生图风格"; + toast(`已把「${selectedThemeLabel}」保存到「${selectedDefaultGroup?.display_name || selectedDefaultGroup?.wechat_group_name || `群 ${defaultGroupId}`}」`); } catch (reason) { - toast(`指定风格保存失败:${String(reason)}`); + toast(`生图风格保存失败:${String(reason)}`); } finally { setDefaultSaving(false); } @@ -426,10 +457,12 @@ export default function AIImages() { const applyRunTheme = async (key: string, custom = runCustom) => { setRunTheme(key); + if (key !== "custom") setRunCustom(""); + if (key === "custom" && !custom.trim()) return; try { const resolved = await resolveImageTheme({ image_theme: key, - image_theme_custom: custom, + image_theme_custom: key === "custom" ? custom : "", prompt: runDraft, group_id: typeof detail?.run.group_id === "number" || typeof detail?.run.group_id === "string" ? detail.run.group_id @@ -439,6 +472,7 @@ export default function AIImages() { setRunTheme(key); setRunDraft(resolved.prompt); if (key === "random_preset") toast(`该群当天随机风格已固定为:${resolved.display_name}`); + else if (key !== "custom") toast(`当天风格已替换为:${resolved.display_name}`); } catch (reason) { toast(`当天主题替换失败:${String(reason)}`); } @@ -452,7 +486,7 @@ export default function AIImages() { content: runDraft, expected_revision: runPrompt.revision, image_theme: runTheme, - image_theme_custom: runCustom.trim(), + image_theme_custom: runTheme === "custom" ? runCustom.trim() : "", }); setRunPrompt(saved); setRunDraft(saved.content); @@ -551,25 +585,29 @@ export default function AIImages() {
-

设置群聊生图风格

先输入风格并确认 Prompt,再选择要保存到的群聊。

+

设置群聊生图风格

可每日随机,也可固定一个风格家族并保留每天的细微变化。

{catalogLoading && !groups.length ? : groupsError && !groups.length ? 重新加载} /> : !groups.length ? : ( <> - + { void applyDefaultTheme(key); }} + label="风格模式" + loading={catalogLoading} + error={themesError} + disabled={defaultSaving} + /> + {defaultTheme === "custom" && }
生成时使用的 Prompt 预览
{defaultPreview || "Prompt 预览暂不可用"}
@@ -586,9 +624,9 @@ export default function AIImages() { {groups.map((group) => )} - +
- {defaultStyleTouched && defaultGroupId !== null && defaultDirty &&
已保留你刚输入的风格,保存后才会应用到当前目标群。
} + {defaultStyleTouched && defaultGroupId !== null && defaultDirty &&
风格草稿已保留,切换目标群不会覆盖;明确保存后才会应用。
} {groupsError &&
{groupsError}
} {themesError &&
{themesError}
} {defaultTemplateError &&
{defaultTemplateError}
} @@ -622,12 +660,12 @@ export default function AIImages() {
{runPrompt.topic_selection.candidates.map((topic) =>
#{topic.rank}{topic.title}{topic.selected ? "已入选" : "候选"}{topic.scores.total.toFixed(1)}

{topic.summary}

-
讨论 {topic.scores.discussion}参与 {topic.scores.participation}有趣 {topic.scores.interestingness}画面 {topic.scores.visual}持续 {topic.scores.continuity}
+
讨论 {topic.scores.discussion}参与 {topic.scores.participation}有趣 {topic.scores.comedy}群内感 {topic.scores.group_recognition}画面 {topic.scores.visual}持续 {topic.scores.continuity}
{topic.evidence_message_count} 条证据 · {topic.participant_count} 人 · {topic.duration_minutes} 分钟{topic.score_reason ? ` · ${topic.score_reason}` : ""}
)}
} {runPrompt &&
- + { void applyRunTheme(key); }} label="替换当天大主题" loading={catalogLoading} error={themesError} disabled={runSaving} /> {runTheme === "custom" && }
}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e44e54f..b0741bd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -5221,6 +5221,292 @@ textarea:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); } +.image-theme-picker { + position: relative; + min-width: 0; +} + +.image-theme-picker-label { + display: block; + margin-bottom: 6px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 650; +} + +.image-theme-picker-trigger { + display: flex; + width: 100%; + min-height: 48px; + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + color: var(--text); + background: var(--card); + text-align: left; + cursor: pointer; +} + +.image-theme-picker-trigger:hover, +.image-theme-picker-trigger:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + outline: none; +} + +.image-theme-picker-trigger:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.image-theme-picker-trigger > span:nth-child(2) { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.image-theme-picker-trigger b, +.image-theme-picker-trigger small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-theme-picker-trigger b { + font-size: 13px; +} + +.image-theme-picker-trigger small { + color: var(--text-secondary); + font-size: 10.5px; +} + +.image-theme-picker-caret { + color: var(--text-secondary); + font-size: 18px; + font-style: normal; +} + +.image-theme-swatches { + display: inline-flex; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: 999px; +} + +.image-theme-swatches i { + display: block; + width: 14px; + height: 24px; +} + +.image-theme-picker-popover { + position: absolute; + z-index: 40; + top: calc(100% + 8px); + left: 0; + width: 100%; + max-height: min(620px, calc(100vh - 96px)); + overflow: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); + box-shadow: 0 18px 48px rgba(24, 39, 75, 0.2); +} + +.image-theme-picker-popover-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.image-theme-picker-popover-head > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.image-theme-picker-popover-head strong { + font-size: 14px; +} + +.image-theme-picker-popover-head span { + color: var(--text-secondary); + font-size: 10.5px; +} + +.image-theme-picker-popover-head button { + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + color: var(--text-secondary); + background: var(--surface-muted); + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.image-theme-mode-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.image-theme-mode-list button, +.image-theme-preset-grid button { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + background: var(--card); + text-align: left; + cursor: pointer; +} + +.image-theme-mode-list button { + justify-content: space-between; + padding: 9px 10px; +} + +.image-theme-mode-list button:hover, +.image-theme-mode-list button:focus-visible, +.image-theme-preset-grid button:hover, +.image-theme-preset-grid button:focus-visible, +.image-theme-mode-list button.is-active, +.image-theme-preset-grid button.is-active { + border-color: var(--accent); + background: var(--accent-soft); + outline: none; +} + +.image-theme-mode-list span, +.image-theme-preset-grid button > span:nth-child(2) { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.image-theme-mode-list b, +.image-theme-preset-grid b { + font-size: 12px; +} + +.image-theme-mode-list small, +.image-theme-preset-grid small { + overflow: hidden; + color: var(--text-secondary); + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-theme-mode-list em, +.image-theme-preset-grid em { + flex: 0 0 auto; + color: var(--accent); + font-size: 10px; + font-style: normal; +} + +.image-theme-search { + display: block; + margin-top: 10px; +} + +.image-theme-search > span { + display: block; + margin-bottom: 5px; + color: var(--text-secondary); + font-size: 10.5px; + font-weight: 650; +} + +.image-theme-search input { + width: 100%; + min-height: 36px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--text); + background: var(--surface); + outline: none; +} + +.image-theme-search input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.image-theme-category-list { + display: flex; + overflow-x: auto; + gap: 6px; + margin: 9px 0; + padding-bottom: 2px; +} + +.image-theme-category-list button { + flex: 0 0 auto; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-secondary); + background: var(--surface); + cursor: pointer; + font-size: 10px; +} + +.image-theme-category-list button.is-active, +.image-theme-category-list button:hover, +.image-theme-category-list button:focus-visible { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); + outline: none; +} + +.image-theme-preset-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.image-theme-preset-grid button { + padding: 8px 9px; +} + +.image-theme-preset-grid .image-theme-swatches i { + width: 9px; + height: 28px; +} + +.image-theme-picker-state { + padding: 18px 10px; + color: var(--text-secondary); + font-size: 12px; + text-align: center; +} + +.image-theme-picker-state.is-error { + color: var(--danger); +} + .ai-images-theme-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -5468,6 +5754,10 @@ textarea:focus-visible { margin: 14px 0; } +.ai-images-run-theme-row > .image-theme-picker { + min-width: 0; +} + .ai-images-topic-score-card { margin-top: 14px; border: 1px solid var(--border); @@ -5516,7 +5806,7 @@ textarea:focus-visible { .ai-images-topic-score-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; } @@ -5627,6 +5917,14 @@ textarea:focus-visible { grid-template-columns: 1fr; } + .image-theme-preset-grid { + grid-template-columns: 1fr; + } + + .ai-images-topic-score-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .ai-images-run-actions, .ai-images-review-actions, .ai-images-theme-footer > div { diff --git a/templates/image_prompt/default.md b/templates/image_prompt/default.md index 55fb90a..21bed6f 100644 --- a/templates/image_prompt/default.md +++ b/templates/image_prompt/default.md @@ -20,6 +20,9 @@ GroupBrief V2 生图 Prompt 默认模板(P4 ImagePromptBuilder 读取)。 【任务】 生成一张竖版微信群日报漫画信息图。 +【使用场景与画布】 +用于微信手机端阅读,画布固定为 1024×1536 竖版。关键文字避开四周安全边距;缩略图状态优先看清主标题、统计日期和两项统计数据。 + 【创作优先级】 事实真实性是准入门槛;通过真实性校验后,好玩程度、群内识别度和视觉笑点是第一优化目标。 正常保留 5~7 个独立话题的密度,用漫画镜头表现“谁做了什么、别人怎样接话”,不要画成栏目列表。 @@ -54,10 +57,18 @@ GroupBrief V2 生图 Prompt 默认模板(P4 ImagePromptBuilder 读取)。 按给定阅读顺序使用全部入选话题,不得遗漏、重复、增删或改选。 一个话题不等于一个矩形模块;同一话题可以用连续的环境、动作、对白、反应或特写镜头展开。 正常 5~7 个话题应形成 7~12 个视觉格,至少一个话题使用两个以上连续镜头。 +每个话题必须写成可绘制的“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”,不得只写抽象总结。 + +【逐话题可见文字合同】 +每个话题只使用:一个不超过 12 个汉字的自然短标题、一个完整真实姓名、一句不超过 24 个汉字的事实短句,以及默认一条不超过 22 个汉字的真实主气泡。 +只有连续镜头确有需要时,才允许增加第二条不超过 22 个汉字的短气泡;完整真实姓名不得缩写、替换或省略。 【画面文字白名单】 只清晰绘制:主标题、统计日期、给定数据、自然的话题短标题、短事实旁白、真实姓名和精选群聊气泡。 -不得绘制程序字段、主题编号、说明性栏目名或 JSON;空间不足时先减少装饰和副标题,保留事实与气泡。 +每段指定文字逐字、恰好出现一次,不得重复;不得绘制程序字段、主题编号、说明性栏目名、JSON、自动创造的栏目名、英文装饰词、Logo、网址或额外标签。 + +【空间不足时的降级顺序】 +严格依次减少:装饰 → 底部总结 → 副标题 → 次要气泡。不得删除主标题、统计日期、两项统计数据、任何入选话题、完整真实姓名、事实短句或主气泡。 【分镜表现】 整页至少有大、中、小三级格子尺寸差;使用嵌套反应小格、连续动作、局部特写或一次跨格主体建立节奏。 @@ -66,6 +77,9 @@ GroupBrief V2 生图 Prompt 默认模板(P4 ImagePromptBuilder 读取)。 【底部总结】 可用一句短文案回收当天讨论;不使用“信息量拉满”“一天顶一周”“比过山车还刺激”等通用套话。 +【重新生图不变量】 +重新生图时只允许改变所选美术家族和当天已解析的画材、配色、纹理、光影细节;聊天事实、统计日期、数字、人物、逐字气泡、话题覆盖和既定漫画分镜不得改变。 + 【硬性要求】 1. 只使用聊天内容中真实存在的事件、人物、对话,禁止编造。 2. 不得凭空补充金额、时间、地点、身份关系。 @@ -80,3 +94,5 @@ GroupBrief V2 生图 Prompt 默认模板(P4 ImagePromptBuilder 读取)。 11. 每个入选话题至少显示一个真实姓名、一句事实短句和一句给定气泡,不得用泛化头像替代人物。 12. 漫画主体与对话必须和对应聊天事实直接相关,视觉比喻只能放大已有笑点,不能另写故事。 13. 必须把“统计日期:{{report_date}}”逐字作为清晰可见的画面文字,放在海报顶部或底部。 +14. 指定文字必须逐字且恰好出现一次;不得自行创造栏目名、英文装饰词、Logo、网址或说明性标签。 +15. 空间不足时只能按既定降级顺序缩减,日期、两项统计、全部话题、真实姓名、事实短句和主气泡是不可删除项。 diff --git a/tests/test_daily_random_theme.py b/tests/test_daily_random_theme.py index 27c5a45..13f5bab 100644 --- a/tests/test_daily_random_theme.py +++ b/tests/test_daily_random_theme.py @@ -1,18 +1,114 @@ -"""每日随机画风的确定性、换日排除和自定义覆盖。""" +"""公开风格目录、每日确定性、历史恢复和旧主题兼容。""" -from app.ai.image_themes import public_image_theme_options, resolve_image_theme +import re +from app.ai.image_themes import ( + STYLE_CATALOG_VERSION, + STYLE_FAMILIES, + STYLE_SAFETY_SUFFIX, + public_image_theme_options, + resolve_image_theme, + validate_style_catalog, +) -def test_same_group_and_date_is_reproducible(): + +EXPECTED_PRESET_KEYS = [ + "silkscreen_editorial", + "paper_cut_layered", + "watercolor_journal", + "retro_futurism", + "clay_stopmotion", + "woodcut_editorial", + "glassmorphism_tech", + "children_science_picturebook", + "architectural_blueprint", + "textile_embroidery", + "ink_wash_editorial", + "art_deco_night", + "isometric_miniature", + "pixel_arcade", + "cel_animation", + "chibi_sticker", + "pencil_storyboard", + "natural_history_engraving", + "minimal_vector", + "gouache_editorial", + "stained_glass", + "mineral_pigment", +] + +EXPECTED_SWATCHES = { + "silkscreen_editorial": ("#21409A", "#F6E8C9", "#F25F5C"), + "paper_cut_layered": ("#63B3ED", "#F6C453", "#E34D3B"), + "watercolor_journal": ("#4FA3B7", "#B8D8BA", "#C97B84"), + "retro_futurism": ("#1E2A5E", "#C56E33", "#F2E9D8"), + "clay_stopmotion": ("#F2C94C", "#5DADE2", "#E96B6B"), + "woodcut_editorial": ("#171717", "#F3E6C8", "#B52A2A"), + "glassmorphism_tech": ("#25304A", "#67E8F9", "#A78BFA"), + "children_science_picturebook": ("#F5C542", "#67B76F", "#5AA7E8"), + "architectural_blueprint": ("#165DFF", "#F8FAFC", "#FF8A34"), + "textile_embroidery": ("#344E7A", "#F4ECD8", "#A64B3C"), + "ink_wash_editorial": ("#1B1D1F", "#264653", "#C43D2F"), + "art_deco_night": ("#0D3B2E", "#D4AF37", "#F5E6C8"), + "isometric_miniature": ("#8EC5FC", "#F9C74F", "#90BE6D"), + "pixel_arcade": ("#2B174A", "#00D4FF", "#FF4D8D"), + "cel_animation": ("#243B6B", "#F2C14E", "#E85D75"), + "chibi_sticker": ("#F8BBD0", "#B39DDB", "#81D4FA"), + "pencil_storyboard": ("#4A4A4A", "#D9CBB6", "#B76E79"), + "natural_history_engraving": ("#5B4636", "#C9B27C", "#6B7D4E"), + "minimal_vector": ("#111827", "#F9FAFB", "#FF6B35"), + "gouache_editorial": ("#D95D39", "#E9C46A", "#2A9D8F"), + "stained_glass": ("#2E1A47", "#1F7A8C", "#C99700"), + "mineral_pigment": ("#B33A3A", "#235789", "#C6A15B"), +} + + +def test_public_catalog_has_two_modes_and_22_stable_presets(): + validate_style_catalog() + options = public_image_theme_options() + assert [item["key"] for item in options[:2]] == ["random_preset", "custom"] + assert all(item["kind"] == "mode" for item in options[:2]) + assert [item["key"] for item in options[2:]] == EXPECTED_PRESET_KEYS + assert all(item["kind"] == "preset" for item in options[2:]) + assert len(options) == 24 + assert options[0]["variation_count"] == 352 + + +def test_every_family_has_16_variations_valid_swatches_and_safe_visual_language(): + forbidden = ( + "分栏", "卡片", "数据面板", "跨格", + "REFERENCE_0", "参考图", "艺术家", "品牌", "角色 IP", + ) + for family in STYLE_FAMILIES: + assert family.variation_count == 16 + assert len(family.swatches) == 3 + assert family.swatches == EXPECTED_SWATCHES[family.key] + assert all(re.fullmatch(r"#[0-9A-F]{6}", color) for color in family.swatches) + visual_text = " ".join(( + family.description, + *family.media, + *family.palette, + *family.texture, + *family.light, + )) + assert all(term not in visual_text for term in forbidden), family.key + + +def test_same_group_date_and_named_family_are_reproducible(): first = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-21") second = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-21") assert second.style_seed == first.style_seed assert second.style_signature == first.style_signature assert second.prompt == first.prompt + named = resolve_image_theme("ink_wash_editorial", group_key="group-1", run_date="2026-08-21") + named_again = resolve_image_theme("ink_wash_editorial", group_key="group-1", run_date="2026-08-21") + assert named.actual_key == "ink_wash_editorial" + assert named.style_signature == named_again.style_signature + assert STYLE_SAFETY_SUFFIX in named.prompt + def test_random_style_only_controls_art_direction_not_panel_geometry(): - resolved = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-21") forbidden_layout_phrases = ( "版式使用", "卡片", @@ -21,26 +117,30 @@ def test_random_style_only_controls_art_direction_not_panel_geometry(): "路线式阅读", "信息节点", "中心主视觉", + "跨格", ) - assert all(phrase not in resolved.prompt for phrase in forbidden_layout_phrases) - assert "配色为" in resolved.prompt - assert "光影为" in resolved.prompt + for family in STYLE_FAMILIES: + resolved = resolve_image_theme(family.key, group_key="group-1", run_date="2026-08-21") + assert all(phrase not in resolved.prompt for phrase in forbidden_layout_phrases), family.key + assert "配色为" in resolved.prompt + assert "光影为" in resolved.prompt -def test_next_date_excludes_previous_style_and_groups_have_independent_seeds(): - first = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-21") +def test_next_date_excludes_previous_signature_and_keeps_named_family(): + first = resolve_image_theme("watercolor_journal", group_key="group-1", run_date="2026-08-21") next_day = resolve_image_theme( - "random_preset", + "watercolor_journal", group_key="group-1", run_date="2026-08-22", previous_signature=first.style_signature, ) - other_group = resolve_image_theme("random_preset", group_key="group-2", run_date="2026-08-21") + other_group = resolve_image_theme("watercolor_journal", group_key="group-2", run_date="2026-08-21") + assert next_day.actual_key == first.actual_key == "watercolor_journal" assert next_day.style_signature != first.style_signature assert other_group.style_seed != first.style_seed -def test_force_rerun_reuses_persisted_style(): +def test_current_and_safe_v2_persisted_styles_are_reused_verbatim(): first = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-21") restored = resolve_image_theme( "random_preset", @@ -52,8 +152,18 @@ def test_force_rerun_reuses_persisted_style(): assert restored.style_seed == first.style_seed assert restored.prompt == first.prompt + v2_meta = { + **first.to_meta(), + "resolved_theme": "daily_random", + "style_catalog_version": "daily-style-v2", + "theme_prompt": "统一采用颗粒丝网印刷形式;配色为群青与奶油白;加入纸张颗粒;光影为平面高对比光影。", + } + restored_v2 = resolve_image_theme("random_preset", persisted_meta=v2_meta) + assert restored_v2.prompt == v2_meta["theme_prompt"] + assert restored_v2.catalog_version == "daily-style-v2" + -def test_legacy_random_style_is_not_restored_with_old_layout_language(): +def test_polluted_v1_style_is_not_restored(): current = resolve_image_theme("random_preset", group_key="group-1", run_date="2026-08-24") legacy_meta = { **current.to_meta(), @@ -66,13 +176,14 @@ def test_legacy_random_style_is_not_restored_with_old_layout_language(): run_date="2026-08-24", persisted_meta=legacy_meta, ) - assert rebuilt.catalog_version == "daily-style-v2" + assert rebuilt.catalog_version == STYLE_CATALOG_VERSION assert "数据面板" not in rebuilt.prompt assert "卡片" not in rebuilt.prompt -def test_custom_fully_replaces_random_and_ui_only_has_two_modes(): +def test_custom_and_legacy_concrete_themes_remain_compatible(): custom = resolve_image_theme("custom", "低饱和黏土摄影", group_key="group-1", run_date="2026-08-21") assert "低饱和黏土摄影" in custom.prompt assert "统一采用" not in custom.prompt - assert [item["key"] for item in public_image_theme_options()] == ["random_preset", "custom"] + for key in ("blue_white", "ultraman", "pink", "bull"): + assert resolve_image_theme(key).actual_key == key diff --git a/tests/test_ui_api.py b/tests/test_ui_api.py index b353eef..3b39103 100644 --- a/tests/test_ui_api.py +++ b/tests/test_ui_api.py @@ -56,6 +56,19 @@ def test_resolve_api(): assert resp.status_code == 200 +def test_v2_image_theme_catalog_shape_and_order(): + with client: + response = client.get("/api/v2/image-themes") + assert response.status_code == 200 + themes = response.json()["themes"] + assert [item["key"] for item in themes[:2]] == ["random_preset", "custom"] + assert len(themes) == 24 + assert sum(item["kind"] == "preset" for item in themes) == 22 + assert themes[2]["key"] == "silkscreen_editorial" + assert themes[-1]["key"] == "mineral_pigment" + assert all(set(item) == {"key", "label", "description", "kind", "category", "swatches", "variation_count"} for item in themes) + + @pytest.mark.parametrize("bad_date", ["2026-02-30", "2026-8-18", "not-a-date"]) def test_v2_invalid_dates_return_400(bad_date): with client: @@ -87,12 +100,15 @@ def test_group_image_theme_roundtrip_and_validation(): assert listed["image_theme"] == "custom" assert listed["image_theme_custom"] == "手账拼贴" - # 切换到具体预设时保留自定义文本,以便稍后切回 custom 回显。 - updated = client.put(f"/api/groups/{group_id}", json={"image_theme": "pink"}) + # 切换到公开预设时保存稳定键并清空旧自定义文本。 + updated = client.put(f"/api/groups/{group_id}", json={"image_theme": "ink_wash_editorial"}) assert updated.status_code == 200 listed = next(item for item in client.get("/api/groups").json() if item["id"] == group_id) - assert listed["image_theme"] == "pink" - assert listed["image_theme_custom"] == "手账拼贴" + assert listed["image_theme"] == "ink_wash_editorial" + assert listed["image_theme_custom"] == "" + prompt_config = client.get(f"/api/groups/{group_id}/image-prompt").json() + assert prompt_config["image_theme"] == "ink_wash_editorial" + assert prompt_config["resolved_theme"]["resolved_theme"] == "ink_wash_editorial" bad_payloads = [ {"image_theme": "not_a_theme"}, diff --git a/tests/test_v2_group_prompt_api.py b/tests/test_v2_group_prompt_api.py index 135fb09..0697cbb 100644 --- a/tests/test_v2_group_prompt_api.py +++ b/tests/test_v2_group_prompt_api.py @@ -79,3 +79,24 @@ def test_group_prompt_revision_conflict_returns_409(): assert response.status_code == 409 finally: client.delete(f"/api/groups/{group_id}") + + +def test_named_theme_preview_only_replaces_canonical_theme_section(): + original = "【大主题】\n旧主题\n\n【固定画面日期】\n统计日期:2026-08-23\n\n【事件】\n张三说今天完成 3 项工作。\n" + with client: + response = client.post( + "/api/v2/image-themes/resolve", + json={ + "image_theme": "gouache_editorial", + "prompt": original, + "group_id": "group-1", + "run_date": "2026-08-24", + }, + ) + assert response.status_code == 200 + resolved = response.json() + assert resolved["actual_key"] == "gouache_editorial" + assert "不透明水粉社论" in resolved["prompt"] + assert "统计日期:2026-08-23" in resolved["prompt"] + assert "张三说今天完成 3 项工作。" in resolved["prompt"] + assert "旧主题" not in resolved["prompt"] diff --git a/tests/test_v2_prompt_builder.py b/tests/test_v2_prompt_builder.py index ca41bfd..15d764e 100644 --- a/tests/test_v2_prompt_builder.py +++ b/tests/test_v2_prompt_builder.py @@ -12,13 +12,15 @@ import pytest -from app.ai.prompt_builder import DeepSeekImagePromptBuilder +from app.ai.prompt_builder import DeepSeekImagePromptBuilder, build_grounded_story_material from app.ai.prompt_builder_types import PromptInput +from app.ai.image_themes import STYLE_FAMILY_KEYS from app.ai.prompt_templates import ( DEFAULT_IMAGE_PROMPT_TEMPLATE, ImagePromptTemplateError, ImagePromptTemplateService, ) +from app.config.settings import PROJECT_ROOT from app.data_sources.base import V2Message @@ -326,18 +328,71 @@ def test_all_concrete_themes_are_supported(): assert out.meta["resolved_theme"] == key +def test_all_public_style_families_build_with_fixed_family_keys(): + for key in STYLE_FAMILY_KEYS: + out = _builder().build(_input(image_theme=key)) + assert out.success, key + assert out.meta["requested_theme"] == key + assert out.meta["resolved_theme"] == key + assert out.meta["style_catalog_version"] == "daily-style-v3" + + def test_random_theme_records_daily_reproducible_theme(): b = _builder() out = b.build(_input(image_theme="random_preset")) assert out.success assert out.meta["requested_theme"] == "random_preset" - assert out.meta["resolved_theme"] == "daily_random" + assert out.meta["resolved_theme"] in STYLE_FAMILY_KEYS assert out.meta["style_signature"] assert out.meta["style_seed"] second = _builder().build(_input(image_theme="random_preset")) assert second.meta["style_signature"] == out.meta["style_signature"] +def test_default_file_and_builtin_prompt_contract_are_synchronized(): + file_text = (PROJECT_ROOT / "templates" / "image_prompt" / "default.md").read_text(encoding="utf-8") + file_body = re.sub(r"", "", file_text, flags=re.DOTALL).strip() + assert file_body == DEFAULT_IMAGE_PROMPT_TEMPLATE.strip() + for required in ( + "【使用场景与画布】", + "1024×1536", + "【逐话题可见文字合同】", + "不超过 12 个汉字", + "不超过 24 个汉字", + "不超过 22 个汉字", + "景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡", + "装饰 → 底部总结 → 副标题 → 次要气泡", + "【重新生图不变量】", + "逐字且恰好出现一次", + ): + assert required in DEFAULT_IMAGE_PROMPT_TEMPLATE + + +def test_grounded_story_material_enforces_visible_text_budgets_and_shot_contract(): + material = build_grounded_story_material( + { + "candidates": [{ + "topic_id": "topic-01", + "selected": True, + "title": "这是一个明显超过十二个汉字的话题标题", + "summary": "这是一句明显超过二十四个汉字而且仍然继续延伸的真实事实摘要", + "visible_participants": ["张三完整昵称"], + "quotes": ["这是一句明显超过二十二个汉字而且仍然继续延伸的真实原话"], + }], + }, + ("topic-01",), + ) + title = re.search(r"短标题逐字写《([^》]+)》", material).group(1) + fact = re.search(r"事实短句逐字写“([^”]+)”", material).group(1) + quote = re.search(r"主气泡逐字写“([^”]+)”", material).group(1) + assert len(title) <= 12 + assert len(fact) <= 24 + assert len(quote) <= 22 + assert "完整姓名逐字写“张三完整昵称”" in material + assert "景别、该人物的具体动作" in material + assert "恰好出现一次" in material + + def test_custom_theme_and_ai_free_theme_are_explicitly_injected(): b = _builder() custom = b.build(_input(image_theme="custom", image_theme_custom="夏日海边漫画")) diff --git a/tests/test_v2_prompt_editing.py b/tests/test_v2_prompt_editing.py index fd3495e..67dde79 100644 --- a/tests/test_v2_prompt_editing.py +++ b/tests/test_v2_prompt_editing.py @@ -21,10 +21,13 @@ def test_theme_replacement_preserves_facts_and_other_manual_content(): 【手工补充】 这句话必须保留。 """ - updated = replace_theme_section(original, resolve_image_theme("pink")) + updated = replace_theme_section( + original, + resolve_image_theme("ink_wash_editorial", group_key="group-1", run_date="2026-08-24"), + ) assert updated.count("【大主题】") == 1 - assert "粉红色" in updated + assert "水墨留白漫画" in updated assert "小王发布 3 个版本,金额 128 元。" in updated assert "这句话必须保留。" in updated assert "旧主题" not in updated From 68b6ca697ed9f1812fba4de6c0d6663e2d688847 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 11:41:00 +0800 Subject: [PATCH 02/42] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=85=A8=E9=9D=A2=E5=B7=A5=E7=A8=8B=E4=BD=93=E6=A3=80?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codemap/codemap.html | 634 ++++++++++++++++++ .codemap/codemap.md | 225 +++++++ .codemap/config.json | 8 + .codemap/modules.json | 1225 +++++++++++++++++++++++++++++++++++ .codemap/sonar-summary.json | 48 ++ PROJECT_AUDIT.md | 724 +++++++++++++++++++++ sonar-project.properties | 14 + 7 files changed, 2878 insertions(+) create mode 100644 .codemap/codemap.html create mode 100644 .codemap/codemap.md create mode 100644 .codemap/config.json create mode 100644 .codemap/modules.json create mode 100644 .codemap/sonar-summary.json create mode 100644 PROJECT_AUDIT.md create mode 100644 sonar-project.properties diff --git a/.codemap/codemap.html b/.codemap/codemap.html new file mode 100644 index 0000000..cec06c8 --- /dev/null +++ b/.codemap/codemap.html @@ -0,0 +1,634 @@ + + + + + + +Functional Architecture Map + + + +
+
+
+
+

Functional Architecture Map

+
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
click a module · scroll to pan
+
+ +
+ + + + + diff --git a/.codemap/codemap.md b/.codemap/codemap.md new file mode 100644 index 0000000..5989372 --- /dev/null +++ b/.codemap/codemap.md @@ -0,0 +1,225 @@ + + +# GroupBrief V1 — Functional Module Quality Audit + +> **Interactive view:** [`.codemap/codemap.html`](codemap.html) — per-module scores, findings, LoC, and the dependency graph. This file is the written report. + +**Generated:** 2026-08-24 · **Modules:** 12 · **Size:** 28149 tracked LoC across 116 files + +## Health by layer + +| Layer | Modules | Avg score | +|---|--:|--:| +| 前端 · 管理界面 | 2 | 67 | +| 后端 · 入口与 API | 1 | 58 | +| 编排 · 调度与状态机 | 1 | 54 | +| 领域 · 数据到内容 | 6 | 60 | +| 持久化 · SQLite 与工件 | 1 | 62 | +| 运维 · 脚本 | 1 | 58 | + +## Per-module lines of code & score + +_LoC is the representative file/folder per module; folder-level modules overlap and are not additive._ + +### 前端 · 管理界面 + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| 前端界面 | 10,683 | 62 C | god-component, silent-except, fallback, duplication, dual-format, legacy, bloat, glue, stub, placeholder | +| 前端 API 客户端 | 427 | 72 C | dual-format, duplication, glue, any-escape | + +### 后端 · 入口与 API + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| 运行时与 HTTP API | 2,419 | 58 D | god-component, bloat, silent-except, fallback, legacy, dual-format, duplication, fake-output, glue | + +### 编排 · 调度与状态机 + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| Pipeline 与调度 | 2,160 | 54 D | god-component, bloat, glue, fallback, silent-except, legacy, dual-format, duplication, placeholder | + +### 领域 · 数据到内容 + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| AI 摘要与生图提示词 | 3,601 | 58 D | fallback, silent-except, legacy, dual-format, duplication, bloat, god-component, glue, any-escape, over-fit | +| 微信数据接入 | 2,334 | 64 C | god-component, bloat, fallback, fake-output, duplication, dual-format, glue, silent-except, legacy | +| 图片生成与恢复 | 1,951 | 61 C | god-component, bloat, fallback, silent-except, legacy, dual-format, duplication, any-escape, glue | +| 微信与邮件交付 | 1,725 | 61 C | fallback, silent-except, legacy, dual-format, duplication, bloat, god-component, glue, over-fit | +| V1 报告兼容链 | 696 | 52 D | fake-output, silent-except, legacy, dual-format, duplication, glue, bloat | +| 排行榜与统计 | 376 | 64 C | legacy, dual-format, duplication, bloat, glue, over-fit | + +### 持久化 · SQLite 与工件 + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| 数据库与运行状态 | 1,125 | 62 C | silent-except, fallback, legacy, dual-format, duplication, bloat, god-component, glue, any-escape | + +### 运维 · 脚本 + +| Module | LoC | Score | Tags | +|---|--:|:--|:--| +| 运维与计划任务脚本 | 652 | 58 D | legacy, dual-format, fake-output, glue, over-fit, bloat | + +## Worst offenders + +- **V1 报告兼容链 (52/D)** — app/services/report_service.py:247: Prompt 失败时仍继续保存 Report 并调用 HandoffService;handoff_service.py:82-90 无条件写入 status=prompt_ready、poster_file=null,可能把空 Prompt 暴露为可交接产物。另在 268-271 行捕获文件输出异常只写 error_message,145-147 行仅检查 ranking_status/prompt_status,文件缺失仍可能被判定为成功。 +- **Pipeline 与调度 (54/D)** — app/pipeline/daily_pipeline.py:72: DailyPipeline 文件共约 1358 行,单类同时编排配置、群名同步、数据源、排行榜、Prompt、图片生成、RunStore、发送、恢复与人工确认;构造函数在 85-95 行直接连接多个外部子系统,职责和 blast radius 过大。 +- **运行时与 HTTP API (58/D)** — app/api/files.py:25: 路径安全检查使用字符串 startswith;report_date、group_dir、filename 均未规范化。可用 sibling-prefix 绕过目录边界,例如 output/2026-08-1/../2026-08-10/ranking.txt 解析后仍满足 startswith,存在跨日期目录读取风险。 +- **AI 摘要与生图提示词 (58/D)** — app/ai/prompt_builder.py:76-80,558-673; app/providers/ai/codex.py:131-152,159-221; app/providers/ai/deepseek.py:227-259: 存在多层重试与回退叠加:Builder 的事件、候选主题、版式和最终 Prompt 各自重试,Codex/DeepSeek Provider 内部再次重试,Codex 任意异常还会切换 DeepSeek。一次超时、空响应或已被服务端接受但客户端断连的请求可能产生多次模型调用、延迟放大和重复计费。 +- **运维与计划任务脚本 (58/D)** — scripts/test_wechat_data.py:31: 脚本声明只读取微信数据,但每个命令在 138 行都会先调用 _load_settings;该函数执行 repo.init_db 和 repo.apply_db_settings,初始化过程包含建表、默认值写入和迁移,健康检查/列群等只读操作可能修改生产数据库。 +- **图片生成与恢复 (61/C)** — app/image/codex_generator.py:143: CodexImageGenerator 所在文件约 892 行,单类同时负责 CLI 健康检查、跨进程互斥、子进程树终止、超时恢复、attempt manifest、候选扫描、哈希去重、图片验证、原子提升和 smoke 状态,属于高 blast-radius God Component。 +- **微信与邮件交付 (61/C)** — app/services/email_service.py:200-243; scripts/send_daily_email.py:203-243: SMTP 发送失败后直接重试同一 EmailMessage;如果 SMTP 服务端已经接收邮件但客户端在响应阶段断开,第二次尝试可能造成重复邮件。代码没有幂等键、Message-ID 去重或提交状态确认。 +- **前端界面 (62/C)** — frontend/src/pages/v2/AIImages.tsx:130: 单一 AIImages 组件从 130 行延伸至文件末尾约 696 行,132-177 行集中维护约 40 个 state/ref,同时承载运行列表、群级与运行级 Prompt、主题解析、图片再生、恢复、发送确认和轮询,是核心流程 God Component。 +- **数据库与运行状态 (62/C)** — app/db/models.py:10-79: 持久化模型缺少关系约束:wechat_group_id 仅普通索引,GroupRun.run_id/group_id 与 Report.group_run_id 都是无 ForeignKey 的裸整数,也没有复合唯一约束;重复群组、重复运行和孤儿记录只能依赖业务层避免。 +- **微信数据接入 (64/C)** — app/providers/history/wechat_data_analysis.py:92: WeChatDataAnalysisProvider 文件约 858 行,单个 Provider 同时负责 MCP 配置与健康检查、JSON 导出、群发现/解析、范围分页、旧锚点分页、响应解析、联系人映射、发送人冲突修复和消息转换,属于历史接入 God Component,修改任一上游协议都可能影响整条取数链。 + +## All findings + +### HIGH (29) + +- **前端界面** · `frontend/src/pages/v2/AIImages.tsx:130` — 单一 AIImages 组件从 130 行延伸至文件末尾约 696 行,132-177 行集中维护约 40 个 state/ref,同时承载运行列表、群级与运行级 Prompt、主题解析、图片再生、恢复、发送确认和轮询,是核心流程 God Component。 +- **运行时与 HTTP API** · `app/api/files.py:25` — 路径安全检查使用字符串 startswith;report_date、group_dir、filename 均未规范化。可用 sibling-prefix 绕过目录边界,例如 output/2026-08-1/../2026-08-10/ranking.txt 解析后仍满足 startswith,存在跨日期目录读取风险。 +- **运行时与 HTTP API** · `app/api/v2_ui.py:84` — 单文件 833 行同时承担 Dashboard、归档聚合、Prompt 编辑、图片重生成、恢复、健康检查、Pipeline 控制和文件读取等多个边界,包含约 20 个路由与大量内嵌业务逻辑,属于高耦合 God Component。 +- **Pipeline 与调度** · `app/pipeline/daily_pipeline.py:72` — DailyPipeline 文件共约 1358 行,单类同时编排配置、群名同步、数据源、排行榜、Prompt、图片生成、RunStore、发送、恢复与人工确认;构造函数在 85-95 行直接连接多个外部子系统,职责和 blast radius 过大。 +- **Pipeline 与调度** · `app/pipeline/daily_pipeline.py:124` — PeriodResolver 支持 schedule_rule(app/scheduler/period.py:35-47),但 generate_all、force_generate、rebuild_prompt_from_snapshot 均未传入 Group.schedule_rule(本行及 1045、1115 行),群级周期配置会被静默按 weekday_default 执行。 +- **Pipeline 与调度** · `app/pipeline/daily_pipeline.py:1304` — _save_json 在 1304-1306 行直接 write_text;messages/ranking 等工件与 run.json 分步写入。加载损坏快照时 1310-1313 行直接失败且明确不回源,进程中断可能留下不可恢复的半成品状态。 +- **Pipeline 与调度** · `app/scheduler/daily_v2_job.py:40` — DailyScheduleState.load 在 42-48 行将 OSError/JSONDecodeError 直接吞掉并返回仅含 run_date 的新状态;若已有状态文件损坏,_run_locked 可能把已完成的邮件阶段当成未开始,失去去重依据并触发重复外部发送。 +- **微信数据接入** · `app/providers/history/wechat_data_analysis.py:92` — WeChatDataAnalysisProvider 文件约 858 行,单个 Provider 同时负责 MCP 配置与健康检查、JSON 导出、群发现/解析、范围分页、旧锚点分页、响应解析、联系人映射、发送人冲突修复和消息转换,属于历史接入 God Component,修改任一上游协议都可能影响整条取数链。 +- **微信数据接入** · `app/data_sources/wechat_data_analysis.py:41` — V2 WeChatDataAnalysisSource 在 46-54 行只构造 WeChatDataAnalysisProvider,没有接入 app.providers.history.registry 的 wechat-cli/Mock fallback;文档虽然称 MCP/导出为回退路线,但 V2 真实数据源不可用时不会自动切换到 CLI 或 Mock,和 V1 provider registry 的契约不一致。 +- **微信数据接入** · `app/providers/history/registry.py:33` — build_providers 在 33-34 行会在 history_provider_mock_enabled 开启时自动追加 MockProvider;HistoryService.fetch 在 99-116 行把第一个 OK/EMPTY_RESULT 当作有效结果返回。真实 Provider 失败时,fixtures 可能被当成真实日报输入,形成 fake-output 风险。 +- **排行榜与统计** · `app/ranking/engine.py:18-64; app/services/ranking_service.py:17-76` — 项目同时维护两套 RankingEngine/RankingResult:V2 处理 V2Message 并输出结构化 JSON,V1 service 处理 NormalizedMessage 并内置 Top10 文本渲染。两套实现都做发言统计和确定性排序,但过滤入口、字段协议、上限和输出格式不同,规则修改容易发生语义漂移。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:76-80,558-673; app/providers/ai/codex.py:131-152,159-221; app/providers/ai/deepseek.py:227-259` — 存在多层重试与回退叠加:Builder 的事件、候选主题、版式和最终 Prompt 各自重试,Codex/DeepSeek Provider 内部再次重试,Codex 任意异常还会切换 DeepSeek。一次超时、空响应或已被服务端接受但客户端断连的请求可能产生多次模型调用、延迟放大和重复计费。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:225-460,512-673` — DeepSeekImagePromptBuilder 是明显 god-component:同一类同时负责模板读取、主题解析、消息分块、事件提取、候选选题、证据回查、版式导演、最终 Prompt 拼接、敏感字段检查、元数据和 Provider 调用,约 677 行且跨越多个业务阶段,修改一个阶段容易影响整条链路。 +- **图片生成与恢复** · `app/image/codex_generator.py:143` — CodexImageGenerator 所在文件约 892 行,单类同时负责 CLI 健康检查、跨进程互斥、子进程树终止、超时恢复、attempt manifest、候选扫描、哈希去重、图片验证、原子提升和 smoke 状态,属于高 blast-radius God Component。 +- **图片生成与恢复** · `scripts/codex_image_automation.py:449` — 人工 adopt 流程在 449-458 行按共享 generated_images 目录的快照增量选择唯一 PNG,但 begin/adopt 没有跨进程互斥;两个并行人工任务各自产生单个候选时,候选可能被错误群任务认领并写入错误 run.json。 +- **图片生成与恢复** · `scripts/codex_image_automation.py:336` — _sync_scheduler_result 在 336-379 行对 output/.scheduler/.json 做无锁 read-modify-write。它与 DailyScheduleState 的进程内锁不共享,可能覆盖同时写入的 generation/email 字段,造成调度状态丢失或重复阶段判断。 +- **微信与邮件交付** · `app/services/email_service.py:200-243; scripts/send_daily_email.py:203-243` — SMTP 发送失败后直接重试同一 EmailMessage;如果 SMTP 服务端已经接收邮件但客户端在响应阶段断开,第二次尝试可能造成重复邮件。代码没有幂等键、Message-ID 去重或提交状态确认。 +- **微信与邮件交付** · `app/services/email_service.py:111-154` — EmailService.send 只返回汇总结果,不写入每群 email_status、发送批次或幂等记录;同一 run 被 scheduler、手动脚本或恢复流程再次调用时,已成功群仍可能重新发送。 +- **V1 报告兼容链** · `app/services/report_service.py:247` — Prompt 失败时仍继续保存 Report 并调用 HandoffService;handoff_service.py:82-90 无条件写入 status=prompt_ready、poster_file=null,可能把空 Prompt 暴露为可交接产物。另在 268-271 行捕获文件输出异常只写 error_message,145-147 行仅检查 ranking_status/prompt_status,文件缺失仍可能被判定为成功。 +- **V1 报告兼容链** · `app/services/report_service.py:317` — _find_success_group_run 只查询 GroupRun.ranking_status == success,没有要求 prompt_status == success。此前排行榜成功但 Prompt 失败的记录会在 175-177 行被当作已完成,非 force 重试无法修复,只会再次返回失败。 +- **V1 报告兼容链** · `app/services/report_service.py:148` — GroupRun 初始 running/pending 状态在 179-187 行先提交;后续异常在 148-159 行通过新增一条 failed GroupRun 处理,原记录会遗留为 running/pending。父 Run 只有在 118-126 行全部 worker 返回后才收口,进程或 worker 异常可留下孤儿状态。 +- **V1 报告兼容链** · `app/services/handoff_service.py:34` — safe_dir_name 仅替换字符并截断 display_name,不加入稳定 group_id,也不拒绝 '.'、'..' 或 Windows 保留名;save_outputs 在 52-61 行直接使用该目录并覆盖写文件。不同群名可能碰撞覆盖,'.'/'..' 还可把写入指向日期目录或 output 根目录。 +- **V1 报告兼容链** · `app/services/handoff_service.py:105` — list_group_outputs 将未经格式校验的 report_date 直接拼入 output_dir 路径;文件接口把用户路径参数直接传入,绝对路径或 '..' 可突破预期日期目录并列出其他目录,同时返回完整本地 filesystem path。 +- **V1 报告兼容链** · `scripts/run_daily_pipeline.py:35` — _print_results 只打印每群状态;generate、send、force-generate、rebuild-prompt、force-send 分支分别在 99、105、114、119、124 行无条件 return 0。即使结果为 failed/partial,调度器仍会收到成功退出码。 +- **数据库与运行状态** · `app/db/models.py:10-79` — 持久化模型缺少关系约束:wechat_group_id 仅普通索引,GroupRun.run_id/group_id 与 Report.group_run_id 都是无 ForeignKey 的裸整数,也没有复合唯一约束;重复群组、重复运行和孤儿记录只能依赖业务层避免。 +- **数据库与运行状态** · `app/db/models.py:38-79; app/v2/run_store.py:1-7` — 存在双持久化格式:V1 将 Run/GroupRun/Report 写入 SQLite,V2 将同一类运行状态和输出元数据写入每群每日 run.json;当前模块没有统一同步或一致性边界,状态可能分叉。 +- **数据库与运行状态** · `app/v2/run_store.py:153-162` — load_run 对 JSONDecodeError/OSError 静默 pass,并回退为全新的 PENDING 状态;损坏或部分写入的状态会被当成未执行任务,可能触发重复生成或重复后续操作。 +- **运维与计划任务脚本** · `scripts/test_wechat_data.py:31` — 脚本声明只读取微信数据,但每个命令在 138 行都会先调用 _load_settings;该函数执行 repo.init_db 和 repo.apply_db_settings,初始化过程包含建表、默认值写入和迁移,健康检查/列群等只读操作可能修改生产数据库。 +- **运维与计划任务脚本** · `scripts/test_wechat_data.py:177` — main 无论 health、list-groups、resolve 或 fetch 返回什么状态都固定 return 0;数据源不可用、读取失败或空结果仍会被自动化环境视为成功,脚本没有把 Provider 状态映射为退出码。 + +### MED (65) + +- **前端界面** · `frontend/src/pages/v2/AIImages.tsx:376` — 图片再生状态通过每 2 秒或 5 秒 setInterval 轮询;请求失败在 388 行以 catch(() => undefined) 静默丢弃,既无用户错误状态也无退避,网络/API 异常时会持续轮询并隐藏失败。 +- **前端界面** · `frontend/src/pages/v2/Tasks.tsx:113` — 任务页先读取全部 runs,再对每个 run 调用 getRunDetail,形成无分页/批量接口保护的线性 N+1 请求扇出;历史记录增多时请求数和页面等待时间同步增长。 +- **前端界面** · `frontend/src/pages/v2/AIImages.tsx:47` — STATUS_LABELS、runKey、statusTone 等展示与身份逻辑在 AIImages 47/74/98、Archive 46/98/112、ChatRecords 26/48/52、Ranking 25/60/119、Tasks 29/47/51 多处重复,状态协议和标签容易出现页面间漂移。 +- **前端界面** · `frontend/src/pages/v2/Settings.tsx:28` — 前端手工维护 SENSITIVE_KEYS、BOOLEAN_KEYS、NUMBER_KEYS、LABELS、SETTING_GROUPS 多套设置契约;61-72 与 110-115 行还混有 V1 兼容邮件字段和 Provider fallback 字段,后端设置变更可能造成控件、类型或序列化不一致。 +- **前端界面** · `frontend/src/styles.css:1197` — 全局样式存在明确的‘旧页面兼容与窄屏布局’区域,3803 行开始仍有 archive-legacy 样式;5939 行单文件同时容纳新旧页面级联规则,增加跨页面回归和未使用 CSS 累积风险。 +- **前端 API 客户端** · `frontend/src/api.ts:3` — request 仅调用 fetch,没有 AbortSignal 超时、取消、重试或网络错误分类;AI 生图、刷新、发送等长任务请求可能长期悬挂,页面无法主动结束旧请求或恢复瞬时网络失败。 +- **前端 API 客户端** · `frontend/src/api.ts:8` — HTTP 错误只读取原始 response.text() 并构造普通 Error(8-11 行),没有统一解析后端结构化错误字段、error_type、状态码或可恢复性;调用页面只能按字符串处理失败。 +- **前端 API 客户端** · `frontend/src/api.ts:22` — 文件同时保留 V1 的 Group/Run/LatestReport 类型和接口,以及 65 行之后的 GroupV2/V2Run/Archive 与 V2 pipeline 接口;同一前端客户端维护两套后端协议,形成 dual-format/duplication。 +- **运行时与 HTTP API** · `app/main.py:29` — FastAPI lifespan 在 yield 前同步执行 WeChat、Codex、模板和本地环境检查;外部依赖不可用时可能拖慢服务启动,且 app/main.py:34-35 捕获 Exception 后直接置空 startup_checks,没有日志或失败原因。 +- **运行时与 HTTP API** · `app/main.py:48` — 所有设置、删除、生成、发送和日志路由均直接挂载,没有认证/授权依赖。默认 host 是本机回环,但一旦通过 APP_HOST 或容器暴露到网络,管理和发送接口即无身份边界。 +- **运行时与 HTTP API** · `app/api/settings.py:99` — 设置 API 接收任意字符串并先持久化;app/config/settings.py:163-166 对类型转换异常静默跳过,接口仍返回 ok=true,导致数据库值、运行时值和 UI 成功提示可能不一致。 +- **运行时与 HTTP API** · `app/config/settings.py:177` — 布尔配置遇到非标准字符串时执行 return bool(text),例如 'falsee' 会被静默转换为 True;错误配置不会被拒绝,可能改变发送、邮件或 Mock Provider 行为。 +- **运行时与 HTTP API** · `app/api/system.py:92` — status 接口直接对可由设置 API 写入的 schedule_generate_time 执行 split/int,未捕获格式错误;非法配置会让状态接口返回 500,而调度器自身另有回退规则,形成配置行为分裂。 +- **运行时与 HTTP API** · `app/api/v2_ui.py:98` — Dashboard 用 display_name 作为 RunStore 的运行目录键;同一文件后续 archive_groups 使用稳定 group_id/wechat_group_id 匹配,而 retry_failed 在 app/api/v2_ui.py:724-735 又按 display_name 查询,改名或重名时可能显示 Pending、找不到任务或恢复错误群。 +- **运行时与 HTTP API** · `app/core/logging.py:68` — setup_logging 在 root.handlers 已存在时立即 return,导致 uvicorn/宿主已预配置 root handler 时不会执行 app/provider/ai/scheduler/email 文件 handler 配置,日志可能只进宿主输出而不进入声明的分类日志。 +- **运行时与 HTTP API** · `app/config/settings.py:30` — V1/V2 和主备 Provider 配置同时存在:history_provider_primary/fallback/mock_enabled、summary_provider_primary/fallback 与旧 ai_provider/ai_model/ai_api_key 并列;app/api/settings.py:24-75 又手工复制一套可编辑键,配置来源和行为边界容易漂移。 +- **Pipeline 与调度** · `app/scheduler/send_job.py:12` — run_send_due_job 在 14-19 行捕获所有 Exception,只记录日志且返回 None;APScheduler 调度层无法得到失败状态,分钟级发送异常可能表现为任务成功但无人感知。 +- **Pipeline 与调度** · `app/scheduler/daily_v2_job.py:232` — 邮件阶段只用 proc.returncode 判定 email_status=sent/failed(232-245 行),不解析子进程输出中的逐群结果或工件状态,存在子任务部分失败却被记录为 sent 的协议缺口。 +- **Pipeline 与调度** · `app/scheduler/generate_job.py:1` — generate_job.py 与 email_job.py 仍保留 V1 ReportService/EmailService 任务(generate_job.py:15-24、email_job.py:14-22),而 manager.py:44-65 只注册 V2 任务;两套调度/状态格式仍可被外部调用,形成 legacy/dual-format 维护面。 +- **Pipeline 与调度** · `app/scheduler/calendar_rules.py:11` — V1 仍定义 ReportWindow/get_report_window(11-35 行),V2 又定义独立 PeriodWindow/PeriodResolver(app/scheduler/period.py:14-57);两套日期窗口模型实现相同的前一自然日逻辑,增加跨版本语义漂移和 duplication 风险。 +- **Pipeline 与调度** · `app/services/group_name_sync.py:81` — 健康检查、群列表读取异常或空结果在 81-94 行统一转为 unavailable,157-165 行只标记 skipped 并保留本地旧名称;DailyPipeline 在 126、682 行继续生成/发送,数据源不可用时可能继续使用过期自动发送目标。 +- **Pipeline 与调度** · `app/pipeline/daily_pipeline.py:205` — _record_group_failure 仅尽力写 FAILED;220-230 行再次捕获状态落盘异常并继续,导致返回结果显示 failed 但 run.json 可能没有失败状态,恢复与人工排查依赖日志而非持久化状态。 +- **微信数据接入** · `app/data_sources/base.py:20` — V2 定义 V2Message、FetchResult、DataSourceStatus、WeChatDataSource(20-115 行),V1 又定义 RawMessage、FetchResult、ProviderStatus、ChatHistoryProvider(app/providers/history/base.py:15-85);V2 wrapper 在 app/data_sources/wechat_data_analysis.py:100-134 反复做两套模型转换,存在 dual-format/duplication 和额外 glue 层。 +- **微信数据接入** · `app/data_sources/wechat_data_analysis.py:82` — _group_exists 在 84-90 行捕获所有异常并直接返回 False;当上游 list_groups 临时失败且消息结果为空时,fetch_messages 在 114-123 行会把暂时不可用误判为 GROUP_NOT_FOUND,而不是数据源异常。 +- **微信数据接入** · `app/services/history_service.py:130` — discover_groups 在 133-143 行对 Provider 健康检查或 list_groups 的所有异常直接 continue 且不记录日志;所有 Provider 同时异常时调用方只得到空群列表,缺少可诊断的失败原因。 +- **微信数据接入** · `app/providers/history/contact_resolver.py:37` — find_contact_db 在 37-43 行按目录排序后返回第一个账号的 contact.db;WeChatDataAnalysisProvider 虽保存 wechat_mcp_account(app/providers/history/wechat_data_analysis.py:110),构造 ContactResolver 时并未按账号选择数据库(117-118 行),多账号环境可能把联系人显示名映射到错误账号。 +- **微信数据接入** · `app/providers/history/wechat_data_analysis.py:267` — JSON 导出路径在 284-287 行直接读取 item['timestamp'],并由 _to_raw 在 693 行直接读取 item['group_id'];单条导出记录缺字段或格式损坏会使整个群取数异常,没有逐条隔离或明确 INVALID_RESULT 状态。 +- **排行榜与统计** · `app/ranking/renderer.py:22-70; app/services/ranking_service.py:26-46` — 排行榜渲染存在两条路径:V2 使用模板变量渲染,V1 RankingResult.render 直接拼接固定文本。相同统计数据可能产生不同的标题、字段和格式,邮件/报告与 V2 ranking.txt 的展示契约不统一。 +- **排行榜与统计** · `app/ranking/template_service.py:17-20,64-75,105-110` — 默认模板同时以内嵌 DEFAULT_RANKING_TEMPLATE 和磁盘 default.txt 形式存在;_ensure_default 与 reset 都从代码常量写回文件。模板文件可被编辑而代码常量不会同步,恢复默认或重新初始化可能覆盖文件侧修改。 +- **排行榜与统计** · `app/ranking/engine_types.py:31-33; app/ranking/renderer.py:35-38,66-70` — V2 数据结构和渲染器保留多处旧协议兼容:top_limit 被追加到字段末尾以维持位置参数,top10_lines 作为旧变量别名,render_simple 继续保留无模板调用点。兼容层已进入核心类型/渲染路径,增加长期 dual-format/legacy 维护成本。 +- **排行榜与统计** · `app/ranking/engine.py:21-27,40-54; app/services/ranking_service.py:49-67` — 两套引擎的可计数判定来源不同:V2 自己检查 message_type 与 SYSTEM_KEYWORDS,V1 依赖 NormalizedMessage.countable;同一原始数据经过不同标准化链路时,消息数、发言人数和 Top 排名可能不一致。 +- **AI 摘要与生图提示词** · `app/providers/ai/deepseek.py:227-259` — _chat 将 retryable 初始设为 True,并在 except Exception 中保留可重试状态;JSON 解析异常、字段缺失、空内容、非瞬态网络/协议错误都会进入重试路径,未区分瞬态错误和确定性输入/响应错误。 +- **AI 摘要与生图提示词** · `app/providers/ai/codex.py:42-65,123-152` — CodexGPTProvider 继承 DeepSeekV4FlashProvider 却绕过父类 __init__,复用父类内部编排并自行覆盖关键方法;这是对实现细节的 over-fit 耦合,父类初始化或分块协议变化时容易产生隐蔽回归。 +- **AI 摘要与生图提示词** · `app/ai/prompt_templates.py:16-96,124-165; templates/image_prompt/default.md:1-98` — 默认 Prompt 模板存在两份来源:Python 内嵌 DEFAULT_IMAGE_PROMPT_TEMPLATE 与可编辑 default.md。_ensure_default 只在缺失时写入,reset 又把内嵌版本写回,两个文件可独立漂移,模板修改和恢复行为不一致。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:97-124,393-447; app/ai/prompt_templates.py:17-96; app/providers/ai/deepseek.py:37-67; app/ai/layouts.py:222-233` — 事实约束、输出结构、主题/版式规则和禁止词分散在多个 Python 常量及 Markdown 模板中,存在重复 Prompt 契约;规则变更需要同步多处,容易出现主模型、备用模型、模板和最终检查器不一致。 +- **AI 摘要与生图提示词** · `app/providers/ai/base.py:19-20; app/providers/ai/deepseek.py:84-91; app/ai/prompt_builder.py:225-226,480-491,676-677` — 同时维护新 message_items 与旧 messages_text 两套输入格式,并通过 legacy-* 合成消息 ID;Builder 还保留旧类名 DeepSeekImagePromptBuilder 并导出 GroupSummaryImagePromptBuilder 别名,说明 V1/V2 兼容层仍渗透核心路径,存在 dual-format/legacy 维护成本。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:453-455; app/ai/topic_selection.py:177-184` — 元数据和候选字段保留旧字段(deepseek_ms、interestingness_score)以兼容历史读取;代码虽能运行,但新旧协议长期并存,字段语义和版本边界不清晰。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:415-447` — 最终 Prompt 约束主要依赖 forbidden term 的字符串包含检查和若干必需块的字符串包含检查,无法验证语义上的事实一致性、每段文字是否真正只出现一次或模型是否改写了证据;校验容易出现误报和漏报。 +- **图片生成与恢复** · `app/image/image_task.py:67` — verify_image 在 67-77 行只检查文件存在、大小和魔数签名,没有真正解码图片;app/image/regeneration.py:106-115 直接用该结果决定替换正式图片,损坏但带合法 PNG/JPEG 头的文件可能被接受。函数文档所称的可解析校验与实现不一致。 +- **图片生成与恢复** · `app/image/codex_generator.py:1` — 主链路使用 Codex CLI 自有 attempt manifest(文件头及 236-430 行),同时 scripts/codex_image_automation.py:1-18 保留 Desktop begin/adopt/verify marker 工作流;两套图片认领、候选发现和 run 状态同步协议并存,属于 legacy/dual-format duplication。 +- **图片生成与恢复** · `app/image/regeneration.py:46` — enqueue_regeneration 在 46-51 行先把任务 key 加入全局 _ACTIVE,随后 53-68 行才执行状态写入、生成器构造和线程提交;任一更新或 submit 异常都没有回滚 _ACTIVE,后续同一群/日期会永久被判定为正在队列中,直到进程重启。 +- **图片生成与恢复** · `app/image/image_task.py:169` — SerialImageQueue 在 169-173 行吞掉 run_hook 的全部异常并继续返回图片结果;如果 hook 的 run.json 更新失败,调用方仍可能看到成功图片但持久化状态未进入 IMAGE_READY/READY_TO_SEND。 +- **微信与邮件交付** · `app/services/email_service.py:156-243; scripts/send_daily_email.py:82-243` — 生产 EmailService 与手动脚本重复实现邮件构造、图片附件校验、SMTP 连接和重试逻辑;两套实现的降级语义不同(服务侧无效图片降级为纯排行榜,脚本侧直接跳过/失败),修复容易出现行为漂移。 +- **微信与邮件交付** · `app/sender/wechat_automation.py:131-143` — legacy CLI 的 JSON 解析失败时回退为 proc.returncode == 0;CLI 可能退出码为 0 但没有可验证成功响应,此处仍返回 success,存在把未确认发送报告为成功的风险。 +- **微信与邮件交付** · `app/sender/wechat_automation.py:104-117` — 日志将完整命令拼接输出,命令包含 --to 目标和 --content 消息正文;真实发送时群名、接收目标及部分聊天内容可能进入日志,属于敏感数据泄漏风险。 +- **微信与邮件交付** · `app/sender/wechat_native.py:37-745` — WindowsWechatDriver 集中实现 OCR、窗口激活、坐标点击、剪贴板、图片转换、提交后截图差异验证和发送互斥,文件 868 行、约 55 个定义,属于高复杂度 god-component;桌面版本、DPI 或 OCR 变化时 Blast Radius 很大。 +- **微信与邮件交付** · `app/sender/wechat_automation.py:7-13,31-56; app/sender/wechat_native.py:862-868; scripts/test_wechat_send.py:23-25,68-76` — 旧 wechat-automation 路径文档已明确当前微信版本 UIA 不兼容并会返回 WECHAT_WINDOW_NOT_FOUND,但配置为 legacy_cli 时仍会选择该 Provider,手动真实发送测试脚本也固定使用它;存在 legacy/stub 路径继续被误用的风险。 +- **V1 报告兼容链** · `app/services/handoff_service.py:116` — handoff.json 通过 json.loads 无保护读取;save_outputs 在 57-92 行顺序写入多个文件且非原子,进程中断或部分文件损坏时,归档列表接口会直接抛异常而不是返回明确的不完整状态。 +- **V1 报告兼容链** · `scripts/run_daily_pipeline.py:28` — _pipeline 每次都执行 repo.init_db 和 repo.apply_db_settings;status 分支在 83-89 行也经过该初始化路径,因此看似只读的状态查询可能创建或修改数据库设置。 +- **V1 报告兼容链** · `app/services/report_service.py:1` — ReportService 明确是 V1 生成链路,而 scripts/run_daily_pipeline.py:1 使用独立 V2 DailyPipeline;handoff_service.py:11-12 又同时维护 V1/V2 状态语义。两套生成、状态和输出协议并存,后续修复容易发生 legacy/dual-format 漂移。 +- **V1 报告兼容链** · `scripts/run_daily_pipeline.py:72` — send 命令提供 --dry-run,但 force-send 子命令没有该选项;91 行通过 getattr 缺省为 False,因此 force-send 无法在 CLI 层进行 dry-run,误调用会直接进入真实发送路径。 +- **数据库与运行状态** · `app/db/repository.py:34-184` — 迁移由多个手写 _migrate_* 函数组成,重复使用 marker 查询、更新、插入和 commit;没有版本表、统一迁移注册或全局迁移事务,属于明显 legacy/bloat/duplication。 +- **数据库与运行状态** · `app/db/repository.py:187-206` — init_db 每次启动按固定顺序执行 create_all、补列、种默认值和多轮数据迁移;多个进程并发启动或中途异常时可能出现部分迁移,且旧 schema 默认值与模型默认值容易漂移。 +- **数据库与运行状态** · `app/db/repository.py:331-425` — Repository 的 save_group、delete_group、restore_group、create_run、save_report、set_setting_value 等操作都立即 commit,缺少跨实体事务边界;核心流程的多阶段写入无法由该层保证原子性。 +- **数据库与运行状态** · `app/v2/run_store.py:164-181` — 普通 save_run/update 只使用进程内 RLock,临时文件名固定为 run.json.tmp;跨进程写同一 run 时仍可能互相覆盖临时文件或丢失更新,跨进程互斥只在发送 claim 路径启用。 +- **数据库与运行状态** · `app/v2/recovery.py:85-105` — verify_output 只检查文件存在且 size>0,不校验 JSON schema、内容可解析性、图片实际解码或状态字段一致性;损坏文件可能被判定为完整。 +- **数据库与运行状态** · `app/v2/recovery.py:113-153` — recover_incomplete 通过 display_name 查询 group_id,而不是使用持久化稳定 ID;重名群组时可能恢复到错误群组,且动态导入 DailyPipeline/DB,形成较强 glue 耦合。 +- **数据库与运行状态** · `app/v2/run_store.py:119-121` — 运行目录以 safe_dir_name(group_name)/run_date 定位,没有稳定 group_id;名称清洗、特殊字符归一化或截断会造成不同群组目录碰撞并覆盖状态/输出。 +- **数据库与运行状态** · `app/v2/constants.py:19-32` — STATUS_FLOW 只声明状态顺序,is_terminal 只做终态集合判断,没有统一的合法状态转移校验;状态推进规则仍分散在上层 pipeline,状态机约定容易被绕过。 +- **数据库与运行状态** · `app/v2/run_store.py:113-365` — RunStore 同时承担路径生成、JSON 状态读写、历史布局读取、跨进程发送锁、发送租约和运行枚举,职责集中度偏高,已接近 persistence god-component;后续修改发送状态时容易影响文件状态逻辑。 +- **运维与计划任务脚本** · `scripts/daily_auto.py:69` — already_running 被纳入成功退出集合。任务实际没有执行任何生成或发送时仍返回 0,计划任务监控无法区分‘已完成’和‘被并发锁跳过’。 +- **运维与计划任务脚本** · `scripts/install_daily_task.py:114` — 安装、卸载和状态命令的 main 固定 return 0;_install 即使 schtasks 返回失败也只打印字符串,调用方无法通过退出码发现任务安装失败。install_autostart.py:64-74 存在同样问题。 +- **运维与计划任务脚本** · `scripts/install_daily_task.py:44` — 两阶段安装先创建生成任务,再创建发送任务;发送任务创建失败时 55-71 行只返回警告,不回滚已创建的生成任务,系统会留下半安装状态。 +- **运维与计划任务脚本** · `scripts/test_wechat_data.py:45` — fetch 结果会把完整 messages、消息详情和 Provider 错误写入 output/test-data,固定文件名会覆盖旧证据,且没有脱敏、保留期限或显式清理边界;真实聊天验证可能留下敏感数据。 +- **运维与计划任务脚本** · `scripts/install_daily_task.py:35` — 计划任务的生成阶段调用 daily_auto.py,发送阶段调用另一套 run_daily_pipeline.py;两个入口拥有不同的状态文件、失败语义和退出码,调度链路存在 dual-format/glue 漂移风险。 + +### LOW (31) + +- **前端界面** · `frontend/src/components/ui.tsx:39` — useFetch 接收 caller 自行提供的 unknown[] 依赖,内部却省略 loader 依赖并在 67 行关闭 exhaustive-deps 检查;未来传入捕获筛选条件的 inline loader 时可能保留旧闭包,且失去静态提示。 +- **前端界面** · `frontend/src/pages/v2/History.tsx:3` — History 仅包装 Archive,System.tsx:3 仅包装 Settings;navigation.ts:61-66 已将 history/system 归一到 archive/settings,App.tsx 也直接渲染 Archive/Settings,两个 wrapper 当前未被引用,属于 legacy/stub 死代码候选。 +- **前端界面** · `frontend/src/components/layout/AppShell.tsx:108` — 通知按钮只有图标和 aria-label,没有 onClick、状态或目标路由,当前是可点击外观但无行为的 placeholder UI。 +- **前端 API 客户端** · `frontend/src/api.ts:367` — readV2TextFile 直接重新实现 fetch、状态码检查和错误文本处理,绕过 3-16 行的 request 通用封装;它与其他请求的 headers、错误格式和未来超时策略容易发生漂移。 +- **前端 API 客户端** · `frontend/src/api.ts:146` — V2Run 使用 [key: string]: unknown 放开任意字段,削弱 run 状态、发送字段和恢复字段的静态类型约束;调用方访问动态字段只能运行时判断。 +- **前端 API 客户端** · `frontend/src/api.ts:364` — getV2File 只对 group 做 encodeURIComponent,date/file 直接拼接 URL(364-365 行);当前日期和白名单文件名通常安全,但通用客户端边界没有统一的路径参数编码/校验策略。 +- **运行时与 HTTP API** · `app/api/system.py:58` — 旧 API stats 只接受 Run.status 为 success/partial;V2 运行状态使用独立的大写状态协议并由 /api/v2/dashboard 读取,V1/V2 双格式并存,旧统计接口可能长期返回空或过期数据。 +- **运行时与 HTTP API** · `app/api/v2_ui.py:759` — pipeline/generate、pipeline/send-due、pipeline/send 在 HTTP 请求内同步构造 DailyPipeline 并执行长耗时取数、AI、生图或桌面发送,API 层承担任务编排和外部副作用,易阻塞请求线程并扩大重复点击的影响面。 +- **Pipeline 与调度** · `app/scheduler/manager.py:23` — 无效 schedule_generate_time 在 23-33 行静默回退到 00:15;虽然有 warning,但配置错误不会阻止启动,运行顺序依赖日志才能发现实际调度时间。 +- **Pipeline 与调度** · `app/scheduler/period.py:45` — PeriodResolver 对除 weekday_default 外的规则直接抛出 NotImplementedError(45-47 行),而 Group.schedule_rule 已暴露为可配置字段,属于已接入界面但未完成执行语义的 placeholder。 +- **微信数据接入** · `app/providers/history/wechat_cli.py:65` — wechat-cli 的 list-groups 在 65-80 行对非零退出、JSON 解析失败和任意异常均返回空列表;作为 fallback 时会把命令故障表现为无群,且没有保留错误上下文。 +- **微信数据接入** · `app/providers/history/base.py:68` — ChatHistoryProvider 的接口方法在 73-85 行仅抛出 NotImplementedError,属于未使用 ABC 的 placeholder 接口;目前不影响已接入实现,但错误实例化基类时才会暴露,类型边界约束较弱。 +- **排行榜与统计** · `app/ranking/engine.py:29-38; app/ranking/engine_types.py:23-43` — V2 支持可配置 top_limit,但只校验大于 0;没有上限范围或非整数输入边界,结果结构也不校验 rank/count 非负。当前调用方通常传固定值,属于低影响输入约束缺口。 +- **排行榜与统计** · `app/ranking/renderer.py:41-45` — 模板替换对无法匹配正则的占位符保持原文,对未知/畸形占位符没有渲染后残留检查;虽然 validate_template 能拦截常规未知变量,但最终输出仍可能含未替换模板标记。 +- **排行榜与统计** · `app/ranking/renderer.py:66-70` — render_simple 通过函数内动态导入 DEFAULT_RANKING_TEMPLATE 保留旧调用点,属于 glue 兼容实现;未直接造成错误,但继续扩大了 V1/V2 渲染边界。 +- **排行榜与统计** · `app/ranking/engine.py:1-7,46-64` — 排行榜核心本身是确定性、无 LLM、按消息数和稳定名称排序的纯计算路径,职责清晰且复杂度低;主要问题集中在 V1/V2 双实现与输出协议分裂,而非算法本身。 +- **AI 摘要与生图提示词** · `app/ai/prompt_builder.py:456-461` — build 使用宽泛 except Exception 统一记录并返回失败结果;异常不会静默丢失,但错误类型边界被压平,调用方只能依据截断后的字符串判断 Provider、模板还是验证失败。 +- **AI 摘要与生图提示词** · `app/ai/image_themes.py:351-368; app/ai/prompt_builder_types.py:9,21,30; app/ai/layouts.py:13` — 多个核心数据入口使用 Any(主题 rng、消息列表、持久化元数据、布局定义),降低了 Prompt 协议的静态约束,属于低影响 any-escape。 +- **AI 摘要与生图提示词** · `app/ai/layouts.py:478-547; app/ai/prompt_builder.py:612-622` — 版式导演失败两次后使用确定性 fallback_layout_plan;该回退不编造聊天事实且有稳定 seed,属于有界安全兼容,但会掩盖模型版式能力或协议问题并降低输出丰富度。 +- **图片生成与恢复** · `app/image/regeneration.py:38` — 重新生图入口的 generator 参数使用 Any,app/image/image_task.py:94 也以 Any 接收生成器,没有统一协议或结构化能力检查;生成器异常只能在运行时被捕获,属于 glue/any-escape。 +- **图片生成与恢复** · `app/image/codex_generator.py:865` — _load_last_smoke 在 865-872 行只要 smoke JSON 能解析就返回 ok=True,不验证记录中的 source/output 文件仍存在;健康页可能继续展示过期的最近实测成功状态。 +- **微信与邮件交付** · `scripts/send_daily_email.py:132-140,246-278` — 手动邮件脚本把未验证的 run_date 直接拼接到 output/{group}/{run_date} 路径;run_date 没有 YYYY-MM-DD 校验,虽然通常由本机操作者输入,仍缺少与 V2 RunStore 一致的路径边界。 +- **微信与邮件交付** · `app/sender/base.py:41-46; app/sender/wechat_native.py:823-857` — 文字和图片按顺序提交,不具备原子 bundle;文字成功后图片可能失败或结果未知,调用方必须正确处理部分成功。SendResult 已保留 submitted、verification_level 和 outcome_unknown 字段,这是当前较好的 fail-closed 兼容设计。 +- **微信与邮件交付** · `scripts/test_wechat_send.py:60-66; app/sender/wechat_automation.py:96-102` — dry-run 测试把 ranking_demo_group_2026-08-17.txt 作为图片路径,而 legacy send_image 只检查文件存在、不验证图片格式;测试可能显示‘图片发送’成功但实际输入不是图片。 +- **V1 报告兼容链** · `app/services/prompt_service.py:89` — _build_context_text 明确标注为兼容旧调用,ranking 参数未使用,且仓库内没有实际调用;它与 _build_message_items/新 provider message_items 路径重复构造完整上下文,属于 legacy 兼容代码候选。 +- **数据库与运行状态** · `app/v2/run_store.py:339-364` — list_runs 对损坏 run.json 直接静默跳过,调用方无法区分“没有运行”和“状态文件损坏”,导致恢复/审计列表不完整。 +- **数据库与运行状态** · `app/v2/recovery.py:38-52` — 旧 run 缺少 image_enabled 或值未知时固定回退为 True;这是兼容性 fallback,但会把旧格式或异常数据强制解释为需要图片。 +- **数据库与运行状态** · `app/db/repository.py:13,428-431` — 全局 engine 使用 Any,_now 返回 Any,削弱了 persistence 边界的静态类型约束,属于低影响 any-escape。 +- **运维与计划任务脚本** · `scripts/generate_fixtures.py:170` — 生成器按当前 date.today() 滚动生成最近 8 天 fixtures,并在 171-183 行直接覆盖 groups.json 和消息文件;没有 --output、日期或确认参数,容易覆盖手工 fixture,且输出结果随执行日期变化。 +- **运维与计划任务脚本** · `scripts/generate_fixtures.py:24` — groups.json 声明 member_count 为 48/112,但实际生成的 MEMBERS_A/MEMBERS_B 只有 15/8 人;这是 mock_fixture 的不一致数据契约,可能让 UI 或测试展示错误人数。 +- **运维与计划任务脚本** · `scripts/install_autostart.py:24` — 开机自启注释仍称启动 V1 脚本,_START_CMD 通过 start_windows.bat 启动旧入口;该兼容包装与当前 V2 调度入口并存,版本边界和实际启动流程不清晰。 + +## Cross-cutting themes + +- **V1/V2 双协议是最广泛的系统性债务.** 运行状态、持久化、排行榜、Prompt、文件 API、调度与前端契约都保留两套可触达路径;它解释了项目为何兼容旧数据,也放大了状态分叉和维护成本。 +- **数据约束债务已经产生真实后果.** SQLite 关系主要靠应用代码维持,当前只读检查发现 224 条 group_runs 中 192 条已失去群组关联;缺少 FK/唯一约束不再只是理论风险。 +- **复杂度集中在少数高 Blast Radius 模块.** DailyPipeline、V2 API、PromptBuilder、CodexImageGenerator、WDA Provider、WechatNativeSender 与 AIImages 页面同时承担多个阶段,任何局部变化都需要跨链路回归。 +- **失败安全已有正确骨架,但状态损坏仍会被误解释.** V2 的发送 claim、result_unknown、手工 hold 和图片 staging 值得保留;相反,损坏 run.json/scheduler JSON 的静默重置可能把未知结果当成未开始。 +- **配置存在可保存但未进入真实执行链的漂移.** 群级 schedule_rule、summary_model、prompt_model、provider_preference 已进入模型与 UI,但主 Pipeline 没有完整消费;设置 API 的宽松转换又可能造成数据库、运行时和 UI 三方不一致。 +- **外部副作用的幂等边界不一致.** 微信 V2 发送偏向 fail-closed,但 SMTP 重试、Legacy CLI 退出码、人工图片 adopt 与部分运维脚本仍可能重复执行、误报成功或覆盖状态。 diff --git a/.codemap/config.json b/.codemap/config.json new file mode 100644 index 0000000..2960606 --- /dev/null +++ b/.codemap/config.json @@ -0,0 +1,8 @@ +{ + "lang": "zh", + "project": "GroupBrief V1", + "subtitle": "本地微信群报生成与安全发送系统工程体检", + "outputDir": ".codemap", + "htmlFile": "codemap.html", + "mdFile": "codemap.md" +} diff --git a/.codemap/modules.json b/.codemap/modules.json new file mode 100644 index 0000000..a011444 --- /dev/null +++ b/.codemap/modules.json @@ -0,0 +1,1225 @@ +{ + "meta": { + "project": "GroupBrief V1", + "lang": "zh", + "subtitle": "本地微信群报生成与安全发送系统工程体检", + "generatedAt": "2026-08-24", + "htmlPath": ".codemap/codemap.html", + "mdPath": ".codemap/codemap.md", + "spineDesc": "管理界面发起或调度器触发任务,经 API/Pipeline 读取群配置和微信数据,生成排行榜、提示词与图片,以 run.json/SQLite 持久化状态,最后在安全认领后发送。", + "tracked_loc": 28149, + "tracked_files": 116, + "rev": "cf29e2decf5ae74e5a7037531edb0218dc4bdcea" + }, + "excludes": [ + "__pycache__", + "/node_modules/", + "/frontend/dist/", + "/frontend/.vite/", + "/.git/", + "/.venv/", + "/data/", + "/output/", + "/logs/", + "/fixtures/", + "/tests/", + "/.codemap/" + ], + "bands": [ + { + "id": "frontend", + "tier": "fe", + "t": "前端 · 管理界面", + "d": "页面、交互状态与 API 客户端" + }, + { + "id": "wire", + "wire": true, + "t": "HTTP / JSON" + }, + { + "id": "entry", + "tier": "be", + "t": "后端 · 入口与 API", + "d": "FastAPI 生命周期、配置、路由" + }, + { + "id": "orchestration", + "tier": "be", + "t": "编排 · 调度与状态机", + "d": "每日生成、恢复、发送窗口" + }, + { + "id": "domain", + "tier": "be", + "t": "领域 · 数据到内容", + "d": "微信数据、排行、AI、图片、交付" + }, + { + "id": "storage", + "tier": "db", + "t": "持久化 · SQLite 与工件", + "d": "关系数据、run.json、恢复状态" + }, + { + "id": "ops", + "tier": "ops", + "t": "运维 · 脚本", + "d": "Windows 计划任务、手工检查与恢复入口" + } + ], + "spine": [ + "frontend_ui", + "frontend_api", + "runtime_api", + "pipeline_scheduler", + "history_ingestion", + "ranking", + "ai_prompting", + "image_generation", + "persistence_state", + "delivery" + ], + "reportThemes": [ + [ + "V1/V2 双协议是最广泛的系统性债务", + "运行状态、持久化、排行榜、Prompt、文件 API、调度与前端契约都保留两套可触达路径;它解释了项目为何兼容旧数据,也放大了状态分叉和维护成本。" + ], + [ + "数据约束债务已经产生真实后果", + "SQLite 关系主要靠应用代码维持,当前只读检查发现 224 条 group_runs 中 192 条已失去群组关联;缺少 FK/唯一约束不再只是理论风险。" + ], + [ + "复杂度集中在少数高 Blast Radius 模块", + "DailyPipeline、V2 API、PromptBuilder、CodexImageGenerator、WDA Provider、WechatNativeSender 与 AIImages 页面同时承担多个阶段,任何局部变化都需要跨链路回归。" + ], + [ + "失败安全已有正确骨架,但状态损坏仍会被误解释", + "V2 的发送 claim、result_unknown、手工 hold 和图片 staging 值得保留;相反,损坏 run.json/scheduler JSON 的静默重置可能把未知结果当成未开始。" + ], + [ + "配置存在可保存但未进入真实执行链的漂移", + "群级 schedule_rule、summary_model、prompt_model、provider_preference 已进入模型与 UI,但主 Pipeline 没有完整消费;设置 API 的宽松转换又可能造成数据库、运行时和 UI 三方不一致。" + ], + [ + "外部副作用的幂等边界不一致", + "微信 V2 发送偏向 fail-closed,但 SMTP 重试、Legacy CLI 退出码、人工图片 adopt 与部分运维脚本仍可能重复执行、误报成功或覆盖状态。" + ] + ], + "modules": [ + { + "id": "frontend_ui", + "label": "前端界面", + "band": "frontend", + "path": "frontend/src/**/*.tsx + styles.css", + "paths": [ + "frontend/src/**/*.tsx", + "frontend/src/styles.css", + "frontend/src/navigation.ts" + ], + "coupling": "high", + "deps": [ + "frontend_api" + ], + "desc": "React 管理界面、页面状态、轮询、图片查看与操作入口。", + "tests": [], + "loc": 10683, + "contentHash": "d9c0dc23e2932bdececb0f7a3aad82bf53f2d7c32f6cffb9a47f6ed2590c7572", + "score": 62, + "grade": "C", + "tags": [ + "god-component", + "silent-except", + "fallback", + "duplication", + "dual-format", + "legacy", + "bloat", + "glue", + "stub", + "placeholder" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "frontend/src/pages/v2/AIImages.tsx:130", + "text": "单一 AIImages 组件从 130 行延伸至文件末尾约 696 行,132-177 行集中维护约 40 个 state/ref,同时承载运行列表、群级与运行级 Prompt、主题解析、图片再生、恢复、发送确认和轮询,是核心流程 God Component。" + }, + { + "sev": "MED", + "loc": "frontend/src/pages/v2/AIImages.tsx:376", + "text": "图片再生状态通过每 2 秒或 5 秒 setInterval 轮询;请求失败在 388 行以 catch(() => undefined) 静默丢弃,既无用户错误状态也无退避,网络/API 异常时会持续轮询并隐藏失败。" + }, + { + "sev": "MED", + "loc": "frontend/src/pages/v2/Tasks.tsx:113", + "text": "任务页先读取全部 runs,再对每个 run 调用 getRunDetail,形成无分页/批量接口保护的线性 N+1 请求扇出;历史记录增多时请求数和页面等待时间同步增长。" + }, + { + "sev": "MED", + "loc": "frontend/src/pages/v2/AIImages.tsx:47", + "text": "STATUS_LABELS、runKey、statusTone 等展示与身份逻辑在 AIImages 47/74/98、Archive 46/98/112、ChatRecords 26/48/52、Ranking 25/60/119、Tasks 29/47/51 多处重复,状态协议和标签容易出现页面间漂移。" + }, + { + "sev": "MED", + "loc": "frontend/src/pages/v2/Settings.tsx:28", + "text": "前端手工维护 SENSITIVE_KEYS、BOOLEAN_KEYS、NUMBER_KEYS、LABELS、SETTING_GROUPS 多套设置契约;61-72 与 110-115 行还混有 V1 兼容邮件字段和 Provider fallback 字段,后端设置变更可能造成控件、类型或序列化不一致。" + }, + { + "sev": "MED", + "loc": "frontend/src/styles.css:1197", + "text": "全局样式存在明确的‘旧页面兼容与窄屏布局’区域,3803 行开始仍有 archive-legacy 样式;5939 行单文件同时容纳新旧页面级联规则,增加跨页面回归和未使用 CSS 累积风险。" + }, + { + "sev": "LOW", + "loc": "frontend/src/components/ui.tsx:39", + "text": "useFetch 接收 caller 自行提供的 unknown[] 依赖,内部却省略 loader 依赖并在 67 行关闭 exhaustive-deps 检查;未来传入捕获筛选条件的 inline loader 时可能保留旧闭包,且失去静态提示。" + }, + { + "sev": "LOW", + "loc": "frontend/src/pages/v2/History.tsx:3", + "text": "History 仅包装 Archive,System.tsx:3 仅包装 Settings;navigation.ts:61-66 已将 history/system 归一到 archive/settings,App.tsx 也直接渲染 Archive/Settings,两个 wrapper 当前未被引用,属于 legacy/stub 死代码候选。" + }, + { + "sev": "LOW", + "loc": "frontend/src/components/layout/AppShell.tsx:108", + "text": "通知按钮只有图标和 aria-label,没有 onClick、状态或目标路由,当前是可点击外观但无行为的 placeholder UI。" + } + ], + "auditedHash": "d9c0dc23e2932bdececb0f7a3aad82bf53f2d7c32f6cffb9a47f6ed2590c7572", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "frontend_api", + "label": "前端 API 客户端", + "band": "frontend", + "path": "frontend/src/api.ts", + "paths": [ + "frontend/src/api.ts" + ], + "coupling": "high", + "deps": [ + "runtime_api" + ], + "desc": "前端全部 HTTP 类型、请求封装与后端契约集中点。", + "tests": [], + "loc": 427, + "contentHash": "338a3903e576b08ebcd2283087254f9637bc75c09aab03c6871370edaa07832c", + "score": 72, + "grade": "C", + "tags": [ + "dual-format", + "duplication", + "glue", + "any-escape" + ], + "findings": [ + { + "sev": "MED", + "loc": "frontend/src/api.ts:3", + "text": "request 仅调用 fetch,没有 AbortSignal 超时、取消、重试或网络错误分类;AI 生图、刷新、发送等长任务请求可能长期悬挂,页面无法主动结束旧请求或恢复瞬时网络失败。" + }, + { + "sev": "MED", + "loc": "frontend/src/api.ts:8", + "text": "HTTP 错误只读取原始 response.text() 并构造普通 Error(8-11 行),没有统一解析后端结构化错误字段、error_type、状态码或可恢复性;调用页面只能按字符串处理失败。" + }, + { + "sev": "MED", + "loc": "frontend/src/api.ts:22", + "text": "文件同时保留 V1 的 Group/Run/LatestReport 类型和接口,以及 65 行之后的 GroupV2/V2Run/Archive 与 V2 pipeline 接口;同一前端客户端维护两套后端协议,形成 dual-format/duplication。" + }, + { + "sev": "LOW", + "loc": "frontend/src/api.ts:367", + "text": "readV2TextFile 直接重新实现 fetch、状态码检查和错误文本处理,绕过 3-16 行的 request 通用封装;它与其他请求的 headers、错误格式和未来超时策略容易发生漂移。" + }, + { + "sev": "LOW", + "loc": "frontend/src/api.ts:146", + "text": "V2Run 使用 [key: string]: unknown 放开任意字段,削弱 run 状态、发送字段和恢复字段的静态类型约束;调用方访问动态字段只能运行时判断。" + }, + { + "sev": "LOW", + "loc": "frontend/src/api.ts:364", + "text": "getV2File 只对 group 做 encodeURIComponent,date/file 直接拼接 URL(364-365 行);当前日期和白名单文件名通常安全,但通用客户端边界没有统一的路径参数编码/校验策略。" + } + ], + "auditedHash": "338a3903e576b08ebcd2283087254f9637bc75c09aab03c6871370edaa07832c", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "runtime_api", + "label": "运行时与 HTTP API", + "band": "entry", + "path": "app/main.py, app/config, app/core, app/api", + "paths": [ + "app/main.py", + "app/config/**/*.py", + "app/core/**/*.py", + "app/api/**/*.py" + ], + "coupling": "core", + "deps": [ + "pipeline_scheduler", + "persistence_state", + "v1_reporting", + "history_ingestion", + "ai_prompting", + "delivery" + ], + "desc": "FastAPI 生命周期、设置加载、健康检查、V1/V2 路由与文件接口。", + "tests": [ + "tests/test_ui_api.py", + "tests/test_v2_archive_api.py", + "tests/test_v2_group_prompt_api.py" + ], + "loc": 2419, + "contentHash": "855c2a0150a138f15c34bfe3e6c4b76562f02873cf3810546b7f7ed632c8a5d0", + "score": 58, + "grade": "D", + "tags": [ + "god-component", + "bloat", + "silent-except", + "fallback", + "legacy", + "dual-format", + "duplication", + "fake-output", + "glue" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/api/files.py:25", + "text": "路径安全检查使用字符串 startswith;report_date、group_dir、filename 均未规范化。可用 sibling-prefix 绕过目录边界,例如 output/2026-08-1/../2026-08-10/ranking.txt 解析后仍满足 startswith,存在跨日期目录读取风险。" + }, + { + "sev": "HIGH", + "loc": "app/api/v2_ui.py:84", + "text": "单文件 833 行同时承担 Dashboard、归档聚合、Prompt 编辑、图片重生成、恢复、健康检查、Pipeline 控制和文件读取等多个边界,包含约 20 个路由与大量内嵌业务逻辑,属于高耦合 God Component。" + }, + { + "sev": "MED", + "loc": "app/main.py:29", + "text": "FastAPI lifespan 在 yield 前同步执行 WeChat、Codex、模板和本地环境检查;外部依赖不可用时可能拖慢服务启动,且 app/main.py:34-35 捕获 Exception 后直接置空 startup_checks,没有日志或失败原因。" + }, + { + "sev": "MED", + "loc": "app/main.py:48", + "text": "所有设置、删除、生成、发送和日志路由均直接挂载,没有认证/授权依赖。默认 host 是本机回环,但一旦通过 APP_HOST 或容器暴露到网络,管理和发送接口即无身份边界。" + }, + { + "sev": "MED", + "loc": "app/api/settings.py:99", + "text": "设置 API 接收任意字符串并先持久化;app/config/settings.py:163-166 对类型转换异常静默跳过,接口仍返回 ok=true,导致数据库值、运行时值和 UI 成功提示可能不一致。" + }, + { + "sev": "MED", + "loc": "app/config/settings.py:177", + "text": "布尔配置遇到非标准字符串时执行 return bool(text),例如 'falsee' 会被静默转换为 True;错误配置不会被拒绝,可能改变发送、邮件或 Mock Provider 行为。" + }, + { + "sev": "MED", + "loc": "app/api/system.py:92", + "text": "status 接口直接对可由设置 API 写入的 schedule_generate_time 执行 split/int,未捕获格式错误;非法配置会让状态接口返回 500,而调度器自身另有回退规则,形成配置行为分裂。" + }, + { + "sev": "MED", + "loc": "app/api/v2_ui.py:98", + "text": "Dashboard 用 display_name 作为 RunStore 的运行目录键;同一文件后续 archive_groups 使用稳定 group_id/wechat_group_id 匹配,而 retry_failed 在 app/api/v2_ui.py:724-735 又按 display_name 查询,改名或重名时可能显示 Pending、找不到任务或恢复错误群。" + }, + { + "sev": "MED", + "loc": "app/core/logging.py:68", + "text": "setup_logging 在 root.handlers 已存在时立即 return,导致 uvicorn/宿主已预配置 root handler 时不会执行 app/provider/ai/scheduler/email 文件 handler 配置,日志可能只进宿主输出而不进入声明的分类日志。" + }, + { + "sev": "MED", + "loc": "app/config/settings.py:30", + "text": "V1/V2 和主备 Provider 配置同时存在:history_provider_primary/fallback/mock_enabled、summary_provider_primary/fallback 与旧 ai_provider/ai_model/ai_api_key 并列;app/api/settings.py:24-75 又手工复制一套可编辑键,配置来源和行为边界容易漂移。" + }, + { + "sev": "LOW", + "loc": "app/api/system.py:58", + "text": "旧 API stats 只接受 Run.status 为 success/partial;V2 运行状态使用独立的大写状态协议并由 /api/v2/dashboard 读取,V1/V2 双格式并存,旧统计接口可能长期返回空或过期数据。" + }, + { + "sev": "LOW", + "loc": "app/api/v2_ui.py:759", + "text": "pipeline/generate、pipeline/send-due、pipeline/send 在 HTTP 请求内同步构造 DailyPipeline 并执行长耗时取数、AI、生图或桌面发送,API 层承担任务编排和外部副作用,易阻塞请求线程并扩大重复点击的影响面。" + } + ], + "auditedHash": "855c2a0150a138f15c34bfe3e6c4b76562f02873cf3810546b7f7ed632c8a5d0", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "pipeline_scheduler", + "label": "Pipeline 与调度", + "band": "orchestration", + "path": "app/pipeline, app/scheduler, generation runtime", + "paths": [ + "app/pipeline/**/*.py", + "app/scheduler/**/*.py", + "app/services/generation_runtime.py", + "app/services/group_name_sync.py" + ], + "coupling": "core", + "deps": [ + "persistence_state", + "history_ingestion", + "ranking", + "ai_prompting", + "image_generation", + "delivery" + ], + "desc": "每日任务编排、并发隔离、阶段状态、调度恢复与发送窗口。", + "tests": [ + "tests/test_v2_pipeline.py", + "tests/test_scheduler.py", + "tests/test_daily_auto.py", + "tests/test_generation_concurrency.py" + ], + "loc": 2160, + "contentHash": "27298f50129acd93b946ff31d176f3912e39faea0f52e686daf041051ff5439d", + "score": 54, + "grade": "D", + "tags": [ + "god-component", + "bloat", + "glue", + "fallback", + "silent-except", + "legacy", + "dual-format", + "duplication", + "placeholder" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/pipeline/daily_pipeline.py:72", + "text": "DailyPipeline 文件共约 1358 行,单类同时编排配置、群名同步、数据源、排行榜、Prompt、图片生成、RunStore、发送、恢复与人工确认;构造函数在 85-95 行直接连接多个外部子系统,职责和 blast radius 过大。" + }, + { + "sev": "HIGH", + "loc": "app/pipeline/daily_pipeline.py:124", + "text": "PeriodResolver 支持 schedule_rule(app/scheduler/period.py:35-47),但 generate_all、force_generate、rebuild_prompt_from_snapshot 均未传入 Group.schedule_rule(本行及 1045、1115 行),群级周期配置会被静默按 weekday_default 执行。" + }, + { + "sev": "HIGH", + "loc": "app/pipeline/daily_pipeline.py:1304", + "text": "_save_json 在 1304-1306 行直接 write_text;messages/ranking 等工件与 run.json 分步写入。加载损坏快照时 1310-1313 行直接失败且明确不回源,进程中断可能留下不可恢复的半成品状态。" + }, + { + "sev": "HIGH", + "loc": "app/scheduler/daily_v2_job.py:40", + "text": "DailyScheduleState.load 在 42-48 行将 OSError/JSONDecodeError 直接吞掉并返回仅含 run_date 的新状态;若已有状态文件损坏,_run_locked 可能把已完成的邮件阶段当成未开始,失去去重依据并触发重复外部发送。" + }, + { + "sev": "MED", + "loc": "app/scheduler/send_job.py:12", + "text": "run_send_due_job 在 14-19 行捕获所有 Exception,只记录日志且返回 None;APScheduler 调度层无法得到失败状态,分钟级发送异常可能表现为任务成功但无人感知。" + }, + { + "sev": "MED", + "loc": "app/scheduler/daily_v2_job.py:232", + "text": "邮件阶段只用 proc.returncode 判定 email_status=sent/failed(232-245 行),不解析子进程输出中的逐群结果或工件状态,存在子任务部分失败却被记录为 sent 的协议缺口。" + }, + { + "sev": "MED", + "loc": "app/scheduler/generate_job.py:1", + "text": "generate_job.py 与 email_job.py 仍保留 V1 ReportService/EmailService 任务(generate_job.py:15-24、email_job.py:14-22),而 manager.py:44-65 只注册 V2 任务;两套调度/状态格式仍可被外部调用,形成 legacy/dual-format 维护面。" + }, + { + "sev": "MED", + "loc": "app/scheduler/calendar_rules.py:11", + "text": "V1 仍定义 ReportWindow/get_report_window(11-35 行),V2 又定义独立 PeriodWindow/PeriodResolver(app/scheduler/period.py:14-57);两套日期窗口模型实现相同的前一自然日逻辑,增加跨版本语义漂移和 duplication 风险。" + }, + { + "sev": "MED", + "loc": "app/services/group_name_sync.py:81", + "text": "健康检查、群列表读取异常或空结果在 81-94 行统一转为 unavailable,157-165 行只标记 skipped 并保留本地旧名称;DailyPipeline 在 126、682 行继续生成/发送,数据源不可用时可能继续使用过期自动发送目标。" + }, + { + "sev": "MED", + "loc": "app/pipeline/daily_pipeline.py:205", + "text": "_record_group_failure 仅尽力写 FAILED;220-230 行再次捕获状态落盘异常并继续,导致返回结果显示 failed 但 run.json 可能没有失败状态,恢复与人工排查依赖日志而非持久化状态。" + }, + { + "sev": "LOW", + "loc": "app/scheduler/manager.py:23", + "text": "无效 schedule_generate_time 在 23-33 行静默回退到 00:15;虽然有 warning,但配置错误不会阻止启动,运行顺序依赖日志才能发现实际调度时间。" + }, + { + "sev": "LOW", + "loc": "app/scheduler/period.py:45", + "text": "PeriodResolver 对除 weekday_default 外的规则直接抛出 NotImplementedError(45-47 行),而 Group.schedule_rule 已暴露为可配置字段,属于已接入界面但未完成执行语义的 placeholder。" + } + ], + "auditedHash": "27298f50129acd93b946ff31d176f3912e39faea0f52e686daf041051ff5439d", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "history_ingestion", + "label": "微信数据接入", + "band": "domain", + "path": "app/data_sources, app/providers/history, history services", + "paths": [ + "app/data_sources/**/*.py", + "app/providers/history/**/*.py", + "app/services/history_service.py", + "app/services/message_normalizer.py", + "app/services/speaker_identity.py" + ], + "coupling": "core", + "deps": [ + "persistence_state" + ], + "desc": "WDA/MCP/CLI/Mock 数据源、联系人解析、消息归一化与身份聚合。", + "tests": [ + "tests/test_wechat_mcp.py", + "tests/test_v2_data_source.py", + "tests/test_contact_resolver.py" + ], + "loc": 2334, + "contentHash": "2d7391184024b7dec08adc33cb71ad3fd0715ecab177058cfe4ee364738eb8b0", + "score": 64, + "grade": "C", + "tags": [ + "god-component", + "bloat", + "fallback", + "fake-output", + "duplication", + "dual-format", + "glue", + "silent-except", + "legacy" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/providers/history/wechat_data_analysis.py:92", + "text": "WeChatDataAnalysisProvider 文件约 858 行,单个 Provider 同时负责 MCP 配置与健康检查、JSON 导出、群发现/解析、范围分页、旧锚点分页、响应解析、联系人映射、发送人冲突修复和消息转换,属于历史接入 God Component,修改任一上游协议都可能影响整条取数链。" + }, + { + "sev": "HIGH", + "loc": "app/data_sources/wechat_data_analysis.py:41", + "text": "V2 WeChatDataAnalysisSource 在 46-54 行只构造 WeChatDataAnalysisProvider,没有接入 app.providers.history.registry 的 wechat-cli/Mock fallback;文档虽然称 MCP/导出为回退路线,但 V2 真实数据源不可用时不会自动切换到 CLI 或 Mock,和 V1 provider registry 的契约不一致。" + }, + { + "sev": "HIGH", + "loc": "app/providers/history/registry.py:33", + "text": "build_providers 在 33-34 行会在 history_provider_mock_enabled 开启时自动追加 MockProvider;HistoryService.fetch 在 99-116 行把第一个 OK/EMPTY_RESULT 当作有效结果返回。真实 Provider 失败时,fixtures 可能被当成真实日报输入,形成 fake-output 风险。" + }, + { + "sev": "MED", + "loc": "app/data_sources/base.py:20", + "text": "V2 定义 V2Message、FetchResult、DataSourceStatus、WeChatDataSource(20-115 行),V1 又定义 RawMessage、FetchResult、ProviderStatus、ChatHistoryProvider(app/providers/history/base.py:15-85);V2 wrapper 在 app/data_sources/wechat_data_analysis.py:100-134 反复做两套模型转换,存在 dual-format/duplication 和额外 glue 层。" + }, + { + "sev": "MED", + "loc": "app/data_sources/wechat_data_analysis.py:82", + "text": "_group_exists 在 84-90 行捕获所有异常并直接返回 False;当上游 list_groups 临时失败且消息结果为空时,fetch_messages 在 114-123 行会把暂时不可用误判为 GROUP_NOT_FOUND,而不是数据源异常。" + }, + { + "sev": "MED", + "loc": "app/services/history_service.py:130", + "text": "discover_groups 在 133-143 行对 Provider 健康检查或 list_groups 的所有异常直接 continue 且不记录日志;所有 Provider 同时异常时调用方只得到空群列表,缺少可诊断的失败原因。" + }, + { + "sev": "MED", + "loc": "app/providers/history/contact_resolver.py:37", + "text": "find_contact_db 在 37-43 行按目录排序后返回第一个账号的 contact.db;WeChatDataAnalysisProvider 虽保存 wechat_mcp_account(app/providers/history/wechat_data_analysis.py:110),构造 ContactResolver 时并未按账号选择数据库(117-118 行),多账号环境可能把联系人显示名映射到错误账号。" + }, + { + "sev": "MED", + "loc": "app/providers/history/wechat_data_analysis.py:267", + "text": "JSON 导出路径在 284-287 行直接读取 item['timestamp'],并由 _to_raw 在 693 行直接读取 item['group_id'];单条导出记录缺字段或格式损坏会使整个群取数异常,没有逐条隔离或明确 INVALID_RESULT 状态。" + }, + { + "sev": "LOW", + "loc": "app/providers/history/wechat_cli.py:65", + "text": "wechat-cli 的 list-groups 在 65-80 行对非零退出、JSON 解析失败和任意异常均返回空列表;作为 fallback 时会把命令故障表现为无群,且没有保留错误上下文。" + }, + { + "sev": "LOW", + "loc": "app/providers/history/base.py:68", + "text": "ChatHistoryProvider 的接口方法在 73-85 行仅抛出 NotImplementedError,属于未使用 ABC 的 placeholder 接口;目前不影响已接入实现,但错误实例化基类时才会暴露,类型边界约束较弱。" + } + ], + "auditedHash": "2d7391184024b7dec08adc33cb71ad3fd0715ecab177058cfe4ee364738eb8b0", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "ranking", + "label": "排行榜与统计", + "band": "domain", + "path": "app/ranking + ranking_service", + "paths": [ + "app/ranking/**/*.py", + "app/services/ranking_service.py" + ], + "coupling": "med", + "deps": [], + "desc": "消息统计、人物榜单、模板渲染与 V1 兼容排行。", + "tests": [ + "tests/test_ranking.py", + "tests/test_v2_ranking.py", + "tests/test_v2_ranking_template.py" + ], + "loc": 376, + "contentHash": "ba5462ebaf26b376b09b83cf3b77fc096bee391083fa0dabe9c3a7c2ffaafac8", + "score": 64, + "grade": "C", + "tags": [ + "legacy", + "dual-format", + "duplication", + "bloat", + "glue", + "over-fit" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/ranking/engine.py:18-64; app/services/ranking_service.py:17-76", + "text": "项目同时维护两套 RankingEngine/RankingResult:V2 处理 V2Message 并输出结构化 JSON,V1 service 处理 NormalizedMessage 并内置 Top10 文本渲染。两套实现都做发言统计和确定性排序,但过滤入口、字段协议、上限和输出格式不同,规则修改容易发生语义漂移。" + }, + { + "sev": "MED", + "loc": "app/ranking/renderer.py:22-70; app/services/ranking_service.py:26-46", + "text": "排行榜渲染存在两条路径:V2 使用模板变量渲染,V1 RankingResult.render 直接拼接固定文本。相同统计数据可能产生不同的标题、字段和格式,邮件/报告与 V2 ranking.txt 的展示契约不统一。" + }, + { + "sev": "MED", + "loc": "app/ranking/template_service.py:17-20,64-75,105-110", + "text": "默认模板同时以内嵌 DEFAULT_RANKING_TEMPLATE 和磁盘 default.txt 形式存在;_ensure_default 与 reset 都从代码常量写回文件。模板文件可被编辑而代码常量不会同步,恢复默认或重新初始化可能覆盖文件侧修改。" + }, + { + "sev": "MED", + "loc": "app/ranking/engine_types.py:31-33; app/ranking/renderer.py:35-38,66-70", + "text": "V2 数据结构和渲染器保留多处旧协议兼容:top_limit 被追加到字段末尾以维持位置参数,top10_lines 作为旧变量别名,render_simple 继续保留无模板调用点。兼容层已进入核心类型/渲染路径,增加长期 dual-format/legacy 维护成本。" + }, + { + "sev": "MED", + "loc": "app/ranking/engine.py:21-27,40-54; app/services/ranking_service.py:49-67", + "text": "两套引擎的可计数判定来源不同:V2 自己检查 message_type 与 SYSTEM_KEYWORDS,V1 依赖 NormalizedMessage.countable;同一原始数据经过不同标准化链路时,消息数、发言人数和 Top 排名可能不一致。" + }, + { + "sev": "LOW", + "loc": "app/ranking/engine.py:29-38; app/ranking/engine_types.py:23-43", + "text": "V2 支持可配置 top_limit,但只校验大于 0;没有上限范围或非整数输入边界,结果结构也不校验 rank/count 非负。当前调用方通常传固定值,属于低影响输入约束缺口。" + }, + { + "sev": "LOW", + "loc": "app/ranking/renderer.py:41-45", + "text": "模板替换对无法匹配正则的占位符保持原文,对未知/畸形占位符没有渲染后残留检查;虽然 validate_template 能拦截常规未知变量,但最终输出仍可能含未替换模板标记。" + }, + { + "sev": "LOW", + "loc": "app/ranking/renderer.py:66-70", + "text": "render_simple 通过函数内动态导入 DEFAULT_RANKING_TEMPLATE 保留旧调用点,属于 glue 兼容实现;未直接造成错误,但继续扩大了 V1/V2 渲染边界。" + }, + { + "sev": "LOW", + "loc": "app/ranking/engine.py:1-7,46-64", + "text": "排行榜核心本身是确定性、无 LLM、按消息数和稳定名称排序的纯计算路径,职责清晰且复杂度低;主要问题集中在 V1/V2 双实现与输出协议分裂,而非算法本身。" + } + ], + "auditedHash": "ba5462ebaf26b376b09b83cf3b77fc096bee391083fa0dabe9c3a7c2ffaafac8", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "ai_prompting", + "label": "AI 摘要与生图提示词", + "band": "domain", + "path": "app/ai, app/providers/ai, templates/image_prompt", + "paths": [ + "app/ai/**/*.py", + "app/providers/ai/**/*.py", + "templates/image_prompt/**/*.md" + ], + "coupling": "high", + "deps": [], + "desc": "Codex/DeepSeek 调用、事件提取、分块、主题与布局选择、提示词生成。", + "tests": [ + "tests/test_v2_prompt_builder.py", + "tests/test_codex_summary_provider.py", + "tests/test_topic_selection.py" + ], + "loc": 3601, + "contentHash": "5f58c145a3bbe60705954bbefd164c3412bede327740708bd7f70826499c1b07", + "score": 58, + "grade": "D", + "tags": [ + "fallback", + "silent-except", + "legacy", + "dual-format", + "duplication", + "bloat", + "god-component", + "glue", + "any-escape", + "over-fit" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/ai/prompt_builder.py:76-80,558-673; app/providers/ai/codex.py:131-152,159-221; app/providers/ai/deepseek.py:227-259", + "text": "存在多层重试与回退叠加:Builder 的事件、候选主题、版式和最终 Prompt 各自重试,Codex/DeepSeek Provider 内部再次重试,Codex 任意异常还会切换 DeepSeek。一次超时、空响应或已被服务端接受但客户端断连的请求可能产生多次模型调用、延迟放大和重复计费。" + }, + { + "sev": "HIGH", + "loc": "app/ai/prompt_builder.py:225-460,512-673", + "text": "DeepSeekImagePromptBuilder 是明显 god-component:同一类同时负责模板读取、主题解析、消息分块、事件提取、候选选题、证据回查、版式导演、最终 Prompt 拼接、敏感字段检查、元数据和 Provider 调用,约 677 行且跨越多个业务阶段,修改一个阶段容易影响整条链路。" + }, + { + "sev": "MED", + "loc": "app/providers/ai/deepseek.py:227-259", + "text": "_chat 将 retryable 初始设为 True,并在 except Exception 中保留可重试状态;JSON 解析异常、字段缺失、空内容、非瞬态网络/协议错误都会进入重试路径,未区分瞬态错误和确定性输入/响应错误。" + }, + { + "sev": "MED", + "loc": "app/providers/ai/codex.py:42-65,123-152", + "text": "CodexGPTProvider 继承 DeepSeekV4FlashProvider 却绕过父类 __init__,复用父类内部编排并自行覆盖关键方法;这是对实现细节的 over-fit 耦合,父类初始化或分块协议变化时容易产生隐蔽回归。" + }, + { + "sev": "MED", + "loc": "app/ai/prompt_templates.py:16-96,124-165; templates/image_prompt/default.md:1-98", + "text": "默认 Prompt 模板存在两份来源:Python 内嵌 DEFAULT_IMAGE_PROMPT_TEMPLATE 与可编辑 default.md。_ensure_default 只在缺失时写入,reset 又把内嵌版本写回,两个文件可独立漂移,模板修改和恢复行为不一致。" + }, + { + "sev": "MED", + "loc": "app/ai/prompt_builder.py:97-124,393-447; app/ai/prompt_templates.py:17-96; app/providers/ai/deepseek.py:37-67; app/ai/layouts.py:222-233", + "text": "事实约束、输出结构、主题/版式规则和禁止词分散在多个 Python 常量及 Markdown 模板中,存在重复 Prompt 契约;规则变更需要同步多处,容易出现主模型、备用模型、模板和最终检查器不一致。" + }, + { + "sev": "MED", + "loc": "app/providers/ai/base.py:19-20; app/providers/ai/deepseek.py:84-91; app/ai/prompt_builder.py:225-226,480-491,676-677", + "text": "同时维护新 message_items 与旧 messages_text 两套输入格式,并通过 legacy-* 合成消息 ID;Builder 还保留旧类名 DeepSeekImagePromptBuilder 并导出 GroupSummaryImagePromptBuilder 别名,说明 V1/V2 兼容层仍渗透核心路径,存在 dual-format/legacy 维护成本。" + }, + { + "sev": "MED", + "loc": "app/ai/prompt_builder.py:453-455; app/ai/topic_selection.py:177-184", + "text": "元数据和候选字段保留旧字段(deepseek_ms、interestingness_score)以兼容历史读取;代码虽能运行,但新旧协议长期并存,字段语义和版本边界不清晰。" + }, + { + "sev": "MED", + "loc": "app/ai/prompt_builder.py:415-447", + "text": "最终 Prompt 约束主要依赖 forbidden term 的字符串包含检查和若干必需块的字符串包含检查,无法验证语义上的事实一致性、每段文字是否真正只出现一次或模型是否改写了证据;校验容易出现误报和漏报。" + }, + { + "sev": "LOW", + "loc": "app/ai/prompt_builder.py:456-461", + "text": "build 使用宽泛 except Exception 统一记录并返回失败结果;异常不会静默丢失,但错误类型边界被压平,调用方只能依据截断后的字符串判断 Provider、模板还是验证失败。" + }, + { + "sev": "LOW", + "loc": "app/ai/image_themes.py:351-368; app/ai/prompt_builder_types.py:9,21,30; app/ai/layouts.py:13", + "text": "多个核心数据入口使用 Any(主题 rng、消息列表、持久化元数据、布局定义),降低了 Prompt 协议的静态约束,属于低影响 any-escape。" + }, + { + "sev": "LOW", + "loc": "app/ai/layouts.py:478-547; app/ai/prompt_builder.py:612-622", + "text": "版式导演失败两次后使用确定性 fallback_layout_plan;该回退不编造聊天事实且有稳定 seed,属于有界安全兼容,但会掩盖模型版式能力或协议问题并降低输出丰富度。" + } + ], + "auditedHash": "5f58c145a3bbe60705954bbefd164c3412bede327740708bd7f70826499c1b07", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "image_generation", + "label": "图片生成与恢复", + "band": "domain", + "path": "app/image + Codex image automation", + "paths": [ + "app/image/**/*.py", + "scripts/codex_image_automation.py", + "scripts/test_image_generation.py" + ], + "coupling": "high", + "deps": [ + "ai_prompting", + "persistence_state" + ], + "desc": "Codex ImageGen 子进程、串行队列、图片校验、暂存、重生成与人工恢复。", + "tests": [ + "tests/test_codex_image_automation.py", + "tests/test_v2_image_task.py", + "tests/test_v2_image_regeneration.py" + ], + "loc": 1951, + "contentHash": "2d9181c0f9b013bf8ec7ff7a3878e4ee34733093a0444ffe6d960dc3cca5799d", + "score": 61, + "grade": "C", + "tags": [ + "god-component", + "bloat", + "fallback", + "silent-except", + "legacy", + "dual-format", + "duplication", + "any-escape", + "glue" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/image/codex_generator.py:143", + "text": "CodexImageGenerator 所在文件约 892 行,单类同时负责 CLI 健康检查、跨进程互斥、子进程树终止、超时恢复、attempt manifest、候选扫描、哈希去重、图片验证、原子提升和 smoke 状态,属于高 blast-radius God Component。" + }, + { + "sev": "MED", + "loc": "app/image/image_task.py:67", + "text": "verify_image 在 67-77 行只检查文件存在、大小和魔数签名,没有真正解码图片;app/image/regeneration.py:106-115 直接用该结果决定替换正式图片,损坏但带合法 PNG/JPEG 头的文件可能被接受。函数文档所称的可解析校验与实现不一致。" + }, + { + "sev": "HIGH", + "loc": "scripts/codex_image_automation.py:449", + "text": "人工 adopt 流程在 449-458 行按共享 generated_images 目录的快照增量选择唯一 PNG,但 begin/adopt 没有跨进程互斥;两个并行人工任务各自产生单个候选时,候选可能被错误群任务认领并写入错误 run.json。" + }, + { + "sev": "HIGH", + "loc": "scripts/codex_image_automation.py:336", + "text": "_sync_scheduler_result 在 336-379 行对 output/.scheduler/.json 做无锁 read-modify-write。它与 DailyScheduleState 的进程内锁不共享,可能覆盖同时写入的 generation/email 字段,造成调度状态丢失或重复阶段判断。" + }, + { + "sev": "MED", + "loc": "app/image/codex_generator.py:1", + "text": "主链路使用 Codex CLI 自有 attempt manifest(文件头及 236-430 行),同时 scripts/codex_image_automation.py:1-18 保留 Desktop begin/adopt/verify marker 工作流;两套图片认领、候选发现和 run 状态同步协议并存,属于 legacy/dual-format duplication。" + }, + { + "sev": "MED", + "loc": "app/image/regeneration.py:46", + "text": "enqueue_regeneration 在 46-51 行先把任务 key 加入全局 _ACTIVE,随后 53-68 行才执行状态写入、生成器构造和线程提交;任一更新或 submit 异常都没有回滚 _ACTIVE,后续同一群/日期会永久被判定为正在队列中,直到进程重启。" + }, + { + "sev": "MED", + "loc": "app/image/image_task.py:169", + "text": "SerialImageQueue 在 169-173 行吞掉 run_hook 的全部异常并继续返回图片结果;如果 hook 的 run.json 更新失败,调用方仍可能看到成功图片但持久化状态未进入 IMAGE_READY/READY_TO_SEND。" + }, + { + "sev": "LOW", + "loc": "app/image/regeneration.py:38", + "text": "重新生图入口的 generator 参数使用 Any,app/image/image_task.py:94 也以 Any 接收生成器,没有统一协议或结构化能力检查;生成器异常只能在运行时被捕获,属于 glue/any-escape。" + }, + { + "sev": "LOW", + "loc": "app/image/codex_generator.py:865", + "text": "_load_last_smoke 在 865-872 行只要 smoke JSON 能解析就返回 ok=True,不验证记录中的 source/output 文件仍存在;健康页可能继续展示过期的最近实测成功状态。" + } + ], + "auditedHash": "2d9181c0f9b013bf8ec7ff7a3878e4ee34733093a0444ffe6d960dc3cca5799d", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "delivery", + "label": "微信与邮件交付", + "band": "domain", + "path": "app/sender, email service, send scripts", + "paths": [ + "app/sender/**/*.py", + "app/services/email_service.py", + "scripts/send_daily_email.py", + "scripts/test_wechat_send.py" + ], + "coupling": "core", + "deps": [ + "persistence_state" + ], + "desc": "微信桌面/OCR 目标验证、安全发送认领、邮件交付与结果未知保护。", + "tests": [ + "tests/test_v2_wechat_sender.py", + "tests/test_v2_wechat_native.py", + "tests/test_email.py" + ], + "loc": 1725, + "contentHash": "56d8a864de2706eaebbe8194ebfb4baa2cb0acfcce915fcc60f9dae136a6196f", + "score": 61, + "grade": "C", + "tags": [ + "fallback", + "silent-except", + "legacy", + "dual-format", + "duplication", + "bloat", + "god-component", + "glue", + "over-fit" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/services/email_service.py:200-243; scripts/send_daily_email.py:203-243", + "text": "SMTP 发送失败后直接重试同一 EmailMessage;如果 SMTP 服务端已经接收邮件但客户端在响应阶段断开,第二次尝试可能造成重复邮件。代码没有幂等键、Message-ID 去重或提交状态确认。" + }, + { + "sev": "HIGH", + "loc": "app/services/email_service.py:111-154", + "text": "EmailService.send 只返回汇总结果,不写入每群 email_status、发送批次或幂等记录;同一 run 被 scheduler、手动脚本或恢复流程再次调用时,已成功群仍可能重新发送。" + }, + { + "sev": "MED", + "loc": "app/services/email_service.py:156-243; scripts/send_daily_email.py:82-243", + "text": "生产 EmailService 与手动脚本重复实现邮件构造、图片附件校验、SMTP 连接和重试逻辑;两套实现的降级语义不同(服务侧无效图片降级为纯排行榜,脚本侧直接跳过/失败),修复容易出现行为漂移。" + }, + { + "sev": "MED", + "loc": "app/sender/wechat_automation.py:131-143", + "text": "legacy CLI 的 JSON 解析失败时回退为 proc.returncode == 0;CLI 可能退出码为 0 但没有可验证成功响应,此处仍返回 success,存在把未确认发送报告为成功的风险。" + }, + { + "sev": "MED", + "loc": "app/sender/wechat_automation.py:104-117", + "text": "日志将完整命令拼接输出,命令包含 --to 目标和 --content 消息正文;真实发送时群名、接收目标及部分聊天内容可能进入日志,属于敏感数据泄漏风险。" + }, + { + "sev": "MED", + "loc": "app/sender/wechat_native.py:37-745", + "text": "WindowsWechatDriver 集中实现 OCR、窗口激活、坐标点击、剪贴板、图片转换、提交后截图差异验证和发送互斥,文件 868 行、约 55 个定义,属于高复杂度 god-component;桌面版本、DPI 或 OCR 变化时 Blast Radius 很大。" + }, + { + "sev": "MED", + "loc": "app/sender/wechat_automation.py:7-13,31-56; app/sender/wechat_native.py:862-868; scripts/test_wechat_send.py:23-25,68-76", + "text": "旧 wechat-automation 路径文档已明确当前微信版本 UIA 不兼容并会返回 WECHAT_WINDOW_NOT_FOUND,但配置为 legacy_cli 时仍会选择该 Provider,手动真实发送测试脚本也固定使用它;存在 legacy/stub 路径继续被误用的风险。" + }, + { + "sev": "LOW", + "loc": "scripts/send_daily_email.py:132-140,246-278", + "text": "手动邮件脚本把未验证的 run_date 直接拼接到 output/{group}/{run_date} 路径;run_date 没有 YYYY-MM-DD 校验,虽然通常由本机操作者输入,仍缺少与 V2 RunStore 一致的路径边界。" + }, + { + "sev": "LOW", + "loc": "app/sender/base.py:41-46; app/sender/wechat_native.py:823-857", + "text": "文字和图片按顺序提交,不具备原子 bundle;文字成功后图片可能失败或结果未知,调用方必须正确处理部分成功。SendResult 已保留 submitted、verification_level 和 outcome_unknown 字段,这是当前较好的 fail-closed 兼容设计。" + }, + { + "sev": "LOW", + "loc": "scripts/test_wechat_send.py:60-66; app/sender/wechat_automation.py:96-102", + "text": "dry-run 测试把 ranking_demo_group_2026-08-17.txt 作为图片路径,而 legacy send_image 只检查文件存在、不验证图片格式;测试可能显示‘图片发送’成功但实际输入不是图片。" + } + ], + "auditedHash": "56d8a864de2706eaebbe8194ebfb4baa2cb0acfcce915fcc60f9dae136a6196f", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "v1_reporting", + "label": "V1 报告兼容链", + "band": "domain", + "path": "legacy report/prompt/handoff services", + "paths": [ + "app/services/report_service.py", + "app/services/prompt_service.py", + "app/services/handoff_service.py", + "scripts/run_daily_pipeline.py" + ], + "coupling": "high", + "deps": [ + "persistence_state", + "history_ingestion", + "ranking", + "ai_prompting", + "delivery" + ], + "desc": "仍可触达的 V1 数据库报告、Prompt fallback、工件交接与 CLI 流程。", + "tests": [ + "tests/test_ui_api.py", + "tests/test_handoff.py", + "tests/test_prompt.py" + ], + "loc": 696, + "contentHash": "af44a66794ab4fa97b307e1452bdb5ab97c4855397a690df9c6754f4a27b9e4d", + "score": 52, + "grade": "D", + "tags": [ + "fake-output", + "silent-except", + "legacy", + "dual-format", + "duplication", + "glue", + "bloat" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/services/report_service.py:247", + "text": "Prompt 失败时仍继续保存 Report 并调用 HandoffService;handoff_service.py:82-90 无条件写入 status=prompt_ready、poster_file=null,可能把空 Prompt 暴露为可交接产物。另在 268-271 行捕获文件输出异常只写 error_message,145-147 行仅检查 ranking_status/prompt_status,文件缺失仍可能被判定为成功。" + }, + { + "sev": "HIGH", + "loc": "app/services/report_service.py:317", + "text": "_find_success_group_run 只查询 GroupRun.ranking_status == success,没有要求 prompt_status == success。此前排行榜成功但 Prompt 失败的记录会在 175-177 行被当作已完成,非 force 重试无法修复,只会再次返回失败。" + }, + { + "sev": "HIGH", + "loc": "app/services/report_service.py:148", + "text": "GroupRun 初始 running/pending 状态在 179-187 行先提交;后续异常在 148-159 行通过新增一条 failed GroupRun 处理,原记录会遗留为 running/pending。父 Run 只有在 118-126 行全部 worker 返回后才收口,进程或 worker 异常可留下孤儿状态。" + }, + { + "sev": "HIGH", + "loc": "app/services/handoff_service.py:34", + "text": "safe_dir_name 仅替换字符并截断 display_name,不加入稳定 group_id,也不拒绝 '.'、'..' 或 Windows 保留名;save_outputs 在 52-61 行直接使用该目录并覆盖写文件。不同群名可能碰撞覆盖,'.'/'..' 还可把写入指向日期目录或 output 根目录。" + }, + { + "sev": "HIGH", + "loc": "app/services/handoff_service.py:105", + "text": "list_group_outputs 将未经格式校验的 report_date 直接拼入 output_dir 路径;文件接口把用户路径参数直接传入,绝对路径或 '..' 可突破预期日期目录并列出其他目录,同时返回完整本地 filesystem path。" + }, + { + "sev": "HIGH", + "loc": "scripts/run_daily_pipeline.py:35", + "text": "_print_results 只打印每群状态;generate、send、force-generate、rebuild-prompt、force-send 分支分别在 99、105、114、119、124 行无条件 return 0。即使结果为 failed/partial,调度器仍会收到成功退出码。" + }, + { + "sev": "MED", + "loc": "app/services/handoff_service.py:116", + "text": "handoff.json 通过 json.loads 无保护读取;save_outputs 在 57-92 行顺序写入多个文件且非原子,进程中断或部分文件损坏时,归档列表接口会直接抛异常而不是返回明确的不完整状态。" + }, + { + "sev": "MED", + "loc": "scripts/run_daily_pipeline.py:28", + "text": "_pipeline 每次都执行 repo.init_db 和 repo.apply_db_settings;status 分支在 83-89 行也经过该初始化路径,因此看似只读的状态查询可能创建或修改数据库设置。" + }, + { + "sev": "MED", + "loc": "app/services/report_service.py:1", + "text": "ReportService 明确是 V1 生成链路,而 scripts/run_daily_pipeline.py:1 使用独立 V2 DailyPipeline;handoff_service.py:11-12 又同时维护 V1/V2 状态语义。两套生成、状态和输出协议并存,后续修复容易发生 legacy/dual-format 漂移。" + }, + { + "sev": "MED", + "loc": "scripts/run_daily_pipeline.py:72", + "text": "send 命令提供 --dry-run,但 force-send 子命令没有该选项;91 行通过 getattr 缺省为 False,因此 force-send 无法在 CLI 层进行 dry-run,误调用会直接进入真实发送路径。" + }, + { + "sev": "LOW", + "loc": "app/services/prompt_service.py:89", + "text": "_build_context_text 明确标注为兼容旧调用,ranking 参数未使用,且仓库内没有实际调用;它与 _build_message_items/新 provider message_items 路径重复构造完整上下文,属于 legacy 兼容代码候选。" + } + ], + "auditedHash": "af44a66794ab4fa97b307e1452bdb5ab97c4855397a690df9c6754f4a27b9e4d", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "persistence_state", + "label": "数据库与运行状态", + "band": "storage", + "path": "app/db + app/v2", + "paths": [ + "app/db/**/*.py", + "app/v2/**/*.py" + ], + "coupling": "core", + "deps": [ + "v1_reporting" + ], + "desc": "SQLModel/SQLite、启动迁移、V2 run.json 状态、发送 lease 与恢复。", + "tests": [ + "tests/test_v2_recovery.py", + "tests/test_v2_group_migration.py", + "tests/test_v2_period.py" + ], + "loc": 1125, + "contentHash": "f6293bb537b00804adedf91e75ec40c2f5c913bb5688887009da44baddb2f533", + "score": 62, + "grade": "C", + "tags": [ + "silent-except", + "fallback", + "legacy", + "dual-format", + "duplication", + "bloat", + "god-component", + "glue", + "any-escape" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "app/db/models.py:10-79", + "text": "持久化模型缺少关系约束:wechat_group_id 仅普通索引,GroupRun.run_id/group_id 与 Report.group_run_id 都是无 ForeignKey 的裸整数,也没有复合唯一约束;重复群组、重复运行和孤儿记录只能依赖业务层避免。" + }, + { + "sev": "HIGH", + "loc": "app/db/models.py:38-79; app/v2/run_store.py:1-7", + "text": "存在双持久化格式:V1 将 Run/GroupRun/Report 写入 SQLite,V2 将同一类运行状态和输出元数据写入每群每日 run.json;当前模块没有统一同步或一致性边界,状态可能分叉。" + }, + { + "sev": "HIGH", + "loc": "app/v2/run_store.py:153-162", + "text": "load_run 对 JSONDecodeError/OSError 静默 pass,并回退为全新的 PENDING 状态;损坏或部分写入的状态会被当成未执行任务,可能触发重复生成或重复后续操作。" + }, + { + "sev": "MED", + "loc": "app/db/repository.py:34-184", + "text": "迁移由多个手写 _migrate_* 函数组成,重复使用 marker 查询、更新、插入和 commit;没有版本表、统一迁移注册或全局迁移事务,属于明显 legacy/bloat/duplication。" + }, + { + "sev": "MED", + "loc": "app/db/repository.py:187-206", + "text": "init_db 每次启动按固定顺序执行 create_all、补列、种默认值和多轮数据迁移;多个进程并发启动或中途异常时可能出现部分迁移,且旧 schema 默认值与模型默认值容易漂移。" + }, + { + "sev": "MED", + "loc": "app/db/repository.py:331-425", + "text": "Repository 的 save_group、delete_group、restore_group、create_run、save_report、set_setting_value 等操作都立即 commit,缺少跨实体事务边界;核心流程的多阶段写入无法由该层保证原子性。" + }, + { + "sev": "MED", + "loc": "app/v2/run_store.py:164-181", + "text": "普通 save_run/update 只使用进程内 RLock,临时文件名固定为 run.json.tmp;跨进程写同一 run 时仍可能互相覆盖临时文件或丢失更新,跨进程互斥只在发送 claim 路径启用。" + }, + { + "sev": "MED", + "loc": "app/v2/recovery.py:85-105", + "text": "verify_output 只检查文件存在且 size>0,不校验 JSON schema、内容可解析性、图片实际解码或状态字段一致性;损坏文件可能被判定为完整。" + }, + { + "sev": "MED", + "loc": "app/v2/recovery.py:113-153", + "text": "recover_incomplete 通过 display_name 查询 group_id,而不是使用持久化稳定 ID;重名群组时可能恢复到错误群组,且动态导入 DailyPipeline/DB,形成较强 glue 耦合。" + }, + { + "sev": "MED", + "loc": "app/v2/run_store.py:119-121", + "text": "运行目录以 safe_dir_name(group_name)/run_date 定位,没有稳定 group_id;名称清洗、特殊字符归一化或截断会造成不同群组目录碰撞并覆盖状态/输出。" + }, + { + "sev": "MED", + "loc": "app/v2/constants.py:19-32", + "text": "STATUS_FLOW 只声明状态顺序,is_terminal 只做终态集合判断,没有统一的合法状态转移校验;状态推进规则仍分散在上层 pipeline,状态机约定容易被绕过。" + }, + { + "sev": "LOW", + "loc": "app/v2/run_store.py:339-364", + "text": "list_runs 对损坏 run.json 直接静默跳过,调用方无法区分“没有运行”和“状态文件损坏”,导致恢复/审计列表不完整。" + }, + { + "sev": "LOW", + "loc": "app/v2/recovery.py:38-52", + "text": "旧 run 缺少 image_enabled 或值未知时固定回退为 True;这是兼容性 fallback,但会把旧格式或异常数据强制解释为需要图片。" + }, + { + "sev": "LOW", + "loc": "app/db/repository.py:13,428-431", + "text": "全局 engine 使用 Any,_now 返回 Any,削弱了 persistence 边界的静态类型约束,属于低影响 any-escape。" + }, + { + "sev": "MED", + "loc": "app/v2/run_store.py:113-365", + "text": "RunStore 同时承担路径生成、JSON 状态读写、历史布局读取、跨进程发送锁、发送租约和运行枚举,职责集中度偏高,已接近 persistence god-component;后续修改发送状态时容易影响文件状态逻辑。" + } + ], + "auditedHash": "f6293bb537b00804adedf91e75ec40c2f5c913bb5688887009da44baddb2f533", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + }, + { + "id": "operations_scripts", + "label": "运维与计划任务脚本", + "band": "ops", + "path": "scripts/daily_auto.py 等", + "paths": [ + "scripts/daily_auto.py", + "scripts/generate_fixtures.py", + "scripts/install_autostart.py", + "scripts/install_daily_task.py", + "scripts/test_wechat_data.py" + ], + "coupling": "med", + "deps": [ + "pipeline_scheduler", + "history_ingestion" + ], + "desc": "Windows 自动启动/计划任务、每日监控入口、fixture 与数据源手工检查。", + "tests": [ + "tests/test_daily_auto.py", + "tests/test_install_daily_task.py" + ], + "loc": 652, + "contentHash": "ec805b9b5f228de0522b1755d42b5bfa244b32cf5c2202ca4a900b428c3737ee", + "score": 58, + "grade": "D", + "tags": [ + "legacy", + "dual-format", + "fake-output", + "glue", + "over-fit", + "bloat" + ], + "findings": [ + { + "sev": "HIGH", + "loc": "scripts/test_wechat_data.py:31", + "text": "脚本声明只读取微信数据,但每个命令在 138 行都会先调用 _load_settings;该函数执行 repo.init_db 和 repo.apply_db_settings,初始化过程包含建表、默认值写入和迁移,健康检查/列群等只读操作可能修改生产数据库。" + }, + { + "sev": "HIGH", + "loc": "scripts/test_wechat_data.py:177", + "text": "main 无论 health、list-groups、resolve 或 fetch 返回什么状态都固定 return 0;数据源不可用、读取失败或空结果仍会被自动化环境视为成功,脚本没有把 Provider 状态映射为退出码。" + }, + { + "sev": "MED", + "loc": "scripts/daily_auto.py:69", + "text": "already_running 被纳入成功退出集合。任务实际没有执行任何生成或发送时仍返回 0,计划任务监控无法区分‘已完成’和‘被并发锁跳过’。" + }, + { + "sev": "MED", + "loc": "scripts/install_daily_task.py:114", + "text": "安装、卸载和状态命令的 main 固定 return 0;_install 即使 schtasks 返回失败也只打印字符串,调用方无法通过退出码发现任务安装失败。install_autostart.py:64-74 存在同样问题。" + }, + { + "sev": "MED", + "loc": "scripts/install_daily_task.py:44", + "text": "两阶段安装先创建生成任务,再创建发送任务;发送任务创建失败时 55-71 行只返回警告,不回滚已创建的生成任务,系统会留下半安装状态。" + }, + { + "sev": "MED", + "loc": "scripts/test_wechat_data.py:45", + "text": "fetch 结果会把完整 messages、消息详情和 Provider 错误写入 output/test-data,固定文件名会覆盖旧证据,且没有脱敏、保留期限或显式清理边界;真实聊天验证可能留下敏感数据。" + }, + { + "sev": "MED", + "loc": "scripts/install_daily_task.py:35", + "text": "计划任务的生成阶段调用 daily_auto.py,发送阶段调用另一套 run_daily_pipeline.py;两个入口拥有不同的状态文件、失败语义和退出码,调度链路存在 dual-format/glue 漂移风险。" + }, + { + "sev": "LOW", + "loc": "scripts/generate_fixtures.py:170", + "text": "生成器按当前 date.today() 滚动生成最近 8 天 fixtures,并在 171-183 行直接覆盖 groups.json 和消息文件;没有 --output、日期或确认参数,容易覆盖手工 fixture,且输出结果随执行日期变化。" + }, + { + "sev": "LOW", + "loc": "scripts/generate_fixtures.py:24", + "text": "groups.json 声明 member_count 为 48/112,但实际生成的 MEMBERS_A/MEMBERS_B 只有 15/8 人;这是 mock_fixture 的不一致数据契约,可能让 UI 或测试展示错误人数。" + }, + { + "sev": "LOW", + "loc": "scripts/install_autostart.py:24", + "text": "开机自启注释仍称启动 V1 脚本,_START_CMD 通过 start_windows.bat 启动旧入口;该兼容包装与当前 V2 调度入口并存,版本边界和实际启动流程不清晰。" + } + ], + "auditedHash": "ec805b9b5f228de0522b1755d42b5bfa244b32cf5c2202ca4a900b428c3737ee", + "auditedAt": "2026-08-24", + "auditedRev": "1d326e2" + } + ] +} \ No newline at end of file diff --git a/.codemap/sonar-summary.json b/.codemap/sonar-summary.json new file mode 100644 index 0000000..1bc3c74 --- /dev/null +++ b/.codemap/sonar-summary.json @@ -0,0 +1,48 @@ +{ + "server": "SonarQube Community Build 26.8.0.126808", + "projectKey": "groupbrief-v1-local-audit", + "analysisId": "1a07a159-5e1b-41b1-a74b-f68ee6dfe58a", + "ceTaskId": "02a52972-25c5-4dcc-8493-1bf7f0d9c2dd", + "status": "SUCCESS", + "warnings": 0, + "metrics": { + "ncloc": 23570, + "files": 120, + "bugs": 5, + "vulnerabilities": 12, + "securityHotspots": 0, + "codeSmells": 295, + "duplicatedBlocks": 5, + "duplicatedLines": 68, + "duplicatedLinesDensity": 0.2, + "coverage": 63.9, + "lineCoverage": 63.9, + "linesToCover": 10145, + "uncoveredLines": 3664, + "cognitiveComplexity": 3703, + "cyclomaticComplexity": 4368, + "maintainabilityRating": "A", + "reliabilityRating": "D", + "securityRating": "D", + "technicalDebtMinutes": 2141, + "technicalDebtRatioPercent": 0.3 + }, + "humanReview": { + "confirmedSonarBugs": 0, + "sonarBugCandidatesNeedingLowPriorityReview": 5, + "sonarVulnerabilitiesClassifiedLowValue": 12, + "reason": "Vulnerability findings are non-cryptographic random selection and deterministic fixture hashes; source review found more material authentication and path-boundary risks not detected by SonarQube." + }, + "validation": { + "pythonTestsPassed": 413, + "pythonTestsFailed": 1, + "pythonWarnings": 1, + "pythonStatementCoveragePercent": 74.7, + "frontendAutomatedTests": 0, + "frontendBuild": "PASS", + "frontendMainJsBytes": 451300, + "frontendMainJsGzipBytes": 131500, + "npmAudit": {"moderate": 1, "high": 1, "critical": 0}, + "pipAuditKnownVulnerabilities": 0 + } +} diff --git a/PROJECT_AUDIT.md b/PROJECT_AUDIT.md new file mode 100644 index 0000000..b3a3dbc --- /dev/null +++ b/PROJECT_AUDIT.md @@ -0,0 +1,724 @@ +# GroupBrief V1 全面工程体检 + +> 审计日期:2026-08-24(Asia/Shanghai) +> +> 审计基线:`cf29e2decf5ae74e5a7037531edb0218dc4bdcea` 与该时点工作树 +> +> 审计方式:Codemap + Code Overhaul Full Audit + SonarQube Community 26.8 +> +> 原则:只审计,不修改生产代码,不触发真实微信、邮件或收费模型调用 + +## 0. 审计边界与结论可信度 + +本报告覆盖 `app/`、`scripts/`、`frontend/src/`、`templates/`、测试、当前 SQLite Schema/聚合数据、当前本地进程与计划任务。Codemap 跟踪 116 个生产文件、28,149 行;SonarQube 分析 120 个文件、23,570 NCLOC。 + +审计开始时已有 AI 图片主题、Prompt、前端页面和测试改动;这些改动在审计期间由外部流程提交为 `cf29e2d`。本轮没有改动任何生产业务文件。SonarQube 与测试使用审计时点的隔离快照。 + +以下结论需要明确边界: + +- `/api/system/health` 当前返回 `ok`,只证明服务进程可响应,不证明 WDA、Codex、DeepSeek、SMTP、微信桌面链路都健康。 +- `output/.scheduler/2026-08-24.json` 记录 `generation_status=success`、6 个群均 `ready_to_send`、`email_status=sent`;这说明本地状态机收口,但不是外部邮箱或微信送达的不可变证明。 +- 未读取 `.env` 值或任何密钥。Git 历史扫描覆盖 40 个提交,没有发现常见 Secret 模式或历史跟踪 `.env` 的证据,但这不是专业 Secret Scanner 的绝对保证。 +- 本报告将 Sonar 规则命中与人工源码判断分开;不会把规则分数机械等同于真实风险。 + +--- + +## 1. Executive Summary + +### 1.1 一句话结论 + +**当前 GroupBrief 属于“可用 V1”,健康度 54 / 100。** + +它已经不是 Demo:真实数据接入、群级并发、串行生图、状态持久化、安全发送认领、失败隔离、调度恢复和管理界面都已经形成完整链路;当前后端绑定 `127.0.0.1:8766`、健康接口正常,当日 6 群生成状态已收口。 + +它还不是“稳定 V1”:数据库已有 192 条失去群组关联的 `group_runs`,6 个历史 `runs` 永久停在 `running`;V1/V2 两套生成、持久化、排行、Prompt、API 和调度语义仍同时可触达;损坏的 `run.json` 或 scheduler 状态会被静默解释为“未开始”;Docker 支持路径会把无认证管理/发送 API 绑定到 `0.0.0.0`;完整测试不是全绿且存在顺序依赖。 + +### 1.2 为什么现在能够运行 + +1. `app/main.py:22-45` 在启动时创建目录、初始化 SQLite、执行手写迁移并启动调度器,历史数据库通常能被就地升级。 +2. `app/pipeline/daily_pipeline.py:99-199` 对群级工作并发执行并隔离单群异常,不要求一个群失败就终止整批。 +3. `app/v2/run_store.py:164-181,248-302` 对单个 `run.json` 使用临时文件替换,并为发送提供 claim、lease、`result_unknown` 与人工 hold。 +4. `app/image/codex_generator.py:236-430,705-852` 已有任务级 staging、结构化结果、进程树回收、候选校验、SHA256 与原子提升。 +5. `app/providers/history/wechat_mcp.py:56-145` 对 MCP 有 loopback/允许主机、超时、响应大小和 JSON 边界。 +6. `app/sender/wechat_native.py` 与 Pipeline 的发送状态偏向 fail-closed:无法确认时进入 unknown/hold,而不是直接报告成功。 +7. Python 测试已有 400+ 场景;本轮隔离执行为 413 通过、1 失败,Python statement coverage 约 74.7%。 +8. 当前 V2 默认关闭群级微信自动发送,依赖不完整时通常不会在启动阶段直接造成对外误发。 + +### 1.3 哪些部分只是“目前没出问题” + +- 数据关系靠应用代码约定而非 FK/Unique;真实数据库已经出现逻辑孤儿。 +- SQLite 写入、`run.json`、scheduler JSON、图片/文本工件不是同一个事务;进程中断可能留下跨存储半成功。 +- `schedule_rule`、`summary_model`、`prompt_model`、`provider_preference` 能保存和展示,但 V2 主流程没有完整消费。 +- FastAPI 内 APScheduler 与 Windows Task Scheduler 同时存在,当前主要依赖互斥锁和发送 claim 避免重复,而不是单一调度所有权。 +- AI/SMTP 的“请求已提交但响应丢失”没有全链路幂等键,重试可能重复调用或重复邮件。 +- 前端无自动化测试;真实微信、Codex 生图、SMTP、OCR 发送仍依赖人工实机验收。 + +--- + +## 2. 项目架构图 + +### 2.1 主架构 + +```text +┌────────────────────────── Frontend ──────────────────────────┐ +│ React/Vite Pages ── frontend/src/api.ts ── HTTP/JSON │ +└──────────────────────────────┬───────────────────────────────┘ + ▼ +┌──────────────────── FastAPI / Runtime API ───────────────────┐ +│ app/main.py + app/api/* + app/config/* + app/core/* │ +└──────────────┬───────────────────────────┬────────────────────┘ + │ V2 │ V1 兼容链 + ▼ ▼ +┌──────────────────────────┐ ┌───────────────────────────────┐ +│ DailyPipeline/Scheduler │ │ ReportService/Prompt/Handoff │ +│ generate / recover/send │ │ SQLite Run/GroupRun/Report │ +└──────────────┬───────────┘ └──────────────┬────────────────┘ + │ │ + ┌───────┼────────┬─────────┬───────────┼─────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + WDA/MCP Ranking AI Prompt ImageGen Email WeChat + Provider Engine Codex/DS Codex CLI SMTP OCR/UI + │ │ │ │ │ │ + └───────┴────────┴─────────┴───────────┴─────────┘ + │ + ▼ + SQLite + output/{group}/{date}/run.json + 工件 +``` + +### 2.2 核心 V2 数据流 + +```text +群配置/稳定 wechat_group_id + → WDA/MCP 取消息 + → messages.json + → 消息归一化/身份聚合 + → RankingEngine → ranking.json / ranking.txt + → 事件提取/选题/版式/Prompt → image_prompt.txt + → Codex ImageGen → daily_image.png + → RunStore 更新 READY_TO_SEND + → 到点 claim 发送权 + → 精确目标验证 + → 文本 + 图片 + → SENT 或 result_unknown/manual hold +``` + +### 2.3 状态流 + +```text +PENDING + └→ DATA_READY + └→ RANKING_READY + └→ PROMPT_READY + └→ IMAGE_READY + └→ READY_TO_SEND + ├→ SENT + ├→ FAILED + └→ result_unknown / manual hold + +任意生成阶段可进入 FAILED;但损坏 run.json 当前可能被重置为新的 PENDING。 +``` + +### 2.4 Codemap 模块健康 + +Codemap 平均分约 **60.5 / 100(C)**。没有 A/B 模块;5 个 D、7 个 C。 + +| 模块 | 行数 | 分数 | 主要原因 | +|---|---:|---:|---| +| V1 报告兼容链 | 696 | 52 / D | 假成功、路径边界、双状态 | +| Pipeline 与调度 | 2,160 | 54 / D | God Service、静默回退、双调度 | +| 运行时与 HTTP API | 2,419 | 58 / D | 无认证边界、833 行 V2 API、配置漂移 | +| AI 摘要与提示词 | 3,601 | 58 / D | 多层重试、Prompt 契约散落、God Builder | +| 运维脚本 | 652 | 58 / D | 失败仍退出 0、半安装、只读命令写 DB | +| 图片生成与恢复 | 1,951 | 61 / C | 高复杂度、双认领协议、无锁状态同步 | +| 微信与邮件交付 | 1,725 | 61 / C | SMTP 幂等缺口、Legacy CLI、God Driver | +| 前端界面 | 10,683 | 62 / C | 5,939 行 CSS、God 页面、轮询/N+1 | +| 数据库与运行状态 | 1,125 | 62 / C | 无 FK、双持久化、损坏状态重置 | +| 微信数据接入 | 2,334 | 64 / C | 858 行 Provider、V1/V2 双模型、Mock fallback | +| 排行榜与统计 | 376 | 64 / C | V1/V2 双引擎/双渲染 | +| 前端 API 客户端 | 427 | 72 / C | 双协议、无超时/取消、弱错误类型 | + +修改影响最大的路径是: + +```text +DailyPipeline + ├→ RunStore / SQLite + ├→ WDA/MCP + ├→ Ranking + ├→ Prompt/Codex/DeepSeek + ├→ ImageGen + ├→ GroupNameSync + └→ WeChat/Email +``` + +因此 `daily_pipeline.py` 的任何“简单修改”都需要至少覆盖生成、恢复、并发、状态持久化、发送 claim 和外部结果未知分支。 + +--- + +## 3. 项目健康度 + +### 3.1 总分:54 / 100 + +| 维度 | 分数 | 依据 | +|---|---:|---| +| 架构合理性 | 5 / 10 | 模块职责大体可识别,但 V1/V2 并存、存储层反向依赖旧服务、核心 God Service 明显 | +| 业务逻辑 | 6 / 10 | 主链可运行并隔离单群失败;群级配置未完全生效、状态与工件可分叉 | +| 代码质量 | 5 / 10 | Codemap 60.5/C;Sonar 295 smells;多个 600–1,300 行核心文件 | +| 数据设计 | 3 / 10 | 192/224 `group_runs` 孤儿、无 FK/复合唯一、6 个永久 running、手写迁移漂移 | +| 稳定性 | 6 / 10 | 有 mutex、claim、hold、staging 和原子替换;损坏状态静默重置、双调度与退出码仍弱 | +| 测试 | 6 / 10 | Python 413 通过、覆盖较广;1 失败且单测顺序依赖,前端/E2E/真实外部边界缺失 | +| 安全性 | 5 / 10 | 当前 loopback、Secret 忽略、MCP allowlist 较好;Docker 无认证暴露、旧文件路径检查和敏感日志有风险 | +| 性能与资源 | 6 / 10 | 当前规模可承受;存在前后端 N+1、轮询、串行图片长尾、多层 AI 重试和 SQLite 写竞争 | +| 可观测性 | 5 / 10 | 有分类日志、run.json、scheduler 状态;部分异常被吞、健康 GET 有副作用、脚本退出码误导 | +| 文档与可维护性 | 7 / 10 | README、架构、CI、恢复文档较多;V1/V2 文档/代码边界仍漂移,缺版本化迁移与决策记录 | + +### 3.2 已经可靠的部分 + +- V2 发送 claim/lease/unknown hold。 +- Codex 图片 staging、验证、哈希、进程树回收。 +- WDA/MCP 允许主机、超时、响应大小与 JSON 校验。 +- 群级失败隔离和全局图片串行队列。 +- 当前 SQLite `PRAGMA integrity_check=ok`;没有发现重复非空 `wechat_group_id`、重复 `(run_id, group_id)` 或孤儿 Report。 +- 当前服务实际监听 `127.0.0.1:8766`,不是 `0.0.0.0`。 +- 前端严格 TypeScript,当前 `npm run build` 通过。 + +### 3.3 逻辑完整性现状 + +| 项目 | 结果 | +|---|---:| +| SQLite 物理完整性 | `ok` | +| `group_runs` 总数 | 224 | +| 找不到 `groups.id` 的 `group_runs` | 192 | +| `runs.status=running` 且未结束 | 6 | +| 孤儿 Report | 0 | +| 重复非空 WeChat Group ID | 0 | +| 重复 `(run_id, group_id)` | 0 | +| SQLite `foreign_keys` | 0 | +| SQLite journal mode | `delete` | + +物理完整性正常不等于业务一致性正常;当前主要问题正是“数据库文件没坏,但关系语义已丢失”。 + +--- + +## 4. SonarQube 客观指标 + +### 4.1 扫描信息 + +- SonarQube:Community Build `26.8.0.126808` +- Project Key:`groupbrief-v1-local-audit` +- Analysis ID:`1a07a159-5e1b-41b1-a74b-f68ee6dfe58a` +- CE Task:`02a52972-25c5-4dcc-8493-1bf7f0d9c2dd` +- 状态:`SUCCESS`,0 warning +- 本地 Dashboard: + +### 4.2 指标 + +| 指标 | SonarQube 结果 | 人工解释 | +|---|---:|---| +| Bugs | 5 | 0 个已确认高风险;3 个正则分组、1 个变量重赋值、1 个字符串默认排序均偏低价值 | +| Vulnerabilities | 12 | 全部为非安全用途随机数或 fixture MD5,人工判定低价值命中 | +| Security Hotspots | 0 | 不代表安全;真正的 Docker/认证/路径风险由源码审查发现 | +| Code Smells | 295 | 其中复杂度/大模块值得修;大量弃用图标、API 文档、readonly props 属 P3 | +| Duplication | 0.2% | 5 blocks / 68 lines;全局不严重,局部前端最高 9.7% | +| Coverage | 63.9% | Python 约 74.7%,前端 0%,因此总体下降 | +| Cognitive Complexity | 3,703 | 与 Codemap God Service 结论高度一致 | +| Cyclomatic Complexity | 4,368 | 热点集中在 Pipeline、WDA、Codex、Native Sender 和页面 | +| Maintainability Rating | A | 技术债比率仅 0.3%;不应解读为架构健康 | +| Reliability Rating | D | 由规则级 Bug 候选拉低;需人工确认,当前 5 个均未证明真实故障 | +| Security Rating | D | 由 12 个随机数/MD5 命中拉低;与真实安全边界不一致 | +| Technical Debt | 2,141 分钟 | 约 35 小时 41 分;仅是规则修复估算,不包含迁移/数据治理 | +| Technical Debt Ratio | 0.3% | 估算开发成本基数很大,使比例看起来漂亮 | + +Sonar Quality Gate 显示 `OK`,但当前门只检查“新问题为 0”,并以同日先前扫描为比较基线。它不能证明项目达到了可发布质量门槛。 + +### 4.3 最复杂文件 + +| 文件 | Cognitive | Cyclomatic | Sonar Coverage | +|---|---:|---:|---:| +| `app/pipeline/daily_pipeline.py` | 265 | 212 | 86.8% | +| `app/providers/history/wechat_data_analysis.py` | 222 | 193 | 79.8% | +| `app/image/codex_generator.py` | 213 | 160 | 71.9% | +| `frontend/src/pages/v2/AIImages.tsx` | 162 | 250 | 0% | +| `app/sender/wechat_native.py` | 161 | 178 | 56.0% | +| `scripts/codex_image_automation.py` | 154 | 145 | 68.5% | +| `app/api/v2_ui.py` | 130 | 145 | 51.7% | +| `frontend/src/pages/v2/Archive.tsx` | 109 | 236 | 0% | + +### 4.4 最复杂函数/组件 + +| 位置 | 函数/组件 | Cognitive | +|---|---|---:| +| `app/pipeline/daily_pipeline.py:726` | `_send_one` | 59 | +| `app/pipeline/daily_pipeline.py:295` | `_generate_one` | 45 | +| `app/ai/conversation_segments.py:108` | `segment_messages` | 43 | +| `app/image/codex_generator.py:246` | `_generate_locked` | 42 | +| `frontend/src/pages/v2/AIImages.tsx:130` | `AIImages` | 38 | +| `app/ai/prompt_builder.py:243` | `build` | 37 | +| `app/services/group_name_sync.py:65` | `sync` | 34 | +| `app/services/email_service.py:52` | `build_email` | 33 | +| `app/ai/topic_selection.py:314` | `score_and_select_topics` | 31 | +| `app/providers/history/wechat_data_analysis.py:294` | `_fetch_messages_mcp` | 31 | + +### 4.5 重复最严重位置 + +| 文件 | Duplication | +|---|---:| +| `frontend/src/pages/v2/ChatRecords.tsx` | 9.7% | +| `frontend/src/pages/v2/Tasks.tsx` | 5.4% | +| `frontend/src/pages/v2/Ranking.tsx` | 4.9% | +| `frontend/src/pages/v2/AIImages.tsx` | 2.0% | + +这与源码审查发现的 `STATUS_LABELS`、`runKey`、`statusTone`、加载/错误状态重复相符,属于值得处理的局部重复;全局 0.2% 不值得为了评分做大规模抽象。 + +### 4.6 值得修与低价值规则 + +**值得修:** + +- `python:S3776` / `typescript:S3776`:47 个复杂函数候选,与 Codemap 的 God Component 结论交叉确认。 +- CSS contrast 19 处:真实可访问性问题,应在 UI 稳定后按页面修。 +- Vite/npm audit:1 high + 1 moderate,属于开发服务器路径遍历/UNC/读取风险,应规划工具链升级。 +- 前端 0% coverage、`v2_ui.py` 51.7%、`wechat_native.py` 56%、`wechat_cli.py` 37%:比追求总百分比更有价值。 + +**低价值或需人工确认:** + +- 12 个 Sonar Vulnerability:`random.Random` 用于每日视觉风格与 fixture,MD5 用作假数据 ID,不承担密码学安全。 +- 3 个 `_FENCE_RE` 正则分组 Bug:当前表达式意图就是“开头 fenced 或结尾 fenced”,未发现真实错误。 +- `messageTypes.sort()`:集合内容是字符串,默认字典序符合当前展示意图。 +- 203 个 Phosphor 图标弃用命中:主要是库 API 演进,不能为清零而一次性替换所有图标。 +- 53 个 FastAPI `responses` 文档命中、28 个 readonly props、重复主题分类文字:属于 P3 工程卫生。 + +--- + +## 5. 验证与测试体系 + +| 验证 | 结果 | 解释 | +|---|---|---| +| Python compileall | PASS | `app scripts tests` 编译通过 | +| Python 全量测试 + Coverage | 413 passed, 1 failed, 1 warning | 唯一失败是并发耗时 `<0.45s`,实际约 0.488s | +| 失败测试独立运行 | 3/3 失败 | 独立运行变为 `UnboundExecutionError`,证明还存在全局 DB engine/测试顺序依赖 | +| Python statement coverage | 74.7% | 6,481 / 8,671;没有 branch coverage | +| Sonar overall coverage | 63.9% | 包含 0% 前端 | +| Frontend build/typecheck | PASS | `tsc -b && vite build`;4,596 modules | +| Frontend bundle | 451.30 kB JS / 131.50 kB gzip | 单主 chunk;当前可接受但缺 route split | +| Frontend unit/component/E2E | N/A | 未配置 Vitest/Jest/Playwright/Cypress | +| Lint/format | N/A | Python 无 Ruff/Mypy;前端无 ESLint/Prettier | +| `pip check` | PASS | 无破损依赖 | +| `pip-audit` | PASS | 当前解析版本未发现已知 Python 漏洞 | +| `npm audit` | FAIL | 1 high(Vite)+ 1 moderate(esbuild) | +| SonarQube | PASS | 二次扫描成功,Coverage 已实际导入 | + +低覆盖重点: + +- `app/providers/history/wechat_cli.py` 37% +- `app/api/v2_templates.py` 40% +- `app/api/logs.py` 42% +- `app/api/v2_ui.py` 52% +- `app/sender/wechat_native.py` 56% +- `app/providers/ai/deepseek.py` 62% + +完全依赖人工/实机验证的核心流程: + +- 真实 WDA/MCP 数据读取与多账号联系人映射。 +- Codex CLI 登录环境、长时生图、进程树异常与真实产物归属。 +- 微信窗口、DPI、OCR、剪贴板、目标精确匹配、文本/图片实际提交。 +- SMTP 服务端“已接收但客户端断连”的结果未知场景。 +- Windows 重启、锁屏、休眠、计划任务与 FastAPI 内 scheduler 同时存在时的行为。 + +--- + +## 6. 问题优先级与详细问题 + +### P0-1 Docker 模式无认证暴露管理/发送能力,且旧文件路径边界不足 + +- **位置:** `docker-compose.yml:15-27`、`Dockerfile:42-43`、`app/main.py:48-66`、`app/api/files.py:19-34`、`app/services/handoff_service.py:34,105` +- **模块:** 运行时/API、V1 报告兼容链 +- **来源:** Codemap + Code Overhaul;Sonar 未发现 +- **原因:** Docker 使用 `8766:8766` + `APP_HOST=0.0.0.0`;所有设置、生成、发送、邮件、删除/恢复接口无认证。旧文件接口用字符串 `startswith` 做 containment,V1 日期/目录参数校验不足。 +- **实际影响:** 若宿主防火墙/路由允许访问,局域网用户可修改路径/Provider、触发生图/发送/邮件或读取越界文件。 +- **发生概率:** 当前 Windows 进程绑定 loopback 时低;启用仓库提供的 Docker 路径时中到高。 +- **收益 / 成本 / 风险:** 收益极高;成本中;若直接加复杂账号体系风险高,若先限定 loopback + 管理令牌则风险低。 +- **Blast Radius:** 全部数据、外部发送、配置、宿主文件边界。 +- **推荐:** 先将 Docker 端口显式绑定 `127.0.0.1:8766:8766`,用 `Path.is_relative_to`/等价方式统一 containment;若需要 LAN,增加最小管理令牌和路由权限分级。不要引入企业级 IAM。 + +### P0-2 历史数据关系已丢失,缺少 FK/Unique 使问题可继续累积 + +- **位置:** `app/db/models.py:10-79`、`app/db/repository.py:331-425`、`app/api/runs.py:29-37` +- **模块:** 数据库与运行状态 +- **来源:** Codemap + Code Overhaul + 真实 SQLite 聚合 +- **原因:** `GroupRun.run_id/group_id`、`Report.group_run_id` 无 FK;`wechat_group_id`、`(run_id,group_id)`、`reports.group_run_id` 无业务唯一约束;删除/历史迁移靠应用层约定。 +- **实际影响:** 224 条 `group_runs` 中 192 条失去群组关系;历史页面只能回退为 `群 {id}`,统计/恢复可能关联错误。 +- **发生概率:** 已发生。 +- **收益 / 成本 / 风险:** 收益极高;成本高;直接加约束会因现有脏数据失败,必须先备份、分类和回填。 +- **Blast Radius:** 历史报告、统计、恢复、删除策略、未来迁移。 +- **推荐:** 单独执行“只读分类 → WAL 感知备份 → 映射/归档孤儿 → 临时表迁移 → FK/Unique → foreign_key_check → 回滚演练”。不得在普通启动中顺手修。 + +### P1-1 损坏状态被静默重置,可能把“结果未知”解释为“未开始” + +- **位置:** `app/v2/run_store.py:153-162,339-364`、`app/scheduler/daily_v2_job.py:40-61` +- **模块:** 持久化、调度 +- **来源:** Codemap + Code Overhaul +- **原因:** JSONDecodeError/OSError 后返回新 PENDING/空日状态,而不是 quarantined/corrupt。 +- **实际影响:** 已生成、已发邮件或已提交微信但未写完成标记时,重启后可能重新生成或重复外部副作用。 +- **发生概率:** 低到中;断电、磁盘错误、跨进程覆盖时升高。 +- **收益 / 成本 / 风险:** 收益高;成本中;修复需兼容旧状态文件。 +- **Blast Radius:** 单群单日;scheduler 文件损坏时整批。 +- **推荐:** 引入 `CORRUPT/RESULT_UNKNOWN` 隔离状态、保留原文件、manifest-last 校验和人工恢复入口;任何外部提交阶段都不得自动回退 PENDING。 + +### P1-2 V1 状态存在假成功、永久 running 和失败退出码 0 + +- **位置:** `app/services/report_service.py:130-187,247-273,317`、`app/services/handoff_service.py:52-95`、`scripts/run_daily_pipeline.py:35,99-124` +- **模块:** V1 报告兼容链、运维脚本 +- **来源:** Codemap + Code Overhaul + 真实 SQLite +- **原因:** 多阶段 commit;worker 异常新建失败记录而非收口旧记录;成功只看 ranking/prompt 状态,不看工件;CLI 分支无条件返回 0。 +- **实际影响:** 当前有 6 个 `runs=running`;文件缺失/部分失败仍可能被监控视为成功。 +- **发生概率:** 已发生。 +- **收益 / 成本 / 风险:** 收益高;成本中;旧调用方可能依赖当前退出码,需版本化改变。 +- **Blast Radius:** V1 API、Windows 发送任务、历史统计。 +- **推荐:** 先定义可机读 terminal contract;CLI 对 failed/partial/blocked/already_running 分别返回非零或独立码;为旧 running 提供只读审计和显式归档,不自动猜测成功。 + +### P1-3 V1/V2 两套仍可触达的业务系统造成状态分叉 + +- **位置:** `app/services/report_service.py`、`app/pipeline/daily_pipeline.py`、`app/db/models.py:38-79`、`app/v2/run_store.py`、`app/ranking/*`、`app/services/ranking_service.py`、`frontend/src/api.ts:22-365` +- **模块:** 全链路 +- **来源:** 多方共同发现 +- **原因:** 迭代时保留旧 API/服务/数据库/输出/脚本,同时新 V2 使用独立 run.json 与状态协议。 +- **实际影响:** 两个页面/脚本可以对“最新状态”给出不同答案;修一个排行/Prompt/发送问题需要检查两套实现。 +- **发生概率:** 高。 +- **收益 / 成本 / 风险:** 收益高;成本高;一次性删除风险极高。 +- **Blast Radius:** API、前端、数据库、调度、邮件、历史。 +- **推荐:** 冻结 V1 新功能,先记录真实调用者与数据保留要求;用适配层只读 V1 历史,新生成只走 V2;最后分轮退役写路径。 + +### P1-4 群级配置能保存但未完整进入执行路径 + +- **位置:** `app/db/models.py:18,24-27`、`app/scheduler/period.py:33-47`、`app/pipeline/daily_pipeline.py:124,1045,1115` +- **模块:** Pipeline、设置/API +- **来源:** Codemap + Code Overhaul +- **原因:** UI/DB 先扩展字段,Pipeline 仍使用全局默认或固定 Provider。 +- **实际影响:** 用户以为已配置 `schedule_rule/summary_model/prompt_model/provider_preference`,实际运行仍可能走默认值。 +- **发生概率:** 高,只要使用这些字段。 +- **收益 / 成本 / 风险:** 收益高;成本中;真正启用模型路由可能改变费用与结果,必须显式验收。 +- **Blast Radius:** 单群日期窗口、Provider、模型调用。 +- **推荐:** 为每个字段建立“保存 → API 回读 → Pipeline 消费 → run.json 审计”的契约测试;未实现字段应在 UI 标为不可用,而不是静默保存。 + +### P1-5 外部调用重试缺少一致幂等边界 + +- **位置:** `app/providers/ai/codex.py:123-221`、`app/providers/ai/deepseek.py:143-259`、`app/services/email_service.py:111-243`、`scripts/send_daily_email.py:203-243` +- **模块:** AI、交付 +- **来源:** Codemap + Code Overhaul +- **原因:** Builder/Provider 多层重试与 fallback 叠加;SMTP 断线后重发同一消息;没有 request/message idempotency key。 +- **实际影响:** Provider 已接收但响应丢失时可能重复计费;SMTP 已接收时可能重复邮件。 +- **发生概率:** 中;日志已有连接中断类历史证据。 +- **收益 / 成本 / 风险:** 收益高;成本中到高;错误实现幂等可能阻止合法重试。 +- **Blast Radius:** 单个 Prompt chunk、单群邮件或整批。 +- **推荐:** 合并重试所有权;只对 429/明确 5xx/连接前失败重试;为邮件生成稳定 Message-ID 和本地发送 ledger;结果未知时进入 hold。 + +### P1-6 真实数据失败可在 V1 静默落入 Mock + +- **位置:** `app/providers/history/registry.py:21-47`、`app/services/history_service.py:76-128` +- **模块:** 微信数据接入 +- **来源:** Codemap + Code Overhaul +- **原因:** `history_provider_mock_enabled` 时 Mock 自动追加,首个 OK/EMPTY_RESULT 即返回。 +- **实际影响:** 真实 WDA/MCP 不可用时,可能生成结构正常但事实不真实的报告。 +- **发生概率:** 中;当前数据库设置显示 Mock 可启用,真实 Provider 故障历史存在。 +- **收益 / 成本 / 风险:** 收益高;成本低;会减少“可演示性”但提升真实性。 +- **Blast Radius:** 对应日期的全部 V1 报告。 +- **推荐:** 生产模式 fail closed;Mock 只能由显式开发变量 + fixture 标识启用,所有产物必须携带 `source=mock` 且禁止发送。 + +### P1-7 双调度入口与误导性退出码削弱无人值守可信度 + +- **位置:** `app/scheduler/manager.py:36-82`、`scripts/install_daily_task.py:35-71,114`、`scripts/daily_auto.py:69` +- **模块:** 调度、运维脚本 +- **来源:** Codemap + Code Overhaul + 当前 Windows 状态 +- **原因:** FastAPI 内 APScheduler 和 Windows `GroupBriefDaily/GroupBriefDailySend` 同时存在;`already_running` 和安装失败可返回 0。 +- **实际影响:** 任务可能只是被另一实例抢锁,却被监控视为完成;半安装计划任务无法由退出码发现。 +- **发生概率:** 中;当前两类调度都存在。 +- **收益 / 成本 / 风险:** 收益高;成本低到中;切换调度所有权时需避免漏跑。 +- **Blast Radius:** 每日整批生成与发送。 +- **推荐:** 每个部署只保留一个 scheduler owner;另一个只做 watchdog;为 `success/partial/blocked/already_running/not_run` 定义稳定退出码与监控事件。 + +### P1-8 Schema 迁移无版本链且默认值已漂移 + +- **位置:** `app/db/repository.py:34-207` +- **模块:** 数据库与运行状态 +- **来源:** Codemap + Code Overhaul + Schema 对比 +- **原因:** `create_all + PRAGMA + ALTER + settings marker` 在启动时执行,多个迁移各自 commit。 +- **实际影响:** 当前 SQLite 列默认仍保留旧 DeepSeek/blue_white,而运行数据已使用 Codex/random_preset;多进程或中断可产生部分迁移。 +- **发生概率:** 中。 +- **收益 / 成本 / 风险:** 收益高;成本中高;贸然引入 Alembic 也可能过度设计。 +- **Blast Radius:** 启动、恢复、所有新建群。 +- **推荐:** 不必立刻上复杂框架;先建立单一 `schema_version`、顺序迁移注册、每版事务/备份/校验和回滚脚本,再评估 Alembic。 + +### P2-1 复杂度集中在少数 God Module + +- **位置:** `daily_pipeline.py`、`codex_generator.py`、`wechat_data_analysis.py`、`wechat_native.py`、`v2_ui.py`、`AIImages.tsx`、`styles.css` +- **模块:** 多模块 +- **来源:** Codemap + Code Overhaul + Sonar +- **原因:** 快速 V1 迭代把协议、状态、I/O、恢复和 UI 交互集中在同一个类/文件。 +- **实际影响:** 修改成本和回归范围持续增大;Sonar top function complexity 达 59。 +- **发生概率:** 每次维护都发生。 +- **收益 / 成本 / 风险:** 收益中高;成本高;无 characterization tests 时拆分风险高。 +- **Blast Radius:** 核心生成/发送与管理页面。 +- **推荐:** 先补行为网,再按阶段提取纯函数/边界对象;不引入微服务、CQRS、事件溯源或大量模式。 + +### P2-2 测试存在顺序依赖、时间阈值脆弱和前端/E2E 空白 + +- **位置:** `tests/conftest.py:13-23`、`tests/test_generation_concurrency.py:147`、`frontend/package.json:6-9` +- **模块:** 测试体系 +- **来源:** Code Overhaul + 实测 + Sonar Coverage +- **原因:** 全局 repository engine 由其他测试隐式初始化;并发测试用硬墙钟 `<0.45s`;前端没有测试框架。 +- **实际影响:** 全量 413/1 失败,单独运行同一测试 3/3 因 unbound engine 失败;CI 结果可能随顺序/负载变化。 +- **发生概率:** 已发生。 +- **收益 / 成本 / 风险:** 收益高;成本中;不应通过放宽所有断言掩盖真实并发回归。 +- **Blast Radius:** CI 可信度、并发/状态修改安全网。 +- **推荐:** 每测试 fixture 显式 init/dispose DB;用事件/barrier 验证并发而非极窄墙钟;先为 AIImages、Archive、Settings 和发送确认补行为测试,再选少量 Playwright 实机前流程。 + +### P2-3 前后端 N+1、轮询与单主 Bundle + +- **位置:** `app/api/runs.py:25-31`、`app/api/reports.py:59-63`、`frontend/src/pages/v2/Tasks.tsx:113`、`AIImages.tsx:376-388` +- **模块:** API、前端 +- **来源:** Code Overhaul + Codemap +- **原因:** 每行/每 run 再请求明细;固定 2/5 秒轮询失败后静默继续;所有页面打入单主 JS chunk。 +- **实际影响:** 当前小数据影响有限,历史增长后请求数和页面等待线性增长;网络失败会持续轮询。 +- **发生概率:** 中。 +- **收益 / 成本 / 风险:** 收益中;成本低到中;过早引入复杂缓存风险大。 +- **Blast Radius:** Tasks/AIImages/历史列表。 +- **推荐:** 先批量查询/分页、AbortController、退避和可见错误;页面级动态 import 可后置。 + +### P2-4 可观测性存在“看似成功”的空洞 + +- **位置:** `app/main.py:29-35`、`app/scheduler/send_job.py:12-19`、`app/core/logging.py:68`、`app/api/system.py:23-48` +- **模块:** 运行时、调度 +- **来源:** Codemap + Code Overhaul +- **原因:** 启动检查异常被置空;send job 仅日志不返回失败;已有 root handler 时分类日志不初始化;Provider GET 会外呼并写 DB。 +- **实际影响:** 服务“活着”但依赖不可用;scheduler 认为任务完成;健康刷新本身消耗资源并扩张 `provider_health`。 +- **发生概率:** 中。 +- **收益 / 成本 / 风险:** 收益中高;成本中;强制启动门禁可能降低本地可用性。 +- **Blast Radius:** 运维判断、监控、Provider 表。 +- **推荐:** 区分 liveness/readiness/deep diagnostics;调度器记录终态与退出码;深健康检查显式触发并限频,ProviderHealth 设置 retention。 + +### P2-5 依赖可复现性和 Vite 开发服务器漏洞 + +- **位置:** `requirements.txt`、`requirements-dev.txt`、`frontend/package.json`、`.github/workflows/ci.yml` +- **模块:** 工具链 +- **来源:** Code Overhaul + `pip-audit` + `npm audit/outdated` +- **原因:** Python 全部使用 `>=` 且无 lock;前端锁定 Vite 5.4.21,审计发现 1 high + 1 moderate;多个前端直接依赖已落后一个或更多 major。 +- **实际影响:** Python 重建可能解析到不同组合;Vite dev server 在 Windows/路径场景存在风险。 +- **发生概率:** 中。 +- **收益 / 成本 / 风险:** 收益中;成本中;直接跳 React 19/Vite 8 有较大兼容风险。 +- **Blast Radius:** 本地开发、CI、Docker 构建。 +- **推荐:** 先引入 Python constraints/lock 和 Renovate/Dependabot;Vite 单独升级并回归 build/dev;不要把 React major 升级和核心稳定性整改绑在同一轮。 + +### P2-6 工件写入与目录身份仍可能碰撞 + +- **位置:** `app/v2/run_store.py:119-121`、`app/services/handoff_service.py:34-92`、`app/pipeline/daily_pipeline.py:433-671` +- **模块:** 持久化、V1 报告、Pipeline +- **来源:** Codemap + Code Overhaul +- **原因:** 目录使用清洗/截断后的群名;多工件顺序写入;V1 handoff 非原子。 +- **实际影响:** 同名/清洗后相同群可能覆盖;状态和工件不一致。 +- **发生概率:** 当前未发现活动群碰撞,未来中低。 +- **收益 / 成本 / 风险:** 收益中;成本中高;改目录会影响历史 URL/归档。 +- **Blast Radius:** 单群全部历史工件。 +- **推荐:** 新目录加入稳定 group_id,保留旧目录只读映射;用临时执行目录 + manifest-last 原子发布;不得直接批量搬迁现有 output。 + +### P3 低 ROI 工程卫生 + +- Phosphor 图标弃用、readonly props、FastAPI `responses` 文档、主题标签常量、少量 wrapper 页面、通知 placeholder、目录命名美化。 +- 这些问题可以在相关页面/模块被修改时顺手处理,不应占用 P0/P1 稳定性预算。 + +--- + +## 7. 技术债 Top 10 + +按“风险 × 影响 × 未来维护成本 × 修改收益”排序: + +| 排名 | 技术债 | 优先级 | 为什么排在这里 | +|---:|---|---|---| +| 1 | Docker 无认证暴露 + 旧路径边界 | P0 | 可直接扩大为配置、文件和外部发送控制权 | +| 2 | 192 条逻辑孤儿 + 无 FK/Unique | P0 | 已发生的数据关系损失,会继续积累 | +| 3 | 损坏状态静默回退 PENDING | P1 | 可能把结果未知变成重复执行 | +| 4 | V1 假成功/永久 running/退出码 0 | P1 | 已有 6 条卡死记录,破坏监控可信度 | +| 5 | V1/V2 双生成与双状态体系 | P1 | 所有未来功能都要承担双倍维护与状态分叉 | +| 6 | 群级配置未真正路由 | P1 | 用户配置与实际行为不一致,涉及日期和模型费用 | +| 7 | AI/SMTP 重试无统一幂等 | P1 | 可能重复扣费或重复邮件 | +| 8 | 双 scheduler owner + 误导退出码 | P1 | 无人值守可能“没跑却显示成功” | +| 9 | 核心 God Module 与复杂函数 | P2 | 每次修复 Blast Radius 大,长期维护成本复利增长 | +| 10 | 测试顺序依赖 + 前端/E2E 空白 | P2 | 阻碍安全拆分与真实失败场景验证 | + +--- + +## 8. 删除候选清单 + +本轮只列出,不删除。即使标为“可以安全删除”,也应在独立清理轮次执行 `rg → build/test → diff`。 + +### 8.1 可以安全删除候选 + +- `frontend/src/pages/v2/History.tsx`:仅包装 `Archive`,导航已归一到 archive,未发现引用。 +- `frontend/src/pages/v2/System.tsx`:仅包装 `Settings`,导航已归一到 settings,未发现引用。 +- `app/providers/v2/base.py`:自称预留接口,全仓未发现实际引用。 + +### 8.2 需要确认后删除 + +- `app/scheduler/generate_job.py`、`app/scheduler/email_job.py`:当前 manager 不注册,但外部脚本可能仍调用。 +- `app/services/prompt_service.py:_build_context_text`:旧调用兼容候选。 +- `app/providers/history/wechat_cli.py`:V1 registry/配置仍可能路由。 +- `scripts/codex_image_automation.py`、`docs/CODEX_IMAGE_AUTOMATION_PROMPT.md`:旧桌面流程,仍可能用于人工恢复。 +- `scripts/test_wechat_send.py`:固定 legacy sender,作为测试价值低,但可能是现场手工工具。 +- V1 `ReportService/RankingService/HandoffService` 与旧文件 API:必须先证明没有真实调用者并迁移历史读取。 +- `archive-legacy` CSS 与其它旧页面样式:必须先做实际 DOM/CSS coverage,不能凭名称删除。 +- 无引用的 notification 按钮:删除会改变 UI,不属于纯 dead code。 + +### 8.3 暂时不要删除 + +- Mock Provider 与 `fixtures/`:Python 测试和本地开发仍使用;应限制生产路由,不是直接删除。 +- 所有 migration marker/旧列兼容:尚未证明所有数据库都完成升级。 +- V1 SQLite 表与历史记录:即使停止写入,也需保留只读历史迁移期。 +- `top10_lines`、`deepseek_ms`、旧模型字段等兼容数据:先建立版本边界和读取统计。 +- 发送 claim/lease/hold、Codex staging/recovery、WDA allowlist:这些是当前可靠性保护。 + +--- + +## 9. 暂时不要动的地方 + +以下代码可能不漂亮,但运行价值高、修改收益低于风险: + +1. `app/v2/run_store.py:248-302` 的发送 claim、lease、`result_unknown` 和人工 hold。 +2. `app/pipeline/daily_pipeline.py:726-1010` 的发送前认领、提交后未知结果和 fail-closed 分支。 +3. `app/image/codex_generator.py:42-140,236-430,705-852` 的 Windows 进程树终止、跨进程互斥、staging、候选归属和原子提升。 +4. `app/providers/history/wechat_mcp.py:56-145` 的 loopback/allowed-host、超时、响应上限和 JSON 校验。 +5. `app/pipeline/daily_pipeline.py:368-388` 对消息快照无效时拒绝静默回源。 +6. `app/services/group_name_sync.py` 基于稳定 WeChat ID 同步群名并保留人工 `send_target` 的逻辑。 +7. Docker 单 worker 与全局 generation mutex。它们不是最终并发方案,但当前保护 SQLite 和外部副作用。 +8. 数据库设置覆盖 `.env` 的既有行为。来源层次需要清晰化,但不能直接反转,否则会改变生产配置。 +9. 图片全局串行队列。它会拉长总耗时,但符合当前 Codex/桌面资源约束;在有性能证据前不要盲目并行。 + +--- + +## 10. Code Overhaul 影响/成本矩阵 + +| | 低成本 | 高成本 | +|---|---|---| +| **高影响** | Docker loopback、Path containment、非法设置拒绝、脚本退出码、Mock 生产禁用 | 孤儿数据治理/FK、V1/V2 收敛、稳定 group_id 目录、幂等 ledger | +| **低影响** | CSS contrast、客户端超时/取消、错误类型、少量 dead wrapper | 全量图标替换、为清零重复率抽象、全面 CSS 重写、框架 major 迁移 | + +### 当前已有、应复用的能力 + +- `RunStore` 原子 JSON 替换和发送 lease。 +- `generation_mutex` / 单 worker。 +- `verify_image`、Codex staging/manifest/hash;应补强而不是重写。 +- MCP allowed-host/timeout/size guard。 +- `GroupNameSyncService` 稳定身份语义。 +- `tests/conftest.py` 已有外部调用隔离意图;需改为每测试独立 DB 生命周期。 +- GitHub Actions 已有 Windows Python test + frontend build 基线。 + +### 本轮不在范围 + +- 不重构、不修 Sonar、不加 Schema、不删代码、不升级依赖。 +- 不创建 Beads;用户要求报告完成后停止。 +- 不执行真实微信、邮件、WDA、Codex、DeepSeek 验收。 +- 不改变部署、计划任务或当前运行进程。 + +--- + +## 11. 整改路线图 + +每轮都必须小范围、可独立测试、可独立提交、可回滚;禁止“全面重构”。 + +### P0.1 网络与文件安全边界 + +- **范围:** Docker loopback、最小管理令牌(如需要 LAN)、统一 `Path` containment、V1 日期/文件 allowlist。 +- **测试:** API auth/unauth、路径 traversal 参数化测试、Windows 路径变体、Docker 端口检查。 +- **回滚:** 单独配置/路由提交;保留 loopback 本地开发模式。 + +### P0.2 数据一致性治理 + +- **范围:** 只读分类 192 条孤儿、备份/恢复演练、映射策略、FK/Unique 迁移设计。 +- **测试:** `integrity_check`、`foreign_key_check`、重复约束、旧库升级、回滚恢复。 +- **回滚:** 原库只读保留;迁移在副本验证后替换。 + +### P1.1 损坏状态与结果未知恢复 + +- **范围:** run/scheduler JSON schema、CORRUPT 隔离、manifest-last、人工恢复。 +- **测试:** 截断 JSON、空文件、半写、重启、邮件/微信已提交但无完成标记。 +- **回滚:** 新读取器兼容旧格式;不批量改历史文件。 + +### P1.2 单一调度所有权与退出码 + +- **范围:** FastAPI scheduler 与 Windows task 角色、稳定终态/退出码、安装事务。 +- **测试:** 双实例抢锁、already_running、partial、blocked、半安装、系统重启。 +- **回滚:** 保留另一入口为禁用的 watchdog,不同时启用。 + +### P1.3 配置契约与 Provider 真实性 + +- **范围:** `schedule_rule/model/provider_preference` 真正消费或 UI 禁用;生产 Mock fail closed。 +- **测试:** 保存/回读/执行/run.json 审计;真实失败不得落 Mock。 +- **回滚:** 每字段 feature gate;默认保持现行为直到测试通过。 + +### P1.4 AI 与邮件幂等 + +- **范围:** 单层 retry policy、错误分类、请求/邮件稳定 ID、发送 ledger、unknown hold。 +- **测试:** 429、500、连接前断、提交后断、重复进程、SMTP 已接收无响应。 +- **回滚:** 幂等表/文件独立;关闭新策略可回到现有 fail behavior。 + +### P1.5 V1 冻结与退役计划 + +- **范围:** 调用者清单、只读历史适配、新写入只走 V2、逐路由退役。 +- **测试:** 历史页面、导出、V1/V2 同日对照、旧脚本使用统计。 +- **回滚:** 每条路由独立 feature flag;不删除历史表/工件。 + +### P2.1 测试隔离与关键行为网 + +- **范围:** 每测试临时 DB/engine、并发 barrier、前端核心行为、少量 E2E。 +- **测试:** 单文件、随机顺序、重复运行、coverage + 非 coverage 对照。 +- **回滚:** 测试提交独立于生产重构。 + +### P2.2 Pipeline 阶段拆分 + +- **范围:** 先提取纯状态转移/结果分类,再分离生成、图片、发送协调;保留现有 Facade。 +- **测试:** 以 P2.1 characterization 为门;原 API 与 run.json 不变。 +- **回滚:** Facade 可切回旧内部实现。 + +### P2.3 API/前端热点拆分 + +- **范围:** `v2_ui.py` 按领域拆 router;`AIImages` 提取数据 hook/命令 hook;共享状态展示 helper;CSS 按页面渐进拆。 +- **测试:** API contract、组件行为、截图/可访问性。 +- **回滚:** 路由路径和页面对外契约不变。 + +### P2.4 依赖、可观测性与性能小步改进 + +- **范围:** Python lock/constraints、Vite 安全升级、liveness/readiness、Provider retention、批量查询/分页/退避。 +- **测试:** clean install、Docker build、bundle、API 查询数、健康检查无副作用。 +- **回滚:** 每一项独立提交,不与 React major 或核心重构捆绑。 + +--- + +## 12. 最终判断 + +这个项目现在能跑,不是纯靠运气。它已经有一套相当有价值的“外部副作用安全骨架”:发送认领、结果未知 hold、Codex staging、图片归属验证、MCP 边界、群级失败隔离。这些部分是 V1 最可靠、最不应该为了工程美学重写的资产。 + +项目目前最大的风险也不是“代码不够高级”,而是三个现实问题: + +1. **数据关系和状态真相不唯一。** SQLite 与 run.json、V1 与 V2、工件与状态之间仍可能分叉。 +2. **部署安全边界依赖环境约定。** Windows loopback 安全,但仓库提供的 Docker 路径会扩大暴露面。 +3. **测试和监控还不能完全证明无人值守。** 一个完整测试失败、单独运行又暴露顺序依赖;前端和真实外部边界没有自动化网。 + +最安全的提升顺序不是拆大文件,而是: + +```text +先封网络/文件边界 +→ 再保护和修复数据真相 +→ 再让损坏/未知状态 fail closed +→ 再统一调度、退出码、配置与幂等 +→ 最后用测试网逐步收敛 V1/V2 和拆分 God Module +``` + +在完成 P0/P1 之前,不建议进行全面架构重写、React major 升级、CSS 全量重构或为了 Sonar A 评级批量改规则问题。 + +## 13. 尚待用户决策 + +1. 正式部署是否永远只允许本机访问,还是未来需要 LAN/远程访问?这决定认证边界。 +2. V1 API/数据库历史是否仍有实际用户或外部脚本依赖?这决定退役速度。 +3. 192 条孤儿 `group_runs` 是保留审计、映射回群、匿名归档还是删除候选?必须由业务决定。 +4. 群级模型/Provider 字段是计划真实启用,还是应从 V1 UI 暂时隐藏? +5. FastAPI APScheduler 与 Windows Task Scheduler 哪一个是唯一生产 owner? +6. 外部邮件/微信需要怎样的不可变送达证据:Message-ID、日志、截图还是人工确认? + +审计到此停止。等待确认整改路线图后,再进入任何代码或数据修改。 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..dee479e --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=groupbrief-v1-local-audit +sonar.projectName=GroupBrief V1 Local Audit +sonar.projectVersion=1.0.0-audit-20260824 +sonar.sourceEncoding=UTF-8 + +sonar.sources=app,scripts,frontend/src +sonar.tests=tests +sonar.test.inclusions=tests/**/*.py + +sonar.exclusions=**/__pycache__/**,**/.pytest_cache/**,**/.mypy_cache/**,**/.ruff_cache/**,**/node_modules/**,frontend/dist/**,frontend/.vite/**,data/**,output/**,logs/**,fixtures/**,assets/**,docs/**,.codemap/** +sonar.python.version=3.12 +sonar.python.coverage.reportPaths=coverage.xml +sonar.typescript.tsconfigPaths=frontend/tsconfig.json +sonar.scm.provider=git From 5e1b8d6167243890ba38aee9408f61f9a05c2d33 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 15:18:30 +0800 Subject: [PATCH 03/42] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E4=B8=8E=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/files.py | 53 ++++++++++++++++----- app/api/groups.py | 42 ++++++++++++++--- app/api/v2_ui.py | 19 +++++++- app/core/path_security.py | 57 +++++++++++++++++++++++ app/services/handoff_service.py | 45 +++++++++++++----- app/v2/run_store.py | 23 ++++----- docker-compose.yml | 3 +- tests/test_files_api.py | 82 +++++++++++++++++++++++++++++++++ tests/test_handoff.py | 2 + tests/test_path_security.py | 50 ++++++++++++++++++++ tests/test_ui_api.py | 7 +++ tests/test_v2_archive_api.py | 30 ++++++++++++ 12 files changed, 368 insertions(+), 45 deletions(-) create mode 100644 app/core/path_security.py create mode 100644 tests/test_files_api.py create mode 100644 tests/test_path_security.py diff --git a/app/api/files.py b/app/api/files.py index a9d71a0..2a2efca 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -2,32 +2,63 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse, PlainTextResponse +from app.config.settings import Settings, get_settings +from app.core.path_security import resolve_within from app.services.handoff_service import HandoffService router = APIRouter(prefix="/api/files", tags=["files"]) +ALLOWED_RAW_FILES = frozenset( + { + "ranking.txt", + "image_prompt.txt", + "meta.json", + "normalized_messages.json", + "handoff.json", + "messages.json", + "ranking.json", + "run.json", + "image_prompt.original.txt", + "daily_image.png", + "daily_image.previous.png", + } +) + @router.get("/dates") -def dates(): - service = HandoffService() +def dates(settings: Settings = Depends(get_settings)): + service = HandoffService(settings) return service.list_output_dates() @router.get("/{report_date}") -def list_day(report_date: str): - service = HandoffService() - return service.list_group_outputs(report_date) +def list_day(report_date: str, settings: Settings = Depends(get_settings)): + service = HandoffService(settings) + try: + return service.list_group_outputs(report_date) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("/{report_date}/{group_dir}/raw/{filename}") -def raw_file(report_date: str, group_dir: str, filename: str): - service = HandoffService() - day_dir = service.settings.output_dir / report_date - file = (day_dir / group_dir / filename).resolve() - if not file.is_file() or not str(file).startswith(str(day_dir.resolve())): +def raw_file( + report_date: str, + group_dir: str, + filename: str, + settings: Settings = Depends(get_settings), +): + if filename not in ALLOWED_RAW_FILES: + raise HTTPException(status_code=400, detail=f"不允许访问的文件:{filename}") + service = HandoffService(settings) + try: + day_dir = service.output_day_dir(report_date) + file = resolve_within(day_dir, group_dir, filename) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not file.is_file(): return PlainTextResponse("not found", status_code=404) if filename.endswith(".json"): return PlainTextResponse(file.read_text(encoding="utf-8"), media_type="application/json") diff --git a/app/api/groups.py b/app/api/groups.py index 8301133..419ad31 100644 --- a/app/api/groups.py +++ b/app/api/groups.py @@ -6,8 +6,12 @@ from pydantic import BaseModel from sqlmodel import Session, select -from app.config.settings import Settings, get_settings -from app.ai.image_themes import DEFAULT_IMAGE_THEME, ImageThemeError, resolve_image_theme, validate_image_theme_config +from app.ai.image_themes import ( + DEFAULT_IMAGE_THEME, + ImageThemeError, + resolve_image_theme, + validate_image_theme_config, +) from app.ai.prompt_builder import _strip_html_comments from app.ai.prompt_editing import prompt_revision, resolved_theme_text from app.ai.prompt_templates import ( @@ -16,6 +20,8 @@ render_image_prompt_template, validate_image_prompt_template, ) +from app.config.settings import Settings, get_settings +from app.core.path_security import PathBoundaryError, validate_path_label from app.db import repository as repo from app.db.models import Group from app.data_sources.wechat_data_analysis import WeChatDataAnalysisSource @@ -91,6 +97,15 @@ def _validate_group_theme(theme: object, custom: object = "") -> tuple[str, str] raise HTTPException(status_code=422, detail=str(exc)) from exc +def _validate_output_group_name(value: object, *, field_name: str) -> str: + """拒绝会被当作文件路径的群名称,同时保留普通显示名标点。""" + text = str(value or "") + try: + return validate_path_label(text, field_name=field_name) + except PathBoundaryError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + def _validate_prompt_override(content: object) -> str: if content is None: return "" @@ -182,6 +197,13 @@ def list_groups(session: Session = Depends(repo.get_session)): @router.post("") def create_group(payload: GroupCreate, session: Session = Depends(repo.get_session)): values = payload.model_dump() + values["display_name"] = _validate_output_group_name( + values.get("display_name"), field_name="display_name" + ) + if values.get("wechat_group_name"): + values["wechat_group_name"] = _validate_output_group_name( + values["wechat_group_name"], field_name="wechat_group_name" + ) values["send_target"] = str(values.get("send_target") or "").strip() values["image_theme"], values["image_theme_custom"] = _validate_group_theme( values.get("image_theme", "random_preset"), values.get("image_theme_custom", "") @@ -209,6 +231,11 @@ def update_group( ): group = _require_active_group(session, group_id) updates = payload.model_dump(exclude_unset=True) + for field_name in ("display_name", "wechat_group_name"): + if updates.get(field_name): + updates[field_name] = _validate_output_group_name( + updates[field_name], field_name=field_name + ) if "image_theme" in updates or "image_theme_custom" in updates: theme, custom = _validate_group_theme( updates.get("image_theme", group.image_theme), @@ -330,14 +357,17 @@ def bind_group_from_name( }, ) + selected_name = selected.group_name or name + _validate_output_group_name(selected_name, field_name="group_name") + existing = session.exec( select(Group).where(Group.wechat_group_id == selected.group_id) ).first() if existing: if existing.deleted_at is not None: - existing.wechat_group_name = selected.group_name or existing.wechat_group_name + existing.wechat_group_name = selected_name or existing.wechat_group_name if not existing.display_name: - existing.display_name = selected.group_name or name + existing.display_name = selected_name restored = repo.restore_group(session, existing.id) return { "id": restored.id, @@ -349,9 +379,9 @@ def bind_group_from_name( return {"id": existing.id, "bound": True, "already_existed": True} group = Group( - display_name=selected.group_name or name, + display_name=selected_name, wechat_group_id=selected.group_id, - wechat_group_name=selected.group_name, + wechat_group_name=selected_name, ) group = repo.save_group(session, group) return {"id": group.id, "bound": True, "already_existed": False} diff --git a/app/api/v2_ui.py b/app/api/v2_ui.py index b3818ba..70e7eea 100644 --- a/app/api/v2_ui.py +++ b/app/api/v2_ui.py @@ -78,6 +78,14 @@ def _store(settings: Settings) -> RunStore: return RunStore(settings.output_dir) +def _safe_group_dir(store: RunStore, group: str, run_date: str) -> Path: + """把 RunStore 的路径拒绝统一转换成明确的客户端错误。""" + try: + return store.group_dir(group, run_date) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # ---------- Dashboard ---------- @@ -347,8 +355,8 @@ def archive_groups( def run_detail(group: str, run_date: str, settings: Settings = Depends(get_settings)): _validate_run_date(run_date) store = _store(settings) + group_dir = _safe_group_dir(store, group, run_date) run = store.load_run(group, run_date) - group_dir = store.group_dir(group, run_date) files = ( sorted(p.name for p in group_dir.glob("*") if p.is_file() and p.name in ALLOWED_FILES) if group_dir.exists() @@ -399,6 +407,8 @@ def resolve_theme_preview( ) except ImageThemeError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc return { "requested_key": theme.requested_key, "actual_key": theme.actual_key, @@ -411,6 +421,7 @@ def resolve_theme_preview( def _read_run_prompt(store: RunStore, group: str, run_date: str) -> tuple[str, dict]: + _safe_group_dir(store, group, run_date) if not store.run_path(group, run_date).exists(): raise HTTPException(status_code=404, detail="运行记录不存在") path = store.prompt_path(group, run_date) @@ -500,6 +511,7 @@ def update_run_prompt( def restore_run_prompt(group: str, run_date: str, settings: Settings = Depends(get_settings)): _validate_run_date(run_date) store = _store(settings) + _safe_group_dir(store, group, run_date) if not store.run_path(group, run_date).exists(): raise HTTPException(status_code=404, detail="运行记录不存在") original = store.original_prompt_path(group, run_date) @@ -522,6 +534,7 @@ def regenerate_run_image(group: str, run_date: str, settings: Settings = Depends from app.image.regeneration import enqueue_regeneration _validate_run_date(run_date) + _safe_group_dir(_store(settings), group, run_date) try: run = enqueue_regeneration(settings, group, run_date) except FileNotFoundError as exc: @@ -547,6 +560,7 @@ def refresh_run_messages(group: str, run_date: str, settings: Settings = Depends _validate_run_date(run_date) store = _store(settings) + _safe_group_dir(store, group, run_date) if not store.run_path(group, run_date).exists(): raise HTTPException(status_code=404, detail="运行记录不存在") try: @@ -570,6 +584,7 @@ def rebuild_run_prompt(group: str, run_date: str, settings: Settings = Depends(g _validate_run_date(run_date) store = _store(settings) + _safe_group_dir(store, group, run_date) if not store.run_path(group, run_date).exists(): raise HTTPException(status_code=404, detail="运行记录不存在") try: @@ -820,7 +835,7 @@ def read_output_file( _validate_run_date(run_date) if file_name not in ALLOWED_FILES: raise HTTPException(400, f"不允许访问的文件:{file_name}") - path = _store(settings).group_dir(group, run_date) / file_name + path = _safe_group_dir(_store(settings), group, run_date) / file_name if not path.exists(): raise HTTPException(404, "文件不存在") return FileResponse(path, filename=file_name) diff --git a/app/core/path_security.py b/app/core/path_security.py new file mode 100644 index 0000000..1230ba6 --- /dev/null +++ b/app/core/path_security.py @@ -0,0 +1,57 @@ +"""本地文件路径安全边界。 + +所有来自 API、数据库显示名或历史文件的路径片段,在进入 output 目录前 +都必须经过这里的导航检查和真实路径 containment 校验。 +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path, PurePosixPath, PureWindowsPath + + +_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_PATH_SPLIT = re.compile(r"[\\/]+") + + +class PathBoundaryError(ValueError): + """用户输入可能逃出预期文件根目录。""" + + +def validate_iso_date(value: str, *, field_name: str = "date") -> str: + """只接受真实存在的 YYYY-MM-DD 日期。""" + if not isinstance(value, str) or not _ISO_DATE.fullmatch(value): + raise ValueError(f"{field_name} 必须是有效的 YYYY-MM-DD 日期") + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError as exc: + raise ValueError(f"{field_name} 必须是有效的 YYYY-MM-DD 日期") from exc + return value + + +def validate_path_label(value: str, *, field_name: str = "name") -> str: + """允许普通显示名中的标点,但拒绝路径导航、盘符和绝对路径。""" + if not isinstance(value, str) or "\x00" in value: + raise PathBoundaryError(f"{field_name} 包含不安全的路径内容") + if PurePosixPath(value).is_absolute(): + raise PathBoundaryError(f"{field_name} 不能是绝对路径") + windows_path = PureWindowsPath(value) + if windows_path.is_absolute() or windows_path.drive: + raise PathBoundaryError(f"{field_name} 不能包含盘符或 UNC 路径") + if any(part in {".", ".."} for part in _PATH_SPLIT.split(value)): + raise PathBoundaryError(f"{field_name} 不能包含路径导航段") + return value + + +def resolve_within(root: Path | str, *parts: Path | str, allow_root: bool = False) -> Path: + """解析路径并证明它位于 root 内;同时阻断 symlink 和 sibling-prefix 绕过。""" + try: + resolved_root = Path(root).resolve() + candidate = resolved_root.joinpath(*(Path(part) for part in parts)).resolve() + candidate.relative_to(resolved_root) + except (OSError, RuntimeError, ValueError) as exc: + raise PathBoundaryError("路径超出允许的文件目录") from exc + if not allow_root and candidate == resolved_root: + raise PathBoundaryError("路径不能指向文件根目录") + return candidate diff --git a/app/services/handoff_service.py b/app/services/handoff_service.py index 43d8c38..d37868d 100644 --- a/app/services/handoff_service.py +++ b/app/services/handoff_service.py @@ -21,6 +21,7 @@ from app.config.settings import Settings, get_settings from app.core.logging import get_logger +from app.core.path_security import resolve_within, validate_iso_date, validate_path_label from app.db.models import Group from app.scheduler.calendar_rules import ReportWindow from app.services.message_normalizer import NormalizedMessage @@ -33,13 +34,19 @@ def safe_dir_name(name: str, fallback: str = "group") -> str: cleaned = _INVALID_CHARS.sub("-", name).strip("-") - return cleaned[:60] or fallback + cleaned = cleaned[:60] + return fallback if cleaned in {"", ".", ".."} else cleaned class HandoffService: def __init__(self, settings: Settings | None = None): self.settings = settings or get_settings() + def output_day_dir(self, report_date: str) -> Path: + """返回 output 内的合法日期目录,不接受任意路径片段。""" + valid_date = validate_iso_date(report_date, field_name="report_date") + return resolve_within(self.settings.output_dir, valid_date) + def save_outputs( self, group: Group, @@ -49,9 +56,10 @@ def save_outputs( normalized: list[NormalizedMessage], provider: str, ) -> Path: - base_dir = self.settings.output_dir / window.report_date.isoformat() + base_dir = self.output_day_dir(window.report_date.isoformat()) base = group.display_name or group.wechat_group_name or f"group-{group.id}" - group_dir = base_dir / safe_dir_name(base) + validate_path_label(base, field_name="group_name") + group_dir = resolve_within(base_dir, safe_dir_name(base)) group_dir.mkdir(parents=True, exist_ok=True) ranking_file = group_dir / "ranking.txt" @@ -97,28 +105,41 @@ def save_outputs( def list_output_dates(self) -> list[str]: if not self.settings.output_dir.exists(): return [] - return sorted( - (d.name for d in self.settings.output_dir.iterdir() if d.is_dir()), - reverse=True, - ) + dates: list[str] = [] + for directory in self.settings.output_dir.iterdir(): + if not directory.is_dir(): + continue + try: + valid_date = validate_iso_date(directory.name, field_name="report_date") + resolve_within(self.settings.output_dir, directory) + except ValueError: + continue + dates.append(valid_date) + return sorted(dates, reverse=True) def list_group_outputs(self, report_date: str) -> list[dict]: - day_dir = self.settings.output_dir / report_date + day_dir = self.output_day_dir(report_date) if not day_dir.exists(): return [] result = [] - for group_dir in sorted(day_dir.iterdir()): - if not group_dir.is_dir(): + output_root = self.settings.output_dir.resolve() + for directory in sorted(day_dir.iterdir()): + if not directory.is_dir(): + continue + try: + group_dir = resolve_within(day_dir, directory) + except ValueError: continue handoff_file = group_dir / "handoff.json" handoff = {} if handoff_file.exists(): - handoff = json.loads(handoff_file.read_text(encoding="utf-8")) + safe_handoff = resolve_within(group_dir, handoff_file) + handoff = json.loads(safe_handoff.read_text(encoding="utf-8")) result.append( { "date": report_date, "directory": group_dir.name, - "path": str(group_dir), + "path": group_dir.relative_to(output_root).as_posix(), "handoff": handoff, "files": sorted(p.name for p in group_dir.iterdir()), } diff --git a/app/v2/run_store.py b/app/v2/run_store.py index f4321db..622ae49 100644 --- a/app/v2/run_store.py +++ b/app/v2/run_store.py @@ -12,7 +12,6 @@ import json import hashlib import os -import re import threading import uuid from contextlib import contextmanager @@ -20,6 +19,7 @@ from pathlib import Path from typing import Iterator +from app.core.path_security import resolve_within, validate_iso_date, validate_path_label from app.services.handoff_service import safe_dir_name from app.v2.constants import ( FILE_IMAGE, @@ -35,7 +35,6 @@ ) -_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _RUN_WRITE_LOCK = threading.RLock() _WAIT_OBJECT_0 = 0 _WAIT_ABANDONED = 0x80 @@ -43,13 +42,7 @@ def validate_run_date(value: str) -> str: """校验 V2 运行目录日期,拒绝路径段和不存在的日历日期。""" - if not isinstance(value, str) or not _ISO_DATE.fullmatch(value): - raise ValueError("run_date 必须是有效的 YYYY-MM-DD 日期") - try: - datetime.strptime(value, "%Y-%m-%d") - except ValueError as exc: - raise ValueError("run_date 必须是有效的 YYYY-MM-DD 日期") from exc - return value + return validate_iso_date(value, field_name="run_date") @contextmanager @@ -116,9 +109,13 @@ def __init__(self, output_root: Path | str): # ---------- 路径 ---------- + def _group_root(self, group_name: str) -> Path: + validate_path_label(group_name, field_name="group_name") + return resolve_within(self.root, safe_dir_name(group_name)) + def group_dir(self, group_name: str, run_date: str) -> Path: - validate_run_date(run_date) - return self.root / safe_dir_name(group_name) / run_date + valid_date = validate_run_date(run_date) + return resolve_within(self._group_root(group_name), valid_date) def run_path(self, group_name: str, run_date: str) -> Path: return self.group_dir(group_name, run_date) / FILE_RUN @@ -183,7 +180,7 @@ def update(self, group_name: str, run_date: str, **fields) -> dict: def previous_theme_signature(self, group_name: str, run_date: str) -> str: """读取当前日期之前最近一次运行的实际风格签名。""" validate_run_date(run_date) - group_dir = self.root / safe_dir_name(group_name) + group_dir = self._group_root(group_name) if not group_dir.is_dir(): return "" candidates = sorted( @@ -210,7 +207,7 @@ def recent_layout_history( if limit <= 0: return () limit = min(int(limit), 12) - group_dir = self.root / safe_dir_name(group_name) + group_dir = self._group_root(group_name) if not group_dir.is_dir(): return () candidates = sorted( diff --git a/docker-compose.yml b/docker-compose.yml index b782c54..baf2880 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,8 @@ services: image: groupbrief-v2:latest container_name: groupbrief-v2 ports: - - "8766:8766" + # 管理与发送 API 没有远程认证;默认只发布到宿主机回环地址。 + - "127.0.0.1:8766:8766" volumes: # 数据持久化(SQLite / 日报输出 / 日志)挂载到宿主机当前目录 - ./data:/app/data diff --git a/tests/test_files_api.py b/tests/test_files_api.py new file mode 100644 index 0000000..9706ac2 --- /dev/null +++ b/tests/test_files_api.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import files as files_api +from app.config.settings import get_settings + + +@pytest.fixture(scope="module") +def files_client(tmp_path_factory): + output_dir = tmp_path_factory.mktemp("files-api") / "output" + api = FastAPI() + api.include_router(files_api.router) + api.dependency_overrides[get_settings] = lambda: SimpleNamespace(output_dir=output_dir) + with TestClient(api) as client: + yield client, output_dir + + +def test_dates_only_returns_valid_date_directories(files_client): + client, output_dir = files_client + (output_dir / "2026-08-24").mkdir(parents=True) + (output_dir / "群目录" / "2026-08-24").mkdir(parents=True) + (output_dir / "2026-02-30").mkdir() + + response = client.get("/api/files/dates") + + assert response.status_code == 200 + assert response.json() == ["2026-08-24"] + + +def test_list_day_returns_relative_path_without_host_path(files_client): + client, output_dir = files_client + group_dir = output_dir / "2026-08-24" / "安全群" + group_dir.mkdir(parents=True) + (group_dir / "handoff.json").write_text(json.dumps({"version": 1}), encoding="utf-8") + (group_dir / "ranking.txt").write_text("排行榜", encoding="utf-8") + + response = client.get("/api/files/2026-08-24") + + assert response.status_code == 200 + assert response.json() == [ + { + "date": "2026-08-24", + "directory": "安全群", + "path": "2026-08-24/安全群", + "handoff": {"version": 1}, + "files": ["handoff.json", "ranking.txt"], + } + ] + assert str(output_dir.resolve()) not in response.text + + +@pytest.mark.parametrize("report_date", ["2026-02-30", "..%5Clogs", "C:%5CWindows"]) +def test_list_day_rejects_invalid_or_path_like_dates(files_client, report_date): + client, _ = files_client + assert client.get(f"/api/files/{report_date}").status_code == 400 + + +def test_raw_file_uses_allowlist_and_blocks_sibling_prefix_escape(files_client): + client, output_dir = files_client + day_dir = output_dir / "2026-08-24" / "原始文件群" + day_dir.mkdir(parents=True) + (day_dir / "ranking.txt").write_text("合法排行榜", encoding="utf-8") + (day_dir / "private.txt").write_text("secret", encoding="utf-8") + + sibling = output_dir / "2026-08-24-extra" + sibling.mkdir(parents=True) + (sibling / "ranking.txt").write_text("不应读取", encoding="utf-8") + + allowed = client.get("/api/files/2026-08-24/原始文件群/raw/ranking.txt") + assert allowed.status_code == 200 + assert allowed.text == "合法排行榜" + assert client.get("/api/files/2026-08-24/原始文件群/raw/private.txt").status_code == 400 + assert ( + client.get("/api/files/2026-08-24/..%5C2026-08-24-extra/raw/ranking.txt").status_code + == 400 + ) diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 51457ff..9ad9fba 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -22,6 +22,8 @@ def test_safe_dir_name(): assert ":" not in safe_dir_name("a:b/c*d?e") assert "/" not in safe_dir_name("a/b") assert safe_dir_name("") == "group" + assert safe_dir_name(".") == "group" + assert safe_dir_name("..") == "group" def test_generate_writes_files(): diff --git a/tests/test_path_security.py b/tests/test_path_security.py new file mode 100644 index 0000000..5498173 --- /dev/null +++ b/tests/test_path_security.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.core.path_security import PathBoundaryError, resolve_within, validate_path_label +from app.v2.run_store import RunStore + + +def test_resolve_within_accepts_child_and_rejects_parent_or_sibling(tmp_path): + root = tmp_path / "output" / "2026-08-24" + root.mkdir(parents=True) + + child = resolve_within(root, "群A", "ranking.txt") + assert child == (root / "群A" / "ranking.txt").resolve() + + with pytest.raises(PathBoundaryError): + resolve_within(root, Path("..") / "logs" / "ranking.txt") + with pytest.raises(PathBoundaryError): + resolve_within(root, Path("..") / "2026-08-24-extra" / "ranking.txt") + with pytest.raises(PathBoundaryError): + resolve_within(root, root) + + +@pytest.mark.parametrize( + "value", + [".", "..", "../logs", r"..\logs", r"C:\Windows", r"\\server\share", "/etc"], +) +def test_validate_path_label_rejects_navigation_and_absolute_paths(value): + with pytest.raises(PathBoundaryError): + validate_path_label(value, field_name="group_name") + + +def test_validate_path_label_keeps_ordinary_display_name_punctuation(): + assert validate_path_label("设计/开发群(A.B)", field_name="group_name") == "设计/开发群(A.B)" + + +@pytest.mark.parametrize("group_name", [".", "..", "../logs", r"..\logs", r"C:\Windows", r"\\server\share"]) +def test_run_store_rejects_unsafe_group_paths(tmp_path, group_name): + store = RunStore(tmp_path / "output") + with pytest.raises(PathBoundaryError): + store.group_dir(group_name, "2026-08-24") + + +def test_run_store_group_directory_is_resolved_under_output(tmp_path): + root = tmp_path / "output" + path = RunStore(root).group_dir("设计/开发群", "2026-08-24") + assert path == (root / "设计-开发群" / "2026-08-24").resolve() + assert path.is_relative_to(root.resolve()) diff --git a/tests/test_ui_api.py b/tests/test_ui_api.py index 3b39103..bea9a17 100644 --- a/tests/test_ui_api.py +++ b/tests/test_ui_api.py @@ -77,6 +77,13 @@ def test_v2_invalid_dates_return_400(bad_date): assert client.get(f"/api/v2/files/test-group/{bad_date}/ranking.txt").status_code == 400 +def test_group_create_rejects_unsafe_output_names(): + with client: + for bad_name in ("..", "../logs", r"..\logs", r"C:\Windows", r"\\server\share"): + response = client.post("/api/groups", json={"display_name": bad_name}) + assert response.status_code == 422 + + def test_group_image_theme_roundtrip_and_validation(): display_name = "主题配置测试群" with client: diff --git a/tests/test_v2_archive_api.py b/tests/test_v2_archive_api.py index b5b103f..6962402 100644 --- a/tests/test_v2_archive_api.py +++ b/tests/test_v2_archive_api.py @@ -182,6 +182,36 @@ def test_run_detail_only_lists_downloadable_files_and_serves_original_png(archiv assert client.get(f"/api/v2/files/{group_name}/{run_date}/..%2Fprivate.txt").status_code != 200 +def test_v2_file_and_run_routes_reject_unsafe_group_paths(archive_client): + client, _, _ = archive_client + run_date = "2026-08-24" + + for encoded_group in ("..%5Clogs", "C:%5CWindows", "%5C%5Cserver%5Cshare"): + assert client.get(f"/api/v2/runs/{encoded_group}/{run_date}").status_code == 400 + assert client.get(f"/api/v2/files/{encoded_group}/{run_date}/ranking.txt").status_code == 400 + assert ( + client.put( + f"/api/v2/runs/{encoded_group}/{run_date}/prompt", + json={ + "content": "安全 Prompt", + "expected_revision": "missing", + "image_theme": "random_preset", + }, + ).status_code + == 400 + ) + + response = client.post( + "/api/v2/image-themes/resolve", + json={ + "image_theme": "random_preset", + "group_id": r"..\logs", + "run_date": "2026-08-24", + }, + ) + assert response.status_code == 400 + + def test_soft_delete_preserves_database_history_and_output_then_restores_disabled(archive_client): client, engine, output_dir = archive_client group = _save_group( From 714f2cf389942936a00a84f31b3ca75552e4fd11 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 19:36:00 +0800 Subject: [PATCH 04/42] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20P0.2A=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=80=E8=87=B4=E6=80=A7=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/audits/P0_2A_DATA_INTEGRITY.md | 256 ++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/audits/P0_2A_DATA_INTEGRITY.md diff --git a/docs/audits/P0_2A_DATA_INTEGRITY.md b/docs/audits/P0_2A_DATA_INTEGRITY.md new file mode 100644 index 0000000..03c5f95 --- /dev/null +++ b/docs/audits/P0_2A_DATA_INTEGRITY.md @@ -0,0 +1,256 @@ +# GroupBrief P0.2A 数据一致性取证 + +> 取证时间:2026-08-24 19:22(Asia/Shanghai) +> +> 数据基线:`data/groupbrief.db` 的 SQLite Online Backup 一致性快照 +> +> 原则:只备份、只读聚合、只设计迁移;未修改正式数据库、Schema 或业务数据 + +## 1. 结论 + +正式数据库的物理结构当前完整,`PRAGMA integrity_check=ok`;问题属于**逻辑关系损失**,不是数据库文件损坏。 + +224 条 `group_runs` 中有 192 条(85.7%)引用已不存在的群 ID `1-22`。当前群 ID 只有 `23-29`。Git 历史证实旧版本曾对群执行物理删除,直到 2026-08-22 才改为软删除。因此,最可信解释是:旧群被物理删除并重新创建后,历史 `group_runs`/`reports` 被保留,但数据库没有外键阻止关系断裂。 + +目前不能安全自动修复旧群关系:`group_runs` 只保存本地整数 `group_id`,没有历史 `wechat_group_id` 快照;不能假设旧 ID `1` 对应新 ID `23`,也不能按顺序映射。 + +本轮决定: + +- 保留全部 192 条历史记录。 +- 不删除、不伪造群、不自动重关联。 +- P0.2B 先建立“历史孤儿”正式表达,再加外键。 +- 正式迁移前必须停止所有数据库写入者,并只在备份副本完成演练。 + +## 2. 一致性备份 + +使用 `sqlite3.Connection.backup()` 从只读源连接创建一致性快照;没有复制、移动或替换活动数据库。 + +备份文件: + +```text +data/backups/groupbrief-p0-2a-consistent-20260824-192257.db +``` + +校验 Manifest: + +```text +data/backups/groupbrief-p0-2a-consistent-20260824-192257.manifest.json +``` + +备份证据: + +```text +字节数 1,052,672 +SHA256 a17fca934b40d4c076e605df899c53a97082f415ea9a7e3699f725cba10fd6a4 +integrity ok +groups 7 +runs 68 +group_runs 224 +reports 214 +settings 55 +provider 48 +execution_log 0 +``` + +源库与备份的四个核心表行数完全一致。备份目标的 `schema_version` cookie 从源库的 `25` 变为 `1`,这是 SQLite 内部 schema cookie,不是业务迁移版本,不能用于迁移判断。真正可用于版本判断的 `PRAGMA user_version` 在源库和备份中都为 `0`。 + +`PRAGMA foreign_key_check` 返回 0 行,但当前表根本没有声明外键,因此这不表示逻辑关系健康。 + +## 3. 已证实的数据问题 + +### 3.1 192 条孤儿 GroupRun + +当前活动/软删除群 ID: + +```text +23, 24, 25, 26, 27, 28, 29 +``` + +孤儿记录引用的旧 ID 及数量: + +```text +1:19 2:18 3:15 4:14 5:13 6:11 +7:10 8:10 9:8 10:8 11:8 12:6 +13:7 14:6 15:6 16:6 17:5 18:5 +19:4 20:5 21:3 22:5 +``` + +按报告日期: + +```text +2026-08-13 166 +2026-08-17 24 +2026-08-18 2 +``` + +最早日期为 2026-08-13,最晚日期为 2026-08-18。2026-08-13 的 166 条 `group_runs` 全部已经失去群关系,说明这是一批历史关系断裂,不是偶发单行错误。 + +### 3.2 孤儿 GroupRun 的内容完整程度 + +父 Run 状态: + +```text +success 181 +running 10 +failed 1 +``` + +阶段状态: + +```text +ranking=success, prompt=success 164 +ranking=success, prompt=skipped 22 +ranking=success, prompt=pending 5 +ranking=failed, prompt=skipped 1 +``` + +报告关系: + +```text +有且仅有 1 条 Report 186 +没有 Report 6 +多条 Report 0 +``` + +这意味着绝大多数孤儿仍包含可用历史结果。直接删除会丢失 186 条已有报告的归属上下文,不可接受。 + +### 3.3 五条“空成功”Run + +以下父 Run 状态为 `success`,但没有任何 `GroupRun`: + +```text +Run 32 success auto 2026-08-17 +Run 37 success auto 2026-08-17 +Run 60 success manual 2026-08-18 +Run 61 success manual 2026-08-18 +Run 65 success auto 2026-08-18 +``` + +它们不能证明核心业务成功,属于状态语义问题。当前没有足够日志确认是空群执行、旧流程写入还是中途清理;P0.2B 不应猜测性改成失败,先标记为待分类。 + +### 3.4 当前没有重复关系 + +以下聚合均为 0: + +- 重复 `(run_id, group_id)` 关系 +- 同一 `group_run_id` 对应多条 Report +- 非空 `wechat_group_id` 重复 +- `group_runs` 缺失父 Run +- Report 缺失父 GroupRun + +没有重复数据是好消息,但它完全依赖应用代码;数据库没有唯一约束阻止未来重复。 + +## 4. 根因置信度 + +### 已证实 + +- 初始版本到提交 `05b7da0` 之前,`repository.delete_group()` 使用 `session.delete(group)` 物理删除。 +- 2026-08-22 才切换为当前软删除实现。 +- 数据库没有 `group_runs.group_id → groups.id` 外键。 +- 当前群 ID 从 23 开始,而全部孤儿只引用 1-22。 +- 孤儿数据集中在旧群重新创建前后的 2026-08-13 至 2026-08-18。 + +### 高概率推断 + +旧群 ID `1-22` 曾存在,后来通过旧版本物理删除;群重新绑定/创建后获得 ID `23-29`。由于没有外键和稳定身份快照,旧 `group_runs`/`reports` 被保留成逻辑孤儿。 + +### 尚未证实 + +- 每个旧群 ID 对应哪个新群 ID。 +- 五条空成功 Run 的具体形成动作。 +- 10 条父 Run 为 `running` 的孤儿是否真的中断,还是旧流程没有收口。 +- 是否能从历史 output 工件为每条孤儿恢复稳定微信群 ID;本轮没有读取业务内容。 + +## 5. Schema 与迁移问题 + +当前状态: + +```text +PRAGMA foreign_keys = 0 +PRAGMA user_version = 0 +正式 migration version table = 不存在 +``` + +缺少的关系保护: + +- `group_runs.run_id → runs.id` +- `group_runs.group_id → groups.id` +- `reports.group_run_id → group_runs.id` +- `execution_logs.run_id → runs.id` + +缺少的唯一/查询保护: + +- 每个 GroupRun 最多一条 Report +- 非空 `wechat_group_id` 的唯一约束 +- `runs(report_date, status)` 查询索引 +- `execution_logs(run_id)` 查询索引 + +`UNIQUE(run_id, group_id)` 暂时不能直接添加。当前失败路径可能在同一个 Run/Group 上追加另一条失败记录;需要先统一 attempt/revision 语义,或者引入 `attempt_no`。 + +数据库列默认值也已经漂移: + +```text +groups.summary_model 数据库默认 deepseek-v4-flash / 代码默认 gpt-5.6-sol +groups.image_theme 数据库默认 blue_white / 代码默认 random_preset +``` + +现有 `ALTER TABLE ADD COLUMN` 与 settings marker 不能表达列重建、约束升级和回滚,因此 P0.2B 需要正式 Schema 版本机制。 + +## 6. P0.2B 推荐设计 + +### 6.1 先建立历史身份表达 + +不要把外键直接套到当前非空 `group_id`。建议先将历史身份拆开: + +```text +group_id INTEGER NULL,引用当前 groups.id +legacy_group_id INTEGER NULL,保存旧本地 ID +identity_state TEXT NOT NULL,例如 active / legacy_orphan / unresolved +orphan_reason TEXT NOT NULL,例如 legacy_group_hard_deleted +``` + +迁移规则: + +- 当前能关联到 `groups` 的 32 条 GroupRun:保留 `group_id`。 +- 192 条孤儿:将旧整数移入 `legacy_group_id`,`group_id=NULL`。 +- 不创建假群,不做顺序映射。 +- Reports 继续关联原 GroupRun,保留 186 条历史报告。 + +### 6.2 外键删除策略 + +建议历史数据优先: + +- Group → GroupRun 使用 `ON DELETE RESTRICT`,禁止再次物理删除仍有历史的群。 +- Run → GroupRun 使用 `ON DELETE RESTRICT`。 +- GroupRun → Report 使用 `ON DELETE RESTRICT`。 +- 业务删除继续使用现有 `deleted_at` 软删除。 + +不要使用级联删除历史报告。 + +### 6.3 正式迁移顺序 + +1. 停止 FastAPI scheduler、Windows 计划任务及所有数据库写入者。 +2. 再创建一份停机前备份并验证 hash/integrity。 +3. 在独立副本创建 Schema V1 迁移表和新表。 +4. 按确定规则复制数据,不原地批量 UPDATE。 +5. 建立外键、唯一约束和索引。 +6. 开启 `PRAGMA foreign_keys=ON`,执行 `foreign_key_check` 和 `integrity_check`。 +7. 用归档 API、历史 Report 数、Run 状态和完整测试验收。 +8. 通过后才原子替换正式数据库。 + +### 6.4 回滚 + +- 保留原库和迁移后库,不在原库执行 down migration。 +- 失败时停止所有写入者,用已验证备份原子恢复。 +- 恢复迁移前代码提交。 +- 再次核对完整性、核心表行数和 8766 健康。 + +## 7. 本轮明确未做 + +- 未修改正式数据库、Schema、索引或 PRAGMA。 +- 未删除或重关联任何孤儿记录。 +- 未读取聊天正文、Prompt、群名、发送目标或 Secret。 +- 未停止/重启 8766 服务、调度器或计划任务。 +- 未运行真实 AI、微信或邮件动作。 + +P0.2A 至此完成。下一轮只有在确认“历史孤儿建模方案”后,才进入 P0.2B 迁移实现与副本演练。 From df8966f58305a54b641e3301f648fdf445eba9eb Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 21:49:56 +0800 Subject: [PATCH 05/42] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E7=9A=84=E7=A6=BB=E7=BA=BF=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/offline_migrations.py | 696 +++++++++++++++++++++++ docs/audits/P0_2B_MIGRATION_REHEARSAL.md | 211 +++++++ scripts/migrate_db.py | 17 + tests/test_db_migration.py | 310 ++++++++++ 4 files changed, 1234 insertions(+) create mode 100644 app/db/offline_migrations.py create mode 100644 docs/audits/P0_2B_MIGRATION_REHEARSAL.md create mode 100644 scripts/migrate_db.py create mode 100644 tests/test_db_migration.py diff --git a/app/db/offline_migrations.py b/app/db/offline_migrations.py new file mode 100644 index 0000000..f36e3fb --- /dev/null +++ b/app/db/offline_migrations.py @@ -0,0 +1,696 @@ +"""GroupBrief 的显式离线数据库迁移。 + +该模块故意不接入应用启动流程。迁移只读取明确指定的源数据库,并把结果 +写入一个全新的目标文件;源数据库不会被原地修改或覆盖。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sqlite3 +import sys +import uuid +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Sequence + + +MIGRATION_ID = "p0_2b_group_run_identity_v1" +TARGET_USER_VERSION = 1 +_MIGRATION_SIGNATURE = """group_runs:nullable-group,legacy-id,identity-state,restrict-fks +reports:unique-group-run,restrict-fk +execution_logs:nullable-run,restrict-fk +indexes:runs-date-status,execution-run,active-wechat-id +""" +MIGRATION_CHECKSUM = hashlib.sha256(_MIGRATION_SIGNATURE.encode("utf-8")).hexdigest() + +_CORE_TABLES = ("groups", "runs", "group_runs", "reports", "execution_logs") +_REQUIRED_COLUMNS: dict[str, set[str]] = { + "groups": {"id", "wechat_group_id", "deleted_at"}, + "runs": {"id", "report_date", "status"}, + "group_runs": { + "id", + "run_id", + "group_id", + "provider_used", + "message_count", + "speaker_count", + "ranking_status", + "prompt_status", + "error_message", + }, + "reports": { + "id", + "group_run_id", + "ranking_text", + "prompt_text", + "ranking_file", + "prompt_file", + "poster_file", + "poster_status", + "email_status", + "created_at", + "updated_at", + }, + "execution_logs": {"id", "run_id", "level", "message", "created_at"}, +} +_REBUILT_TABLES = ("group_runs", "reports", "execution_logs") +_KNOWN_REBUILT_INDEXES = { + "ix_group_runs_group_id", + "ix_group_runs_run_id", + "ix_reports_group_run_id", +} + + +class MigrationError(RuntimeError): + """迁移前置条件、执行或验证失败。""" + + +@dataclass(frozen=True) +class DatabaseSnapshot: + integrity_check: str + user_version: int + table_counts: dict[str, int] + orphan_group_runs: int + orphan_reports: int + group_runs_missing_run: int + execution_logs_missing_run: int + duplicate_report_relations: int + duplicate_active_wechat_ids: int + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA query_only = ON") + return connection + + +def _table_exists(connection: sqlite3.Connection, table: str) -> bool: + row = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + return row is not None + + +def _table_columns(connection: sqlite3.Connection, table: str) -> set[str]: + return {str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")')} + + +def _scalar(connection: sqlite3.Connection, sql: str) -> int: + return int(connection.execute(sql).fetchone()[0]) + + +def _validate_source_schema(connection: sqlite3.Connection) -> None: + missing_tables = [table for table in _CORE_TABLES if not _table_exists(connection, table)] + if missing_tables: + raise MigrationError(f"数据库缺少必要表:{', '.join(missing_tables)}") + + if _table_exists(connection, "schema_migrations"): + migration_columns = _table_columns(connection, "schema_migrations") + expected = {"migration_id", "applied_at", "checksum"} + if not expected.issubset(migration_columns): + raise MigrationError("schema_migrations 表结构不兼容,拒绝继续") + applied = connection.execute( + "SELECT checksum FROM schema_migrations WHERE migration_id=?", + (MIGRATION_ID,), + ).fetchone() + if applied is not None: + raise MigrationError(f"迁移 {MIGRATION_ID} 已经执行,拒绝重复迁移") + + for table, required in _REQUIRED_COLUMNS.items(): + actual = _table_columns(connection, table) + missing = sorted(required - actual) + if missing: + raise MigrationError(f"表 {table} 缺少必要列:{', '.join(missing)}") + if table in _REBUILT_TABLES: + unexpected = sorted(actual - required) + if unexpected: + raise MigrationError( + f"待重建表 {table} 包含未知列,拒绝静默丢弃:{', '.join(unexpected)}" + ) + + unknown_indexes = [ + str(row[0]) + for row in connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type='index' + AND tbl_name IN ('group_runs', 'reports', 'execution_logs') + AND sql IS NOT NULL + ORDER BY name + """ + ) + if str(row[0]) not in _KNOWN_REBUILT_INDEXES + ] + if unknown_indexes: + raise MigrationError( + "待重建表包含未知显式索引,拒绝静默丢弃:" + ",".join(unknown_indexes) + ) + + dependent_objects = [ + f"{row[0]}:{row[1]}" + for row in connection.execute( + """ + SELECT type, name + FROM sqlite_master + WHERE + (type='trigger' AND tbl_name IN ('group_runs', 'reports', 'execution_logs')) + OR + ( + type='view' + AND ( + LOWER(sql) LIKE '%group_runs%' + OR LOWER(sql) LIKE '%reports%' + OR LOWER(sql) LIKE '%execution_logs%' + ) + ) + ORDER BY type, name + """ + ) + ] + if dependent_objects: + raise MigrationError( + "待重建表存在未知触发器或依赖视图,拒绝静默破坏:" + ",".join(dependent_objects) + ) + +def _validate_no_sqlite_sidecars(source: Path) -> None: + sidecars = [ + Path(f"{source}-wal"), + Path(f"{source}-shm"), + Path(f"{source}-journal"), + ] + present = [path.name for path in sidecars if path.exists()] + if present: + raise MigrationError( + "源数据库存在 SQLite 写入/日志侧文件,不能视为离线源:" + ",".join(present) + ) + + +def _snapshot(connection: sqlite3.Connection) -> DatabaseSnapshot: + integrity_rows = [str(row[0]) for row in connection.execute("PRAGMA integrity_check")] + integrity = "ok" if integrity_rows == ["ok"] else "; ".join(integrity_rows) + counts = {table: _scalar(connection, f'SELECT COUNT(*) FROM "{table}"') for table in _CORE_TABLES} + return DatabaseSnapshot( + integrity_check=integrity, + user_version=int(connection.execute("PRAGMA user_version").fetchone()[0]), + table_counts=counts, + orphan_group_runs=_scalar( + connection, + """ + SELECT COUNT(*) + FROM group_runs gr + LEFT JOIN groups g ON g.id = gr.group_id + WHERE g.id IS NULL + """, + ), + orphan_reports=_scalar( + connection, + """ + SELECT COUNT(*) + FROM reports rep + LEFT JOIN group_runs gr ON gr.id = rep.group_run_id + WHERE gr.id IS NULL + """, + ), + group_runs_missing_run=_scalar( + connection, + """ + SELECT COUNT(*) + FROM group_runs gr + LEFT JOIN runs r ON r.id = gr.run_id + WHERE r.id IS NULL + """, + ), + execution_logs_missing_run=_scalar( + connection, + """ + SELECT COUNT(*) + FROM execution_logs log + LEFT JOIN runs r ON r.id = log.run_id + WHERE log.run_id IS NOT NULL AND r.id IS NULL + """, + ), + duplicate_report_relations=_scalar( + connection, + """ + SELECT COUNT(*) + FROM ( + SELECT group_run_id + FROM reports + GROUP BY group_run_id + HAVING COUNT(*) > 1 + ) + """, + ), + duplicate_active_wechat_ids=_scalar( + connection, + """ + SELECT COUNT(*) + FROM ( + SELECT wechat_group_id + FROM groups + WHERE TRIM(wechat_group_id) <> '' AND deleted_at IS NULL + GROUP BY wechat_group_id + HAVING COUNT(*) > 1 + ) + """, + ), + ) + + +def _validate_preflight(snapshot: DatabaseSnapshot) -> None: + if snapshot.integrity_check != "ok": + raise MigrationError(f"源数据库完整性检查失败:{snapshot.integrity_check}") + if snapshot.user_version != 0: + raise MigrationError( + f"源数据库 user_version={snapshot.user_version},本迁移只接受旧版 user_version=0" + ) + failures = { + "缺失父 Run 的 GroupRun": snapshot.group_runs_missing_run, + "孤儿 Report": snapshot.orphan_reports, + "同一 GroupRun 的重复 Report": snapshot.duplicate_report_relations, + "重复的活动微信群 ID": snapshot.duplicate_active_wechat_ids, + "缺失父 Run 的执行日志": snapshot.execution_logs_missing_run, + } + present = [f"{name}={count}" for name, count in failures.items() if count] + if present: + raise MigrationError("迁移前置检查失败:" + ",".join(present)) + + +def preflight_database(source: str | Path) -> dict[str, Any]: + """只读检查一个候选源数据库,不创建任何输出文件。""" + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + raise MigrationError(f"源数据库不存在或不是文件:{source_path}") + _validate_no_sqlite_sidecars(source_path) + with _connect_read_only(source_path) as connection: + _validate_source_schema(connection) + snapshot = _snapshot(connection) + _validate_preflight(snapshot) + return { + "migration_id": MIGRATION_ID, + "source": str(source_path), + "source_sha256": _sha256(source_path), + "snapshot": asdict(snapshot), + "ready": True, + } + + +def _backup_database(source: Path, destination: Path) -> None: + source_connection = _connect_read_only(source) + destination_connection = sqlite3.connect(destination) + try: + source_connection.backup(destination_connection) + destination_connection.commit() + finally: + destination_connection.close() + source_connection.close() + + +def _apply_relationship_migration(database: Path) -> None: + connection = sqlite3.connect(database) + try: + connection.execute("PRAGMA foreign_keys = OFF") + connection.execute("BEGIN IMMEDIATE") + + connection.execute("ALTER TABLE reports RENAME TO reports_p0_2b_legacy") + connection.execute("ALTER TABLE execution_logs RENAME TO execution_logs_p0_2b_legacy") + connection.execute("ALTER TABLE group_runs RENAME TO group_runs_p0_2b_legacy") + + connection.execute( + """ + CREATE TABLE group_runs ( + id INTEGER NOT NULL PRIMARY KEY, + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE RESTRICT, + group_id INTEGER REFERENCES groups(id) ON DELETE RESTRICT, + legacy_group_id INTEGER, + identity_state TEXT NOT NULL DEFAULT 'linked', + orphan_reason TEXT NOT NULL DEFAULT '', + provider_used VARCHAR NOT NULL, + message_count INTEGER NOT NULL, + speaker_count INTEGER NOT NULL, + ranking_status VARCHAR NOT NULL, + prompt_status VARCHAR NOT NULL, + error_message VARCHAR NOT NULL, + CHECK ( + ( + identity_state = 'linked' + AND group_id IS NOT NULL + AND legacy_group_id IS NULL + AND orphan_reason = '' + ) + OR + ( + identity_state = 'orphaned' + AND group_id IS NULL + AND legacy_group_id IS NOT NULL + AND orphan_reason = 'historical_group_missing' + ) + ) + ) + """ + ) + connection.execute( + """ + INSERT INTO group_runs ( + id, run_id, group_id, legacy_group_id, identity_state, + orphan_reason, provider_used, message_count, speaker_count, + ranking_status, prompt_status, error_message + ) + SELECT + gr.id, + gr.run_id, + CASE WHEN g.id IS NULL THEN NULL ELSE gr.group_id END, + CASE WHEN g.id IS NULL THEN gr.group_id ELSE NULL END, + CASE WHEN g.id IS NULL THEN 'orphaned' ELSE 'linked' END, + CASE WHEN g.id IS NULL THEN 'historical_group_missing' ELSE '' END, + gr.provider_used, + gr.message_count, + gr.speaker_count, + gr.ranking_status, + gr.prompt_status, + gr.error_message + FROM group_runs_p0_2b_legacy gr + LEFT JOIN groups g ON g.id = gr.group_id + """ + ) + + connection.execute( + """ + CREATE TABLE reports ( + id INTEGER NOT NULL PRIMARY KEY, + group_run_id INTEGER NOT NULL UNIQUE + REFERENCES group_runs(id) ON DELETE RESTRICT, + ranking_text VARCHAR NOT NULL, + prompt_text VARCHAR NOT NULL, + ranking_file VARCHAR NOT NULL, + prompt_file VARCHAR NOT NULL, + poster_file VARCHAR NOT NULL, + poster_status VARCHAR NOT NULL, + email_status VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ) + """ + ) + connection.execute( + """ + INSERT INTO reports ( + id, group_run_id, ranking_text, prompt_text, ranking_file, + prompt_file, poster_file, poster_status, email_status, + created_at, updated_at + ) + SELECT + id, group_run_id, ranking_text, prompt_text, ranking_file, + prompt_file, poster_file, poster_status, email_status, + created_at, updated_at + FROM reports_p0_2b_legacy + """ + ) + + connection.execute( + """ + CREATE TABLE execution_logs ( + id INTEGER NOT NULL PRIMARY KEY, + run_id INTEGER REFERENCES runs(id) ON DELETE RESTRICT, + level VARCHAR NOT NULL, + message VARCHAR NOT NULL, + created_at DATETIME NOT NULL + ) + """ + ) + connection.execute( + """ + INSERT INTO execution_logs (id, run_id, level, message, created_at) + SELECT id, run_id, level, message, created_at + FROM execution_logs_p0_2b_legacy + """ + ) + + connection.execute("DROP TABLE reports_p0_2b_legacy") + connection.execute("DROP TABLE execution_logs_p0_2b_legacy") + connection.execute("DROP TABLE group_runs_p0_2b_legacy") + + connection.execute("CREATE INDEX ix_group_runs_run_id ON group_runs(run_id)") + connection.execute("CREATE INDEX ix_group_runs_group_id ON group_runs(group_id)") + connection.execute("CREATE INDEX ix_runs_report_date_status ON runs(report_date, status)") + connection.execute("CREATE INDEX ix_execution_logs_run_id ON execution_logs(run_id)") + connection.execute( + """ + CREATE UNIQUE INDEX uq_groups_wechat_group_id_active + ON groups(wechat_group_id) + WHERE TRIM(wechat_group_id) <> '' AND deleted_at IS NULL + """ + ) + + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + migration_id TEXT NOT NULL PRIMARY KEY, + applied_at TEXT NOT NULL, + checksum TEXT NOT NULL + ) + """ + ) + connection.execute( + "INSERT INTO schema_migrations(migration_id, applied_at, checksum) VALUES (?, ?, ?)", + (MIGRATION_ID, datetime.now(timezone.utc).isoformat(), MIGRATION_CHECKSUM), + ) + connection.execute(f"PRAGMA user_version = {TARGET_USER_VERSION}") + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +def _validate_migrated_database(database: Path, before: DatabaseSnapshot) -> dict[str, Any]: + connection = sqlite3.connect(database) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA foreign_keys = ON") + integrity_rows = [str(row[0]) for row in connection.execute("PRAGMA integrity_check")] + if integrity_rows != ["ok"]: + raise MigrationError("迁移后完整性检查失败:" + "; ".join(integrity_rows)) + + foreign_key_rows = [tuple(row) for row in connection.execute("PRAGMA foreign_key_check")] + if foreign_key_rows: + raise MigrationError(f"迁移后外键检查失败,共 {len(foreign_key_rows)} 行") + + counts = {table: _scalar(connection, f'SELECT COUNT(*) FROM "{table}"') for table in _CORE_TABLES} + if counts != before.table_counts: + raise MigrationError(f"迁移前后核心表行数不一致:before={before.table_counts}, after={counts}") + + orphaned = _scalar(connection, "SELECT COUNT(*) FROM group_runs WHERE identity_state='orphaned'") + linked = _scalar(connection, "SELECT COUNT(*) FROM group_runs WHERE identity_state='linked'") + invalid_links = _scalar( + connection, + """ + SELECT COUNT(*) + FROM group_runs gr + LEFT JOIN groups g ON g.id = gr.group_id + WHERE gr.group_id IS NOT NULL AND g.id IS NULL + """, + ) + preserved_legacy_ids = _scalar( + connection, + """ + SELECT COUNT(*) + FROM group_runs + WHERE identity_state='orphaned' + AND group_id IS NULL + AND legacy_group_id IS NOT NULL + AND orphan_reason='historical_group_missing' + """, + ) + if orphaned != before.orphan_group_runs or preserved_legacy_ids != before.orphan_group_runs: + raise MigrationError("历史孤儿数量或旧 ID 保留数量不一致") + if linked != before.table_counts["group_runs"] - before.orphan_group_runs: + raise MigrationError("可关联 GroupRun 数量不一致") + if invalid_links: + raise MigrationError(f"迁移后仍存在 {invalid_links} 条无效活动 group_id") + + migration_row = connection.execute( + "SELECT checksum FROM schema_migrations WHERE migration_id=?", + (MIGRATION_ID,), + ).fetchone() + if migration_row is None or str(migration_row[0]) != MIGRATION_CHECKSUM: + raise MigrationError("schema_migrations 记录缺失或 checksum 不一致") + user_version = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if user_version != TARGET_USER_VERSION: + raise MigrationError(f"user_version 不正确:{user_version}") + + foreign_keys = { + table: [dict(row) for row in connection.execute(f'PRAGMA foreign_key_list("{table}")')] + for table in ("group_runs", "reports", "execution_logs") + } + actual_foreign_keys = { + (table, str(row["from"]), str(row["table"]), str(row["to"]), str(row["on_delete"])) + for table, rows in foreign_keys.items() + for row in rows + } + expected_foreign_keys = { + ("group_runs", "run_id", "runs", "id", "RESTRICT"), + ("group_runs", "group_id", "groups", "id", "RESTRICT"), + ("reports", "group_run_id", "group_runs", "id", "RESTRICT"), + ("execution_logs", "run_id", "runs", "id", "RESTRICT"), + } + if actual_foreign_keys != expected_foreign_keys: + raise MigrationError( + f"迁移后外键结构不符合预期:actual={sorted(actual_foreign_keys)}" + ) + return { + "integrity_check": "ok", + "foreign_key_check_rows": 0, + "table_counts": counts, + "linked_group_runs": linked, + "orphaned_group_runs": orphaned, + "preserved_legacy_group_ids": preserved_legacy_ids, + "invalid_linked_group_ids": invalid_links, + "user_version": user_version, + "foreign_keys": foreign_keys, + } + finally: + connection.close() + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _resolve_and_validate_targets( + source: str | Path, + output: str | Path, + manifest: str | Path | None, +) -> tuple[Path, Path, Path]: + source_path = Path(source).expanduser().resolve() + output_path = Path(output).expanduser().resolve() + manifest_path = ( + Path(manifest).expanduser().resolve() + if manifest is not None + else output_path.with_suffix(output_path.suffix + ".manifest.json") + ) + if source_path == output_path: + raise MigrationError("源数据库和输出数据库不能是同一个文件") + if manifest_path in {source_path, output_path}: + raise MigrationError("Manifest 路径不能与源数据库或输出数据库相同") + if output_path.exists(): + raise MigrationError(f"输出文件已存在,拒绝覆盖:{output_path}") + if manifest_path.exists(): + raise MigrationError(f"Manifest 已存在,拒绝覆盖:{manifest_path}") + return source_path, output_path, manifest_path + + +def migrate_database( + source: str | Path, + output: str | Path, + *, + manifest: str | Path | None = None, +) -> dict[str, Any]: + """将旧数据库迁移到一个全新的输出文件,并返回验证 Manifest。""" + source_path, output_path, manifest_path = _resolve_and_validate_targets( + source, + output, + manifest, + ) + + preflight = preflight_database(source_path) + before = DatabaseSnapshot(**preflight["snapshot"]) + source_hash_before = str(preflight["source_sha256"]) + + output_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + token = uuid.uuid4().hex + temporary_database = output_path.with_name(f".{output_path.name}.{token}.tmp") + temporary_manifest = manifest_path.with_name(f".{manifest_path.name}.{token}.tmp") + output_promoted = False + manifest_promoted = False + try: + _backup_database(source_path, temporary_database) + _apply_relationship_migration(temporary_database) + after = _validate_migrated_database(temporary_database, before) + + source_hash_after = _sha256(source_path) + if source_hash_after != source_hash_before: + raise MigrationError("迁移期间源数据库文件发生变化,拒绝产出结果") + + result: dict[str, Any] = { + "migration_id": MIGRATION_ID, + "migration_checksum": MIGRATION_CHECKSUM, + "created_at": datetime.now(timezone.utc).isoformat(), + "source": str(source_path), + "output": str(output_path), + "manifest": str(manifest_path), + "source_sha256_before": source_hash_before, + "source_sha256_after": source_hash_after, + "source_unchanged": True, + "output_sha256": _sha256(temporary_database), + "before": asdict(before), + "after": after, + } + _write_json(temporary_manifest, result) + os.replace(temporary_database, output_path) + output_promoted = True + os.replace(temporary_manifest, manifest_path) + manifest_promoted = True + return result + except Exception: + for temporary_path in (temporary_database, temporary_manifest): + if temporary_path.exists(): + temporary_path.unlink() + if output_promoted and not manifest_promoted and output_path.exists(): + output_path.unlink() + raise + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="把 GroupBrief 旧数据库迁移到一个全新的、经过校验的输出副本。", + ) + parser.add_argument("--source", required=True, type=Path, help="只读源数据库路径") + parser.add_argument("--output", required=True, type=Path, help="必须不存在的输出数据库路径") + parser.add_argument("--manifest", type=Path, help="可选 Manifest 路径") + actions = parser.add_mutually_exclusive_group(required=True) + actions.add_argument("--dry-run", action="store_true", help="仅执行只读前置检查") + actions.add_argument("--apply", action="store_true", help="创建并迁移新的输出副本") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + if args.dry_run: + source_path, output_path, _ = _resolve_and_validate_targets( + args.source, + args.output, + args.manifest, + ) + result = preflight_database(source_path) + result["planned_output"] = str(output_path) + else: + result = migrate_database(args.source, args.output, manifest=args.manifest) + except (MigrationError, OSError, sqlite3.Error) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr) + return 2 + print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/audits/P0_2B_MIGRATION_REHEARSAL.md b/docs/audits/P0_2B_MIGRATION_REHEARSAL.md new file mode 100644 index 0000000..c07805c --- /dev/null +++ b/docs/audits/P0_2B_MIGRATION_REHEARSAL.md @@ -0,0 +1,211 @@ +# GroupBrief P0.2B-1 离线迁移与副本演练 + +> 执行时间:2026-08-24(Asia/Shanghai) +> +> 范围:离线迁移工具、自动化测试、P0.2A 备份副本演练 +> +> 未执行:正式数据库替换、ORM/API 切换、服务重启、真实外部调用 + +## 1. 结论 + +P0.2B-1 已达到“可以进入切换设计”的状态,但尚未部署到正式数据库。 + +显式离线迁移工具已经在 P0.2A 一致性备份上成功完成一次真实演练:192 条历史孤儿 `group_runs` 全部保留旧 ID,并从错误的活动关联转换为明确的 `orphaned` 状态;32 条仍能关联当前群的记录保持 `linked`;224 条 GroupRun、214 条 Report、68 条 Run 和 7 条 Group 总数均未变化。 + +迁移后副本的物理完整性、关系完整性和关键语义检查全部通过: + +```text +integrity_check ok +foreign_key_check_rows 0 +linked_group_runs 32 +orphaned_group_runs 192 +preserved_legacy_group_ids 192 +invalid_linked_group_ids 0 +user_version 1 +``` + +正式数据库 `data/groupbrief.db` 没有被迁移或替换,当前应用仍运行旧 Schema。 + +## 2. 交付内容 + +```text +app/db/offline_migrations.py 离线迁移、前置检查、事务重建和结果验证 +scripts/migrate_db.py 薄 CLI 入口 +tests/test_db_migration.py 迁移成功与失败边界测试 +``` + +CLI 必须显式选择动作: + +```powershell +.\.venv\Scripts\python.exe scripts\migrate_db.py ` + --source <只读源数据库> ` + --output <必须不存在的新数据库> ` + --dry-run + +.\.venv\Scripts\python.exe scripts\migrate_db.py ` + --source <只读源数据库> ` + --output <必须不存在的新数据库> ` + --apply +``` + +该模块没有接入 `app.main` 或 `repository.init_db()`,应用启动不会自动迁移。 + +## 3. 真实演练证据 + +演练源: + +```text +data/backups/groupbrief-p0-2a-consistent-20260824-192257.db +SHA256 a17fca934b40d4c076e605df899c53a97082f415ea9a7e3699f725cba10fd6a4 +``` + +最终演练输出: + +```text +data/backups/groupbrief-p0-2b-rehearsal-final-20260824.db +SHA256 5990c894be7e05a073fb9b69447df41bb71b9d0be36fb31e3f97ae2e1ffa3e37 +``` + +Manifest: + +```text +data/backups/groupbrief-p0-2b-rehearsal-final-20260824.db.manifest.json +``` + +源文件迁移前后 SHA256 完全相同。演练输出和 Manifest 位于已被 Git 忽略的 `data/backups/`,不会进入仓库。 + +第一次演练产生的以下两个文件已被最终演练取代: + +```text +data/backups/groupbrief-p0-2b-rehearsal-20260824.db +data/backups/groupbrief-p0-2b-rehearsal-20260824.db.manifest.json +``` + +本机安全策略阻止了自动删除,因此它们仍被保留为可恢复的旧演练产物;正式备份未受影响。 + +## 4. 迁移后的关系设计 + +### GroupRun 身份 + +仍可关联当前群: + +```text +group_id 当前 groups.id +legacy_group_id NULL +identity_state linked +orphan_reason 空字符串 +``` + +历史孤儿: + +```text +group_id NULL +legacy_group_id 原旧本地 ID +identity_state orphaned +orphan_reason historical_group_missing +``` + +数据库 `CHECK` 约束禁止出现“显示 linked 但没有当前群”或“显示 orphaned 但丢失旧 ID”的矛盾状态。 + +### 外键与约束 + +- `group_runs.run_id → runs.id ON DELETE RESTRICT` +- `group_runs.group_id → groups.id ON DELETE RESTRICT` +- `reports.group_run_id → group_runs.id ON DELETE RESTRICT` +- `execution_logs.run_id → runs.id ON DELETE RESTRICT` +- 同一 GroupRun 最多一个 Report +- 非空、未删除群的 `wechat_group_id` 唯一 + +没有添加 `UNIQUE(run_id, group_id)`:当前强制重试/重新生成语义尚未正式建模,贸然添加可能阻断合法重试。 + +## 5. 安全边界和失败模式 + +- **输入输出相同:** 迁移拒绝,不打开写连接。 +- **输出或 Manifest 已存在:** 拒绝覆盖,原文件保持不变。 +- **WAL/SHM/Journal 侧文件存在:** 认为源库可能仍有写入者,迁移拒绝。 +- **未知 `user_version`:** 拒绝覆盖其他迁移体系。 +- **未知列、显式索引、触发器或依赖视图:** 拒绝静默丢失未来 Schema。 +- **缺失父 Run、孤儿 Report、重复 Report、重复活动微信群 ID:** 前置检查失败,不创建输出。 +- **表重建中断:** 事务回滚并删除本轮精确命名的临时文件。 +- **迁移已执行:** 依据 `schema_migrations` 明确拒绝重复应用。 +- **迁移后计数、外键或语义不一致:** 不提升临时数据库为正式输出。 + +迁移工具只使用 Python 标准库 `sqlite3`,没有新增第三方依赖。 + +## 6. Impact / Effort + +```text + LOW EFFORT HIGH EFFORT + ┌────────────────────────┬──────────────────────────┐ +HIGH │ 已完成:离线迁移工具 │ 下一轮:ORM/API 正式切换 │ +IMPACT │ 已完成:副本演练和约束验证 │ 下一轮:停机迁移与恢复演练 │ + ├────────────────────────┼──────────────────────────┤ +LOW │ 可后续:CLI 文案美化 │ 暂缓:引入完整 Alembic 体系 │ +IMPACT │ │ 暂缓:自动映射旧群到新群 │ + └────────────────────────┴──────────────────────────┘ +``` + +## 7. 为什么本轮不修改 ORM/API + +当前旧正式库没有 `legacy_group_id`、`identity_state` 和 `orphan_reason`。`SQLModel.metadata.create_all()` 不会给既有表增加这些列。 + +如果先更新 `models.py` 并重启应用,ORM 查询会直接报 `no such column`。因此必须把下一轮设计成一个有明确停机窗口的原子切换: + +```text +停止所有写入者 + → 最终备份 + → 离线迁移新库 + → 更新 ORM/API 与连接级 foreign_keys=ON + → 替换数据库 + → 启动并验证 + → 失败则停止服务并恢复原库/原代码 +``` + +## 8. 下一轮 P0.2B-2 的切换条件 + +1. 明确 FastAPI scheduler、Windows Task 和其他写入者全部停止。 +2. 迁移前确认没有 `-wal`、`-shm`、`-journal`。 +3. 创建新的停机备份并记录 SHA256。 +4. 同一个发布轮次更新 ORM、Run API、Dashboard 和邮件孤儿过滤。 +5. SQLAlchemy 每个 SQLite 连接显式开启 `PRAGMA foreign_keys=ON`。 +6. 定向测试、全量测试、旧库迁移测试和回滚测试全部通过。 +7. 启动后只验证本地读取和健康状态,不触发 AI、微信或邮件。 + +在这些条件满足前,不应把演练数据库替换成正式数据库。 + +## 9. 本轮不在范围内 + +- 不修复 5 条“success 但无 GroupRun”的历史 Run。 +- 不修复历史 `running` 状态。 +- 不自动猜测旧群与当前群的对应关系。 +- 不改 V1/V2 业务流程。 +- 不引入 Alembic、微服务、事件总线或其他高复杂度方案。 + +## 10. 最终验证 + +当前最终工作树的独立验证结果: + +```text +迁移定向测试 17 passed +完整 Python 测试 455 passed, 1 warning +Python 编译检查 passed +git diff --check passed +8766 根页面 HTTP 200 +``` + +唯一 warning 是现有 Starlette `TestClient` 对 `httpx` 调用方式的弃用提示,与本次迁移逻辑无关;应在依赖维护轮次处理,不值得阻塞本轮。 + +```text +╔══════════════════════════════════════════════╗ +║ P0.2B-1 CODE OVERHAUL SUMMARY ║ +╠══════════════════════════════════════════════╣ +║ Mode: SURGICAL ║ +║ Stack: Python / SQLite ║ +║ Production DB: unchanged ║ +║ Orphans preserved: 192 / 192 ║ +║ Reports preserved: 214 / 214 ║ +║ Test failures: 0 ║ +║ Critical gaps: runtime cutover pending ║ +║ Beads filed: 0 ║ +╚══════════════════════════════════════════════╝ +``` diff --git a/scripts/migrate_db.py b/scripts/migrate_db.py new file mode 100644 index 0000000..4c0dbca --- /dev/null +++ b/scripts/migrate_db.py @@ -0,0 +1,17 @@ +"""显式运行 GroupBrief 离线数据库迁移。""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.db.offline_migrations import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_db_migration.py b/tests/test_db_migration.py new file mode 100644 index 0000000..8d4dc37 --- /dev/null +++ b/tests/test_db_migration.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from pathlib import Path + +import pytest + +from app.db import offline_migrations as migrations + + +def _hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _create_legacy_database(path: Path) -> Path: + connection = sqlite3.connect(path) + try: + connection.executescript( + """ + CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + wechat_group_id VARCHAR NOT NULL, + deleted_at DATETIME + ); + CREATE TABLE runs ( + id INTEGER PRIMARY KEY, + report_date VARCHAR NOT NULL, + status VARCHAR NOT NULL + ); + CREATE TABLE group_runs ( + id INTEGER PRIMARY KEY, + run_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + provider_used VARCHAR NOT NULL, + message_count INTEGER NOT NULL, + speaker_count INTEGER NOT NULL, + ranking_status VARCHAR NOT NULL, + prompt_status VARCHAR NOT NULL, + error_message VARCHAR NOT NULL + ); + CREATE TABLE reports ( + id INTEGER PRIMARY KEY, + group_run_id INTEGER NOT NULL, + ranking_text VARCHAR NOT NULL, + prompt_text VARCHAR NOT NULL, + ranking_file VARCHAR NOT NULL, + prompt_file VARCHAR NOT NULL, + poster_file VARCHAR NOT NULL, + poster_status VARCHAR NOT NULL, + email_status VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE execution_logs ( + id INTEGER PRIMARY KEY, + run_id INTEGER, + level VARCHAR NOT NULL, + message VARCHAR NOT NULL, + created_at DATETIME NOT NULL + ); + + INSERT INTO groups(id, wechat_group_id, deleted_at) + VALUES (1, 'active@chatroom', NULL); + INSERT INTO runs(id, report_date, status) + VALUES (1, '2026-08-24', 'success'); + INSERT INTO group_runs( + id, run_id, group_id, provider_used, message_count, + speaker_count, ranking_status, prompt_status, error_message + ) VALUES + (10, 1, 1, 'mock', 12, 3, 'success', 'success', ''), + (11, 1, 99, 'mock', 8, 2, 'success', 'success', ''); + INSERT INTO reports( + id, group_run_id, ranking_text, prompt_text, ranking_file, + prompt_file, poster_file, poster_status, email_status, + created_at, updated_at + ) VALUES + (20, 10, 'linked', 'linked prompt', '', '', '', '', '', + '2026-08-24 00:00:00', '2026-08-24 00:00:00'), + (21, 11, 'orphaned', 'orphaned prompt', '', '', '', '', '', + '2026-08-24 00:00:00', '2026-08-24 00:00:00'); + INSERT INTO execution_logs(id, run_id, level, message, created_at) + VALUES (30, 1, 'info', 'test', '2026-08-24 00:00:00'); + """ + ) + connection.commit() + finally: + connection.close() + return path + + +def test_migration_preserves_history_and_adds_constraints(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + output = tmp_path / "migrated.db" + source_hash = _hash(source) + + result = migrations.migrate_database(source, output) + + assert output.is_file() + assert _hash(source) == source_hash + assert result["source_unchanged"] is True + assert result["before"]["orphan_group_runs"] == 1 + assert result["after"]["orphaned_group_runs"] == 1 + assert result["after"]["linked_group_runs"] == 1 + assert result["after"]["table_counts"] == result["before"]["table_counts"] + + manifest = output.with_suffix(".db.manifest.json") + payload = json.loads(manifest.read_text(encoding="utf-8")) + assert payload["output_sha256"] == _hash(output) + assert payload["migration_id"] == migrations.MIGRATION_ID + + connection = sqlite3.connect(output) + try: + connection.execute("PRAGMA foreign_keys = ON") + linked = connection.execute( + "SELECT group_id, legacy_group_id, identity_state, orphan_reason FROM group_runs WHERE id=10" + ).fetchone() + orphaned = connection.execute( + "SELECT group_id, legacy_group_id, identity_state, orphan_reason FROM group_runs WHERE id=11" + ).fetchone() + assert linked == (1, None, "linked", "") + assert orphaned == (None, 99, "orphaned", "historical_group_missing") + assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] + assert connection.execute("PRAGMA user_version").fetchone()[0] == 1 + + with pytest.raises(sqlite3.IntegrityError): + connection.execute("DELETE FROM groups WHERE id=1") + connection.rollback() + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + INSERT INTO reports( + id, group_run_id, ranking_text, prompt_text, ranking_file, + prompt_file, poster_file, poster_status, email_status, + created_at, updated_at + ) VALUES (22, 10, '', '', '', '', '', '', '', '', '') + """ + ) + finally: + connection.close() + + +def test_preflight_is_read_only_and_reports_orphans(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + before = _hash(source) + + result = migrations.preflight_database(source) + + assert result["ready"] is True + assert result["snapshot"]["user_version"] == 0 + assert result["snapshot"]["orphan_group_runs"] == 1 + assert result["snapshot"]["orphan_reports"] == 0 + assert _hash(source) == before + + +@pytest.mark.parametrize("conflict", ["same", "existing_output", "existing_manifest"]) +def test_migration_refuses_destructive_path_conflicts(tmp_path: Path, conflict: str) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + output = source if conflict == "same" else tmp_path / "migrated.db" + manifest = tmp_path / "manifest.json" + if conflict == "existing_output": + output.write_bytes(b"do not overwrite") + if conflict == "existing_manifest": + manifest.write_text("do not overwrite", encoding="utf-8") + + with pytest.raises(migrations.MigrationError): + migrations.migrate_database(source, output, manifest=manifest) + + if conflict == "existing_output": + assert output.read_bytes() == b"do not overwrite" + if conflict == "existing_manifest": + assert manifest.read_text(encoding="utf-8") == "do not overwrite" + + +def test_migration_rejects_orphan_report_without_creating_output(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + with sqlite3.connect(source) as connection: + connection.execute( + """ + INSERT INTO reports( + id, group_run_id, ranking_text, prompt_text, ranking_file, + prompt_file, poster_file, poster_status, email_status, + created_at, updated_at + ) VALUES (22, 999, '', '', '', '', '', '', '', '', '') + """ + ) + output = tmp_path / "migrated.db" + + with pytest.raises(migrations.MigrationError, match="孤儿 Report"): + migrations.migrate_database(source, output) + + assert not output.exists() + assert not output.with_suffix(".db.manifest.json").exists() + + +def test_migration_rejects_missing_parent_run(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + with sqlite3.connect(source) as connection: + connection.execute( + """ + INSERT INTO group_runs( + id, run_id, group_id, provider_used, message_count, + speaker_count, ranking_status, prompt_status, error_message + ) VALUES (12, 999, 1, '', 0, 0, 'failed', 'skipped', '') + """ + ) + + with pytest.raises(migrations.MigrationError, match="缺失父 Run"): + migrations.preflight_database(source) + + +def test_migration_rejects_an_already_migrated_source(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + migrated = tmp_path / "migrated.db" + migrations.migrate_database(source, migrated) + + with pytest.raises(migrations.MigrationError, match="已经执行"): + migrations.migrate_database(migrated, tmp_path / "second.db") + + +@pytest.mark.parametrize("suffix", ["-wal", "-shm", "-journal"]) +def test_preflight_rejects_sqlite_sidecars(tmp_path: Path, suffix: str) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + sidecar = Path(f"{source}{suffix}") + sidecar.write_bytes(b"writer evidence") + + with pytest.raises(migrations.MigrationError, match="不能视为离线源"): + migrations.preflight_database(source) + + +def test_preflight_rejects_unknown_user_version(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + with sqlite3.connect(source) as connection: + connection.execute("PRAGMA user_version = 7") + + with pytest.raises(migrations.MigrationError, match="user_version=7"): + migrations.preflight_database(source) + + +def test_preflight_rejects_unknown_columns_on_rebuilt_tables(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + with sqlite3.connect(source) as connection: + connection.execute("ALTER TABLE group_runs ADD COLUMN future_data TEXT") + + with pytest.raises(migrations.MigrationError, match="未知列"): + migrations.preflight_database(source) + + +def test_preflight_rejects_unknown_dependent_schema_objects(tmp_path: Path) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + with sqlite3.connect(source) as connection: + connection.execute( + "CREATE TRIGGER future_trigger AFTER INSERT ON reports BEGIN SELECT 1; END" + ) + + with pytest.raises(migrations.MigrationError, match="未知触发器"): + migrations.preflight_database(source) + + +def test_failed_migration_removes_only_its_temporary_files(tmp_path: Path, monkeypatch) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + output = tmp_path / "migrated.db" + source_hash = _hash(source) + + def fail_after_backup(_database: Path) -> None: + raise sqlite3.OperationalError("injected failure") + + monkeypatch.setattr(migrations, "_apply_relationship_migration", fail_after_backup) + + with pytest.raises(sqlite3.OperationalError, match="injected failure"): + migrations.migrate_database(source, output) + + assert _hash(source) == source_hash + assert not output.exists() + assert not output.with_suffix(".db.manifest.json").exists() + assert list(tmp_path.glob(".*.tmp")) == [] + + +def test_cli_requires_explicit_action_and_supports_dry_run(tmp_path: Path, capsys) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + output = tmp_path / "planned.db" + + exit_code = migrations.main( + ["--source", str(source), "--output", str(output), "--dry-run"] + ) + + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["ready"] is True + assert payload["planned_output"] == str(output.resolve()) + assert not output.exists() + + +def test_cli_dry_run_rejects_an_existing_output(tmp_path: Path, capsys) -> None: + source = _create_legacy_database(tmp_path / "legacy.db") + output = tmp_path / "existing.db" + output.write_bytes(b"keep me") + + exit_code = migrations.main( + ["--source", str(source), "--output", str(output), "--dry-run"] + ) + + assert exit_code == 2 + payload = json.loads(capsys.readouterr().err) + assert payload["ok"] is False + assert "拒绝覆盖" in payload["error"] + assert output.read_bytes() == b"keep me" From 4039596285e4e5e2ae60d990ddeacc47ac0c1866 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 24 Aug 2026 22:49:24 +0800 Subject: [PATCH 06/42] =?UTF-8?q?fix:=20=E5=AE=8C=E6=88=90=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=85=B3=E7=B3=BB=E7=BA=A6=E6=9D=9F=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/reports.py | 3 + app/api/runs.py | 15 +- app/api/system.py | 5 +- app/db/models.py | 58 +++++++- app/db/repository.py | 154 +++++++++++++++++++++ app/services/email_service.py | 5 + docs/audits/P0_2B_PRODUCTION_CUTOVER.md | 158 ++++++++++++++++++++++ tests/conftest.py | 34 ++++- tests/test_db_runtime_schema.py | 173 ++++++++++++++++++++++++ tests/test_handoff.py | 24 ++-- tests/test_v2_pipeline.py | 9 +- 11 files changed, 616 insertions(+), 22 deletions(-) create mode 100644 docs/audits/P0_2B_PRODUCTION_CUTOVER.md create mode 100644 tests/test_db_runtime_schema.py diff --git a/app/api/reports.py b/app/api/reports.py index 8d17665..2a512bc 100644 --- a/app/api/reports.py +++ b/app/api/reports.py @@ -65,6 +65,9 @@ def latest(session: Session = Depends(repo.get_session)): "id": r.id, "group_run_id": r.group_run_id, "group_id": group_run.group_id if group_run else None, + "legacy_group_id": group_run.legacy_group_id if group_run else None, + "identity_state": group_run.identity_state if group_run else "unresolved", + "orphan_reason": group_run.orphan_reason if group_run else "group_run_missing", "ranking_text": r.ranking_text, "prompt_text": r.prompt_text, "ranking_file": r.ranking_file, diff --git a/app/api/runs.py b/app/api/runs.py index ca71931..13efc56 100644 --- a/app/api/runs.py +++ b/app/api/runs.py @@ -27,14 +27,21 @@ def run_detail(run_id: int, session: Session = Depends(repo.get_session)): ).all() details = [] for gr in group_runs: - group = session.get(Group, gr.group_id) + group = session.get(Group, gr.group_id) if gr.group_id is not None else None + if group: + group_name = group.display_name or group.wechat_group_name + elif gr.identity_state == "orphaned": + group_name = f"历史群(旧 ID {gr.legacy_group_id})" + else: + group_name = "未知群" details.append( { "id": gr.id, "group_id": gr.group_id, - "group_name": (group.display_name or group.wechat_group_name) - if group - else f"群 {gr.group_id}", + "legacy_group_id": gr.legacy_group_id, + "identity_state": gr.identity_state, + "orphan_reason": gr.orphan_reason, + "group_name": group_name, "provider_used": gr.provider_used, "message_count": gr.message_count, "speaker_count": gr.speaker_count, diff --git a/app/api/system.py b/app/api/system.py index fdffefb..3fa6904 100644 --- a/app/api/system.py +++ b/app/api/system.py @@ -65,7 +65,10 @@ def stats(session: Session = Depends(repo.get_session)): "run_id": None, } rows = session.exec( - select(GroupRun).where(GroupRun.run_id == latest.id) + select(GroupRun).where( + GroupRun.run_id == latest.id, + GroupRun.identity_state == "linked", + ) ).all() return { "total_messages": sum(r.message_count for r in rows), diff --git a/app/db/models.py b/app/db/models.py index 4533983..380c837 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -4,11 +4,20 @@ from datetime import datetime +from sqlalchemy import CheckConstraint, Index, text from sqlmodel import Field, SQLModel class Group(SQLModel, table=True): __tablename__ = "groups" + __table_args__ = ( + Index( + "uq_groups_wechat_group_id_active", + "wechat_group_id", + unique=True, + sqlite_where=text("TRIM(wechat_group_id) <> '' AND deleted_at IS NULL"), + ), + ) id: int | None = Field(default=None, primary_key=True) display_name: str = Field(default="", max_length=128) @@ -37,6 +46,9 @@ class Group(SQLModel, table=True): class Run(SQLModel, table=True): __tablename__ = "runs" + __table_args__ = ( + Index("ix_runs_report_date_status", "report_date", "status"), + ) id: int | None = Field(default=None, primary_key=True) report_date: str = "" # YYYY-MM-DD(报告归属日) @@ -51,10 +63,38 @@ class Run(SQLModel, table=True): class GroupRun(SQLModel, table=True): __tablename__ = "group_runs" + __table_args__ = ( + CheckConstraint( + """ + ( + identity_state = 'linked' + AND group_id IS NOT NULL + AND legacy_group_id IS NULL + AND orphan_reason = '' + ) + OR + ( + identity_state = 'orphaned' + AND group_id IS NULL + AND legacy_group_id IS NOT NULL + AND orphan_reason = 'historical_group_missing' + ) + """, + name="ck_group_runs_identity", + ), + ) id: int | None = Field(default=None, primary_key=True) - run_id: int = Field(index=True) - group_id: int = Field(index=True) + run_id: int = Field(foreign_key="runs.id", ondelete="RESTRICT", index=True) + group_id: int | None = Field( + default=None, + foreign_key="groups.id", + ondelete="RESTRICT", + index=True, + ) + legacy_group_id: int | None = None + identity_state: str = "linked" # linked / orphaned + orphan_reason: str = "" provider_used: str = "" message_count: int = 0 speaker_count: int = 0 @@ -67,7 +107,12 @@ class Report(SQLModel, table=True): __tablename__ = "reports" id: int | None = Field(default=None, primary_key=True) - group_run_id: int = Field(index=True) + group_run_id: int = Field( + foreign_key="group_runs.id", + ondelete="RESTRICT", + index=True, + unique=True, + ) ranking_text: str = "" prompt_text: str = "" ranking_file: str = "" @@ -101,7 +146,12 @@ class ExecutionLog(SQLModel, table=True): __tablename__ = "execution_logs" id: int | None = Field(default=None, primary_key=True) - run_id: int | None = None + run_id: int | None = Field( + default=None, + foreign_key="runs.id", + ondelete="RESTRICT", + index=True, + ) level: str = "info" # info / warning / error message: str = "" created_at: datetime = Field(default_factory=datetime.now) diff --git a/app/db/repository.py b/app/db/repository.py index 1a18f93..26b89b2 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -2,16 +2,168 @@ from __future__ import annotations +from datetime import datetime, timezone from pathlib import Path from typing import Any +from sqlalchemy import event from sqlmodel import Session, SQLModel, create_engine, select from app.config.settings import Settings +from app.db.offline_migrations import MIGRATION_CHECKSUM, MIGRATION_ID, TARGET_USER_VERSION from app.db.models import Group, Report, Run, Setting engine: Any = None +_RELATIONSHIP_COLUMNS = {"legacy_group_id", "identity_state", "orphan_reason"} +_EXPECTED_FOREIGN_KEYS = { + ("group_runs", "run_id", "runs", "id", "RESTRICT"), + ("group_runs", "group_id", "groups", "id", "RESTRICT"), + ("reports", "group_run_id", "group_runs", "id", "RESTRICT"), + ("execution_logs", "run_id", "runs", "id", "RESTRICT"), +} + + +class DatabaseSchemaError(RuntimeError): + """正式数据库尚未迁移或关系 Schema 与当前代码不一致。""" + + +def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None: + """SQLite 默认按连接关闭外键;每个运行时连接都必须显式开启。""" + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys = ON") + finally: + cursor.close() + + +def _schema_error(detail: str) -> DatabaseSchemaError: + return DatabaseSchemaError( + f"数据库 Schema 与当前 GroupBrief 不兼容:{detail}。" + "请先停止所有写入者,再使用 scripts/migrate_db.py 执行 P0.2B 离线迁移。" + ) + + +def _ensure_relationship_schema_current() -> None: + """接受 Fresh/已迁移数据库,拒绝旧非空或伪迁移数据库。""" + with engine.begin() as connection: + foreign_keys_enabled = int(connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one()) + if foreign_keys_enabled != 1: + raise _schema_error("当前 SQLite 连接没有启用 foreign_keys") + + columns = { + str(row[1]) + for row in connection.exec_driver_sql("PRAGMA table_info(group_runs)") + } + missing_columns = sorted(_RELATIONSHIP_COLUMNS - columns) + if missing_columns: + raise _schema_error("group_runs 缺少列 " + ", ".join(missing_columns)) + + tables = { + str(row[0]) + for row in connection.exec_driver_sql( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + has_migration_table = "schema_migrations" in tables + user_version = int(connection.exec_driver_sql("PRAGMA user_version").scalar_one()) + + if not has_migration_table: + core_rows = sum( + int(connection.exec_driver_sql(f'SELECT COUNT(*) FROM "{table}"').scalar_one()) + for table in ("groups", "runs", "group_runs", "reports", "execution_logs") + ) + if core_rows: + raise _schema_error("非空数据库缺少 schema_migrations 记录") + connection.exec_driver_sql( + """ + CREATE TABLE schema_migrations ( + migration_id TEXT NOT NULL PRIMARY KEY, + applied_at TEXT NOT NULL, + checksum TEXT NOT NULL + ) + """ + ) + connection.exec_driver_sql( + "INSERT INTO schema_migrations(migration_id, applied_at, checksum) VALUES (?, ?, ?)", + (MIGRATION_ID, datetime.now(timezone.utc).isoformat(), MIGRATION_CHECKSUM), + ) + connection.exec_driver_sql(f"PRAGMA user_version = {TARGET_USER_VERSION}") + user_version = TARGET_USER_VERSION + + migration = connection.exec_driver_sql( + "SELECT checksum FROM schema_migrations WHERE migration_id=?", + (MIGRATION_ID,), + ).first() + if migration is None: + raise _schema_error(f"缺少迁移记录 {MIGRATION_ID}") + if str(migration[0]) != MIGRATION_CHECKSUM: + raise _schema_error(f"迁移 {MIGRATION_ID} checksum 不一致") + if user_version != TARGET_USER_VERSION: + raise _schema_error( + f"user_version={user_version},预期 {TARGET_USER_VERSION}" + ) + + actual_foreign_keys = { + (table, str(row[3]), str(row[2]), str(row[4]), str(row[6])) + for table in ("group_runs", "reports", "execution_logs") + for row in connection.exec_driver_sql(f'PRAGMA foreign_key_list("{table}")') + } + if actual_foreign_keys != _EXPECTED_FOREIGN_KEYS: + raise _schema_error("外键结构或删除策略不符合 P0.2B 目标") + + report_unique = False + for index_row in connection.exec_driver_sql("PRAGMA index_list(reports)"): + if not bool(index_row[2]): + continue + index_name = str(index_row[1]).replace('"', '""') + index_columns = [ + str(row[2]) + for row in connection.exec_driver_sql(f'PRAGMA index_info("{index_name}")') + ] + if index_columns == ["group_run_id"]: + report_unique = True + break + if not report_unique: + raise _schema_error("reports.group_run_id 缺少唯一约束") + + required_indexes = { + ("groups", "uq_groups_wechat_group_id_active"): (True, ["wechat_group_id"]), + ("runs", "ix_runs_report_date_status"): (False, ["report_date", "status"]), + ("group_runs", "ix_group_runs_run_id"): (False, ["run_id"]), + ("group_runs", "ix_group_runs_group_id"): (False, ["group_id"]), + ("execution_logs", "ix_execution_logs_run_id"): (False, ["run_id"]), + } + for (table, index_name), (expected_unique, expected_columns) in required_indexes.items(): + index_row = next( + ( + row + for row in connection.exec_driver_sql(f'PRAGMA index_list("{table}")') + if str(row[1]) == index_name + ), + None, + ) + if index_row is None or bool(index_row[2]) != expected_unique: + raise _schema_error(f"缺少索引 {index_name} 或唯一性不一致") + escaped_name = index_name.replace('"', '""') + actual_columns = [ + str(row[2]) + for row in connection.exec_driver_sql( + f'PRAGMA index_info("{escaped_name}")' + ) + ] + if actual_columns != expected_columns: + raise _schema_error(f"索引 {index_name} 列定义不一致") + + active_group_index_sql = connection.exec_driver_sql( + "SELECT sql FROM sqlite_master WHERE type='index' AND name=?", + ("uq_groups_wechat_group_id_active",), + ).scalar_one_or_none() + normalized_index_sql = " ".join(str(active_group_index_sql or "").lower().split()) + expected_predicate = "where trim(wechat_group_id) <> '' and deleted_at is null" + if expected_predicate not in normalized_index_sql: + raise _schema_error("uq_groups_wechat_group_id_active 条件定义不一致") + # V2 群配置扩展列(幂等迁移:仅在列不存在时 ALTER TABLE ADD COLUMN) _V2_GROUP_COLUMNS: dict[str, str] = { @@ -193,7 +345,9 @@ def init_db(settings: Settings) -> Any: f"sqlite:///{db_path}", connect_args={"check_same_thread": False}, ) + event.listen(engine, "connect", _enable_sqlite_foreign_keys) SQLModel.metadata.create_all(engine) + _ensure_relationship_schema_current() _migrate_group_v2_columns() _seed_defaults(settings) # 先种默认值再迁移,确保旧 .env 中的 deepseek-chat / 12000 也会升级。 diff --git a/app/services/email_service.py b/app/services/email_service.py index 0064635..0f6ebde 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -68,6 +68,11 @@ def build_email(self, session: Session, run: Run | None = None) -> EmailBuildRes group_runs = session.exec(select(GroupRun).where(GroupRun.run_id == run.id)).all() for gr in group_runs: + if gr.identity_state != "linked" or gr.group_id is None: + missing.append( + f"历史群(旧 ID {gr.legacy_group_id}):关联已归档,不发送" + ) + continue if gr.ranking_status != "success": missing.append(f"群 {gr.group_id}:排行榜未生成({gr.ranking_status})") continue diff --git a/docs/audits/P0_2B_PRODUCTION_CUTOVER.md b/docs/audits/P0_2B_PRODUCTION_CUTOVER.md new file mode 100644 index 0000000..373426c --- /dev/null +++ b/docs/audits/P0_2B_PRODUCTION_CUTOVER.md @@ -0,0 +1,158 @@ +# GroupBrief P0.2B-2 正式数据库关系切换 + +> 执行时间:2026-08-24(Asia/Shanghai) +> +> 模式:SURGICAL +> +> 范围:ORM/API、连接级外键、测试隔离、正式 SQLite 离线迁移与切换 + +## 1. 结论 + +P0.2B-2 已完成正式切换。正式服务当前使用 P0.2B Schema,8766 已由原 Alter 登记进程恢复运行,Windows 计划任务已恢复原启用状态。 + +迁移没有猜测历史群映射,也没有丢弃历史记录: + +```text +groups 7 → 7 +runs 68 → 68 +group_runs 224 → 224 +reports 214 → 214 +linked - → 32 +orphaned 192 → 192 +``` + +192 条已经失去现存群关系的历史 GroupRun 现在使用 `identity_state=orphaned`、`group_id=NULL` 和 `legacy_group_id=<旧 ID>` 明确表达。32 条仍能关联当前群的记录保持 `linked`。 + +## 2. 正式切换证据 + +旧正式库在所有写入者停止后保持稳定: + +```text +旧库路径 data/groupbrief.db +旧库 SHA256 a677eaa7e2654a1aee07f6a5e8712a1e73c228b3db0d1746ffd887fb3324bbd4 +integrity ok +user_version 0 +``` + +正式迁移输出: + +```text +迁移 Manifest data/backups/groupbrief-p0-2b2-candidate-20260824-224331.json +新库 SHA256 6de4c332cf51b4313aeb52c5cdd4dd624bdeda656e78455133860445bef35f6a +integrity ok +FK violations 0 +user_version 1 +migration_id p0_2b_group_run_identity_v1 +checksum c14ecdc63408fa30cbfe02803098ddf9ffb2ce25fa3440d758f5e3a60accbb24 +``` + +旧库已移动到精确回滚路径: + +```text +data/backups/groupbrief-p0-2b2-original-20260824-224331.db +SHA256 a677eaa7e2654a1aee07f6a5e8712a1e73c228b3db0d1746ffd887fb3324bbd4 +``` + +上述数据库与 Manifest 位于被 Git 忽略的 `data/backups/`,不会进入仓库。 + +## 3. 运行时改动 + +- ORM 增加 GroupRun 身份状态、历史群 ID、外键和一致性 `CHECK`。 +- Report 与 GroupRun 建立 `RESTRICT` 外键及一对一唯一约束。 +- ExecutionLog 与 Run 建立 `RESTRICT` 外键。 +- 活动群 `wechat_group_id` 使用条件唯一索引;Run 增加日期/状态复合索引。 +- SQLite 每个 SQLAlchemy 连接都执行 `PRAGMA foreign_keys=ON`。 +- 启动时校验迁移记录、checksum、`user_version`、外键和关键索引;旧非空 Schema 会 fail closed,不会自动原地迁移。 +- Run/Report API 显式返回历史孤儿身份;统计和邮件只使用 `linked` 记录。 + +## 4. 停写与原子替换 + +切换窗口内执行了以下保护: + +1. 确认原 Alter 进程已停止、8766/8767 无监听。 +2. 禁用 `GroupBriefDaily` 与 `GroupBriefDailySend`。 +3. 确认没有 Uvicorn、日报生成或发送 Python 写入进程。 +4. 确认没有 `groupbrief.db-wal`、`-shm`、`-journal`。 +5. 对正式源库执行 dry-run 和 apply,迁移前后源 SHA256 相同。 +6. 候选库先在 8767 启动并验证六个只读接口。 +7. 同盘移动旧库到回滚路径,再移动候选库到正式路径;第二步失败时会立即移回旧库。 +8. 启动 8766、验证数据库与接口后,恢复两个计划任务。 + +## 5. 启动后验收 + +正式库: + +```text +integrity_check ok +foreign_key_check rows 0 +connection foreign_keys 1 +user_version 1 +groups/runs/gr/reports 7 / 68 / 224 / 214 +linked/orphaned 32 / 192 +``` + +只读 HTTP 验收: + +```text +/ 200 +/api/system/health 200 +/api/system/status 200 +/api/system/stats 200 +/api/runs 200 +/api/reports/latest 200 +``` + +运行状态: + +```text +Alter process ID 94507bc8-4b0e-4f37-89a2-1bffedb15fd3 +Alter status running +8766 listener python/uvicorn child of the registered Alter process +GroupBriefDaily enabled, Ready, next 2026-08-25 00:15 +GroupBriefDailySend enabled, Ready, next 2026-08-25 08:30 +``` + +没有执行真实 AI、微信或邮件发送。 + +## 6. 测试证据 + +主控与独立 Operator 最终结果一致: + +```text +完整测试 458 passed, 1 warning +定向测试 80 passed, 1 warning +git diff check passed +8767 shadow six read-only endpoints HTTP 200 +8766 formal six read-only endpoints HTTP 200 +``` + +唯一 warning 是现有 Starlette `TestClient` 对 `httpx` 调用方式的弃用提示,与数据库切换无关。 + +测试数据库改为每个 pytest 进程独占的临时 SQLite 文件,避免并行测试、失败重跑和固定测试库残留互相污染。旧测试中依赖“无外键时可直接删父记录”的清场逻辑也已按依赖顺序修正。 + +## 7. 运行异常与处置 + +首次恢复服务时误用了 `alter start GroupBrief-Backend`。当前 Alter 版本把该参数解释为新脚本,因此短暂创建了一个同名空壳登记项,但它没有监听 8766,也没有访问正式数据库。 + +该空壳已按精确 ID 删除,并确认不存在。随后使用原登记 ID 执行 `alter restart`,原进程成功恢复;其他 Alter 项目没有被修改。 + +## 8. 回滚边界 + +P0.2B-2 的新代码会主动拒绝旧 Schema,因此回滚必须成对执行,不能只换回数据库: + +1. 停止原 Alter 进程并禁用两个 GroupBrief 计划任务。 +2. 确认无 8766/8767 监听、无 Python 写入者、无 SQLite sidecar。 +3. 将代码恢复到本轮父提交 `df8966f58305a54b641e3301f648fdf445eba9eb`。 +4. 将当前正式库移到新的故障留存路径。 +5. 将 `groupbrief-p0-2b2-original-20260824-224331.db` 恢复为 `data/groupbrief.db`。 +6. 启动并验证旧版本,再恢复计划任务。 + +不要在新代码下直接恢复旧库;Schema guard 会按设计拒绝启动。 + +## 9. 本轮未处理 + +- 不猜测 192 条历史孤儿对应哪个现存群。 +- 不重写历史 Run 的 success/running 状态。 +- 不添加 `UNIQUE(run_id, group_id)`,避免误伤现有强制重跑语义。 +- 不重构 V1/V2 Pipeline、Provider 或调度架构。 +- 不触发真实生成、发送或第三方服务。 diff --git a/tests/conftest.py b/tests/conftest.py index 33fd9de..194fd79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,16 @@ """ import os +import tempfile +import uuid +from pathlib import Path -os.environ["DATABASE_URL"] = "sqlite:///data/test_groupbrief.db" +_TEST_DB_PATH = ( + Path(tempfile.gettempdir()) + / f"groupbrief-pytest-{os.getpid()}-{uuid.uuid4().hex}.db" +) + +os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_DB_PATH.as_posix()}" os.environ["GROUPBRIEF_NO_SCHEDULER"] = "1" # 测试不读取真实微信联系人库(避免本机 APPDATA 下的 contact.db 影响断言) os.environ["GROUPBRIEF_NO_CONTACT_DB"] = "1" @@ -21,3 +29,27 @@ os.environ["AI_API_KEY"] = "" # 默认集成测试强制使用无需外部调用的旧兼容分支;Codex 主备路由由专门单测覆盖。 os.environ["SUMMARY_PROVIDER_PRIMARY"] = "deepseek" + + +def pytest_sessionfinish(session, exitstatus) -> None: + """释放并删除本次 pytest 独占的临时 SQLite 文件。""" + del session, exitstatus + try: + from app.db import repository as repo + + if repo.engine is not None: + repo.engine.dispose() + except Exception: + # 测试收尾不能覆盖更早、更有价值的失败信息。 + pass + + for candidate in ( + _TEST_DB_PATH, + Path(f"{_TEST_DB_PATH}-wal"), + Path(f"{_TEST_DB_PATH}-shm"), + Path(f"{_TEST_DB_PATH}-journal"), + ): + try: + candidate.unlink(missing_ok=True) + except OSError: + pass diff --git a/tests/test_db_runtime_schema.py b/tests/test_db_runtime_schema.py new file mode 100644 index 0000000..cb67f9d --- /dev/null +++ b/tests/test_db_runtime_schema.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session + +from app.api import reports as reports_api +from app.api import runs as runs_api +from app.api import system as system_api +from app.config.settings import Settings +from app.db import repository as repo +from app.db.models import Group, GroupRun, Report, Run +from app.db.offline_migrations import MIGRATION_CHECKSUM, MIGRATION_ID +from app.services.email_service import EmailService + + +@pytest.fixture(autouse=True) +def _restore_repository_engine(): + """本文件会切换全局引擎;每条用例结束后恢复会话测试库。""" + original_engine = repo.engine + yield + current_engine = repo.engine + if current_engine is not None and current_engine is not original_engine: + current_engine.dispose() + repo.engine = original_engine + + +def _settings(path: Path) -> Settings: + return Settings(database_url=f"sqlite:///{path.as_posix()}", _env_file=None) + + +def test_fresh_database_gets_current_schema_and_enforces_foreign_keys(tmp_path: Path) -> None: + database = tmp_path / "fresh.db" + engine = repo.init_db(_settings(database)) + try: + with engine.connect() as connection: + assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 1 + assert connection.exec_driver_sql("PRAGMA user_version").scalar_one() == 1 + migration = connection.exec_driver_sql( + "SELECT checksum FROM schema_migrations WHERE migration_id=?", + (MIGRATION_ID,), + ).one() + assert migration[0] == MIGRATION_CHECKSUM + columns = { + row[1] for row in connection.exec_driver_sql("PRAGMA table_info(group_runs)") + } + assert {"legacy_group_id", "identity_state", "orphan_reason"}.issubset(columns) + group_indexes = { + row[1]: bool(row[2]) + for row in connection.exec_driver_sql("PRAGMA index_list(groups)") + } + assert group_indexes["uq_groups_wechat_group_id_active"] is True + run_indexes = { + row[1] for row in connection.exec_driver_sql("PRAGMA index_list(runs)") + } + assert "ix_runs_report_date_status" in run_indexes + + with Session(engine) as session: + group = Group(display_name="活动群", wechat_group_id="active@chatroom") + run = Run(report_date="2026-08-24", status="success") + session.add(group) + session.add(run) + session.commit() + session.refresh(group) + session.refresh(run) + group_run = GroupRun(run_id=run.id, group_id=group.id) + session.add(group_run) + session.commit() + session.refresh(group_run) + session.add(Report(group_run_id=group_run.id, ranking_text="排行榜")) + session.commit() + + session.delete(group) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + # 重复初始化当前 Schema 必须是只读兼容检查,而不是重复迁移。 + second_engine = repo.init_db(_settings(database)) + second_engine.dispose() + finally: + engine.dispose() + + +def test_legacy_database_fails_closed_with_migration_guidance(tmp_path: Path) -> None: + database = tmp_path / "legacy.db" + with sqlite3.connect(database) as connection: + connection.execute( + """ + CREATE TABLE group_runs ( + id INTEGER PRIMARY KEY, + run_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + provider_used VARCHAR NOT NULL, + message_count INTEGER NOT NULL, + speaker_count INTEGER NOT NULL, + ranking_status VARCHAR NOT NULL, + prompt_status VARCHAR NOT NULL, + error_message VARCHAR NOT NULL + ) + """ + ) + + with pytest.raises(repo.DatabaseSchemaError, match="scripts/migrate_db.py"): + repo.init_db(_settings(database)) + + +def test_orphaned_history_is_visible_but_excluded_from_active_stats_and_email( + tmp_path: Path, +) -> None: + engine = repo.init_db(_settings(tmp_path / "orphaned.db")) + try: + with Session(engine) as session: + group = Group(display_name="当前群", wechat_group_id="current@chatroom") + run = Run(report_date="2026-08-24", status="success") + session.add(group) + session.add(run) + session.commit() + session.refresh(group) + session.refresh(run) + + linked = GroupRun( + run_id=run.id, + group_id=group.id, + message_count=10, + speaker_count=3, + ranking_status="success", + prompt_status="success", + ) + orphaned = GroupRun( + run_id=run.id, + group_id=None, + legacy_group_id=77, + identity_state="orphaned", + orphan_reason="historical_group_missing", + message_count=90, + speaker_count=30, + ranking_status="success", + prompt_status="success", + ) + session.add(linked) + session.add(orphaned) + session.commit() + session.refresh(linked) + session.refresh(orphaned) + session.add(Report(group_run_id=linked.id, ranking_text="当前群排行榜")) + session.add(Report(group_run_id=orphaned.id, ranking_text="历史群排行榜")) + session.commit() + + detail = runs_api.run_detail(run.id, session) + by_state = {row["identity_state"]: row for row in detail["group_runs"]} + assert by_state["orphaned"]["group_id"] is None + assert by_state["orphaned"]["legacy_group_id"] == 77 + assert by_state["orphaned"]["group_name"] == "历史群(旧 ID 77)" + assert "None" not in by_state["orphaned"]["group_name"] + + latest = reports_api.latest(session) + orphan_report = next(row for row in latest if row["identity_state"] == "orphaned") + assert orphan_report["group_id"] is None + assert orphan_report["legacy_group_id"] == 77 + + stats = system_api.stats(session) + assert stats["total_messages"] == 10 + assert stats["total_speakers"] == 3 + + email = EmailService(_settings(tmp_path / "unused.db")).build_email(session, run) + assert [block.group_name for block in email.blocks] == ["当前群"] + assert any("旧 ID 77" in item and "不发送" in item for item in email.missing) + finally: + engine.dispose() diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 9ad9fba..8564f1c 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -17,6 +17,20 @@ repo.init_db(settings) +def _get_or_create_group(session: Session, display_name: str, wechat_group_id: str) -> Group: + group = repo.find_group_by_wechat_id( + session, + wechat_group_id, + include_deleted=False, + ) + if group is not None: + return group + return repo.save_group( + session, + Group(display_name=display_name, wechat_group_id=wechat_group_id), + ) + + def test_safe_dir_name(): assert safe_dir_name("示例UED-4群") == "示例UED-4群" assert ":" not in safe_dir_name("a:b/c*d?e") @@ -28,10 +42,7 @@ def test_safe_dir_name(): def test_generate_writes_files(): with Session(repo.engine) as session: - group = repo.save_group( - session, - Group(display_name="示例UED-4群", wechat_group_id="group-a"), - ) + group = _get_or_create_group(session, "示例UED-4群", "group-a") service = ReportService() run = service.generate(session, group=group, report_date="2026-08-13", force=True) assert run.status == "success" @@ -77,10 +88,7 @@ def test_generate_writes_files(): def test_two_groups_isolated(): with Session(repo.engine) as session: - repo.save_group( - session, - Group(display_name="产品经理交流群", wechat_group_id="group-b"), - ) + _get_or_create_group(session, "产品经理交流群", "group-b") service = ReportService() run = service.generate(session, report_date="2026-08-13", trigger_type="auto", force=True) assert run.status == "success" diff --git a/tests/test_v2_pipeline.py b/tests/test_v2_pipeline.py index 93f6883..8ac1df6 100644 --- a/tests/test_v2_pipeline.py +++ b/tests/test_v2_pipeline.py @@ -24,7 +24,7 @@ from sqlmodel import Session from app.db import repository as repo -from app.db.models import Group +from app.db.models import Group, GroupRun, Report from app.pipeline.daily_pipeline import DailyPipeline from app.v2.constants import ( FAILED, @@ -40,9 +40,10 @@ def _clear_groups() -> None: with Session(repo.engine) as session: - # 测试数据库需要真正清空;产品 delete_group 已改为保留历史的软删除。 - for g in repo.list_groups(session, include_deleted=True): - session.delete(g) + # 测试数据库需要真正清空;先按外键依赖顺序删除历史关系。 + session.exec(Report.__table__.delete()) + session.exec(GroupRun.__table__.delete()) + session.exec(Group.__table__.delete()) session.commit() From 05b1f643274bfbbb9c6f57866da8b6263e8597c4 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 25 Aug 2026 08:48:13 +0800 Subject: [PATCH 07/42] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Codex=20?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E7=BB=93=E6=9E=9C=E8=AE=A4=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/image/codex_generator.py | 18 ++++---- app/pipeline/daily_pipeline.py | 9 +++- tests/test_v2_image_task.py | 80 +++++++++++++++++++++++++++++++++- tests/test_v2_pipeline.py | 37 ++++++++++++++++ 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/app/image/codex_generator.py b/app/image/codex_generator.py index 3e68e4e..2cdeb96 100644 --- a/app/image/codex_generator.py +++ b/app/image/codex_generator.py @@ -338,7 +338,7 @@ def _generate_locked(self, prompt_file: Path, output_path: Path) -> ImageTaskRes command, timeout=self.timeout, cwd=str(task_dir), - input=self._attempt_prompt(prompt_text, Path(attempt["staging_path"])), + input=self._attempt_prompt(prompt_text), env=environment, on_start=lambda pid, record=attempt: self._record_attempt_pid( manifest_path, record, pid @@ -483,12 +483,13 @@ def _safe_attempt_number(value: object) -> int: return 1 @staticmethod - def _attempt_prompt(prompt_text: str, staging_path: Path) -> str: + def _attempt_prompt(prompt_text: str) -> str: return ( "$imagegen " + prompt_text + "\n\n" - "只为本次任务生成一张最终图片。不要读取、引用或复用任何已有图片。" - f"完成后把最终图片复制到这个精确路径:{staging_path.resolve()}。" - "最终回复必须严格符合 output schema,并在 image_path 中返回该最终图片路径。" + "只为本次任务调用一次 ImageGen,并只生成、选择一张最终图片。" + "不要读取、引用或复用任何已有图片,也不要复制或另存生成结果。" + "最终回复必须严格符合 output schema,并在 image_path 中返回 " + "ImageGen 产生的这张最终图片当前存在的绝对路径。" ) def _new_attempt(self, task_dir: Path, attempt_number: int, history: list[dict]) -> dict: @@ -579,7 +580,8 @@ def _is_new_candidate(self, path: Path, task_dir: Path, before: dict) -> bool: or int(old.get("size") or -1) != stat.st_size ) - def _structured_result_path(self, result_path: Path, task_dir: Path) -> Path | None: + @staticmethod + def _structured_result_path(result_path: Path) -> Path | None: try: parsed = json.loads(result_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -591,7 +593,7 @@ def _structured_result_path(self, result_path: Path, task_dir: Path) -> Path | N return None candidate = Path(raw.strip()).expanduser() if not candidate.is_absolute(): - candidate = task_dir / candidate + return None return candidate @staticmethod @@ -609,7 +611,7 @@ def _candidate_records(self, attempt: dict, task_dir: Path) -> list[tuple[Path, paths: list[tuple[Path, str]] = [] if self._is_new_candidate(staging_path, task_dir, before): paths.append((staging_path, "staging")) - structured = self._structured_result_path(result_path, task_dir) + structured = self._structured_result_path(result_path) if structured is not None and self._is_new_candidate(structured, task_dir, before): paths.append((structured, "structured")) current = self._snapshot(task_dir) diff --git a/app/pipeline/daily_pipeline.py b/app/pipeline/daily_pipeline.py index 4309c77..8201dd4 100644 --- a/app/pipeline/daily_pipeline.py +++ b/app/pipeline/daily_pipeline.py @@ -600,6 +600,11 @@ def _image_hook(self, job: ImageJob, result: dict) -> None: # 每群生图完成后更新 run.json(不在此处判断 need_image) status = IMAGE_READY if result["success"] else FAILED error_type = result.get("error_type") or IMAGE_GENERATION_FAILED + error_detail = ( + str(result.get("detail") or "图片生成失败")[:300] + if not result["success"] + else None + ) current = self.store.load_run(job.group_name, job.output_path.parent.name) stage_timings = dict(current.get("stage_timings") or {}) imagegen_ms = int(result.get("imagegen_ms") or 0) @@ -611,7 +616,9 @@ def _image_hook(self, job: ImageJob, result: dict) -> None: self.store.update( job.group_name, job.output_path.parent.name, status=status, - image_error=result.get("detail") if not result["success"] else None, + failed_stage="image" if not result["success"] else None, + error=error_detail, + image_error=error_detail, image_status=result["status"], error_type=error_type if not result["success"] else None, stage_timings=stage_timings, diff --git a/tests/test_v2_image_task.py b/tests/test_v2_image_task.py index eee2f39..06fa063 100644 --- a/tests/test_v2_image_task.py +++ b/tests/test_v2_image_task.py @@ -232,8 +232,11 @@ def fake_run(command, **kwargs): captured["command"] = command captured["input"] = kwargs["input"] staging, result_path = _attempt_paths(command) - staging.write_bytes(_PNG_1PX) - result_path.write_text(json.dumps({"image_path": str(staging)}), encoding="utf-8") + generated = tmp_path / "generated_images" / "execution-1" / "final.png" + generated.parent.mkdir() + generated.write_bytes(_PNG_1PX) + result_path.write_text(json.dumps({"image_path": str(generated.resolve())}), encoding="utf-8") + assert not staging.exists() return _Proc() monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run) @@ -246,6 +249,9 @@ def fake_run(command, **kwargs): assert captured["command"][-1] == "-" assert "包含引号" not in " ".join(captured["command"]) assert "包含引号" in captured["input"] + assert "复制到这个精确路径" not in captured["input"] + assert "不要复制或另存" in captured["input"] + assert "绝对路径" in captured["input"] assert result.detail["attempt_count"] == 1 assert result.detail["recovery_status"] == "completed" @@ -293,6 +299,49 @@ def fake_run(command, **kwargs): assert result.detail["candidate_diagnostics"] == [] +def test_codex_rejects_relative_structured_path(tmp_path, monkeypatch): + generator, prompt = _codex_test_generator(tmp_path, monkeypatch) + calls = 0 + + def fake_run(command, **kwargs): + nonlocal calls + calls += 1 + _, result_path = _attempt_paths(command) + result_path.write_text(json.dumps({"image_path": "final.png"}), encoding="utf-8") + return _Proc() + + monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run) + result = generator.generate(prompt, prompt.parent / "daily_image.png") + + assert result.success is False + assert calls == 2 + assert result.detail["candidate_diagnostics"] == [] + + +@pytest.mark.parametrize("create_before", [False, True], ids=["missing", "stale"]) +def test_codex_rejects_missing_or_stale_structured_path(tmp_path, monkeypatch, create_before): + generator, prompt = _codex_test_generator(tmp_path, monkeypatch) + candidate = tmp_path / "generated_images" / "execution-old" / "final.png" + candidate.parent.mkdir() + if create_before: + candidate.write_bytes(_PNG_1PX) + calls = 0 + + def fake_run(command, **kwargs): + nonlocal calls + calls += 1 + _, result_path = _attempt_paths(command) + result_path.write_text(json.dumps({"image_path": str(candidate.resolve())}), encoding="utf-8") + return _Proc() + + monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run) + result = generator.generate(prompt, prompt.parent / "daily_image.png") + + assert result.success is False + assert calls == 2 + assert result.detail["candidate_diagnostics"] == [] + + def test_codex_does_not_guess_between_different_images(tmp_path, monkeypatch): generator, prompt = _codex_test_generator(tmp_path, monkeypatch) calls = 0 @@ -315,6 +364,33 @@ def fake_run(command, **kwargs): assert len(result.detail["candidate_diagnostics"]) == 2 +def test_codex_structured_path_selects_one_of_multiple_candidates(tmp_path, monkeypatch): + generator, prompt = _codex_test_generator(tmp_path, monkeypatch) + + def fake_run(command, **kwargs): + _, result_path = _attempt_paths(command) + execution = tmp_path / "generated_images" / "execution-1" + execution.mkdir() + selected = execution / "selected.png" + selected.write_bytes(_PNG_1PX) + (execution / "alternate.png").write_bytes(_PNG_1PX + b"different") + result_path.write_text(json.dumps({"image_path": str(selected.resolve())}), encoding="utf-8") + return _Proc() + + monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run) + output = prompt.parent / "daily_image.png" + result = generator.generate(prompt, output) + + assert result.success is True + assert len(result.detail["candidate_diagnostics"]) == 2 + structured = next( + item + for item in result.detail["candidate_diagnostics"] + if "structured" in item["sources"] + ) + assert structured["relative_path"] == "execution-1/selected.png" + + def test_codex_deduplicates_staging_and_generated_copy_by_hash(tmp_path, monkeypatch): generator, prompt = _codex_test_generator(tmp_path, monkeypatch) diff --git a/tests/test_v2_pipeline.py b/tests/test_v2_pipeline.py index 8ac1df6..206aeee 100644 --- a/tests/test_v2_pipeline.py +++ b/tests/test_v2_pipeline.py @@ -490,6 +490,43 @@ def test_force_generate_image_failure_returns_failed_state(tmp_path): assert result["error_type"] == IMAGE_GENERATION_FAILED run = pipeline.store.load_run("测试群", "2026-08-18") assert run["status"] == FAILED + assert run["failed_stage"] == "image" + assert run["error"] == "生图失败" + assert run["image_error"] == "生图失败" + + +def test_image_success_clears_stale_failure_fields(tmp_path): + pipeline, group = _make_pipeline(tmp_path) + pipeline.generate_all(run_date="2026-08-18") + pipeline.store.update( + "测试群", + "2026-08-18", + status=FAILED, + failed_stage="image", + error="旧生图失败", + error_type=IMAGE_GENERATION_FAILED, + image_error="旧生图失败", + ) + job = pipeline._make_image_job(group, "2026-08-18", force=True) + + pipeline._image_hook( + job, + { + "success": True, + "status": "success", + "detail": "图片已落盘", + "error_type": "", + "imagegen_ms": 1, + "generator_detail": {}, + }, + ) + + run = pipeline.store.load_run("测试群", "2026-08-18") + assert run["status"] == IMAGE_READY + assert run["failed_stage"] is None + assert run["error"] is None + assert run["error_type"] is None + assert run["image_error"] is None def test_generate_data_failure_marks_failed(tmp_path): From e15f8e5eb13d47ec8d43a09a3ba7801b386d7b06 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 25 Aug 2026 09:35:35 +0800 Subject: [PATCH 08/42] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=E6=8D=9F?= =?UTF-8?q?=E5=9D=8F=E7=8A=B6=E6=80=81=E5=B9=B6=E9=98=BB=E6=AD=A2=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v2_ui.py | 17 ++- app/pipeline/daily_pipeline.py | 14 +++ app/scheduler/daily_v2_job.py | 102 +++++++++++++++- app/scheduler/manager.py | 3 + app/v2/constants.py | 6 +- app/v2/recovery.py | 34 +++++- app/v2/run_store.py | 100 ++++++++++++++-- docs/audits/P1_1_CORRUPT_STATE_RECOVERY.md | 85 ++++++++++++++ tests/test_scheduler.py | 130 ++++++++++++++++++++- tests/test_v2_pipeline.py | 23 ++++ tests/test_v2_recovery.py | 116 +++++++++++++++++- 11 files changed, 609 insertions(+), 21 deletions(-) create mode 100644 docs/audits/P1_1_CORRUPT_STATE_RECOVERY.md diff --git a/app/api/v2_ui.py b/app/api/v2_ui.py index 70e7eea..34ce675 100644 --- a/app/api/v2_ui.py +++ b/app/api/v2_ui.py @@ -18,7 +18,7 @@ from app.config.settings import Settings, get_settings from app.db import repository as repo from app.scheduler.period import PeriodResolver -from app.v2.constants import FILE_IMAGE +from app.v2.constants import FILE_IMAGE, RUN_STATE_CORRUPT from app.v2.run_store import RunStore, validate_run_date router = APIRouter(prefix="/api/v2", tags=["v2-ui"]) @@ -732,14 +732,27 @@ def retry_failed(body: RetryBody | None = None, settings: Settings = Depends(get body = body or RetryBody() if body.run_date is not None: _validate_run_date(body.run_date) - pipeline = DailyPipeline() if body.group_id: + pipeline = DailyPipeline() r = pipeline.force_generate(body.group_id, body.run_date) return {"results": [{"group_id": body.group_id, "status": r.get("status"), "detail": r.get("error") or ""}]} incomplete = scan_incomplete(_store(settings), body.run_date) results: list[dict] = [] + pipeline = None for run in incomplete: group_name = run["group_name"] + if run.get("recovery_type") == "manual_review": + results.append( + { + "group_name": group_name, + "status": "blocked", + "error_type": run.get("error_type") or RUN_STATE_CORRUPT, + "detail": "运行状态文件损坏,需人工复核", + } + ) + continue + if pipeline is None: + pipeline = DailyPipeline() from sqlmodel import Session, select from app.db import repository as repo diff --git a/app/pipeline/daily_pipeline.py b/app/pipeline/daily_pipeline.py index 8201dd4..3b5fb64 100644 --- a/app/pipeline/daily_pipeline.py +++ b/app/pipeline/daily_pipeline.py @@ -48,6 +48,7 @@ send_target_mode, ) from app.v2.constants import ( + CORRUPT, DATA_READY, FAILED, IMAGE_GENERATION_FAILED, @@ -61,6 +62,7 @@ RANKING_FAILED, RANKING_READY, READY_TO_SEND, + RUN_STATE_CORRUPT, SENT, WECHAT_DATA_UNAVAILABLE, ) @@ -1045,6 +1047,18 @@ def force_generate( "error": "run_date 必须是有效的 YYYY-MM-DD 日期", } run_date = parsed_run_date.isoformat() + group = self._get_group(group_id) + if not group: + return {"status": "failed", "error": f"群不存在 {group_id}"} + group_name = self._group_name(group) + current = self.store.load_run(group_name, run_date) + if current.get("status") == CORRUPT: + return { + "group_name": group_name, + "status": "blocked", + "error_type": RUN_STATE_CORRUPT, + "detail": "运行状态文件损坏,需人工复核", + } self._last_name_sync_report = self._sync_group_names([group_id]) group = self._get_group(group_id) if not group: diff --git a/app/scheduler/daily_v2_job.py b/app/scheduler/daily_v2_job.py index 81194f7..2e74aeb 100644 --- a/app/scheduler/daily_v2_job.py +++ b/app/scheduler/daily_v2_job.py @@ -21,6 +21,7 @@ from app.db import repository as repo from app.pipeline.daily_pipeline import DailyPipeline, parse_date from app.services.generation_runtime import GenerationBusyError, generation_mutex +from app.v2.constants import SCHEDULER_STATE_CORRUPT logger = get_logger("groupbrief.scheduler") _STATE_LOCK = threading.RLock() @@ -28,6 +29,10 @@ _daily_mutex = generation_mutex +class ScheduleStateCorruptionError(RuntimeError): + """已有 scheduler 状态损坏;禁止用新任务状态覆盖。""" + + class DailyScheduleState: def __init__(self, output_root: Path | str): self.root = Path(output_root) / ".scheduler" @@ -39,17 +44,96 @@ def path(self, run_date: str) -> Path: def load(self, run_date: str) -> dict: path = self.path(run_date) + if not path.exists(): + return {"run_date": run_date} try: parsed = json.loads(path.read_text(encoding="utf-8")) - if isinstance(parsed, dict): - return parsed - except (OSError, json.JSONDecodeError): - pass - return {"run_date": run_date} + except (OSError, UnicodeError): + return self._corrupt_state(run_date, path, "read_failed") + except json.JSONDecodeError: + return self._corrupt_state(run_date, path, "json_invalid") + schema_error = self._schema_error(parsed, run_date) + if schema_error: + return self._corrupt_state(run_date, path, schema_error) + return parsed + + def _corrupt_state(self, run_date: str, path: Path, reason: str) -> dict: + return { + "run_date": run_date, + "state_status": "corrupt", + "error_type": SCHEDULER_STATE_CORRUPT, + "state_error_reason": reason, + "state_file": path.name, + "generation_hold": True, + "email_hold": True, + "needs_manual_review": True, + "detail": "调度状态文件损坏,已阻止自动补偿、生成和邮件发送", + } + + @staticmethod + def _schema_error(data: object, run_date: str) -> str | None: + if not isinstance(data, dict): + return "root_not_object" + if data.get("run_date") != run_date: + return "run_date_invalid" + timestamp_fields = ( + "generation_started_at", + "generation_completed_at", + "generation_resumed_at", + "generation_recovered_at", + "email_started_at", + "email_completed_at", + "updated_at", + ) + for field in timestamp_fields: + value = data.get(field) + if value is None: + continue + if not isinstance(value, str) or not value.strip(): + return f"{field}_invalid" + try: + datetime.fromisoformat(value.strip()) + except ValueError: + return f"{field}_invalid" + for field in ("generation_status", "email_status"): + value = data.get(field) + if value is not None and (not isinstance(value, str) or not value.strip()): + return f"{field}_invalid" + for field in ("generation_hold", "email_hold"): + value = data.get(field) + if value is not None and not isinstance(value, bool): + return f"{field}_invalid" + generation_results = data.get("generation_results") + if generation_results is not None and ( + not isinstance(generation_results, list) + or any(not isinstance(item, dict) for item in generation_results) + ): + return "generation_results_invalid" + if data.get("generation_started_at") and not data.get("generation_status"): + return "generation_status_missing" + if data.get("generation_completed_at") and not data.get("generation_status"): + return "generation_status_missing" + if data.get("email_started_at") and not data.get("email_status"): + return "email_status_missing" + if data.get("email_completed_at") and not data.get("email_status"): + return "email_status_missing" + if not any( + data.get(field) + for field in ( + "generation_started_at", + "generation_completed_at", + "email_started_at", + "email_completed_at", + ) + ): + return "lifecycle_marker_missing" + return None def update(self, run_date: str, **fields) -> dict: with _STATE_LOCK: data = self.load(run_date) + if data.get("state_status") == "corrupt": + raise ScheduleStateCorruptionError("调度状态文件损坏,禁止自动覆盖") data.update(fields) data["run_date"] = run_date data["updated_at"] = _now_iso() @@ -89,6 +173,14 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict run_date_text = run_date.isoformat() state_store = DailyScheduleState(settings.output_dir) state = state_store.load(run_date_text) + if state.get("state_status") == "corrupt": + logger.error("V2 每日任务已阻断:run_date=%s scheduler state corrupt", run_date_text) + return { + "status": "blocked", + "run_date": run_date_text, + "error_type": SCHEDULER_STATE_CORRUPT, + "detail": "调度状态文件损坏,需人工复核", + } repo.init_db(settings) repo.apply_db_settings(settings) diff --git a/app/scheduler/manager.py b/app/scheduler/manager.py index ecbcb22..3cf1efd 100644 --- a/app/scheduler/manager.py +++ b/app/scheduler/manager.py @@ -111,6 +111,9 @@ def _schedule_startup_catchup( return False run_date = now.date().isoformat() state = DailyScheduleState(settings.output_dir).load(run_date) + if state.get("state_status") == "corrupt": + logger.error("跳过启动补偿:run_date=%s scheduler state corrupt", run_date) + return False if state.get("generation_completed_at"): return False scheduler.add_job( diff --git a/app/v2/constants.py b/app/v2/constants.py index 76d09f7..39ab20c 100644 --- a/app/v2/constants.py +++ b/app/v2/constants.py @@ -15,6 +15,7 @@ READY_TO_SEND = "READY_TO_SEND" # 内容齐备,等待发送 SENT = "SENT" # 已发送完成 FAILED = "FAILED" # 失败(failed_stage 记录失败阶段) +CORRUPT = "CORRUPT" # 状态文件存在但损坏;只读隔离,禁止自动推进 STATUS_FLOW = ( PENDING, @@ -25,11 +26,12 @@ READY_TO_SEND, SENT, FAILED, + CORRUPT, ) # 是否允许跳过生图直接进入发送就绪(image_enabled=false 时 PROMPT_READY → READY_TO_SEND) # 已发送状态不可再发送 -TERMINAL_STATUSES = frozenset({SENT, FAILED}) +TERMINAL_STATUSES = frozenset({SENT, FAILED, CORRUPT}) # ---------- V2 错误类型 ---------- @@ -45,6 +47,8 @@ WECHAT_OFFLINE = "WECHAT_OFFLINE" SEND_TEXT_FAILED = "SEND_TEXT_FAILED" SEND_IMAGE_FAILED = "SEND_IMAGE_FAILED" +RUN_STATE_CORRUPT = "RUN_STATE_CORRUPT" +SCHEDULER_STATE_CORRUPT = "SCHEDULER_STATE_CORRUPT" # ---------- V2 输出文件命名(output/{群}/{日期}/) ---------- FILE_MESSAGES = "messages.json" diff --git a/app/v2/recovery.py b/app/v2/recovery.py index 68c72ac..78cce0d 100644 --- a/app/v2/recovery.py +++ b/app/v2/recovery.py @@ -12,12 +12,14 @@ from app.core.logging import get_logger from app.v2.constants import ( + CORRUPT, FILE_IMAGE, FILE_PROMPT, FILE_RANKING_TXT, FAILED, IMAGE_READY, READY_TO_SEND, + RUN_STATE_CORRUPT, SENT, ) from app.v2.run_store import RunStore @@ -71,6 +73,11 @@ def scan_incomplete(store: RunStore, run_date: str | None = None) -> list[dict]: incomplete: list[dict] = [] for run in store.list_runs(run_date): status = run.get("status", "") + if status == CORRUPT: + item = dict(run) + item["recovery_type"] = "manual_review" + incomplete.append(item) + continue if status in (SENT, FAILED): continue item = dict(run) @@ -87,6 +94,19 @@ def verify_output(store: RunStore, run_date: str | None = None) -> list[dict]: results: list[dict] = [] for run in store.list_runs(run_date): status = run.get("status", "") + if status == CORRUPT: + results.append( + { + "group_name": run.get("group_name", "未知群"), + "run_date": run.get("run_date", ""), + "status": CORRUPT, + "missing": [], + "ok": False, + "error_type": RUN_STATE_CORRUPT, + "detail": "运行状态文件损坏,需人工复核", + } + ) + continue required = _required_files(status, run) missing = [] for f in required: @@ -122,13 +142,25 @@ def recover_incomplete( """ from app.pipeline.daily_pipeline import DailyPipeline - pipeline = DailyPipeline() incomplete = scan_incomplete(store, run_date) if not incomplete: return [{"status": "ok", "detail": "无未完成任务"}] results: list[dict] = [] + pipeline = None for run in incomplete: group_name = run["group_name"] + if run.get("recovery_type") == "manual_review": + results.append( + { + "group_name": group_name, + "status": "blocked", + "error_type": RUN_STATE_CORRUPT, + "detail": "运行状态文件损坏,需人工复核", + } + ) + continue + if pipeline is None: + pipeline = DailyPipeline() # 找到群 id(按显示名) gid = _find_group_id_by_name(group_name) if gid is None: diff --git a/app/v2/run_store.py b/app/v2/run_store.py index 622ae49..d626c8e 100644 --- a/app/v2/run_store.py +++ b/app/v2/run_store.py @@ -3,6 +3,7 @@ run.json 是每个群每次运行的唯一状态文件(路线文档 §十)。 状态机:PENDING → DATA_READY → RANKING_READY → PROMPT_READY → IMAGE_READY → READY_TO_SEND → SENT / FAILED。 +已有状态文件无法可信解析时进入合成的 CORRUPT 只读隔离态,不参与自动推进。 同时统一管理该群该日期的输出文件命名与目录。 """ @@ -22,6 +23,7 @@ from app.core.path_security import resolve_within, validate_iso_date, validate_path_label from app.services.handoff_service import safe_dir_name from app.v2.constants import ( + CORRUPT, FILE_IMAGE, FILE_IMAGE_PREVIOUS, FILE_IMAGE_REGENERATING, @@ -32,12 +34,19 @@ FILE_RANKING_TXT, FILE_RUN, PENDING, + RUN_STATE_CORRUPT, + STATUS_FLOW, ) _RUN_WRITE_LOCK = threading.RLock() _WAIT_OBJECT_0 = 0 _WAIT_ABANDONED = 0x80 +_PERSISTED_STATUSES = frozenset(STATUS_FLOW) - {CORRUPT} + + +class RunStateCorruptionError(RuntimeError): + """已有 run.json 损坏;任何自动写入都必须 fail closed。""" def validate_run_date(value: str) -> str: @@ -147,23 +156,83 @@ def regenerating_image_path(self, group_name: str, run_date: str) -> Path: # ---------- run.json ---------- + def _corrupt_run(self, group_name: str, run_date: str, path: Path, reason: str) -> dict: + try: + state_file = str(path.relative_to(self.root)) + except ValueError: + state_file = path.name + try: + updated_at = datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat() + except OSError: + updated_at = "" + return { + "group_name": group_name, + "run_date": run_date, + "status": CORRUPT, + "state_status": "corrupt", + "error_type": RUN_STATE_CORRUPT, + "state_error_reason": reason, + "state_file": state_file, + "updated_at": updated_at, + "send_hold": True, + "needs_manual_review": True, + "detail": "运行状态文件损坏,已阻止自动覆盖、生成和发送", + } + + @staticmethod + def _run_schema_error(data: object, run_date: str) -> str | None: + if not isinstance(data, dict): + return "root_not_object" + group_name = data.get("group_name") + if not isinstance(group_name, str) or not group_name.strip(): + return "group_name_invalid" + stored_date = data.get("run_date") + if not isinstance(stored_date, str) or stored_date != run_date: + return "run_date_invalid" + status = data.get("status") + if not isinstance(status, str) or status not in _PERSISTED_STATUSES: + return "status_invalid" + return None + + def _read_run_file(self, path: Path, group_name: str, run_date: str) -> dict: + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return self._corrupt_run(group_name, run_date, path, "read_failed") + try: + data = json.loads(raw) + except json.JSONDecodeError: + return self._corrupt_run(group_name, run_date, path, "json_invalid") + schema_error = self._run_schema_error(data, run_date) + if schema_error: + return self._corrupt_run(group_name, run_date, path, schema_error) + return data + + @staticmethod + def _is_corrupt(data: dict) -> bool: + return data.get("status") == CORRUPT and data.get("error_type") == RUN_STATE_CORRUPT + def load_run(self, group_name: str, run_date: str) -> dict: path = self.run_path(group_name, run_date) if path.exists(): - try: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except (json.JSONDecodeError, OSError): - pass + return self._read_run_file(path, group_name, run_date) return {"group_name": group_name, "run_date": run_date, "status": PENDING} def save_run(self, group_name: str, run_date: str, data: dict) -> dict: with _RUN_WRITE_LOCK: path = self.run_path(group_name, run_date) + if path.exists(): + existing = self._read_run_file(path, group_name, run_date) + if self._is_corrupt(existing): + raise RunStateCorruptionError("运行状态文件损坏,禁止自动覆盖") path.parent.mkdir(parents=True, exist_ok=True) + data = dict(data) data.setdefault("group_name", group_name) + data.setdefault("status", PENDING) data["run_date"] = run_date + schema_error = self._run_schema_error(data, run_date) + if schema_error: + raise ValueError(f"run.json 写入数据不符合 Schema:{schema_error}") data["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") temp = path.with_suffix(".json.tmp") temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") @@ -174,6 +243,8 @@ def update(self, group_name: str, run_date: str, **fields) -> dict: """加载 → 合并字段 → 保存,返回最新 run。""" with _RUN_WRITE_LOCK: data = self.load_run(group_name, run_date) + if self._is_corrupt(data): + raise RunStateCorruptionError("运行状态文件损坏,禁止自动覆盖") data.update(fields) return self.save_run(group_name, run_date, data) @@ -260,6 +331,8 @@ def claim_send( path = self.run_path(group_name, run_date) with _run_mutex(path): data = self.load_run(group_name, run_date) + if self._is_corrupt(data): + return None, data, "state_corrupt" if data.get("sent_at") and not allow_sent: return None, data, "already_sent" if data.get("send_state") == "unknown": @@ -309,6 +382,8 @@ def update_send_claim( path = self.run_path(group_name, run_date) with _run_mutex(path): data = self.load_run(group_name, run_date) + if self._is_corrupt(data): + return False, data if data.get("send_claim_id") != claim_id: return False, data data.update(fields) @@ -355,8 +430,17 @@ def list_runs(self, run_date: str | None = None) -> list[dict]: run_path = d / FILE_RUN if run_path.exists(): try: - runs.append(json.loads(run_path.read_text(encoding="utf-8"))) - except (json.JSONDecodeError, OSError): + valid_date = validate_run_date(d.name) + except ValueError: + runs.append( + self._corrupt_run( + group_dir.name, + d.name, + run_path, + "directory_date_invalid", + ) + ) continue + runs.append(self._read_run_file(run_path, group_dir.name, valid_date)) runs.sort(key=lambda r: r.get("updated_at", ""), reverse=True) return runs diff --git a/docs/audits/P1_1_CORRUPT_STATE_RECOVERY.md b/docs/audits/P1_1_CORRUPT_STATE_RECOVERY.md new file mode 100644 index 0000000..6c9d74a --- /dev/null +++ b/docs/audits/P1_1_CORRUPT_STATE_RECOVERY.md @@ -0,0 +1,85 @@ +# P1.1 损坏状态与“结果未知”恢复 + +日期:2026-08-25 + +## 结论 + +P1.1 将“文件不存在”和“文件存在但不可信”拆成了两个完全不同的状态: + +```text +文件不存在 ───────────────────→ PENDING / 新任务 +文件合法且未完成 ─────────────→ 沿用现有安全续跑 +外部调用已开始但结果无法确认 ─→ RESULT_UNKNOWN + hold +文件存在但损坏或结构不可信 ───→ CORRUPT + manual_review + │ + └─ 禁止覆盖、生成、邮件和微信发送 +``` + +此前,非法 JSON、空文件、截断文件和部分结构错误会被宽松回退为 PENDING 或空调度状态。这可能把已经发生过外部副作用的任务误判为“从未开始”,造成重复生成、重复扣费或重复发送。本轮采取 fail-closed:不猜测、不自动重建、不覆盖原证据,由人工根据日志、产物和外部平台结果决定后续处理。 + +## 修改范围 + +- `app/v2/run_store.py`:统一解析和结构校验,合成不含业务正文的 CORRUPT 摘要;所有写入口拒绝覆盖损坏文件;列表不再静默漏掉损坏运行。 +- `app/v2/recovery.py`:扫描和完整性检查显式返回损坏项;自动恢复在创建 Pipeline 前阻断。 +- `app/scheduler/daily_v2_job.py`:调度状态损坏时返回 blocked/manual_review,不初始化数据库、不生成、不调用邮件。 +- `app/scheduler/manager.py`:启动补偿跳过损坏调度状态。 +- `app/api/v2_ui.py`:手动 retry 在查库或创建 Pipeline 前阻断损坏运行。 +- `app/pipeline/daily_pipeline.py`:直接指定群的强制生成在群名同步和生成前检查损坏状态,并返回结构化 blocked。 +- `app/v2/constants.py`:增加只读隔离状态和统一错误类型。 +- `tests/test_v2_recovery.py`、`tests/test_scheduler.py`、`tests/test_v2_pipeline.py`:覆盖损坏文件、结构错误、证据保留、直接群重试及结果未知状态。 + +## 风险与收益 + +| 项目 | 评估 | +| --- | --- | +| 实际影响 | 避免损坏状态触发重复外部调用,是高影响防线 | +| 发生概率 | 当前磁盘未发现损坏,但异常退出、磁盘故障或人工编辑都可能触发 | +| 修改收益 | 高;把不可证明安全的恢复从自动执行改成人工确认 | +| 修改成本 | 低至中;集中在状态边界和恢复入口 | +| 修改风险 | 低;正常 PENDING、合法中断续跑和结果未知语义保持不变 | +| Blast Radius | V2 运行状态、每日调度、恢复 API 和启动补偿 | + +## 失败模式与处理 + +| 失败模式 | 旧行为 | 新行为 | +| --- | --- | --- | +| `run.json` 不存在 | PENDING | PENDING,不变 | +| `run.json` 非法、空、截断、非对象 | PENDING 或被列表忽略 | CORRUPT,显式可见,禁止写入和恢复 | +| `run.json` 缺身份或状态字段 | 可能继续使用 | CORRUPT,人工复核 | +| scheduler 状态非法或结构错误 | 当作从未执行,可能重跑 | blocked,禁止生成和邮件 | +| 邮件 started 但无 completed | 结果未知保护 | EMAIL_RESULT_UNKNOWN + hold,不变 | +| 合法生成中断 | 从断点续跑 | 从断点续跑,不变 | + +## 数据检查 + +本轮只读扫描了当前正式状态文件,没有读取或输出业务正文,也没有重写任何状态: + +- V2 `run.json`:45 个;结构损坏 0 个。 +- 状态分布:SENT 19、PROMPT_READY 1、READY_TO_SEND 20、FAILED 5。 +- scheduler 状态:4 个;结构损坏 0 个。 + +因此本次改动是补齐故障边界,不是对当前数据进行修复。 + +## 验证 + +- 定向恢复/调度/Pipeline 测试:最终 76 项通过。 +- 将仅 P1.1 暂存差异应用到基于 `05b1f64` 的隔离 worktree 后,完整 Python 测试 478 项通过,1 个上游弃用 warning。 +- 独立验证首次完整测试出现一次并发 timing 抖动:阈值 `<0.45s`,实测 `0.466s`;未修改阈值,完整重跑通过。该证据留到 P2.1 处理测试稳定性。 +- Python 编译检查:通过。 +- 前端生产构建:通过(4,596 个模块)。 +- `git diff --check`:通过,仅有工作区 LF/CRLF 提示。 +- 8766:核心状态隔离改动写入后,由原 Alter 项目 `94507bc8-4b0e-4f37-89a2-1bffedb15fd3` 重启;监听进程仍由该项目的进程树持有,`/api/version` 返回 `1.0.0`,恢复接口可读且报告 CORRUPT 数量为 0。当天调度状态已完成,Windows 调度任务均为 Ready;重启日志无 catch-up 和 ERROR。随后补充的“直接指定群重试”保护已在隔离完整测试中验证;为避免把同期另一任务的未提交源码一起载入 8766,没有再次重启。未触发真实 AI、邮件或微信发送。 + +## 回滚 + +代码可通过单独回滚本次提交恢复。P1.1 没有数据库迁移,也没有修改现有 `run.json` 或 scheduler 文件。若未来出现 CORRUPT,必须先备份原文件,再由人工依据日志、磁盘产物和外部发送结果重建可信状态;不能直接删除文件让系统回到 PENDING。 + +## 并发工作区说明 + +验收期间工作区出现了另一任务正在进行的 AI 图片主题、Prompt、群 API、数据库默认值和前端修改。它们没有纳入 P1.1 暂存或提交;直接在混合工作区运行全量测试时有 7 项相关断言失败。为避免污染结论,本轮以“HEAD + 仅 P1.1 暂存差异”的隔离 worktree 完整测试作为提交验收依据。 + +## 非目标 + +- 不自动猜测或修复损坏状态。 +- 不改变调度进程所有权、退出码和假成功语义;这些属于 P1.2。 +- 不统一 Provider、Mock、AI/邮件幂等或 V1 双轨;这些按后续轮次处理。 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9952884..54791ef 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -109,7 +109,11 @@ def __init__(self, _output_root): super().__init__(tmp_path) state = TempState(tmp_path) - state.update("2026-08-21", generation_started_at="2026-08-21T00:15:00+08:00") + state.update( + "2026-08-21", + generation_started_at="2026-08-21T00:15:00+08:00", + generation_status="running", + ) calls = [] class FakePipeline: @@ -169,6 +173,117 @@ def generate_all(self, run_date, acquire_lock=True): assert state["generation_hold"] is True +def test_corrupt_scheduler_state_blocks_generation_email_and_overwrite(tmp_path, monkeypatch): + import pytest + + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + state = TempState(tmp_path) + path = state.path("2026-08-25") + path.parent.mkdir(parents=True) + original = b"{broken" + path.write_bytes(original) + + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + monkeypatch.setattr( + daily, + "DailyPipeline", + lambda settings: pytest.fail("损坏调度状态不得启动 Pipeline"), + ) + monkeypatch.setattr( + daily.repo, + "init_db", + lambda settings: pytest.fail("损坏调度状态应在数据库初始化前阻断"), + ) + monkeypatch.setattr( + daily.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("损坏调度状态不得启动邮件子进程"), + ) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + + assert result["status"] == "blocked" + assert result["error_type"] == "SCHEDULER_STATE_CORRUPT" + with pytest.raises(daily.ScheduleStateCorruptionError): + state.update("2026-08-25", generation_status="running") + assert path.read_bytes() == original + + +def test_scheduler_schema_corruption_is_not_treated_as_new_run(tmp_path, monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings(_env_file=None, email_enabled=False, email_smtp_host="") + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + state = TempState(tmp_path) + path = state.path("2026-08-25") + path.parent.mkdir(parents=True) + path.write_text( + '{"run_date":"2026-08-25","generation_started_at":"2026-08-25T00:15:00+08:00",' + '"generation_status":"running","generation_results":"not-a-list"}', + encoding="utf-8", + ) + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + + assert result["status"] == "blocked" + assert result["error_type"] == "SCHEDULER_STATE_CORRUPT" + + +def test_email_started_without_completion_remains_result_unknown(tmp_path, monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + state = TempState(tmp_path) + state.update( + "2026-08-25", + generation_started_at="2026-08-25T00:15:00+08:00", + generation_completed_at="2026-08-25T00:20:00+08:00", + generation_status="success", + generation_results=[], + email_started_at="2026-08-25T08:30:00+08:00", + email_status="running", + ) + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + monkeypatch.setattr(daily.repo, "init_db", lambda settings: None) + monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: []) + monkeypatch.setattr( + daily.subprocess, + "run", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("结果未知时不得重发邮件")), + ) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + saved = state.load("2026-08-25") + + assert result["status"] == "blocked" + assert result["error_type"] == "EMAIL_RESULT_UNKNOWN" + assert saved["email_status"] == "unknown" + assert saved["email_hold"] is True + + def test_startup_catchup_is_added_only_when_today_is_incomplete(monkeypatch): from app.config.settings import Settings from app.scheduler import manager @@ -206,3 +321,16 @@ def load(self, run_date): monkeypatch.setattr(manager, "DailyScheduleState", CompletedState) assert _schedule_startup_catchup(FakeScheduler(), settings, now=now) is False + + class CorruptState(IncompleteState): + def load(self, run_date): + return { + "run_date": run_date, + "state_status": "corrupt", + "error_type": "SCHEDULER_STATE_CORRUPT", + } + + monkeypatch.setattr(manager, "DailyScheduleState", CorruptState) + captured.clear() + assert _schedule_startup_catchup(FakeScheduler(), settings, now=now) is False + assert captured == [] diff --git a/tests/test_v2_pipeline.py b/tests/test_v2_pipeline.py index 206aeee..6744ee8 100644 --- a/tests/test_v2_pipeline.py +++ b/tests/test_v2_pipeline.py @@ -482,6 +482,29 @@ def test_force_generate_runs_image_queue_and_returns_final_state(tmp_path): assert pipeline.store.load_run("测试群", "2026-08-18")["status"] == READY_TO_SEND +def test_force_generate_blocks_corrupt_state_before_name_sync(tmp_path, monkeypatch): + pipeline, group = _make_pipeline(tmp_path) + run_path = pipeline.store.run_path("测试群", "2026-08-18") + run_path.parent.mkdir(parents=True, exist_ok=True) + original = b'{"status": "PENDING"' + run_path.write_bytes(original) + monkeypatch.setattr( + pipeline, + "_sync_group_names", + lambda *_args, **_kwargs: pytest.fail("损坏状态不得触发群名同步或生成"), + ) + + result = pipeline.force_generate(group.id, "2026-08-18") + + assert result == { + "group_name": "测试群", + "status": "blocked", + "error_type": "RUN_STATE_CORRUPT", + "detail": "运行状态文件损坏,需人工复核", + } + assert run_path.read_bytes() == original + + def test_force_generate_image_failure_returns_failed_state(tmp_path): gen = FakeGenerator(fail=True) pipeline, group = _make_pipeline(tmp_path, gen=gen) diff --git a/tests/test_v2_recovery.py b/tests/test_v2_recovery.py index e2775af..5314759 100644 --- a/tests/test_v2_recovery.py +++ b/tests/test_v2_recovery.py @@ -11,9 +11,9 @@ from app.core.logging import clean_old_logs from app.core.startup_check import run_startup_checks -from app.v2.constants import FAILED, IMAGE_READY, READY_TO_SEND, SENT -from app.v2.recovery import scan_incomplete, verify_output -from app.v2.run_store import RunStore +from app.v2.constants import CORRUPT, FAILED, IMAGE_READY, READY_TO_SEND, SENT +from app.v2.recovery import recover_incomplete, scan_incomplete, verify_output +from app.v2.run_store import RunStateCorruptionError, RunStore def _mk_run( @@ -130,6 +130,116 @@ def test_recent_layout_history_skips_legacy_and_corrupt_runs_without_rewriting(t assert corrupt_path.read_text(encoding="utf-8") == before +@pytest.mark.parametrize( + "raw", + [b"", b"{broken", b"null", b"[]", b'"text"'], + ids=["empty", "truncated", "null", "array", "string"], +) +def test_corrupt_run_is_not_treated_as_pending_or_overwritten(tmp_path, raw): + store = RunStore(tmp_path / "output") + path = store.run_path("群A", "2026-08-21") + path.parent.mkdir(parents=True) + path.write_bytes(raw) + + run = store.load_run("群A", "2026-08-21") + + assert run["status"] == CORRUPT + assert run["error_type"] == "RUN_STATE_CORRUPT" + assert run["needs_manual_review"] is True + assert "{broken" not in str(run) + with pytest.raises(RunStateCorruptionError): + store.update("群A", "2026-08-21", status=READY_TO_SEND) + assert path.read_bytes() == raw + + +@pytest.mark.parametrize( + "payload", + [ + {"group_name": "群A", "run_date": "2026-08-21"}, + {"group_name": "群A", "run_date": "wrong", "status": READY_TO_SEND}, + {"group_name": None, "run_date": "2026-08-21", "status": READY_TO_SEND}, + {"group_name": "群A", "run_date": "2026-08-21", "status": []}, + ], + ids=["missing-status", "wrong-date", "bad-group", "bad-status"], +) +def test_run_schema_corruption_requires_manual_review(tmp_path, payload): + store = RunStore(tmp_path / "output") + path = store.run_path("群A", "2026-08-21") + path.parent.mkdir(parents=True) + original = json.dumps(payload, ensure_ascii=False).encode("utf-8") + path.write_bytes(original) + + listed = store.list_runs("2026-08-21") + incomplete = scan_incomplete(store, "2026-08-21") + integrity = verify_output(store, "2026-08-21") + + assert listed[0]["status"] == CORRUPT + assert incomplete[0]["recovery_type"] == "manual_review" + assert integrity[0]["ok"] is False + assert integrity[0]["error_type"] == "RUN_STATE_CORRUPT" + assert path.read_bytes() == original + + +def test_recovery_never_executes_corrupt_run(tmp_path, monkeypatch): + store = RunStore(tmp_path / "output") + path = store.run_path("群A", "2026-08-21") + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + + from app.pipeline import daily_pipeline + + monkeypatch.setattr( + daily_pipeline, + "DailyPipeline", + lambda: pytest.fail("损坏状态不得构造自动恢复 Pipeline"), + ) + + result = recover_incomplete(store, run_date="2026-08-21") + + assert result == [ + { + "group_name": "群A", + "status": "blocked", + "error_type": "RUN_STATE_CORRUPT", + "detail": "运行状态文件损坏,需人工复核", + } + ] + + +def test_retry_api_blocks_corrupt_run_before_group_lookup(tmp_path, monkeypatch): + from app.api import v2_ui + from app.pipeline import daily_pipeline + + store = RunStore(tmp_path / "output") + path = store.run_path("群A", "2026-08-21") + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + + class FakeSettings: + output_dir = tmp_path / "output" + + monkeypatch.setattr(v2_ui, "_store", lambda settings: store) + monkeypatch.setattr( + daily_pipeline, + "DailyPipeline", + lambda: pytest.fail("损坏状态不得构造自动恢复 Pipeline"), + ) + + response = v2_ui.retry_failed( + v2_ui.RetryBody(run_date="2026-08-21"), + settings=FakeSettings(), + ) + + assert response["results"] == [ + { + "group_name": "群A", + "status": "blocked", + "error_type": "RUN_STATE_CORRUPT", + "detail": "运行状态文件损坏,需人工复核", + } + ] + + def test_clean_old_logs_removes_expired(tmp_path): old = tmp_path / "old.log" old.write_text("x", encoding="utf-8") From 58a332b649dae6ca0fbbbfa4e34b50caf1a1dd6f Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 25 Aug 2026 10:12:25 +0800 Subject: [PATCH 09/42] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E6=89=80=E6=9C=89=E6=9D=83=E5=92=8C=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + app/api/system.py | 4 + app/config/settings.py | 5 +- app/main.py | 14 +- app/scheduler/daily_v2_job.py | 98 +++++++++-- app/scheduler/manager.py | 22 ++- app/scheduler/outcome.py | 133 ++++++++++++++ app/scheduler/send_job.py | 16 +- docs/audits/P1_2_SCHEDULER_OWNERSHIP.md | 101 +++++++++++ scripts/daily_auto.py | 23 ++- scripts/install_daily_task.py | 222 +++++++++++++++++------- scripts/run_daily_pipeline.py | 123 ++++++++----- tests/test_daily_auto.py | 36 ++-- tests/test_install_daily_task.py | 93 ++++++++-- tests/test_scheduler.py | 180 +++++++++++++++++++ tests/test_scheduler_outcome.py | 140 +++++++++++++++ 16 files changed, 1047 insertions(+), 165 deletions(-) create mode 100644 app/scheduler/outcome.py create mode 100644 docs/audits/P1_2_SCHEDULER_OWNERSHIP.md create mode 100644 tests/test_scheduler_outcome.py diff --git a/.env.example b/.env.example index f0e6df8..c40121e 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,8 @@ EMAIL_SEND_PARTIAL_REPORT=true # --- 自动任务 --- # 业务调度统一由后台 APScheduler 管理:每日 00:15 生成前一日群报,08:30 起按群发送。 +# 正式环境只能有一个 owner。默认 fastapi;切为 external 前必须先停止内部 scheduler。 +SCHEDULER_OWNER=fastapi SCHEDULE_GENERATE_TIME=00:15 SCHEDULE_EMAIL_TIME=after_generate SCHEDULE_STARTUP_CATCHUP_ENABLED=true diff --git a/app/api/system.py b/app/api/system.py index 3fa6904..c9da3d4 100644 --- a/app/api/system.py +++ b/app/api/system.py @@ -80,6 +80,8 @@ def stats(session: Session = Depends(repo.get_session)): @router.get("/status") def status(session: Session = Depends(repo.get_session), settings: Settings = Depends(get_settings)): + from app.scheduler.manager import get_scheduler + try: tz = ZoneInfo(settings.app_timezone) now = datetime.now(tz) @@ -103,6 +105,8 @@ def status(session: Session = Depends(repo.get_session), settings: Settings = De return { "version": "1.0.0", "status": "running", + "scheduler_owner": settings.scheduler_owner, + "scheduler_active": get_scheduler() is not None, "now": now.isoformat() if tz else None, "timezone": settings.app_timezone, "report_date": window.report_date.isoformat(), diff --git a/app/config/settings.py b/app/config/settings.py index 7f5d030..992cd1c 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -4,7 +4,7 @@ from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Literal from pydantic_settings import BaseSettings, SettingsConfigDict @@ -110,6 +110,9 @@ class Settings(BaseSettings): email_send_partial_report: bool = True # 自动任务 + # fastapi:8766 内 APScheduler 是唯一 owner;external:仅允许外部调度; + # disabled:不注册任何自动任务。该字段只由环境配置,不通过设置 API 修改。 + scheduler_owner: Literal["fastapi", "external", "disabled"] = "fastapi" schedule_generate_time: str = "00:15" schedule_email_time: str = "after_generate" schedule_startup_catchup_enabled: bool = True diff --git a/app/main.py b/app/main.py index 049ae7c..9b4f75a 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,13 @@ APP_VERSION = "1.0.0" +def _should_start_scheduler(settings) -> bool: + return ( + settings.scheduler_owner == "fastapi" + and os.environ.get("GROUPBRIEF_NO_SCHEDULER", "") != "1" + ) + + @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() @@ -34,12 +41,15 @@ async def lifespan(app: FastAPI): except Exception: app.state.startup_checks = [] # P9:日志轮转清理已在 setup_logging 中执行 - if os.environ.get("GROUPBRIEF_NO_SCHEDULER", "") != "1": + scheduler_started = _should_start_scheduler(settings) + app.state.scheduler_owner = settings.scheduler_owner + app.state.scheduler_active = scheduler_started + if scheduler_started: from app.scheduler.manager import start_scheduler start_scheduler(settings) yield - if os.environ.get("GROUPBRIEF_NO_SCHEDULER", "") != "1": + if scheduler_started: from app.scheduler.manager import stop_scheduler stop_scheduler() diff --git a/app/scheduler/daily_v2_job.py b/app/scheduler/daily_v2_job.py index 2e74aeb..c18ea64 100644 --- a/app/scheduler/daily_v2_job.py +++ b/app/scheduler/daily_v2_job.py @@ -22,6 +22,7 @@ from app.pipeline.daily_pipeline import DailyPipeline, parse_date from app.services.generation_runtime import GenerationBusyError, generation_mutex from app.v2.constants import SCHEDULER_STATE_CORRUPT +from app.scheduler.outcome import attach_outcome, summarize_results logger = get_logger("groupbrief.scheduler") _STATE_LOCK = threading.RLock() @@ -83,6 +84,7 @@ def _schema_error(data: object, run_date: str) -> str | None: "generation_recovered_at", "email_started_at", "email_completed_at", + "last_invocation_completed_at", "updated_at", ) for field in timestamp_fields: @@ -103,6 +105,14 @@ def _schema_error(data: object, run_date: str) -> str | None: value = data.get(field) if value is not None and not isinstance(value, bool): return f"{field}_invalid" + exit_code = data.get("last_invocation_exit_code") + if exit_code is not None and (isinstance(exit_code, bool) or not isinstance(exit_code, int)): + return "last_invocation_exit_code_invalid" + invocation_status = data.get("last_invocation_status") + if invocation_status is not None and ( + not isinstance(invocation_status, str) or not invocation_status.strip() + ): + return "last_invocation_status_invalid" generation_results = data.get("generation_results") if generation_results is not None and ( not isinstance(generation_results, list) @@ -156,17 +166,49 @@ def run_daily_v2_job( run_date = run_date or datetime.now(tz).date().isoformat() parsed_date = parse_date(run_date) if parsed_date is None: - return {"status": "failed", "error_type": "INVALID_RUN_DATE", "detail": "run_date 格式无效"} + return attach_outcome( + {"status": "failed", "error_type": "INVALID_RUN_DATE", "detail": "run_date 格式无效"} + ) try: with _daily_mutex(): - return _run_locked(settings, parsed_date, skip_email=skip_email) + result = _run_locked(settings, parsed_date, skip_email=skip_email) except GenerationBusyError as exc: logger.info("V2 每日任务未领取:%s", exc) - return {"status": "already_running", "detail": str(exc)} + result = {"status": "already_running", "detail": str(exc)} except Exception as exc: logger.exception("V2 每日任务异常") - return {"status": "failed", "detail": str(exc)[:300]} + result = {"status": "failed", "detail": str(exc)[:300]} + return _finalize_invocation(settings, parsed_date.isoformat(), result) + + +def _finalize_invocation(settings: Settings, run_date: str, result: dict) -> dict: + finalized = attach_outcome(result) + logger.info( + "V2 每日任务终态:run_date=%s source_status=%s outcome=%s exit_code=%d", + run_date, + finalized.get("status"), + finalized["outcome_status"], + finalized["exit_code"], + ) + if finalized["outcome_status"] == "already_running": + return finalized + + state_store = DailyScheduleState(settings.output_dir) + path = state_store.path(run_date) + if not path.is_file(): + return finalized + state = state_store.load(run_date) + if state.get("state_status") == "corrupt": + return finalized + state_store.update( + run_date, + last_invocation_source_status=str(finalized.get("status") or ""), + last_invocation_status=finalized["outcome_status"], + last_invocation_exit_code=finalized["exit_code"], + last_invocation_completed_at=_now_iso(), + ) + return finalized def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict: @@ -244,11 +286,40 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict "email_status": "skipped_by_request", } + generation_status = str(state.get("generation_status") or "failed") + if generation_status in {"failed", "blocked", "not_run"}: + state_store.update( + run_date_text, + email_status="skipped_generation_not_successful", + email_completed_at=_now_iso(), + email_detail=f"生成终态为 {generation_status},未调用邮件", + ) + return { + "status": generation_status, + "run_date": run_date_text, + "generation_status": generation_status, + "email_status": "skipped_generation_not_successful", + } + if generation_status == "partial" and not settings.email_send_partial_report: + state_store.update( + run_date_text, + email_status="skipped_partial_disabled", + email_completed_at=_now_iso(), + email_detail="生成部分成功且未启用部分报告邮件", + ) + return { + "status": "partial", + "run_date": run_date_text, + "generation_status": "partial", + "email_status": "skipped_partial_disabled", + } + if state.get("email_completed_at"): + completed_status = generation_status if generation_status != "success" else "already_completed" return { - "status": "already_completed", + "status": completed_status, "run_date": run_date_text, - "generation_status": state.get("generation_status"), + "generation_status": generation_status, "email_status": state.get("email_status"), } if not settings.email_enabled or not settings.email_smtp_host: @@ -259,7 +330,7 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict email_detail="邮件未启用或 SMTP 未配置", ) return { - "status": state.get("generation_status", "completed"), + "status": generation_status, "run_date": run_date_text, "email_status": "skipped_disabled", } @@ -330,22 +401,15 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict email_detail=output_tail, ) return { - "status": "success" if proc.returncode == 0 else "partial", + "status": generation_status if proc.returncode == 0 else "partial", "run_date": run_date_text, - "generation_status": state.get("generation_status"), + "generation_status": generation_status, "email_status": email_status, } def _generation_status(results: list[dict]) -> str: - statuses = {str(item.get("status") or "") for item in results} - if statuses and statuses <= {"ready_to_send", "skipped", "no_groups"}: - return "success" - if "failed" in statuses and len(statuses) == 1: - return "failed" - if "failed" in statuses: - return "partial" - return "success" + return str(summarize_results(results)["outcome_status"]) def _compact_results(results: list[dict]) -> list[dict]: diff --git a/app/scheduler/manager.py b/app/scheduler/manager.py index 3cf1efd..7e6dffe 100644 --- a/app/scheduler/manager.py +++ b/app/scheduler/manager.py @@ -12,6 +12,7 @@ from app.config.settings import Settings from app.core.logging import get_logger from app.scheduler.daily_v2_job import DailyScheduleState, run_daily_v2_job +from app.scheduler.outcome import require_scheduler_success from app.scheduler.send_job import run_send_due_job logger = get_logger("groupbrief.scheduler") @@ -20,6 +21,23 @@ _DEFAULT_GENERATE_TIME = time(0, 15) +def run_scheduled_daily_v2_job( + run_date: str | None = None, + *, + skip_email: bool = False, +) -> dict: + """APScheduler 包装器:业务非成功时必须让调度器记录异常。""" + result = run_daily_v2_job(run_date, skip_email=skip_email) + logger.info( + "APScheduler 每日任务结果:status=%s outcome=%s exit_code=%s", + result.get("status"), + result.get("outcome_status"), + result.get("exit_code"), + ) + require_scheduler_success(result) + return result + + def _parse_generate_time(value: str) -> time: """解析每日生成时间;无效配置安全回退到 00:15。""" text = str(value or "").strip() @@ -42,7 +60,7 @@ def start_scheduler(settings: Settings) -> BackgroundScheduler: generate_time = _parse_generate_time(settings.schedule_generate_time) scheduler = BackgroundScheduler(timezone=tz) scheduler.add_job( - run_daily_v2_job, + run_scheduled_daily_v2_job, trigger=CronTrigger( hour=generate_time.hour, minute=generate_time.minute, @@ -117,7 +135,7 @@ def _schedule_startup_catchup( if state.get("generation_completed_at"): return False scheduler.add_job( - run_daily_v2_job, + run_scheduled_daily_v2_job, trigger=DateTrigger(run_date=now + timedelta(seconds=3), timezone=tz), args=[run_date], kwargs={"skip_email": True}, diff --git a/app/scheduler/outcome.py b/app/scheduler/outcome.py new file mode 100644 index 0000000..19716e4 --- /dev/null +++ b/app/scheduler/outcome.py @@ -0,0 +1,133 @@ +"""调度与命令行共用的业务终态和退出码契约。""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Iterable, Mapping + + +class ProcessExitCode(IntEnum): + SUCCESS = 0 + FAILED = 1 + PARTIAL = 2 + BLOCKED = 3 + ALREADY_RUNNING = 4 + NOT_RUN = 5 + + +_SUCCESS = frozenset( + { + "success", + "sent", + "ready_to_send", + "already_completed", + "skipped", + "skipped_disabled", + "skipped_by_request", + } +) +_FAILED = frozenset({"failed", "error"}) +_PARTIAL = frozenset({"partial"}) +_BLOCKED = frozenset({"blocked", "held", "unknown", "result_unknown"}) +_ALREADY_RUNNING = frozenset({"already_running"}) +_NOT_RUN = frozenset({"not_run", "no_groups", "no_work", "not_due", "empty"}) + +_EXIT_CODES = { + "success": ProcessExitCode.SUCCESS, + "failed": ProcessExitCode.FAILED, + "partial": ProcessExitCode.PARTIAL, + "blocked": ProcessExitCode.BLOCKED, + "already_running": ProcessExitCode.ALREADY_RUNNING, + "not_run": ProcessExitCode.NOT_RUN, +} + + +class SchedulerOutcomeError(RuntimeError): + """业务终态不是可信成功,必须让 APScheduler 记录本次执行失败。""" + + def __init__(self, outcome: Mapping[str, object]): + self.outcome = dict(outcome) + super().__init__( + "scheduler outcome=" + f"{self.outcome.get('outcome_status', 'failed')} " + f"exit_code={self.outcome.get('exit_code', int(ProcessExitCode.FAILED))}" + ) + + +def normalize_status(value: object) -> str: + """把业务状态收敛成六种稳定终态;未知值一律 fail closed。""" + status = str(value or "").strip().lower() + if status in _SUCCESS: + return "success" + if status in _FAILED: + return "failed" + if status in _PARTIAL: + return "partial" + if status in _BLOCKED: + return "blocked" + if status in _ALREADY_RUNNING: + return "already_running" + if status in _NOT_RUN: + return "not_run" + return "failed" + + +def outcome_for_status(value: object) -> dict: + outcome_status = normalize_status(value) + return { + "outcome_status": outcome_status, + "exit_code": int(_EXIT_CODES[outcome_status]), + } + + +def attach_outcome(payload: Mapping[str, object]) -> dict: + result = dict(payload) + result.update(outcome_for_status(result.get("status"))) + return result + + +def summarize_results(results: Iterable[Mapping[str, object]]) -> dict: + rows = [dict(item) for item in results] + if not rows: + summary = outcome_for_status("not_run") + summary.update({"result_count": 0, "source_statuses": []}) + return summary + + normalized = [normalize_status(item.get("status")) for item in rows] + kinds = set(normalized) + if "blocked" in kinds: + outcome_status = "blocked" + elif "already_running" in kinds: + outcome_status = "already_running" if kinds <= {"already_running", "not_run"} else "partial" + elif "partial" in kinds: + outcome_status = "partial" + elif "failed" in kinds: + outcome_status = "failed" if kinds == {"failed"} else "partial" + elif kinds == {"not_run"}: + outcome_status = "not_run" + elif "not_run" in kinds: + outcome_status = "partial" + else: + outcome_status = "success" + + summary = outcome_for_status(outcome_status) + summary.update( + { + "result_count": len(rows), + "source_statuses": sorted({str(item.get("status") or "") for item in rows}), + } + ) + return summary + + +def require_scheduler_success( + outcome: Mapping[str, object], + *, + allow_not_run: bool = False, +) -> None: + exit_code = int(outcome.get("exit_code", ProcessExitCode.FAILED)) + if exit_code == ProcessExitCode.SUCCESS: + return + if allow_not_run and exit_code == ProcessExitCode.NOT_RUN: + return + raise SchedulerOutcomeError(outcome) diff --git a/app/scheduler/send_job.py b/app/scheduler/send_job.py index 706198f..1f20208 100644 --- a/app/scheduler/send_job.py +++ b/app/scheduler/send_job.py @@ -5,15 +5,25 @@ from app.config.settings import get_settings from app.core.logging import get_logger from app.pipeline.daily_pipeline import DailyPipeline +from app.scheduler.outcome import require_scheduler_success, summarize_results logger = get_logger("groupbrief.scheduler") -def run_send_due_job() -> None: +def run_send_due_job() -> dict: """只处理已显式启用、到时、未发送且未被人工审核拦截的运行。""" try: results = DailyPipeline(settings=get_settings()).send_due() - if results: - logger.info("微信 send_due 扫描结果:%s", results) except Exception: logger.exception("微信 send_due 调度异常") + raise + outcome = summarize_results(results) + logger.info( + "微信 send_due 终态:outcome=%s exit_code=%d result_count=%d source_statuses=%s", + outcome["outcome_status"], + outcome["exit_code"], + outcome["result_count"], + outcome["source_statuses"], + ) + require_scheduler_success(outcome, allow_not_run=True) + return outcome diff --git a/docs/audits/P1_2_SCHEDULER_OWNERSHIP.md b/docs/audits/P1_2_SCHEDULER_OWNERSHIP.md new file mode 100644 index 0000000..b9dd176 --- /dev/null +++ b/docs/audits/P1_2_SCHEDULER_OWNERSHIP.md @@ -0,0 +1,101 @@ +# P1.2 单一调度所有权、退出码与假成功 + +日期:2026-08-25 + +## 结论 + +正式唯一调度 owner 统一为 8766 FastAPI 进程内的 APScheduler。Windows `GroupBriefDaily` 与 `GroupBriefDailySend` 不删除,只禁用保留为可回滚入口;配置和安装脚本会阻止它们在 `fastapi` owner 下重新启用。 + +```text +整改前 +FastAPI APScheduler ─┬─ 00:15 生成+邮件 + └─ 每分钟微信 send_due +Windows Task ───────┬─ 00:15 daily_auto.py + └─ 08:30~09:00 每分钟 send CLI + ↓ + 依赖 mutex/claim 碰撞避重 + +整改后 +Alter 管理的 8766 + └─ FastAPI APScheduler(唯一 owner) + ├─ 00:15 生成+邮件 + ├─ 启动补偿 + └─ 每分钟微信 send_due + +Windows 两任务:Disabled(只作回滚,不同时启用) +手动 CLI:保留,输出稳定 outcome 与退出码 +``` + +## 关键问题 + +- `daily_auto.py` 把 `already_running` 返回为 0,Windows 会误记成成功。 +- `run_daily_pipeline.py` 除参数缺失外所有业务分支固定返回 0;failed、held、结果未知都能假成功。 +- `run_send_due_job()` 捕获异常后正常返回,APScheduler 会把异常扫描记为正常执行。 +- `_generation_status()` 把未识别状态和 `no_groups` 落入 success。 +- 生成 partial 后,只要邮件子进程返回 0,整批状态会被覆盖成 success。 +- Windows 两阶段安装的第二步失败时,第一步残留,且安装命令仍返回 0。 + +## 终态与退出码 + +| outcome | exit code | 运维语义 | +| --- | ---: | --- | +| success | 0 | 本次完整成功或已有可信完成态 | +| failed | 1 | 本次失败 | +| partial | 2 | 部分成功,仍有失败项 | +| blocked | 3 | 结果未知或人工复核,禁止自动推进 | +| already_running | 4 | 本次没有取得执行所有权 | +| not_run | 5 | 没有可执行对象,本次没有实际工作 | + +未知业务状态不再默认 success,而是 fail closed 为 failed。APScheduler 没有进程退出码,因此使用同一 outcome;除分钟级正常空扫描 `not_run` 外,非 success 会抛出 `SchedulerOutcomeError`,让调度器记录失败。 + +## 修改范围 + +- `app/scheduler/outcome.py`:唯一业务终态、聚合和退出码契约。 +- `app/config/settings.py`、`app/main.py`:环境级 `scheduler_owner=fastapi|external|disabled`;只在 fastapi owner 下注册内部调度。 +- `app/api/system.py`:只读返回配置 owner 与实际 scheduler active 状态。 +- `app/scheduler/daily_v2_job.py`:no_groups/未知状态 fail closed;partial 不再被邮件成功覆盖;记录最近调用 outcome/exit code。 +- `app/scheduler/manager.py`、`app/scheduler/send_job.py`:APScheduler 对业务失败不再假成功或吞异常。 +- `scripts/daily_auto.py`、`scripts/run_daily_pipeline.py`:打印可机读 `OUTCOME` 并返回稳定退出码。 +- `scripts/install_daily_task.py`:owner 冲突时拒绝安装/启用,半安装自动回滚,状态冲突返回 blocked。 + +## 失败模式 + +| 场景 | 新行为 | +| --- | --- | +| 两实例争抢生成锁 | 未取得锁的一方 `already_running / exit 4` | +| 全部群生成失败 | `failed / exit 1` | +| 部分群失败、邮件成功 | 整批仍为 `partial / exit 2` | +| scheduler/run 状态损坏或发送结果未知 | `blocked / exit 3` | +| 无启用群 | `not_run / exit 5`,不调用邮件 | +| send_due 没有到点对象 | `not_run`,作为正常分钟扫描,不向 APScheduler 抛错 | +| send_due 返回 failed/held | 调度异常,不能记为成功 | +| Windows 第二个任务安装失败 | 删除刚创建的第一个任务并返回非零 | +| fastapi owner 下安装/启用 Windows 任务 | 操作被阻断 | + +## 验证 + +- P1.2 定向测试:55 项通过(2.42 秒)。 +- 隔离工作区完整测试:在新增最后 1 项 API 断言前为 518 项通过、1 条既有 warning;新增断言已包含在上述 55 项定向测试中。 +- Python `compileall`:通过。 +- 前端生产构建:通过(4596 modules transformed,约 5.65 秒)。 +- `git diff --check`:通过。 +- 独立完整测试随后两次被全局生成 mutex 阻断;进程取证确认锁持有者是另一个正在执行 23–28 号群组 Prompt 重建并调用摘要模型的真实任务,不是 P1.2 回归,也未强制终止。待该写入任务自然结束后补做一次无并发完整复验。 +- 2026-08-25 10:10 正式 owner 切换完成:`GroupBriefDaily` 与 `GroupBriefDailySend` 均为 `Disabled`,导出的任务 XML `Enabled=false`。 +- 切换后 `scripts/install_daily_task.py status` 返回 `scheduler_owner=fastapi`、两任务 disabled、`outcome=success / exit 0`。 +- 切换后 Alter 管理的 8766 仍正常运行,`/api/system/status` 返回 HTTP 200,下一生成时间为 2026-08-26 00:15。 +- 由于同一工作区另有未提交的 Prompt 生产代码正在执行,本轮没有冒险重启 8766;当前内置 APScheduler 原本已在运行,单一 owner 切换立即生效,新退出码/状态接口将在安全重启后加载。 +- 未触发真实 AI、邮件或微信发送。 + +## 回滚 + +回滚必须按顺序进行,不能先启用 Windows 任务: + +1. 将 8766 的 `SCHEDULER_OWNER` 切为 `external` 并重启,确认 API 返回 `scheduler_active=false`。 +2. 再启用 `GroupBriefDaily` 和 `GroupBriefDailySend`。 +3. 如需回滚代码,单独 revert 本次提交;没有数据库 Schema 变更。 + +## 非目标 + +- 不在本轮重写 V1 运行状态;V1 写路径在 P1.5 冻结和退役。 +- 不修改 Provider、Mock、AI/邮件幂等策略。 +- 不新增队列、分布式锁或外部调度平台。 diff --git a/scripts/daily_auto.py b/scripts/daily_auto.py index 1576d69..3f02031 100644 --- a/scripts/daily_auto.py +++ b/scripts/daily_auto.py @@ -18,6 +18,7 @@ from __future__ import annotations import argparse +import json import logging import os import sys @@ -30,6 +31,7 @@ from app.config.settings import get_settings from app.scheduler.daily_v2_job import run_daily_v2_job +from app.scheduler.outcome import attach_outcome LOG_DIR = PROJECT_ROOT / "output" / "logs" @@ -65,10 +67,23 @@ def main() -> int: settings=get_settings(), skip_email=args.skip_email, ) - log.info("===== 每日自动任务结束:%s =====", result) - return 0 if result.get("status") in { - "success", "skipped", "already_completed", "already_running" - } else 1 + outcome = attach_outcome(result) + audit = { + "status": outcome.get("status"), + "outcome_status": outcome["outcome_status"], + "exit_code": outcome["exit_code"], + "run_date": outcome.get("run_date", run_date), + "generation_status": outcome.get("generation_status"), + "email_status": outcome.get("email_status"), + } + log.info( + "===== 每日自动任务结束 status=%s outcome=%s exit_code=%d =====", + audit["status"], + audit["outcome_status"], + audit["exit_code"], + ) + print("OUTCOME " + json.dumps(audit, ensure_ascii=False, sort_keys=True)) + return int(outcome["exit_code"]) if __name__ == "__main__": diff --git a/scripts/install_daily_task.py b/scripts/install_daily_task.py index b229b8c..141a398 100644 --- a/scripts/install_daily_task.py +++ b/scripts/install_daily_task.py @@ -1,12 +1,13 @@ -"""GroupBrief V2 两阶段计划任务管理(Windows 任务计划程序)。 +r"""GroupBrief V2 外部调度回滚入口(Windows 任务计划程序)。 -- 每天 00:15 运行生成任务:统计前一天,生成排行榜、AI Prompt 和图片; -- 每天 08:30 起每分钟扫描一次,共 30 分钟:按群顺序发送排行榜和图片。 +正式默认 owner 是 8766 内 APScheduler。Windows 两个旧任务只能在明确配置 +``SCHEDULER_OWNER=external`` 后安装或启用,禁止与 FastAPI owner 同时工作。 用法(项目根目录打开终端): - .venv\\Scripts\\python.exe scripts/install_daily_task.py install # 安装 - .venv\\Scripts\\python.exe scripts/install_daily_task.py uninstall # 卸载 - .venv\\Scripts\\python.exe scripts/install_daily_task.py status # 查看状态 + .venv\Scripts\python.exe scripts/install_daily_task.py status + .venv\Scripts\python.exe scripts/install_daily_task.py disable + .venv\Scripts\python.exe scripts/install_daily_task.py enable # 仅 external owner + .venv\Scripts\python.exe scripts/install_daily_task.py install # 仅 external owner """ from __future__ import annotations @@ -15,9 +16,15 @@ import subprocess import sys from pathlib import Path +from xml.etree import ElementTree PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台 GBK 不支持 emoji,强制 UTF-8 +sys.path.insert(0, str(PROJECT_ROOT)) +sys.stdout.reconfigure(encoding="utf-8") + +from app.config.settings import get_settings +from app.scheduler.outcome import ProcessExitCode + GENERATE_TASK_NAME = "GroupBriefDaily" SEND_TASK_NAME = "GroupBriefDailySend" GENERATE_START_TIME = "00:15" @@ -31,97 +38,186 @@ TASK_NAME = GENERATE_TASK_NAME START_TIME = GENERATE_START_TIME -# 任务命令行:schtasks /TR 需要把整个命令用引号包住,内部的路径也各自加引号 _GENERATE_CMD = f'"{PYTHON_EXE}" "{DAILY_SCRIPT}"' _SEND_CMD = f'"{PYTHON_EXE}" "{PIPELINE_SCRIPT}" send' def _run(args: list[str]) -> tuple[int, str]: - r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace") - return r.returncode, (r.stdout or "") + (r.stderr or "") + result = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, (result.stdout or "") + (result.stderr or "") + +def _configured_owner(owner: str | None = None) -> str: + return owner or get_settings().scheduler_owner -def _install() -> str: + +def _install(owner: str | None = None) -> tuple[int, str]: + if _configured_owner(owner) != "external": + return ( + int(ProcessExitCode.BLOCKED), + "❌ 当前 scheduler_owner 不是 external,拒绝创建 Windows 业务任务", + ) if not PYTHON_EXE.exists(): - return f"❌ 未找到虚拟环境 Python:{PYTHON_EXE},请先在项目根目录创建 .venv" - - generate_code, generate_out = _run([ - "schtasks", "/Create", "/F", - "/TN", GENERATE_TASK_NAME, - "/TR", _GENERATE_CMD, - "/SC", "DAILY", - "/ST", GENERATE_START_TIME, - ]) + return ( + int(ProcessExitCode.FAILED), + f"❌ 未找到虚拟环境 Python:{PYTHON_EXE},请先创建 .venv", + ) + + generate_code, generate_out = _run( + [ + "schtasks", "/Create", "/F", "/TN", GENERATE_TASK_NAME, + "/TR", _GENERATE_CMD, "/SC", "DAILY", "/ST", GENERATE_START_TIME, + ] + ) if generate_code != 0: - return f"❌ 生成任务安装失败:\n{generate_out.strip()}" - - send_code, send_out = _run([ - "schtasks", "/Create", "/F", - "/TN", SEND_TASK_NAME, - "/TR", _SEND_CMD, - "/SC", "DAILY", - "/ST", SEND_START_TIME, - "/RI", "1", - "/DU", SEND_REPEAT_DURATION, - ]) + return int(ProcessExitCode.FAILED), f"❌ 生成任务安装失败:\n{generate_out.strip()}" + + send_code, send_out = _run( + [ + "schtasks", "/Create", "/F", "/TN", SEND_TASK_NAME, + "/TR", _SEND_CMD, "/SC", "DAILY", "/ST", SEND_START_TIME, + "/RI", "1", "/DU", SEND_REPEAT_DURATION, + ] + ) if send_code != 0: - return ( - f"⚠️ 已安装 {GENERATE_TASK_NAME},但发送任务安装失败:\n" - f"{send_out.strip()}" + rollback_code, rollback_out = _run( + ["schtasks", "/Delete", "/TN", GENERATE_TASK_NAME, "/F"] ) + rollback_detail = ( + "已回滚生成任务" + if rollback_code == 0 + else f"生成任务回滚失败:{rollback_out.strip()}" + ) + code = ProcessExitCode.FAILED if rollback_code == 0 else ProcessExitCode.PARTIAL + return int(code), f"❌ 发送任务安装失败;{rollback_detail}:\n{send_out.strip()}" + return ( - f"✅ 已安装两阶段计划任务:每天 {GENERATE_START_TIME} 生成前一日群报;" - f"{SEND_START_TIME} 起 30 分钟内每分钟扫描并顺序发送\n" - f"{generate_out.strip()}\n{send_out.strip()}" + int(ProcessExitCode.SUCCESS), + f"✅ 已安装 external owner 两阶段任务:每天 {GENERATE_START_TIME} 生成;" + f"{SEND_START_TIME} 起 30 分钟内每分钟扫描\n" + f"{generate_out.strip()}\n{send_out.strip()}", ) -def _uninstall() -> str: +def _uninstall() -> tuple[int, str]: messages: list[str] = [] removed = 0 + failures = 0 for task_name in (GENERATE_TASK_NAME, SEND_TASK_NAME): code, out = _run(["schtasks", "/Delete", "/TN", task_name, "/F"]) if code == 0: removed += 1 messages.append(f"✅ 已卸载计划任务「{task_name}」") else: - messages.append(f"ℹ️ 计划任务「{task_name}」未安装:{out.strip() or '无记录'}") + failures += 1 + messages.append(f"ℹ️ 计划任务「{task_name}」未卸载:{out.strip() or '无记录'}") if removed == 0: - messages.insert(0, "ℹ️ 没有已安装的 GroupBrief 两阶段计划任务") - return "\n".join(messages) + messages.insert(0, "ℹ️ 没有已安装的 GroupBrief 两阶段任务") + code = ProcessExitCode.PARTIAL if removed and failures else ProcessExitCode.SUCCESS + return int(code), "\n".join(messages) -def _status_one(task_name: str) -> str: - code, out = _run(["schtasks", "/Query", "/TN", task_name, "/V", "/FO", "LIST"]) +def _query_enabled(task_name: str) -> tuple[bool, bool | None, str]: + code, out = _run(["schtasks", "/Query", "/TN", task_name, "/XML"]) if code != 0: - return "❌ 计划任务「%s」未安装(%s)" % (task_name, out.strip() or "无记录") - # 提取关键字段,避免整页输出 - lines = [f"【{task_name}】"] - for key in ("任务名", "下次运行时间", "上次运行时间", "上次结果", "状态", "要运行的任务"): - for line in out.splitlines(): - if line.strip().startswith(key): - lines.append(line.strip()) - break - return "\n".join(lines) if len(lines) > 1 else f"【{task_name}】\n{out.strip()}" - - -def _status() -> str: - return "\n\n".join( - _status_one(task_name) for task_name in (GENERATE_TASK_NAME, SEND_TASK_NAME) + return False, None, out.strip() or "未安装" + xml_text = out.lstrip("\ufeff\r\n ") + if xml_text.startswith("" in xml_text: + xml_text = xml_text.split("?>", 1)[1] + try: + root = ElementTree.fromstring(xml_text) + except ElementTree.ParseError: + return True, None, "任务 XML 无法解析" + enabled_text = next( + (node.text for node in root.iter() if node.tag.rsplit("}", 1)[-1] == "Enabled"), + None, + ) + if enabled_text is None: + # Task Scheduler Schema 中 Enabled 可省略,省略时默认 true。 + return True, True, "" + return True, enabled_text.strip().lower() == "true", "" + + +def _status(owner: str | None = None) -> tuple[int, str]: + owner = _configured_owner(owner) + states = {name: _query_enabled(name) for name in (GENERATE_TASK_NAME, SEND_TASK_NAME)} + lines = [f"scheduler_owner={owner}"] + for name, (exists, enabled, detail) in states.items(): + if not exists: + state = "not_installed" + elif enabled is True: + state = "enabled" + elif enabled is False: + state = "disabled" + else: + state = "unknown" + lines.append(f"{name}={state}{f' ({detail})' if detail else ''}") + + enabled_count = sum(enabled is True for _, enabled, _ in states.values()) + installed_count = sum(exists for exists, _, _ in states.values()) + unknown_count = sum(exists and enabled is None for exists, enabled, _ in states.values()) + conflict = ( + unknown_count > 0 + or (owner in {"fastapi", "disabled"} and enabled_count > 0) + or (owner == "external" and (installed_count != 2 or enabled_count != 2)) + ) + if conflict: + lines.append("outcome=blocked:配置 owner 与 Windows 任务状态不一致") + return int(ProcessExitCode.BLOCKED), "\n".join(lines) + lines.append("outcome=success") + return int(ProcessExitCode.SUCCESS), "\n".join(lines) + + +def _set_enabled(enabled: bool, owner: str | None = None) -> tuple[int, str]: + if enabled and _configured_owner(owner) != "external": + return int(ProcessExitCode.BLOCKED), "❌ 只有 external owner 才能启用 Windows 业务任务" + messages: list[str] = [] + failures = 0 + for task_name in (GENERATE_TASK_NAME, SEND_TASK_NAME): + exists, current, detail = _query_enabled(task_name) + if not exists: + messages.append(f"ℹ️ {task_name} 未安装") + continue + if current is enabled: + messages.append(f"✅ {task_name} 已是 {'enabled' if enabled else 'disabled'}") + continue + code, out = _run( + ["schtasks", "/Change", "/TN", task_name, "/Enable" if enabled else "/Disable"] + ) + if code == 0: + messages.append(f"✅ {task_name} 已{'启用' if enabled else '禁用'}") + else: + failures += 1 + messages.append(f"❌ {task_name} 切换失败:{out.strip() or detail}") + return ( + int(ProcessExitCode.FAILED if failures else ProcessExitCode.SUCCESS), + "\n".join(messages), ) def main() -> int: - parser = argparse.ArgumentParser(description="GroupBrief 每日计划任务管理") - parser.add_argument("action", choices=["install", "uninstall", "status"]) + parser = argparse.ArgumentParser(description="GroupBrief Windows 外部调度回滚入口") + parser.add_argument("action", choices=["install", "uninstall", "status", "disable", "enable"]) args = parser.parse_args() if args.action == "install": - print(_install()) + code, message = _install() elif args.action == "uninstall": - print(_uninstall()) + code, message = _uninstall() + elif args.action == "disable": + code, message = _set_enabled(False) + elif args.action == "enable": + code, message = _set_enabled(True) else: - print(_status()) - return 0 + code, message = _status() + print(message) + print(f"OUTCOME exit_code={code}") + return code if __name__ == "__main__": diff --git a/scripts/run_daily_pipeline.py b/scripts/run_daily_pipeline.py index 67fc471..2afd8eb 100644 --- a/scripts/run_daily_pipeline.py +++ b/scripts/run_daily_pipeline.py @@ -14,6 +14,7 @@ from __future__ import annotations import argparse +import json import sys from pathlib import Path @@ -23,6 +24,7 @@ from app.config.settings import get_settings from app.db import repository as repo from app.pipeline.daily_pipeline import DailyPipeline +from app.scheduler.outcome import outcome_for_status, summarize_results def _pipeline(dry_run: bool = False) -> DailyPipeline: @@ -39,6 +41,62 @@ def _print_results(results) -> None: print(f" [{status}] {r.get('group_name', '')} {detail}") +def _print_outcome(outcome: dict) -> int: + audit = { + "outcome_status": outcome["outcome_status"], + "exit_code": int(outcome["exit_code"]), + "result_count": int(outcome.get("result_count") or 0), + "source_statuses": outcome.get("source_statuses") or [], + } + print("OUTCOME " + json.dumps(audit, ensure_ascii=False, sort_keys=True)) + return audit["exit_code"] + + +def _finish_results(results: list[dict]) -> int: + _print_results(results) + return _print_outcome(summarize_results(results)) + + +def _execute(args, pipeline: DailyPipeline) -> int: + if args.cmd == "status": + runs = pipeline.store.list_runs() + print(f"共 {len(runs)} 个运行记录:") + for run in runs[:20]: + print( + f" {run.get('run_date')} | {run.get('group_name')} | " + f"{run.get('status')} | {run.get('updated_at')}" + ) + if any(str(run.get("status") or "").upper() == "CORRUPT" for run in runs): + return _print_outcome(outcome_for_status("blocked")) + return _print_outcome(outcome_for_status("success" if runs else "not_run")) + + if args.cmd == "generate": + return _finish_results( + pipeline.generate_all( + run_date=args.date, + group_ids=args.group, + refresh_messages=args.refresh_messages, + ) + ) + if args.cmd == "send": + return _finish_results(pipeline.send_due()) + if args.cmd == "force-generate": + return _finish_results( + [ + pipeline.force_generate( + args.group, + args.date, + refresh_messages=args.refresh_messages, + ) + ] + ) + if args.cmd == "rebuild-prompt": + return _finish_results([pipeline.rebuild_prompt_from_snapshot(args.group, args.date)]) + if args.cmd == "force-send": + return _finish_results([pipeline.force_send(args.group, args.date)]) + return _print_outcome(outcome_for_status("failed")) + + def main() -> int: parser = argparse.ArgumentParser(description="GroupBrief V2 每日流水线") sub = parser.add_subparsers(dest="cmd") @@ -78,52 +136,33 @@ def main() -> int: args = parser.parse_args() if not args.cmd: parser.print_help() - return 1 - - if args.cmd == "status": - pipeline = _pipeline(dry_run=True) - runs = pipeline.store.list_runs() - print(f"共 {len(runs)} 个运行记录:") - for r in runs[:20]: - print(f" {r.get('run_date')} | {r.get('group_name')} | {r.get('status')} | {r.get('updated_at')}") - return 0 - - pipeline = _pipeline(dry_run=bool(getattr(args, "dry_run", False))) + return _print_outcome(outcome_for_status("failed")) - if args.cmd == "generate": - results = pipeline.generate_all( - run_date=args.date, - group_ids=args.group, - refresh_messages=args.refresh_messages, + try: + pipeline = _pipeline( + dry_run=True if args.cmd == "status" else bool(getattr(args, "dry_run", False)) ) - _print_results(results) - return 0 - - if args.cmd == "send": - results = pipeline.send_due() - _print_results(results) - return 0 - - if args.cmd == "force-generate": - r = pipeline.force_generate( - args.group, - args.date, - refresh_messages=args.refresh_messages, + except Exception as exc: + return _finish_results( + [ + { + "status": "failed", + "detail": f"初始化失败:{type(exc).__name__}: {str(exc)[:240]}", + } + ] ) - _print_results([r]) - return 0 - - if args.cmd == "rebuild-prompt": - r = pipeline.rebuild_prompt_from_snapshot(args.group, args.date) - _print_results([r]) - return 0 - - if args.cmd == "force-send": - r = pipeline.force_send(args.group, args.date) - _print_results([r]) - return 0 - return 1 + try: + return _execute(args, pipeline) + except Exception as exc: + return _finish_results( + [ + { + "status": "failed", + "detail": f"执行失败:{type(exc).__name__}: {str(exc)[:240]}", + } + ] + ) if __name__ == "__main__": diff --git a/tests/test_daily_auto.py b/tests/test_daily_auto.py index 7ba5adf..2edc3b4 100644 --- a/tests/test_daily_auto.py +++ b/tests/test_daily_auto.py @@ -3,32 +3,34 @@ import sys from types import SimpleNamespace -from scripts import daily_auto - - -def test_daily_auto_partial_returns_nonzero(monkeypatch): - monkeypatch.setattr(daily_auto, "_setup_logging", lambda: None) - monkeypatch.setattr(daily_auto, "get_settings", lambda: SimpleNamespace()) - monkeypatch.setattr(daily_auto.os, "chdir", lambda path: None) - monkeypatch.setattr( - daily_auto, - "run_daily_v2_job", - lambda *args, **kwargs: {"status": "partial"}, - ) - monkeypatch.setattr(sys, "argv", ["daily_auto.py", "--skip-email"]) +import pytest - assert daily_auto.main() == 1 +from scripts import daily_auto -def test_daily_auto_success_returns_zero(monkeypatch): +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("success", 0), + ("already_completed", 0), + ("failed", 1), + ("partial", 2), + ("blocked", 3), + ("held", 3), + ("already_running", 4), + ("no_groups", 5), + ], +) +def test_daily_auto_uses_stable_outcome_exit_codes(monkeypatch, capsys, status, expected): monkeypatch.setattr(daily_auto, "_setup_logging", lambda: None) monkeypatch.setattr(daily_auto, "get_settings", lambda: SimpleNamespace()) monkeypatch.setattr(daily_auto.os, "chdir", lambda path: None) monkeypatch.setattr( daily_auto, "run_daily_v2_job", - lambda *args, **kwargs: {"status": "success"}, + lambda *args, **kwargs: {"status": status}, ) monkeypatch.setattr(sys, "argv", ["daily_auto.py", "--skip-email"]) - assert daily_auto.main() == 0 + assert daily_auto.main() == expected + assert f'"exit_code": {expected}' in capsys.readouterr().out diff --git a/tests/test_install_daily_task.py b/tests/test_install_daily_task.py index 4dc6cae..bd91cdf 100644 --- a/tests/test_install_daily_task.py +++ b/tests/test_install_daily_task.py @@ -6,40 +6,105 @@ from scripts import install_daily_task as installer -def test_install_creates_generation_and_repeating_send_tasks(monkeypatch): - calls: list[list[str]] = [] - monkeypatch.setattr(installer, "PYTHON_EXE", Path(sys.executable)) +def _task_xml(enabled: bool) -> str: + value = "true" if enabled else "false" + return f'{value}' + + +def test_install_refuses_to_create_second_owner(monkeypatch): monkeypatch.setattr( installer, "_run", - lambda args: (calls.append(args) or (0, "ok")), + lambda args: (_ for _ in ()).throw(AssertionError("owner 冲突时不得调用 schtasks")), ) - result = installer._install() + code, message = installer._install(owner="fastapi") + + assert code == 3 + assert "拒绝创建" in message + + +def test_external_install_creates_generation_and_repeating_send_tasks(monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr(installer, "PYTHON_EXE", Path(sys.executable)) + monkeypatch.setattr(installer, "_run", lambda args: (calls.append(args) or (0, "ok"))) + + code, message = installer._install(owner="external") - assert "00:15" in result - assert "08:30" in result + assert code == 0 + assert "00:15" in message + assert "08:30" in message assert len(calls) == 2 assert calls[0][calls[0].index("/TN") + 1] == installer.GENERATE_TASK_NAME - assert calls[0][calls[0].index("/ST") + 1] == "00:15" assert calls[1][calls[1].index("/TN") + 1] == installer.SEND_TASK_NAME - assert calls[1][calls[1].index("/ST") + 1] == "08:30" assert calls[1][calls[1].index("/RI") + 1] == "1" assert calls[1][calls[1].index("/DU") + 1] == "00:30" -def test_uninstall_attempts_both_tasks(monkeypatch): +def test_install_rolls_back_generation_when_send_task_fails(monkeypatch): calls: list[list[str]] = [] + monkeypatch.setattr(installer, "PYTHON_EXE", Path(sys.executable)) + + def fake_run(args): + calls.append(args) + if "/Create" in args and installer.SEND_TASK_NAME in args: + return 1, "send failed" + return 0, "ok" + + monkeypatch.setattr(installer, "_run", fake_run) + + code, message = installer._install(owner="external") + + assert code == 1 + assert "已回滚生成任务" in message + assert calls[-1] == ["schtasks", "/Delete", "/TN", installer.GENERATE_TASK_NAME, "/F"] + + +def test_status_detects_enabled_legacy_tasks_as_fastapi_owner_conflict(monkeypatch): + monkeypatch.setattr(installer, "_run", lambda args: (0, _task_xml(True))) + + code, message = installer._status(owner="fastapi") + + assert code == 3 + assert "outcome=blocked" in message + + +def test_task_xml_without_enabled_uses_windows_default_true(monkeypatch): + monkeypatch.setattr(installer, "_run", lambda args: (0, "")) + + assert installer._query_enabled(installer.GENERATE_TASK_NAME) == (True, True, "") + + +def test_status_accepts_disabled_legacy_tasks_for_fastapi_owner(monkeypatch): + monkeypatch.setattr(installer, "_run", lambda args: (0, _task_xml(False))) + + code, message = installer._status(owner="fastapi") + + assert code == 0 + assert "outcome=success" in message + + +def test_enable_requires_external_owner(monkeypatch): monkeypatch.setattr( installer, "_run", - lambda args: (calls.append(args) or (0, "ok")), + lambda args: (_ for _ in ()).throw(AssertionError("owner 冲突时不得启用任务")), ) - result = installer._uninstall() + code, _ = installer._set_enabled(True, owner="fastapi") + + assert code == 3 + + +def test_uninstall_attempts_both_tasks(monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr(installer, "_run", lambda args: (calls.append(args) or (0, "ok"))) + + code, message = installer._uninstall() - assert installer.GENERATE_TASK_NAME in result - assert installer.SEND_TASK_NAME in result + assert code == 0 + assert installer.GENERATE_TASK_NAME in message + assert installer.SEND_TASK_NAME in message assert [call[call.index("/TN") + 1] for call in calls] == [ installer.GENERATE_TASK_NAME, installer.SEND_TASK_NAME, diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 54791ef..dc19f70 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,8 +1,11 @@ """P7 测试:Scheduler 配置与任务函数。""" from datetime import datetime, time +from types import SimpleNamespace from zoneinfo import ZoneInfo +import pytest + from app.scheduler.manager import ( _parse_generate_time, _schedule_startup_catchup, @@ -59,6 +62,18 @@ def test_generate_job_runs_every_day(): assert result["status"] in ("success", "partial", "failed") +def test_scheduler_owner_controls_fastapi_registration(monkeypatch): + from app.config.settings import Settings + from app.main import _should_start_scheduler + + monkeypatch.delenv("GROUPBRIEF_NO_SCHEDULER", raising=False) + assert _should_start_scheduler(Settings(_env_file=None, scheduler_owner="fastapi")) is True + assert _should_start_scheduler(Settings(_env_file=None, scheduler_owner="external")) is False + assert _should_start_scheduler(Settings(_env_file=None, scheduler_owner="disabled")) is False + monkeypatch.setenv("GROUPBRIEF_NO_SCHEDULER", "1") + assert _should_start_scheduler(Settings(_env_file=None, scheduler_owner="fastapi")) is False + + def test_daily_v2_job_persists_generation_and_email_idempotency(tmp_path, monkeypatch): from app.config.settings import Settings from app.scheduler import daily_v2_job as daily @@ -173,6 +188,171 @@ def generate_all(self, run_date, acquire_lock=True): assert state["generation_hold"] is True +def test_daily_v2_job_reports_busy_as_not_executed(monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + from app.services.generation_runtime import GenerationBusyError + + class BusyContext: + def __enter__(self): + raise GenerationBusyError("已有实例") + + def __exit__(self, *_args): + return False + + monkeypatch.setattr(daily, "_daily_mutex", lambda: BusyContext()) + + result = daily.run_daily_v2_job( + "2026-08-25", + settings=Settings(_env_file=None), + skip_email=True, + ) + + assert result["status"] == "already_running" + assert result["outcome_status"] == "already_running" + assert result["exit_code"] == 4 + + +def test_partial_generation_stays_partial_even_when_email_succeeds(tmp_path, monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_send_partial_report=True, + ) + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + class PartialPipeline: + def __init__(self, settings): + pass + + def generate_all(self, run_date, acquire_lock=True): + return [ + {"group_name": "群A", "status": "ready_to_send"}, + {"group_name": "群B", "status": "failed"}, + ] + + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + monkeypatch.setattr(daily, "DailyPipeline", PartialPipeline) + monkeypatch.setattr(daily.repo, "init_db", lambda settings: None) + monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: []) + monkeypatch.setattr( + daily.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="sent", stderr=""), + ) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + + assert result["status"] == "partial" + assert result["outcome_status"] == "partial" + assert result["exit_code"] == 2 + assert result["email_status"] == "sent" + + +def test_no_groups_is_not_run_and_never_calls_email(tmp_path, monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + class EmptyPipeline: + def __init__(self, settings): + pass + + def generate_all(self, run_date, acquire_lock=True): + return [{"status": "no_groups", "reason": "无启用群"}] + + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + monkeypatch.setattr(daily, "DailyPipeline", EmptyPipeline) + monkeypatch.setattr(daily.repo, "init_db", lambda settings: None) + monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: []) + monkeypatch.setattr( + daily.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("no_groups 不得调用邮件"), + ) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + + assert result["status"] == "not_run" + assert result["exit_code"] == 5 + assert result["email_status"] == "skipped_generation_not_successful" + + +@pytest.mark.parametrize( + ("results", "expected"), + [ + ([{"status": "ready_to_send"}], "success"), + ([{"status": "no_groups"}], "not_run"), + ([{"status": "blocked"}], "blocked"), + ([{"status": "held"}], "blocked"), + ([{"status": "unexpected"}], "failed"), + ], +) +def test_generation_status_fails_closed(results, expected): + from app.scheduler.daily_v2_job import _generation_status + + assert _generation_status(results) == expected + + +def test_apscheduler_daily_wrapper_raises_for_partial(monkeypatch): + from app.scheduler import manager + from app.scheduler.outcome import SchedulerOutcomeError + + monkeypatch.setattr( + manager, + "run_daily_v2_job", + lambda *args, **kwargs: {"status": "partial", "outcome_status": "partial", "exit_code": 2}, + ) + + with pytest.raises(SchedulerOutcomeError): + manager.run_scheduled_daily_v2_job("2026-08-25") + + +def test_send_due_scheduler_allows_empty_scan_but_rejects_failure(monkeypatch): + from app.scheduler import send_job + from app.scheduler.outcome import SchedulerOutcomeError + + class EmptyPipeline: + def __init__(self, settings): + pass + + def send_due(self): + return [] + + monkeypatch.setattr(send_job, "DailyPipeline", EmptyPipeline) + assert send_job.run_send_due_job()["outcome_status"] == "not_run" + + class FailedPipeline(EmptyPipeline): + def send_due(self): + return [{"group_name": "群A", "status": "failed"}] + + monkeypatch.setattr(send_job, "DailyPipeline", FailedPipeline) + with pytest.raises(SchedulerOutcomeError): + send_job.run_send_due_job() + + class BrokenPipeline(EmptyPipeline): + def send_due(self): + raise RuntimeError("simulated send scan failure") + + monkeypatch.setattr(send_job, "DailyPipeline", BrokenPipeline) + with pytest.raises(RuntimeError, match="simulated send scan failure"): + send_job.run_send_due_job() + + def test_corrupt_scheduler_state_blocks_generation_email_and_overwrite(tmp_path, monkeypatch): import pytest diff --git a/tests/test_scheduler_outcome.py b/tests/test_scheduler_outcome.py new file mode 100644 index 0000000..cbaeb3f --- /dev/null +++ b/tests/test_scheduler_outcome.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest + +from app.scheduler.outcome import ( + SchedulerOutcomeError, + outcome_for_status, + require_scheduler_success, + summarize_results, +) +from scripts import run_daily_pipeline as cli + + +@pytest.mark.parametrize( + ("status", "outcome", "exit_code"), + [ + ("success", "success", 0), + ("already_completed", "success", 0), + ("failed", "failed", 1), + ("partial", "partial", 2), + ("held", "blocked", 3), + ("already_running", "already_running", 4), + ("no_groups", "not_run", 5), + ("unexpected-new-status", "failed", 1), + ], +) +def test_stable_outcome_contract(status, outcome, exit_code): + result = outcome_for_status(status) + assert result == {"outcome_status": outcome, "exit_code": exit_code} + + +def test_result_aggregation_never_turns_unknown_or_mixed_failure_into_success(): + assert summarize_results([])["outcome_status"] == "not_run" + assert summarize_results([{"status": "no_groups"}])["exit_code"] == 5 + assert summarize_results([{"status": "ready_to_send"}, {"status": "failed"}])[ + "outcome_status" + ] == "partial" + assert summarize_results([{"status": "sent"}, {"status": "held"}])[ + "outcome_status" + ] == "blocked" + assert summarize_results([{"status": "new-provider-state"}])["outcome_status"] == "failed" + + +def test_scheduler_rejects_business_failure_but_allows_periodic_no_work(): + with pytest.raises(SchedulerOutcomeError): + require_scheduler_success(outcome_for_status("partial")) + require_scheduler_success(outcome_for_status("not_run"), allow_not_run=True) + + +def test_system_status_exposes_configured_owner_and_actual_scheduler_state(monkeypatch): + from app.api import system + from app.config.settings import Settings + + monkeypatch.setattr(system.repo, "list_groups", lambda session, only_enabled=False: []) + + result = system.status( + session=object(), + settings=Settings(_env_file=None, scheduler_owner="external"), + ) + + assert result["scheduler_owner"] == "external" + assert result["scheduler_active"] is False + + +class _FakeStore: + def __init__(self, runs=None): + self.runs = runs or [] + + def list_runs(self): + return self.runs + + +class _FakePipeline: + def __init__(self, status: str, *, raises: bool = False): + self.status = status + self.raises = raises + self.store = _FakeStore() + + def _result(self): + if self.raises: + raise RuntimeError("simulated failure") + return {"group_name": "测试群", "status": self.status} + + def generate_all(self, **_kwargs): + return [self._result()] + + def send_due(self): + return [self._result()] + + def force_generate(self, *_args, **_kwargs): + return self._result() + + def rebuild_prompt_from_snapshot(self, *_args, **_kwargs): + return self._result() + + def force_send(self, *_args, **_kwargs): + return self._result() + + +@pytest.mark.parametrize( + ("argv", "status", "expected"), + [ + (["run_daily_pipeline.py", "generate"], "partial", 2), + (["run_daily_pipeline.py", "send"], "held", 3), + (["run_daily_pipeline.py", "force-generate", "--group", "1"], "failed", 1), + ( + ["run_daily_pipeline.py", "rebuild-prompt", "--group", "1", "--date", "2026-08-25"], + "blocked", + 3, + ), + (["run_daily_pipeline.py", "force-send", "--group", "1"], "sent", 0), + ], +) +def test_pipeline_cli_propagates_business_outcome(monkeypatch, capsys, argv, status, expected): + monkeypatch.setattr(sys, "argv", argv) + monkeypatch.setattr(cli, "_pipeline", lambda dry_run=False: _FakePipeline(status)) + + assert cli.main() == expected + assert f'"exit_code": {expected}' in capsys.readouterr().out + + +def test_pipeline_cli_reports_unhandled_action_exception_as_failure(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["run_daily_pipeline.py", "send"]) + monkeypatch.setattr(cli, "_pipeline", lambda dry_run=False: _FakePipeline("sent", raises=True)) + + assert cli.main() == 1 + output = capsys.readouterr().out + assert "RuntimeError" in output + assert '"outcome_status": "failed"' in output + + +def test_pipeline_status_blocks_when_any_run_state_is_corrupt(monkeypatch): + pipeline = _FakePipeline("success") + pipeline.store = _FakeStore([{"status": "CORRUPT"}]) + args = SimpleNamespace(cmd="status") + + assert cli._execute(args, pipeline) == 3 From be24c229c37617b0281df18d06755749c26ab6cf Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 25 Aug 2026 11:05:32 +0800 Subject: [PATCH 10/42] =?UTF-8?q?fix:=20=E8=AE=A9=20Provider=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=BB=98=E8=AE=A4=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 +-- app/api/settings.py | 54 +++++++--- app/config/settings.py | 10 +- app/image/codex_generator.py | 5 + app/providers/ai/codex.py | 20 +++- app/providers/history/mock.py | 8 +- app/providers/history/registry.py | 41 ++++++-- app/providers/history/wechat_cli.py | 8 +- app/providers/history/wechat_data_analysis.py | 2 +- app/scheduler/daily_v2_job.py | 20 +++- app/sender/wechat_native.py | 15 ++- app/services/email_service.py | 26 ++++- app/services/history_service.py | 2 +- app/services/prompt_service.py | 23 ++++- docs/audits/P1_3_PROVIDER_CONFIG.md | 98 +++++++++++++++++++ frontend/src/pages/v2/Settings.tsx | 11 +-- scripts/send_daily_email.py | 9 +- tests/test_codex_summary_provider.py | 12 +++ tests/test_email.py | 42 +++++++- tests/test_handoff.py | 24 ++++- tests/test_prompt.py | 64 ++++++++++-- tests/test_providers.py | 78 ++++++++++++++- tests/test_scheduler.py | 67 ++++++++++++- tests/test_send_daily_email.py | 20 ++++ tests/test_settings_provider_validation.py | 98 +++++++++++++++++++ tests/test_ui_api.py | 2 +- tests/test_v2_image_task.py | 22 ++++- tests/test_v2_wechat_native.py | 10 ++ 28 files changed, 722 insertions(+), 82 deletions(-) create mode 100644 docs/audits/P1_3_PROVIDER_CONFIG.md create mode 100644 tests/test_settings_provider_validation.py diff --git a/.env.example b/.env.example index c40121e..775e966 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,18 @@ APP_HOST=127.0.0.1 APP_PORT=8766 APP_TIMEZONE=Asia/Shanghai +# 仅自动化测试或显式本地假数据演练可开启;真实运行必须保持 false。 +ALLOW_TEST_PROVIDERS=false # --- 数据库 --- DATABASE_URL=sqlite:///data/groupbrief.db -# --- 微信历史读取 Provider --- -# 主 Provider:wechat_data_analysis(WeChatDataAnalysis) -# 备用 Provider:wechat_cli(wechat-cli) +# --- V1 兼容历史读取 Provider(正式 V2 固定使用 WeChatDataAnalysis MCP/导出)--- +# 不通过生产设置 UI 修改;V1 主/备仍保留一版兼容。 HISTORY_PROVIDER_PRIMARY=wechat_data_analysis HISTORY_PROVIDER_FALLBACK=wechat_cli -HISTORY_PROVIDER_MOCK_ENABLED=true +# Mock 必须同时满足 ALLOW_TEST_PROVIDERS=true 才会注册。 +HISTORY_PROVIDER_MOCK_ENABLED=false # 微信数据目录(WeChatDataAnalysis 原始数据探测/状态提示使用,不用于群名解析) WECHAT_DATA_DIR= # 微信导出数据目录(WeChatDataAnalysis 结构化 JSON 导出根目录,群名解析与消息读取使用)。 @@ -52,8 +54,7 @@ CODEX_SUMMARY_TIMEOUT_SECONDS=240 CODEX_SUMMARY_MAX_RETRIES=2 CODEX_SUMMARY_REQUEST_CONCURRENCY=2 -# DeepSeek 备用(只有 Codex 主调用失败时才使用) -AI_PROVIDER=deepseek +# DeepSeek 备用(只有 SUMMARY_PROVIDER_FALLBACK=deepseek 时才使用) AI_BASE_URL=https://api.deepseek.com AI_API_KEY= AI_MODEL=deepseek-v4-flash diff --git a/app/api/settings.py b/app/api/settings.py index 9d71fa8..6050aef 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -5,11 +5,15 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlmodel import Session from app.db import repository as repo +from app.config.settings import get_settings as get_runtime_settings +from app.providers.ai.codex import validate_summary_provider_config +from app.sender.wechat_native import validate_wechat_sender_mode +from app.services.email_service import email_delivery_config_error router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -22,9 +26,6 @@ } EDITABLE_KEYS = { - "history_provider_primary", - "history_provider_fallback", - "history_provider_mock_enabled", "wechat_data_dir", "wechat_export_dir", "wechat_cli_path", @@ -41,7 +42,6 @@ "codex_summary_timeout_seconds", "codex_summary_max_retries", "codex_summary_request_concurrency", - "ai_provider", "ai_base_url", "ai_api_key", "ai_model", @@ -76,6 +76,18 @@ ALL_KEYS = EDITABLE_KEYS | SENSITIVE_KEYS +_SUMMARY_CONFIG_KEYS = {"summary_provider_primary", "summary_provider_fallback"} +_EMAIL_CONFIG_KEYS = { + "email_enabled", + "email_recipient", + "email_from", + "email_smtp_host", + "email_smtp_port", + "email_smtp_user", + "email_smtp_password", + "email_use_ssl", +} + class SettingsPayload(BaseModel): values: dict[str, str] @@ -95,17 +107,35 @@ def get_settings(session: Session = Depends(repo.get_session)): @router.put("") def update_settings(payload: SettingsPayload, session: Session = Depends(repo.get_session)): - applied: dict[str, str] = {} + requested: dict[str, str] = {} for key, value in payload.values.items(): if key not in EDITABLE_KEYS: continue if key in SENSITIVE_KEYS and value in ("", "******"): continue # 不覆盖已有密钥 - repo.set_setting_value(session, key, value) - applied[key] = value - if applied: - # 让本次修改立即在运行中的 Settings 实例生效(类型安全、忽略掩码值)。 - from app.config.settings import get_settings + requested[key] = value + if requested: + runtime_settings = get_runtime_settings() + candidate = runtime_settings.model_copy(deep=True) + converted = set(candidate.apply_runtime_values(requested)) + rejected = sorted(set(requested) - converted) + if rejected: + raise HTTPException(status_code=422, detail=f"设置值类型无效:{', '.join(rejected)}") + changed = set(requested) + try: + if changed & _SUMMARY_CONFIG_KEYS: + validate_summary_provider_config(candidate) + if "wechat_sender_mode" in changed: + validate_wechat_sender_mode(candidate) + if candidate.email_enabled and changed & _EMAIL_CONFIG_KEYS: + email_error = email_delivery_config_error(candidate) + if email_error: + raise ValueError(email_error) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc - get_settings().apply_runtime_values(applied) + # 先完整校验,再写入持久化设置,避免部分无效配置已经入库。 + for key, value in requested.items(): + repo.set_setting_value(session, key, value) + runtime_settings.apply_runtime_values(requested) return {"ok": True} diff --git a/app/config/settings.py b/app/config/settings.py index 992cd1c..56edfc1 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -22,14 +22,16 @@ class Settings(BaseSettings): app_host: str = "127.0.0.1" app_port: int = 8766 app_timezone: str = "Asia/Shanghai" + # 测试 Provider 安全闸门:真实运行默认关闭,且不通过设置 API/数据库修改。 + allow_test_providers: bool = False # 数据库 database_url: str = "sqlite:///data/groupbrief.db" - # 微信历史读取 + # V1 兼容历史读取;正式 V2 使用下方 WeChatDataAnalysis MCP/导出配置。 history_provider_primary: str = "wechat_data_analysis" history_provider_fallback: str = "wechat_cli" - history_provider_mock_enabled: bool = True + history_provider_mock_enabled: bool = False wechat_data_dir: str = "" wechat_export_dir: str = "" wechat_cli_path: str = "" @@ -60,6 +62,8 @@ class Settings(BaseSettings): codex_summary_request_concurrency: int = 2 # DeepSeek 备用 + # 旧设置兼容字段;真实路由只使用 summary_provider_primary/fallback。 + # 不再通过设置 API 暴露,保留一版以兼容旧 .env/数据库。 ai_provider: str = "deepseek" ai_base_url: str = "https://api.deepseek.com" ai_api_key: str = "" @@ -185,7 +189,7 @@ def _coerce_setting_value(key: str, raw: Any, annotation: Any) -> Any: return True if text in _BOOL_FALSE: return False - return bool(text) + raise ValueError(f"{key} 必须是 true/false") if annotation is int or annotation == int: if isinstance(raw, bool): return int(raw) diff --git a/app/image/codex_generator.py b/app/image/codex_generator.py index 2cdeb96..fc81f63 100644 --- a/app/image/codex_generator.py +++ b/app/image/codex_generator.py @@ -234,6 +234,11 @@ def health_check(self) -> tuple[bool, str]: # ---------- 生成 ---------- def generate(self, prompt_file: Path, output_path: Path) -> ImageTaskResult: + # 配置/可执行性错误不应排队等待全局生图锁;进入锁后仍会再次检查, + # 以覆盖等待期间 CLI 状态发生变化的情况。 + ok, detail = self.health_check() + if not ok: + return ImageTaskResult(False, error=detail, detail={"stage": "health"}) try: with _imagegen_mutex((self.timeout * _MAX_ATTEMPTS) + 60): return self._generate_locked(Path(prompt_file), Path(output_path)) diff --git a/app/providers/ai/codex.py b/app/providers/ai/codex.py index c1c0526..c33164d 100644 --- a/app/providers/ai/codex.py +++ b/app/providers/ai/codex.py @@ -24,6 +24,19 @@ logger = get_logger("groupbrief.ai") _CODEX_PROVIDER_NAMES = frozenset({"codex", "codex_gpt", "gpt"}) +_DISABLED_FALLBACK_NAMES = frozenset({"", "none", "disabled"}) +_SUMMARY_FALLBACK_NAMES = _DISABLED_FALLBACK_NAMES | {"deepseek"} + + +def validate_summary_provider_config(settings: Settings) -> tuple[str, str]: + """校验 V1/V2 共用的总结 Provider 配置,未知值禁止静默回退。""" + primary = str(settings.summary_provider_primary or "").strip().lower() + fallback = str(settings.summary_provider_fallback or "").strip().lower() + if primary not in (_CODEX_PROVIDER_NAMES | {"deepseek"}): + raise ValueError(f"不支持的群聊总结主 Provider:{settings.summary_provider_primary}") + if fallback not in _SUMMARY_FALLBACK_NAMES: + raise ValueError(f"不支持的群聊总结备用 Provider:{settings.summary_provider_fallback}") + return primary, fallback def _strip_json_fence(text: str) -> str: @@ -49,6 +62,7 @@ def __init__(self, settings: Settings): # 不调用父类初始化:父类会把 ai_model(DeepSeek 备用模型)写入 # self.model。这里的主模型必须与备用模型配置完全分离。 self.settings = settings + _, fallback = validate_summary_provider_config(settings) self.model = (settings.codex_summary_model or self.model).strip() or self.model self.codex_path = settings.codex_path or "codex" configured_home = ( @@ -60,7 +74,7 @@ def __init__(self, settings: Settings): self._resolved_binary = "" self._fallback = ( DeepSeekV4FlashProvider(settings) - if (settings.summary_provider_fallback or "").strip().lower() == "deepseek" + if fallback == "deepseek" else None ) @@ -239,9 +253,9 @@ def _build_codex_prompt(messages: list[dict], *, response_format: str) -> str: def build_summary_provider(settings: Settings) -> PromptGeneratorProvider: """按配置构造 V1/V2 共用的群聊总结 Provider。""" - primary = (settings.summary_provider_primary or "codex").strip().lower() + primary, _ = validate_summary_provider_config(settings) if primary in _CODEX_PROVIDER_NAMES: return CodexGPTProvider(settings) if primary == "deepseek": return DeepSeekV4FlashProvider(settings) - raise ValueError(f"不支持的群聊总结主 Provider:{settings.summary_provider_primary}") + raise AssertionError("总结 Provider 配置校验未覆盖已知类型") diff --git a/app/providers/history/mock.py b/app/providers/history/mock.py index e0fca20..6f8037f 100644 --- a/app/providers/history/mock.py +++ b/app/providers/history/mock.py @@ -37,8 +37,12 @@ def _normalize_ts(raw: str) -> datetime: class MockProvider(ChatHistoryProvider): name = "mock" - def __init__(self, fixtures_dir: Path | None = None): - settings: Settings = get_settings() + def __init__( + self, + fixtures_dir: Path | None = None, + settings: Settings | None = None, + ): + settings = settings or get_settings() self.fixtures_dir = fixtures_dir or settings.fixtures_dir def health_check(self) -> ProviderHealth: diff --git a/app/providers/history/registry.py b/app/providers/history/registry.py index e211979..e2f0864 100644 --- a/app/providers/history/registry.py +++ b/app/providers/history/registry.py @@ -1,6 +1,6 @@ -"""Provider 注册与自动降级。 +"""Provider 注册与显式降级。 -优先级:主 Provider → 备用 Provider → Mock(仅开发模式启用)。 +优先级:主 Provider → 备用 Provider → Mock(仅显式测试模式启用)。 """ from __future__ import annotations @@ -11,6 +11,10 @@ from app.providers.history.wechat_cli import WechatCliProvider from app.providers.history.wechat_data_analysis import WeChatDataAnalysisProvider + +class ProviderConfigurationError(ValueError): + """历史 Provider 配置无效,禁止静默改用其他实现。""" + PROVIDER_CLASSES = { "wechat_data_analysis": WeChatDataAnalysisProvider, "wechat_cli": WechatCliProvider, @@ -18,20 +22,37 @@ } -def build_providers(settings: Settings | None = None) -> list[ChatHistoryProvider]: - settings = settings or get_settings() - providers: list[ChatHistoryProvider] = [] +def validate_history_provider_config(settings: Settings) -> list[str]: + """只校验并返回 Provider 顺序,不实例化或探测外部依赖。""" order = [settings.history_provider_primary, settings.history_provider_fallback] + names: list[str] = [] seen: set[str] = set() - for name in order: + for raw_name in order: + name = str(raw_name or "").strip().lower() if not name or name in seen: continue seen.add(name) cls = PROVIDER_CLASSES.get(name) - if cls: - providers.append(cls()) - if settings.history_provider_mock_enabled and "mock" not in seen: - providers.append(MockProvider()) + if cls is None: + raise ProviderConfigurationError(f"不支持的历史 Provider:{raw_name}") + if name == "mock" and not settings.allow_test_providers: + raise ProviderConfigurationError("真实运行禁止使用 mock 历史 Provider") + names.append(name) + if not names: + raise ProviderConfigurationError("至少需要配置一个历史 Provider") + return names + + +def build_providers(settings: Settings | None = None) -> list[ChatHistoryProvider]: + settings = settings or get_settings() + names = validate_history_provider_config(settings) + providers = [PROVIDER_CLASSES[name](settings=settings) for name in names] + if ( + settings.history_provider_mock_enabled + and settings.allow_test_providers + and "mock" not in names + ): + providers.append(MockProvider(settings=settings)) return providers diff --git a/app/providers/history/wechat_cli.py b/app/providers/history/wechat_cli.py index 9105120..45547cd 100644 --- a/app/providers/history/wechat_cli.py +++ b/app/providers/history/wechat_cli.py @@ -27,8 +27,12 @@ class WechatCliProvider(ChatHistoryProvider): name = "wechat_cli" - def __init__(self, cli_path: str | None = None): - settings: Settings = get_settings() + def __init__( + self, + cli_path: str | None = None, + settings: Settings | None = None, + ): + settings = settings or get_settings() self.cli_path = cli_path or settings.wechat_cli_path or "wechat-cli" self.export_dir = settings.data_dir / "wechat_cli_export" diff --git a/app/providers/history/wechat_data_analysis.py b/app/providers/history/wechat_data_analysis.py index eab5838..ea96bbe 100644 --- a/app/providers/history/wechat_data_analysis.py +++ b/app/providers/history/wechat_data_analysis.py @@ -193,7 +193,7 @@ def _mcp_health(self) -> ProviderHealth: ) def _find_wechat_dir(self) -> Path | None: - settings = get_settings() + settings = self._settings if settings.wechat_data_dir: p = Path(settings.wechat_data_dir) if p.exists(): diff --git a/app/scheduler/daily_v2_job.py b/app/scheduler/daily_v2_job.py index c18ea64..eb8e080 100644 --- a/app/scheduler/daily_v2_job.py +++ b/app/scheduler/daily_v2_job.py @@ -21,6 +21,7 @@ from app.db import repository as repo from app.pipeline.daily_pipeline import DailyPipeline, parse_date from app.services.generation_runtime import GenerationBusyError, generation_mutex +from app.services.email_service import email_delivery_config_error from app.v2.constants import SCHEDULER_STATE_CORRUPT from app.scheduler.outcome import attach_outcome, summarize_results @@ -322,7 +323,7 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict "generation_status": generation_status, "email_status": state.get("email_status"), } - if not settings.email_enabled or not settings.email_smtp_host: + if not settings.email_enabled: state_store.update( run_date_text, email_status="skipped_disabled", @@ -334,6 +335,23 @@ def _run_locked(settings: Settings, run_date: date, *, skip_email: bool) -> dict "run_date": run_date_text, "email_status": "skipped_disabled", } + email_config_error = email_delivery_config_error(settings) + if email_config_error: + state_store.update( + run_date_text, + email_status="failed_config", + email_completed_at=_now_iso(), + email_error=email_config_error, + email_detail="邮件配置无效,未启动发送子进程", + ) + return { + "status": "partial", + "run_date": run_date_text, + "generation_status": generation_status, + "email_status": "failed_config", + "error_type": "EMAIL_PROVIDER_CONFIG_INVALID", + "detail": email_config_error, + } if state.get("email_started_at"): state_store.update( run_date_text, diff --git a/app/sender/wechat_native.py b/app/sender/wechat_native.py index ff078b3..ff42d61 100644 --- a/app/sender/wechat_native.py +++ b/app/sender/wechat_native.py @@ -859,10 +859,21 @@ def send_bundle(self, target: str, text: str, image_path: str | Path | None) -> return SendResult(False, str(exc), _now()), None +def validate_wechat_sender_mode(settings: Settings) -> str: + """返回规范化 sender mode;未知值禁止默认落到 native。""" + mode = str(settings.wechat_sender_mode or "").strip().lower() + if mode not in {"native", "legacy_cli"}: + raise ValueError(f"不支持的微信发送 Provider:{settings.wechat_sender_mode}") + return mode + + def create_wechat_sender(settings: Settings | None = None, dry_run: bool = False) -> WechatSender: settings = settings or get_settings() - if settings.wechat_sender_mode.strip().lower() == "legacy_cli": + mode = validate_wechat_sender_mode(settings) + if mode == "legacy_cli": from app.sender.wechat_automation import WechatAutomationSender return WechatAutomationSender(settings=settings, dry_run=dry_run) - return WechatNativeSender(settings=settings, dry_run=dry_run) + if mode == "native": + return WechatNativeSender(settings=settings, dry_run=dry_run) + raise AssertionError("微信 sender 配置校验未覆盖已知类型") diff --git a/app/services/email_service.py b/app/services/email_service.py index 0f6ebde..2c095ad 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -27,6 +27,27 @@ logger = get_logger("groupbrief.email") +def email_delivery_config_error(settings: Settings) -> str: + """返回真实邮件发送前的配置错误;空字符串表示配置完整。""" + if not settings.email_enabled: + return "邮件未启用" + if not str(settings.email_smtp_host or "").strip(): + return "邮件 SMTP 主机未配置" + try: + port = int(settings.email_smtp_port) + except (TypeError, ValueError): + return "邮件 SMTP 端口无效" + if not 1 <= port <= 65535: + return "邮件 SMTP 端口无效" + if not str(settings.email_recipient or "").strip(): + return "邮件收件人未配置" + if not str(settings.email_from or settings.email_smtp_user or "").strip(): + return "邮件发件人未配置" + if settings.email_smtp_user and not settings.email_smtp_password: + return "邮件 SMTP 用户已配置但密码缺失" + return "" + + @dataclass class GroupMailBlock: group_name: str @@ -114,8 +135,9 @@ def build_email(self, session: Session, run: Run | None = None) -> EmailBuildRes ) def send(self, session: Session, run: Run | None = None) -> tuple[bool, str]: - if not self.settings.email_enabled or not self.settings.email_smtp_host: - return False, "邮件未启用或未配置 SMTP" + config_error = email_delivery_config_error(self.settings) + if config_error: + return False, config_error result = self.build_email(session, run) if not result.blocks: diff --git a/app/services/history_service.py b/app/services/history_service.py index bc8eea9..e823942 100644 --- a/app/services/history_service.py +++ b/app/services/history_service.py @@ -1,7 +1,7 @@ """历史聊天业务服务。 业务层只依赖 ChatHistoryProvider 接口,不直接依赖任何开源项目内部实现。 -自动降级:主 Provider 失败 → 备用 Provider → Mock。 +自动降级:主 Provider 失败 → 备用 Provider;Mock 仅限显式测试安全闸门。 群名解析(resolve)路径只搜索真实的非 Mock Provider,绝不回落到 fixtures。 """ diff --git a/app/services/prompt_service.py b/app/services/prompt_service.py index f08efea..0d8aea0 100644 --- a/app/services/prompt_service.py +++ b/app/services/prompt_service.py @@ -3,7 +3,7 @@ Codex GPT 主负责:群聊内容 → 理解事件 → 整理话题 → 生成 GPT 生图 Prompt; 主调用失败时使用 DeepSeek 备用。 不负责排行榜计算 / 微信读取 / 邮件 / 调度。 -主备都不可用时优雅降级到本地模板,不阻塞其余流程。 +主备都不可用时真实运行返回失败;本地模板只允许显式测试安全闸门。 """ from __future__ import annotations @@ -40,8 +40,12 @@ def __init__(self, settings: Settings | None = None): def _get_provider(self) -> PromptGeneratorProvider: if self._provider is not None: return self._provider - primary = (self.settings.summary_provider_primary or "codex").strip().lower() - if primary == "deepseek" and not self.settings.ai_api_key: + primary = str(self.settings.summary_provider_primary or "").strip().lower() + if ( + primary == "deepseek" + and not self.settings.ai_api_key + and self.settings.allow_test_providers + ): self._provider = TemplatePromptProvider() else: self._provider = build_summary_provider(self.settings) @@ -54,7 +58,16 @@ def generate( ranking: RankingResult, normalized: list[NormalizedMessage], ) -> PromptOutcome: - provider = self._get_provider() + try: + provider = self._get_provider() + except ValueError as exc: + logger.error("Prompt Provider 配置无效:%s", str(exc)[:200]) + return PromptOutcome( + False, + "", + str(exc), + {"error_type": "SUMMARY_PROVIDER_CONFIG_INVALID"}, + ) message_items = self._build_message_items(normalized) context_text = "\n".join( @@ -76,7 +89,7 @@ def generate( result: ImagePromptResult = provider.generate_image_prompt(context) if result.success: return PromptOutcome(True, result.prompt, meta=result.meta) - if provider.name != "template": + if provider.name != "template" and self.settings.allow_test_providers: template_result = TemplatePromptProvider().generate_image_prompt(context) if template_result.success: logger.warning("主备模型均未完成 Prompt,V1 已降级到本地模板") diff --git a/docs/audits/P1_3_PROVIDER_CONFIG.md b/docs/audits/P1_3_PROVIDER_CONFIG.md new file mode 100644 index 0000000..8f37e4e --- /dev/null +++ b/docs/audits/P1_3_PROVIDER_CONFIG.md @@ -0,0 +1,98 @@ +# P1.3 配置与 Provider fail-closed + +日期:2026-08-25 + +## 结论 + +真实运行现在默认拒绝测试 Provider 和未知 Provider 配置。Mock/本地模板不再因为真实依赖缺失而把任务伪装成成功;设置 API 会在任何数据库写入前完成类型、Provider 名称和邮件配置校验。 + +```text +真实运行(默认) + allow_test_providers=false + ├─ History Mock:不注册 + ├─ V1 Template:不降级为成功 + ├─ 未知 summary/sender:配置失败 + └─ 邮件配置不完整:SMTP 前失败 + +显式自动化测试 + allow_test_providers=true + └─ 可按测试设置启用 Mock/Template,不触发外部依赖 +``` + +## 整改前问题 + +- `history_provider_mock_enabled` 默认 true,所有真实历史 Provider 不可用时会自动读 fixtures。 +- history registry 接收了 Settings,却用 `cls()` 重新读取全局缓存;API 刚保存的路径可能不生效。 +- 未知 history Provider 名称被静默忽略。 +- V1 DeepSeek 缺 Key、或 AI 主备都失败时,本地模板会返回 `success=True`。 +- summary 备用 Provider 未知值被当作“没有备用”,没有配置错误。 +- `wechat_sender_mode` 只判断 `legacy_cli`,其他任意拼写都会默认选择 native。 +- `ai_provider` 可在 UI/API 编辑,但真实路由只读取 `summary_provider_primary/fallback`。 +- V1 history provider 选择可在 UI/API 编辑,但正式 V2 始终使用 WeChatDataAnalysis MCP/导出。 +- 邮件只检查 enabled/host;缺 recipient/from、端口错误或账号缺密码时仍可能进入构造/SMTP。 + +## 修改 + +### 测试 Provider 安全闸门 + +- 新增环境级 `allow_test_providers=false`,不通过数据库或设置 API 开启。 +- `history_provider_mock_enabled` 默认 false;即使旧数据库仍存 true,只要安全闸门关闭,registry 也不会追加 Mock。 +- 显式把 `mock` 配成主/备 Provider 且安全闸门关闭时,直接抛配置错误。 +- V1 Template 只在安全闸门开启时用于测试;真实运行 AI 失败会保持失败。 + +### 配置真正传递 + +- history registry 把同一个 Settings 实例传给 WeChatDataAnalysis、wechat-cli 和 Mock。 +- WeChatDataAnalysis 的微信目录探测改用实例 Settings,不再回到全局缓存。 +- history/summary/sender 未知名称均 fail-closed。 +- 图片 Provider 的健康预检移到全局生图锁之前;CLI 配置错误不会因另一个真实生图任务持锁而等待最长一小时,进入锁后仍会二次检查。 + +### 设置 API 与 UI + +- API 先复制运行时 Settings、完成类型和业务校验,再写数据库并应用运行时值。 +- 非法布尔值不再按“任意非空字符串=true”处理。 +- 生产设置入口移除 Mock 开关、V1-only history provider 选择和无实际路由作用的 `ai_provider`。 +- summary、sender 和邮件仍是正式可编辑配置,错误值返回 HTTP 422,且不会产生部分设置写入。 + +### 邮件预检 + +真实发送前统一检查: + +- email enabled +- SMTP host +- 1–65535 端口 +- recipient +- from 或 SMTP user +- 配置 SMTP user 时必须有 password + +调度发现“邮件已启用但配置不完整”时写入 `email_status=failed_config`、`EMAIL_PROVIDER_CONFIG_INVALID`,整批返回 partial,并且不启动邮件子进程。 + +## 配置边界 + +- V2 历史读取唯一正式入口仍是 WeChatDataAnalysis MCP/JSON 导出;没有为追求统一而引入新的数据源抽象。 +- V1 history provider 字段仅保留环境/旧数据库兼容,为 P1.5 冻结退役做准备。 +- Codex → DeepSeek 是两个真实 Provider 之间的显式 fallback,继续保留。 +- V2 Prompt 的确定性版式 fallback 不是外部 Provider Mock,不在本轮删除。 +- 邮件整体关闭仍是合法配置;只有“已启用但配置残缺”才是失败。 + +## 验证 + +- 主工作区定向测试:87 项通过(2.85 秒)。 +- 图片 Provider/锁顺序补充定向测试:52 项通过(2.32 秒);图片任务测试文件全量 30 项通过(1.88 秒)。 +- 最新隔离快照其余测试 510 项通过(26.04 秒),与图片任务 30 项合计覆盖当前 540 项测试。 +- 隔离快照 Python compileall 通过;最后两项图片改动另行通过 `py_compile`。 +- 隔离前端 production build 通过:TypeScript project build 和 Vite build 成功,4596 个模块完成转换。 +- `git diff --cached --check` 通过。 +- 全局生成 mutex 在验证前已确认可用;此前占锁的 23–28 号群 Prompt 重建任务已自然结束,未强制终止。 +- 首次全量验证又发现 8766 正在真实生图时持有图片 mutex;测试进程已停止且未终止真实生图,并据此修复了 Provider 健康预检与锁的先后顺序。 +- 两次 Operator 全量测试均因与真实生图 mutex 竞争而超出既有时长基线;按执行边界停止重试后,主控通过独立 mutex 的图片测试和排除图片文件的隔离全量测试完成验收。 +- 未调用真实 AI、SMTP、微信或其他外部 Provider。 + +## 部署说明 + +本轮代码提交后需要在工作区无未验证并行生产改动时安全重启 8766,才能让常驻进程加载新安全边界。当前另一个 Prompt/UI 任务仍有未提交文件,因此本轮不把它们一起加载进服务。 + +## 回滚 + +- 代码可整体 revert 本次提交;没有 Schema 变更,也没有修改真实数据库设置值。 +- 回滚后 Mock/Template 的旧 fail-open 行为会恢复,因此只应用于紧急代码回退,不应作为长期配置方案。 diff --git a/frontend/src/pages/v2/Settings.tsx b/frontend/src/pages/v2/Settings.tsx index eade8b8..57a5cfd 100644 --- a/frontend/src/pages/v2/Settings.tsx +++ b/frontend/src/pages/v2/Settings.tsx @@ -26,13 +26,10 @@ import { useToast } from "../../components/ui"; type SettingsTab = "settings" | "health" | "startup" | "recovery"; const SENSITIVE_KEYS = new Set(["ai_api_key", "email_smtp_password", "email_smtp_user", "email_from", "wechat_mcp_token"]); -const BOOLEAN_KEYS = new Set(["history_provider_mock_enabled", "email_enabled", "email_use_ssl", "email_send_partial_report"]); +const BOOLEAN_KEYS = new Set(["email_enabled", "email_use_ssl", "email_send_partial_report"]); const NUMBER_KEYS = new Set(["wechat_mcp_timeout_seconds", "wechat_mcp_range_timeout_seconds", "codex_summary_timeout_seconds", "codex_summary_max_retries", "codex_summary_request_concurrency", "ai_timeout_seconds", "ai_max_retries", "max_context_chars", "generation_group_concurrency", "wechat_fetch_concurrency", "ai_request_concurrency", "email_smtp_port"]); const LABELS: Record = { - history_provider_primary: "主历史数据 Provider", - history_provider_fallback: "备用历史数据 Provider", - history_provider_mock_enabled: "启用 Mock 数据源", wechat_data_dir: "微信数据目录", wechat_export_dir: "微信导出目录", wechat_cli_path: "wechat-cli 路径", @@ -48,7 +45,6 @@ const LABELS: Record = { codex_summary_timeout_seconds: "Codex 总结超时(秒)", codex_summary_max_retries: "Codex 最大重试次数", codex_summary_request_concurrency: "Codex 总结并发数", - ai_provider: "DeepSeek Provider(备用)", ai_base_url: "DeepSeek Base URL(备用)", ai_api_key: "DeepSeek API Key(备用)", ai_model: "DeepSeek 模型 ID(备用)", @@ -78,9 +74,6 @@ const SETTING_GROUPS = [ description: "只编辑当前后端设置 API 返回的 Provider、MCP 与本地读取字段。", icon: Database, keys: [ - "history_provider_primary", - "history_provider_fallback", - "history_provider_mock_enabled", "wechat_data_dir", "wechat_export_dir", "wechat_cli_path", @@ -97,7 +90,7 @@ const SETTING_GROUPS = [ title: "模型与 Prompt", description: "Codex GPT 主用;单次失败时自动切换到已配置的 DeepSeek 备用。", icon: PlugsConnected, - keys: ["summary_provider_primary", "summary_provider_fallback", "codex_summary_model", "codex_summary_timeout_seconds", "codex_summary_max_retries", "ai_provider", "ai_base_url", "ai_model", "ai_api_key", "ai_timeout_seconds", "ai_max_retries", "max_context_chars"], + keys: ["summary_provider_primary", "summary_provider_fallback", "codex_summary_model", "codex_summary_timeout_seconds", "codex_summary_max_retries", "ai_base_url", "ai_model", "ai_api_key", "ai_timeout_seconds", "ai_max_retries", "max_context_chars"], }, { id: "advanced", diff --git a/scripts/send_daily_email.py b/scripts/send_daily_email.py index 504cf4b..e86b4cd 100644 --- a/scripts/send_daily_email.py +++ b/scripts/send_daily_email.py @@ -30,6 +30,7 @@ from app.config.settings import get_settings from app.db import repository as repo from app.image.image_task import detect_image_format, verify_image +from app.services.email_service import email_delivery_config_error from app.services.handoff_service import safe_dir_name @@ -259,9 +260,11 @@ def main() -> int: repo.init_db(settings) repo.apply_db_settings(settings) # 数据库设置优先(收件人/发件人/SMTP 等) - if not args.dry_run and (not settings.email_enabled or not settings.email_smtp_host): - print("❌ 邮件未启用或未配置 SMTP(请检查数据库/环境设置)") - return 1 + if not args.dry_run: + config_error = email_delivery_config_error(settings) + if config_error: + print(f"❌ {config_error}(请检查数据库/环境设置)") + return 1 run_date = args.run_date or datetime.now().date().isoformat() diff --git a/tests/test_codex_summary_provider.py b/tests/test_codex_summary_provider.py index 44de49c..3ca4e7d 100644 --- a/tests/test_codex_summary_provider.py +++ b/tests/test_codex_summary_provider.py @@ -93,3 +93,15 @@ def test_default_factory_builds_codex_gpt_provider(): provider = build_summary_provider(_settings()) assert isinstance(provider, CodexGPTProvider) assert provider.model == "gpt-5.6-sol" + + +@pytest.mark.parametrize( + "overrides, message", + [ + ({"summary_provider_primary": "unknown-ai"}, "主 Provider"), + ({"summary_provider_fallback": "unknown-ai"}, "备用 Provider"), + ], +) +def test_summary_factory_rejects_unknown_provider_names(overrides, message): + with pytest.raises(ValueError, match=message): + build_summary_provider(_settings(**overrides)) diff --git a/tests/test_email.py b/tests/test_email.py index 1adbc3a..a864205 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -10,6 +10,8 @@ from app.db import repository as repo from app.db.models import Group, GroupRun from app.services.email_service import EmailBuildResult, EmailService, GroupMailBlock +from app.services.history_service import HistoryService +from app.services.prompt_service import PromptService from app.services.report_service import ReportService settings = get_settings() @@ -22,7 +24,19 @@ def _prepare_run(session: Session) -> int: session, Group(display_name="示例UED-4群", wechat_group_id="group-a"), ) - service = ReportService() + test_settings = Settings( + _env_file=None, + allow_test_providers=True, + history_provider_primary="mock", + history_provider_fallback="", + history_provider_mock_enabled=True, + summary_provider_primary="deepseek", + ai_api_key="", + ) + service = ReportService( + history=HistoryService(test_settings), + prompt=PromptService(test_settings), + ) run = service.generate(session, group=group, report_date="2026-08-13", force=True) return run.id @@ -78,6 +92,8 @@ def test_email_partial_flag_aborts_before_smtp(monkeypatch): settings2 = Settings( email_enabled=True, email_smtp_host="smtp.example.com", + email_recipient="to@example.com", + email_from="from@example.com", email_send_partial_report=False, ) service = EmailService(settings2) @@ -107,6 +123,30 @@ def fail_if_connected(*args, **kwargs): assert not smtp_calls +def test_email_invalid_config_aborts_before_smtp(monkeypatch): + settings2 = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_recipient="", + email_from="from@example.com", + ) + service = EmailService(settings2) + smtp_calls = [] + + def fail_if_connected(*args, **kwargs): + smtp_calls.append((args, kwargs)) + raise AssertionError("配置无效时不应连接 SMTP") + + monkeypatch.setattr(email_module.smtplib, "SMTP_SSL", fail_if_connected) + with Session(repo.engine) as session: + ok, detail = service.send(session) + + assert not ok + assert "收件人" in detail + assert not smtp_calls + + def test_email_quit_failure_does_not_retry(monkeypatch): settings2 = Settings(email_enabled=True, email_smtp_host="smtp.example.com") service = EmailService(settings2) diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 8564f1c..7930476 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -5,11 +5,13 @@ from sqlmodel import Session -from app.config.settings import get_settings +from app.config.settings import Settings, get_settings from app.db import repository as repo from app.db.models import Group from app.scheduler.calendar_rules import get_report_window from app.services.handoff_service import safe_dir_name +from app.services.history_service import HistoryService +from app.services.prompt_service import PromptService from app.services.report_service import ReportService settings = get_settings() @@ -17,6 +19,22 @@ repo.init_db(settings) +def _test_report_service() -> ReportService: + test_settings = Settings( + _env_file=None, + allow_test_providers=True, + history_provider_primary="mock", + history_provider_fallback="", + history_provider_mock_enabled=True, + summary_provider_primary="deepseek", + ai_api_key="", + ) + return ReportService( + history=HistoryService(test_settings), + prompt=PromptService(test_settings), + ) + + def _get_or_create_group(session: Session, display_name: str, wechat_group_id: str) -> Group: group = repo.find_group_by_wechat_id( session, @@ -43,7 +61,7 @@ def test_safe_dir_name(): def test_generate_writes_files(): with Session(repo.engine) as session: group = _get_or_create_group(session, "示例UED-4群", "group-a") - service = ReportService() + service = _test_report_service() run = service.generate(session, group=group, report_date="2026-08-13", force=True) assert run.status == "success" @@ -89,7 +107,7 @@ def test_generate_writes_files(): def test_two_groups_isolated(): with Session(repo.engine) as session: _get_or_create_group(session, "产品经理交流群", "group-b") - service = ReportService() + service = _test_report_service() run = service.generate(session, report_date="2026-08-13", trigger_type="auto", force=True) assert run.status == "success" diff --git a/tests/test_prompt.py b/tests/test_prompt.py index f593c00..ac6318b 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -78,14 +78,26 @@ def test_deepseek_chunking(): assert "消息44" in chunks[-1].text -def test_explicit_deepseek_without_key_skips_to_template(): - """显式选择 DeepSeek 但未配置 Key 时,PromptService 使用模板 Provider。""" +def test_explicit_deepseek_without_key_stays_fail_closed(): + """真实运行显式选择 DeepSeek 但缺 Key 时,不得偷偷改用模板。""" from app.config.settings import Settings settings = Settings(_env_file=None, summary_provider_primary="deepseek", ai_api_key="") service = PromptService(settings) provider = service._get_provider() - assert provider.name == "template" + assert provider.name == "deepseek" + + +def test_explicit_test_gate_allows_template_provider(): + from app.config.settings import Settings + + settings = Settings( + _env_file=None, + summary_provider_primary="deepseek", + ai_api_key="", + allow_test_providers=True, + ) + assert PromptService(settings)._get_provider().name == "template" def test_default_summary_provider_is_codex_gpt(): @@ -97,7 +109,7 @@ def test_default_summary_provider_is_codex_gpt(): assert provider.model == "gpt-5.6-sol" -def test_v1_model_failure_degrades_to_local_template(): +def test_v1_model_failure_does_not_degrade_to_template_in_real_runtime(): from app.config.settings import Settings from app.db.models import Group from app.providers.ai.base import ImagePromptResult, PromptGeneratorProvider @@ -124,8 +136,41 @@ def generate_image_prompt(self, context): normalized, ) + assert not outcome.success + assert outcome.prompt == "" + assert outcome.error == "主备都失败" + + +def test_v1_model_failure_can_use_template_with_explicit_test_gate(): + from app.config.settings import Settings + from app.db.models import Group + from app.providers.ai.base import ImagePromptResult, PromptGeneratorProvider + + class FailingProvider(PromptGeneratorProvider): + name = "codex_gpt" + + def health_check(self): + return False, "failed" + + def generate_image_prompt(self, context): + return ImagePromptResult(False, error="主备都失败", provider=self.name) + + service = PromptService( + Settings(_env_file=None, summary_provider_primary="codex", allow_test_providers=True) + ) + service._provider = FailingProvider() + window = get_report_window(datetime.fromisoformat("2026-08-14").date()) + normalized = _fetch_norm("group-b") + rank = RankingEngine().compute(normalized, "产品经理交流群", "s", "e") + + outcome = service.generate( + Group(display_name="产品经理交流群", wechat_group_id="group-b"), + window, + rank, + normalized, + ) + assert outcome.success - assert "【任务】" in outcome.prompt assert outcome.meta["fallback"] == "template" @@ -134,7 +179,14 @@ def test_prompt_service_generates_via_template(): from app.config.settings import Settings - service = PromptService(Settings(_env_file=None, summary_provider_primary="deepseek", ai_api_key="")) + service = PromptService( + Settings( + _env_file=None, + summary_provider_primary="deepseek", + ai_api_key="", + allow_test_providers=True, + ) + ) window = get_report_window(datetime.fromisoformat("2026-08-14").date()) normalized = _fetch_norm("group-b") rank = RankingEngine().compute(normalized, "产品经理交流群", "s", "e") diff --git a/tests/test_providers.py b/tests/test_providers.py index 9cef590..4208be9 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -2,9 +2,16 @@ from datetime import datetime, timezone +import pytest + +from app.config.settings import Settings from app.providers.history.base import ProviderStatus from app.providers.history.mock import MockProvider -from app.providers.history.registry import build_providers, check_all_health +from app.providers.history.registry import ( + ProviderConfigurationError, + build_providers, + check_all_health, +) from app.services.history_service import HistoryService TZ = timezone.utc @@ -44,7 +51,13 @@ def test_mock_fetch_empty_group(): def test_fallback_to_mock(): - service = HistoryService() + service = HistoryService( + Settings( + _env_file=None, + allow_test_providers=True, + history_provider_mock_enabled=True, + ) + ) outcome = service.fetch("group-a", "示例UED-4群🤘", datetime(2026, 8, 10), datetime(2026, 8, 17)) assert outcome.status == ProviderStatus.OK assert len(outcome.messages) > 0 @@ -53,7 +66,13 @@ def test_fallback_to_mock(): def test_all_health_contains_expected(): - health = check_all_health() + health = check_all_health( + Settings( + _env_file=None, + allow_test_providers=True, + history_provider_mock_enabled=True, + ) + ) assert "wechat_data_analysis" in health assert "wechat_cli" in health assert "mock" in health @@ -61,7 +80,58 @@ def test_all_health_contains_expected(): def test_providers_order(): - providers = build_providers() + providers = build_providers( + Settings( + _env_file=None, + allow_test_providers=True, + history_provider_mock_enabled=True, + ) + ) names = [p.name for p in providers] assert names[0] == "wechat_data_analysis" assert "mock" in names + + +def test_production_default_does_not_register_mock(): + providers = build_providers(Settings(_env_file=None)) + assert [provider.name for provider in providers] == ["wechat_data_analysis", "wechat_cli"] + + +def test_stored_mock_flag_cannot_bypass_test_provider_gate(): + providers = build_providers( + Settings( + _env_file=None, + allow_test_providers=False, + history_provider_mock_enabled=True, + ) + ) + assert "mock" not in [provider.name for provider in providers] + + +@pytest.mark.parametrize("field", ["history_provider_primary", "history_provider_fallback"]) +def test_unknown_history_provider_is_configuration_error(field): + settings = Settings(_env_file=None, **{field: "typo_provider"}) + with pytest.raises(ProviderConfigurationError, match="不支持的历史 Provider"): + build_providers(settings) + + +def test_explicit_mock_provider_is_blocked_without_test_gate(): + settings = Settings( + _env_file=None, + history_provider_primary="mock", + history_provider_fallback="", + allow_test_providers=False, + ) + with pytest.raises(ProviderConfigurationError, match="真实运行禁止"): + build_providers(settings) + + +def test_registry_passes_the_supplied_settings_to_provider(): + settings = Settings( + _env_file=None, + history_provider_primary="wechat_cli", + history_provider_fallback="", + wechat_cli_path="C:/custom/wechat-cli.exe", + ) + provider = build_providers(settings)[0] + assert provider.cli_path == "C:/custom/wechat-cli.exe" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index dc19f70..90f0ff1 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -116,7 +116,13 @@ def test_daily_v2_job_resumes_interrupted_generation_without_email(tmp_path, mon from app.config.settings import Settings from app.scheduler import daily_v2_job as daily - settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + settings = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_recipient="to@example.com", + email_from="from@example.com", + ) real_state_class = daily.DailyScheduleState class TempState(real_state_class): @@ -221,6 +227,8 @@ def test_partial_generation_stays_partial_even_when_email_succeeds(tmp_path, mon _env_file=None, email_enabled=True, email_smtp_host="smtp.example.com", + email_recipient="to@example.com", + email_from="from@example.com", email_send_partial_report=True, ) real_state_class = daily.DailyScheduleState @@ -257,11 +265,58 @@ def generate_all(self, run_date, acquire_lock=True): assert result["email_status"] == "sent" +def test_invalid_email_config_fails_before_subprocess(tmp_path, monkeypatch): + from app.config.settings import Settings + from app.scheduler import daily_v2_job as daily + + settings = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_recipient="", + email_from="from@example.com", + ) + real_state_class = daily.DailyScheduleState + + class TempState(real_state_class): + def __init__(self, _output_root): + super().__init__(tmp_path) + + class SuccessPipeline: + def __init__(self, settings): + pass + + def generate_all(self, run_date, acquire_lock=True): + return [{"group_name": "群A", "status": "ready_to_send"}] + + monkeypatch.setattr(daily, "DailyScheduleState", TempState) + monkeypatch.setattr(daily, "DailyPipeline", SuccessPipeline) + monkeypatch.setattr(daily.repo, "init_db", lambda settings: None) + monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: []) + monkeypatch.setattr( + daily.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("配置无效时不得启动邮件子进程"), + ) + + result = daily.run_daily_v2_job("2026-08-25", settings=settings) + + assert result["status"] == "partial" + assert result["error_type"] == "EMAIL_PROVIDER_CONFIG_INVALID" + assert result["email_status"] == "failed_config" + + def test_no_groups_is_not_run_and_never_calls_email(tmp_path, monkeypatch): from app.config.settings import Settings from app.scheduler import daily_v2_job as daily - settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + settings = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_recipient="to@example.com", + email_from="from@example.com", + ) real_state_class = daily.DailyScheduleState class TempState(real_state_class): @@ -429,7 +484,13 @@ def test_email_started_without_completion_remains_result_unknown(tmp_path, monke from app.config.settings import Settings from app.scheduler import daily_v2_job as daily - settings = Settings(_env_file=None, email_enabled=True, email_smtp_host="smtp.example.com") + settings = Settings( + _env_file=None, + email_enabled=True, + email_smtp_host="smtp.example.com", + email_recipient="to@example.com", + email_from="from@example.com", + ) real_state_class = daily.DailyScheduleState class TempState(real_state_class): diff --git a/tests/test_send_daily_email.py b/tests/test_send_daily_email.py index 131e76c..430e3bd 100644 --- a/tests/test_send_daily_email.py +++ b/tests/test_send_daily_email.py @@ -282,3 +282,23 @@ def fail_smtp(*args, **kwargs): assert mail_script.main() == 0 assert "预览群" in capsys.readouterr().out + + +def test_main_invalid_email_config_aborts_before_smtp(tmp_path, monkeypatch, capsys): + settings = _settings(tmp_path) + settings.email_recipient = "" + smtp_calls = [] + + def fail_smtp(*args, **kwargs): + smtp_calls.append((args, kwargs)) + raise AssertionError("配置无效时不应连接 SMTP") + + monkeypatch.setattr(mail_script, "get_settings", lambda: settings) + monkeypatch.setattr(mail_script.repo, "init_db", lambda settings: None) + monkeypatch.setattr(mail_script.repo, "apply_db_settings", lambda settings: None) + monkeypatch.setattr(mail_script.smtplib, "SMTP_SSL", fail_smtp) + monkeypatch.setattr("sys.argv", ["send_daily_email.py", "--run-date", "2026-08-21"]) + + assert mail_script.main() == 1 + assert "收件人" in capsys.readouterr().out + assert not smtp_calls diff --git a/tests/test_settings_provider_validation.py b/tests/test_settings_provider_validation.py new file mode 100644 index 0000000..1a45f71 --- /dev/null +++ b/tests/test_settings_provider_validation.py @@ -0,0 +1,98 @@ +"""设置 API 的 Provider/config fail-closed 测试;不写真实数据库。""" + +import pytest +from fastapi import HTTPException + +from app.api import settings as settings_api +from app.config.settings import Settings + + +def _invoke(monkeypatch, values: dict[str, str]): + runtime = Settings(_env_file=None) + writes: list[tuple[str, str]] = [] + monkeypatch.setattr(settings_api, "get_runtime_settings", lambda: runtime) + monkeypatch.setattr( + settings_api.repo, + "set_setting_value", + lambda _session, key, value: writes.append((key, value)), + ) + result = settings_api.update_settings( + settings_api.SettingsPayload(values=values), + session=object(), + ) + return result, runtime, writes + + +@pytest.mark.parametrize( + "values, message", + [ + ({"summary_provider_primary": "typo"}, "总结主 Provider"), + ({"summary_provider_fallback": "typo"}, "总结备用 Provider"), + ({"wechat_sender_mode": "typo"}, "微信发送 Provider"), + ({"email_enabled": "true"}, "SMTP 主机"), + ({"email_use_ssl": "maybe"}, "设置值类型无效"), + ], +) +def test_invalid_provider_or_email_config_is_rejected_before_write( + monkeypatch, + values, + message, +): + writes = [] + runtime = Settings(_env_file=None) + monkeypatch.setattr(settings_api, "get_runtime_settings", lambda: runtime) + monkeypatch.setattr( + settings_api.repo, + "set_setting_value", + lambda _session, key, value: writes.append((key, value)), + ) + + with pytest.raises(HTTPException, match=message) as exc_info: + settings_api.update_settings( + settings_api.SettingsPayload(values=values), + session=object(), + ) + + assert exc_info.value.status_code == 422 + assert not writes + + +def test_test_only_and_legacy_provider_switches_are_not_api_editable(monkeypatch): + result, runtime, writes = _invoke( + monkeypatch, + { + "history_provider_primary": "typo", + "history_provider_fallback": "mock", + "history_provider_mock_enabled": "true", + "allow_test_providers": "true", + "ai_provider": "anything", + }, + ) + + assert result == {"ok": True} + assert runtime.allow_test_providers is False + assert runtime.history_provider_primary == "wechat_data_analysis" + assert runtime.history_provider_fallback == "wechat_cli" + assert runtime.history_provider_mock_enabled is False + assert not writes + + +def test_valid_settings_are_persisted_and_applied_after_validation(monkeypatch): + result, runtime, writes = _invoke( + monkeypatch, + { + "summary_provider_primary": "deepseek", + "summary_provider_fallback": "disabled", + "wechat_sender_mode": "legacy_cli", + }, + ) + + assert result == {"ok": True} + assert runtime.summary_provider_primary == "deepseek" + assert runtime.summary_provider_fallback == "disabled" + assert runtime.wechat_sender_mode == "legacy_cli" + assert writes == [ + ("summary_provider_primary", "deepseek"), + ("summary_provider_fallback", "disabled"), + ("wechat_sender_mode", "legacy_cli"), + ] diff --git a/tests/test_ui_api.py b/tests/test_ui_api.py index bea9a17..7f39742 100644 --- a/tests/test_ui_api.py +++ b/tests/test_ui_api.py @@ -46,7 +46,7 @@ def test_providers_writes_health_db(): assert resp.status_code == 200 data = resp.json() assert "wechat_data_analysis" in data - assert "mock" in data + assert "mock" not in data def test_resolve_api(): diff --git a/tests/test_v2_image_task.py b/tests/test_v2_image_task.py index 06fa063..75f8a80 100644 --- a/tests/test_v2_image_task.py +++ b/tests/test_v2_image_task.py @@ -6,6 +6,7 @@ from __future__ import annotations +from contextlib import nullcontext import json from pathlib import Path import subprocess @@ -188,10 +189,18 @@ def test_codex_health_rejects_existing_but_unexecutable_binary(tmp_path): assert "执行" in detail or "无法" in detail -def test_codex_generate_returns_failure_when_unavailable(tmp_path): +def test_codex_generate_returns_failure_when_unavailable_before_waiting_for_mutex( + tmp_path, + monkeypatch, +): gen = CodexImageGenerator(codex_path="definitely-not-existing-codex-cmd") prompt = tmp_path / "p.txt" prompt.write_text("test", encoding="utf-8") + monkeypatch.setattr( + codex_generator, + "_imagegen_mutex", + lambda *_args, **_kwargs: pytest.fail("Provider 不可用时不得等待生图锁"), + ) result = gen.generate(prompt, tmp_path / "out.png") assert result.success is False assert "不可用" in result.error @@ -214,6 +223,8 @@ def _codex_test_generator(tmp_path, monkeypatch) -> tuple[CodexImageGenerator, P generated_images_dir=str(generated_root), ) monkeypatch.setattr(generator, "health_check", lambda: (True, "ok")) + # 单元测试验证命令/产物契约,不应与 8766 的真实生图任务争抢系统 mutex。 + monkeypatch.setattr(codex_generator, "_imagegen_mutex", lambda *_args: nullcontext()) monkeypatch.setattr(codex_generator, "_RECOVERY_POLL_ROUNDS", 0) return generator, prompt @@ -525,7 +536,14 @@ def kill(self): assert process.communicate_calls == 2 -def test_codex_imagegen_mutex_serializes_concurrent_requests(): +def test_codex_imagegen_mutex_serializes_concurrent_requests(monkeypatch): + # 使用独立的进程内锁和 Windows mutex 名,避免与 8766 的真实生图互相阻塞。 + monkeypatch.setattr(codex_generator, "_PROCESS_IMAGE_LOCK", threading.Lock()) + monkeypatch.setattr( + codex_generator, + "_MUTEX_NAME", + f"Local\\GroupBrief.ImageGen.Test.{id(monkeypatch)}", + ) from app.image.codex_generator import _imagegen_mutex active = 0 diff --git a/tests/test_v2_wechat_native.py b/tests/test_v2_wechat_native.py index 442ff14..877a84b 100644 --- a/tests/test_v2_wechat_native.py +++ b/tests/test_v2_wechat_native.py @@ -10,12 +10,15 @@ OcrLine, WechatNativeSender, WindowsWechatDriver, + create_wechat_sender, _main_chat_horizontal_bounds, _selected_header_matches, _select_group_search_match, _title_matches, ) +import pytest + class FakeNativeDriver: def __init__(self, *, verify=True, text=True, image=True): @@ -48,6 +51,13 @@ def _settings(tmp_path) -> Settings: ) +def test_sender_factory_rejects_unknown_mode(tmp_path): + settings = _settings(tmp_path) + settings.wechat_sender_mode = "typo_sender" + with pytest.raises(ValueError, match="不支持的微信发送 Provider"): + create_wechat_sender(settings=settings, dry_run=True) + + def test_title_match_only_accepts_exact_name_or_member_count(): assert _title_matches("测试群", "测试群") assert _title_matches("测试群(128)", "测试群") From f88c4cc397d0e15d71f2810e853c57e5b7f1efe7 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 25 Aug 2026 11:14:36 +0800 Subject: [PATCH 11/42] =?UTF-8?q?feat:=20=E9=BB=98=E8=AE=A4=E8=87=AA?= =?UTF-8?q?=E7=94=B1=E7=94=9F=E5=9B=BE=E5=B9=B6=E8=A1=A5=E5=85=A8=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=E5=A4=B4=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/image_themes.py | 28 +- app/ai/prompt_builder.py | 281 +++++++++++++++---- app/ai/prompt_builder_types.py | 4 +- app/ai/prompt_editing.py | 7 +- app/ai/prompt_templates.py | 20 +- app/api/groups.py | 6 +- app/db/models.py | 2 +- app/db/repository.py | 2 +- app/pipeline/daily_pipeline.py | 27 +- frontend/src/components/ImageThemePicker.tsx | 6 +- frontend/src/pages/v2/AIImages.tsx | 22 +- templates/image_prompt/default.md | 22 +- tests/test_daily_random_theme.py | 15 +- tests/test_ui_api.py | 6 +- tests/test_v2_group_migration.py | 2 +- tests/test_v2_pipeline.py | 85 +++++- tests/test_v2_prompt_builder.py | 84 +++++- tests/test_v2_prompt_editing.py | 11 + tests/test_v2_ranking_template.py | 4 +- 19 files changed, 509 insertions(+), 125 deletions(-) diff --git a/app/ai/image_themes.py b/app/ai/image_themes.py index 64af977..5513331 100644 --- a/app/ai/image_themes.py +++ b/app/ai/image_themes.py @@ -61,13 +61,25 @@ class ResolvedImageTheme: style_seed: str = "" catalog_version: str = "" + @property + def has_explicit_style(self) -> bool: + """只有手动预设、每日随机或自定义主题才注入具体风格约束。""" + return self.requested_key != AI_FREE_THEME + + @property + def visible_text(self) -> str: + """写入 Prompt 的风格文本;AI 自由发挥只保留一条中性说明。""" + if not self.has_explicit_style: + return self.prompt + return f"{self.display_name}:{self.prompt}" + def to_meta(self) -> dict[str, str]: return { "requested_theme": self.requested_key, "resolved_theme": self.actual_key, "theme_display_name": self.display_name, "theme_prompt": self.prompt, - "theme_text": f"{self.display_name}:{self.prompt}", + "theme_text": self.visible_text, "theme_custom": self.custom_text, "style_signature": self.style_signature, "style_seed": self.style_seed, @@ -75,7 +87,7 @@ def to_meta(self) -> dict[str, str]: } -DEFAULT_IMAGE_THEME = "random_preset" +DEFAULT_IMAGE_THEME = "ai_free" RANDOM_PRESET_THEME = "random_preset" AI_FREE_THEME = "ai_free" CUSTOM_THEME = "custom" @@ -113,8 +125,8 @@ def to_meta(self) -> dict[str, str]: kind="mode", category="模式", variation_count=352, ), AI_FREE_THEME: ImageThemeDefinition( - AI_FREE_THEME, "AI 自由发挥(兼容)", "历史配置兼容模式", - "根据当天真实聊天选择一个统一视觉主题;不得新增或改变聊天事实。", + AI_FREE_THEME, "AI 自由发挥", "默认不指定画材、配色、纹理或光影", + "根据当天真实聊天内容自由选择统一视觉风格。", kind="mode", category="模式", ), CUSTOM_THEME: ImageThemeDefinition( @@ -395,8 +407,12 @@ def _public_option(definition: ImageThemeDefinition) -> dict[str, object]: def public_image_theme_options() -> list[dict[str, object]]: - """返回两种选择模式和稳定排序的 22 个公开风格家族。""" - modes = (IMAGE_THEME_MODE_DEFINITIONS[RANDOM_PRESET_THEME], IMAGE_THEME_MODE_DEFINITIONS[CUSTOM_THEME]) + """返回三种选择模式和稳定排序的 22 个公开风格家族。""" + modes = ( + IMAGE_THEME_MODE_DEFINITIONS[AI_FREE_THEME], + IMAGE_THEME_MODE_DEFINITIONS[RANDOM_PRESET_THEME], + IMAGE_THEME_MODE_DEFINITIONS[CUSTOM_THEME], + ) presets = ( ImageThemeDefinition( family.key, family.label, family.description, "", kind="preset", category=family.category, diff --git a/app/ai/prompt_builder.py b/app/ai/prompt_builder.py index 741d0ad..6063f30 100644 --- a/app/ai/prompt_builder.py +++ b/app/ai/prompt_builder.py @@ -16,6 +16,7 @@ import logging import re +from copy import deepcopy from time import perf_counter from datetime import datetime @@ -90,8 +91,9 @@ _FINAL_PROMPT_RETRY_INSTRUCTION = """\ -上一次最终 Prompt 暴露了内部字段名、主题 ID,或退化成等大模块列表。请完整重写: -按“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”写每个话题;保留全部已选话题、指定画风、统计日期与漫画分镜; +上一次最终 Prompt 暴露了内部字段名、主题 ID、缺少固定头尾,或退化成等大模块列表。请完整重写: +按“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”写每个话题;保留全部已选话题、当前视觉风格说明、统计日期与漫画分镜; +群名称、完整统计时段、真实主标题、真实副标题必须在顶部;真实底部总结、消息数和发言人数必须在底部; 每段指定文字只出现一次,不要输出任何数据字段式栏目名、英文装饰词、Logo、网址或 topic ID,也不要把一个话题机械装进一个等大的矩形区域。""" SYSTEM_BASE = """你是「群报 GroupBrief」的漫画日报海报 Prompt 设计师。 @@ -108,20 +110,21 @@ 6. 数据(消息数、发言人数)必须使用给定数字,禁止自行计算。 7. 必须严格按给定的【输出结构】组织最终 Prompt;给定的漫画分镜骨架控制整张图的大小格与阅读节奏。 8. 候选主题已经过证据校验和程序评分;最终只能使用给定的 2~7 个入选主题,并且每个恰好使用一次。 -9. 【大主题】是全图最高视觉约束,控制配色、画材、服装、造型、装饰、纹理、光影和画风; - 漫画分镜只控制格子几何、阅读路径和镜头节拍,不得替换或削弱【大主题】。 +9. 仅当【视觉风格】给出手动预设或自定义风格时,才把它作为最高视觉约束;默认 AI 自由发挥时, + 只能根据当天真实聊天内容自由选择统一视觉风格,不得追加任何预设风格库词。 10. 一个话题不等于一个矩形模块;5~7 个话题可以展开为 7~12 个镜头,至少一个话题使用连续镜头。 11. 每段内容必须写成“景别 + 人物动作 + 群友反应或道具特写 + 逐字气泡”,不得只给抽象总结。 12. 每个话题只显示一个不超过 12 个汉字的自然短标题、一个完整真实姓名、一句不超过 24 个汉字的事实短句, 以及默认一条不超过 22 个汉字的真实主气泡;只有连续镜头确有需要时才允许第二条短气泡。 13. 所有指定文字必须逐字且恰好出现一次;禁止输出内部字段名、topic ID、表格栏目、说明性标签、 自动创造的栏目名、英文装饰词、Logo 或网址。 -14. 海报用于微信手机端,画布固定为 1024×1536 竖版;关键文字避开四周安全边距,缩略图优先看清主标题、日期和两项统计。 -15. 空间不足时严格依次减少装饰、底部总结、副标题、次要气泡;日期、两项统计、全部话题、完整姓名、事实短句和主气泡不可删除。 -16. 重新生图只允许改变所选美术家族及当天解析出的视觉细节;聊天事实、日期、数字、人物、气泡、话题覆盖和既定分镜不得改变。 +14. 海报用于微信手机端,画布固定为 1024×1536 竖版;顶部固定显示群名称、完整统计时段、主标题和副标题,底部固定显示一句总结、消息数和发言人数。 +15. 空间不足时严格依次减少装饰、次要气泡、次要反应细节;群名称、完整统计时段、主副标题、底部总结、日期、两项统计、全部话题、完整姓名、事实短句和主气泡不可删除。 +16. 重新生图只允许按当前视觉风格说明改变视觉表现;聊天事实、群名称、完整统计时段、主副标题、底部总结、数字、人物、气泡、话题覆盖和既定分镜不得改变。 17. 格子必须有明显的大、中、小三级尺寸差,并按计划使用嵌套特写、连续动作或跨格主体; 禁止整齐两列等高矩形和“每个话题一块”的列表式构图。 -18. 必须把给定的“统计日期:YYYY-MM-DD”作为清晰可见的画面文字,放在海报顶部或底部,不得省略或改写。""" +18. 必须把给定群名称、完整统计时段和“统计日期:YYYY-MM-DD”逐字作为清晰可见的画面文字,不得省略或改写。 +19. 【主标题】【副标题】【底部总结】都必须填入基于已选真实话题生成的实际文案;禁止写“不绘制”“不设置”“省略”或只保留说明占位。""" CHUNK_ANALYZE_SYSTEM = """你是群聊事件分析助手。只提取聊天中真实存在的事件/人物/原话, 输出严格 JSON(不输出其他内容),没有事件就返回空数组。""" @@ -138,7 +141,22 @@ 要求:只提取真实存在的内容;没有事件就返回空数组;每个片段最多提取 10 个事件,最终候选最多 10 个。""" _HTML_COMMENT_RE = re.compile(r"", re.DOTALL) +_STYLE_SECTION_RE = re.compile( + r"(?ms)^【(?:大主题|视觉风格)】\s*\n.*?(?=^【[^\n】]+】\s*$|\Z)" +) _FORBIDDEN_FINAL_PROMPT_TERMS = ("参与群友", "事实信息", "真实原话", "信息卡", "topic-") +_CONFLICTING_VISIBILITY_PHRASES = ( + "不绘制群名称", "不绘制群名", "不单独绘制群名", "不绘制为画面文字", + "不作为画面文字绘制", "仅作为内容语境", "仅作为创作语境", "仅作背景识别", + "不绘制完整时间", "不绘制统计时间", "统计范围,不绘制", + "不绘制副标题", "不设置副标题", "副标题不绘制", "省略副标题", + "不绘制底部总结", "不设置底部总结", "底部总结不绘制", "省略底部总结", +) +_PLACEHOLDER_SECTION_PHRASES = ( + "优先使用", "建议不超过", "一句话概括", "必须生成", "当天生成", + "清晰绘制", "只出现一次", "正在生成", "自动生成", +) +_SECTION_OMISSION_TERMS = ("不绘制", "不设置", "省略", "仅作为", "仅作", "不作为") def _strip_html_comments(text: str) -> str: @@ -146,6 +164,100 @@ def _strip_html_comments(text: str) -> str: return _HTML_COMMENT_RE.sub("", text).strip() +def _normalize_ai_free_style(text: str, neutral_hint: str) -> str: + """AI 自由发挥时移除模型扩写的风格段,只保留一句中性说明。""" + without_style = _STYLE_SECTION_RE.sub("", text).strip() + return f"【视觉风格】\n{neutral_hint}\n\n{without_style}" + + +def _section_body(text: str, heading: str) -> str: + match = re.search( + rf"(?ms)^【{re.escape(heading)}】\s*\n(.*?)(?=^【[^\n】]+】\s*$|\Z)", + text, + ) + return match.group(1).strip() if match else "" + + +def _has_real_section_content(text: str, heading: str) -> bool: + body = _section_body(text, heading) + if ( + not body + or any(phrase in body for phrase in _CONFLICTING_VISIBILITY_PHRASES) + or any(term in body for term in _SECTION_OMISSION_TERMS) + ): + return False + lines = [re.sub(r"^[\s*\-]+", "", line).strip() for line in body.splitlines()] + return any( + line + and not (line.startswith("(") and line.endswith(")")) + and not any(phrase in line for phrase in _PLACEHOLDER_SECTION_PHRASES) + for line in lines + ) + + +def _visible_contract_violations( + text: str, + *, + group_name: str, + period_line: str, + date_line: str, + message_line: str, + speaker_line: str, +) -> list[str]: + """失败关闭校验:固定头尾必须有真实内容,且不能出现相反指令。""" + violations = [phrase for phrase in _CONFLICTING_VISIBILITY_PHRASES if phrase in text] + required_literals = { + "群名称": group_name, + "完整统计时段": period_line, + "统计日期": date_line, + "消息数": message_line, + "发言人数": speaker_line, + } + for label, literal in required_literals.items(): + if literal not in text: + violations.append(f"缺少{label}:{literal}") + if group_name not in _section_body(text, "群名称"): + violations.append("【群名称】未包含实际显示名") + if period_line not in _section_body(text, "统计时间"): + violations.append("【统计时间】未包含完整起止时间") + data_body = _section_body(text, "数据") + if message_line not in data_body or speaker_line not in data_body: + violations.append("【数据】未同时包含消息数和发言人数") + for heading in ("主标题", "副标题", "底部总结"): + if not _has_real_section_content(text, heading): + violations.append(f"【{heading}】缺少真实文案") + return list(dict.fromkeys(violations)) + + +def _validated_persisted_selection(selection: object, messages: list[PromptMessage]) -> dict: + """验证已落盘选题仍完整且能回查快照;不重新调用模型选题。""" + if not isinstance(selection, dict) or not isinstance(selection.get("candidates"), list): + raise ValueError("已保存的选题总结缺少 candidates,已停止重建") + result = deepcopy(selection) + selected = [item for item in result["candidates"] if isinstance(item, dict) and item.get("selected")] + selected_ids = [str(item.get("topic_id") or "").strip() for item in selected] + stored_ids = result.get("selected_topic_ids") + if not isinstance(stored_ids, list) or selected_ids != [str(value) for value in stored_ids]: + raise ValueError("已保存的选题 ID 与入选标记不一致,已停止重建") + if not selected_ids or len(selected_ids) != len(set(selected_ids)): + raise ValueError("已保存的选题 ID 为空或重复,已停止重建") + allowed_message_ids = {message.message_id for message in messages} + for item in selected: + if not str(item.get("title") or "").strip() or not str(item.get("summary") or "").strip(): + raise ValueError("已保存的入选主题缺少标题或总结,已停止重建") + evidence_ids = item.get("message_ids") if isinstance(item.get("message_ids"), list) else [] + if not evidence_ids or any(str(message_id) not in allowed_message_ids for message_id in evidence_ids): + raise ValueError("已保存的入选主题无法从 messages.json 回查,已停止重建") + quotes = item.get("quotes") if isinstance(item.get("quotes"), list) else [] + if not any(str(value).strip() for value in quotes): + raise ValueError("已保存的入选主题缺少真实原话,已停止重建") + visible_people = item.get("visible_participants") if isinstance(item.get("visible_participants"), list) else [] + if not any(str(value).strip() for value in visible_people) and not str(item.get("participant_label") or "").strip(): + raise ValueError("已保存的入选主题缺少可见人物,已停止重建") + selected_topics_json(result) + return result + + _MEDIA_PREFIX = { "image": "[图片]", "emoji": "[表情]", @@ -258,22 +370,33 @@ def build(self, data: PromptInput) -> PromptOutput: report_date = (data.report_date or data.period_end[:10]).strip() if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", report_date): raise ValueError("report_date 必须来自统计周期并使用 YYYY-MM-DD") - theme_text = f"{theme.display_name}:{theme.prompt}" + theme_text = theme.visible_text + explicit_theme_text = theme_text if theme.has_explicit_style else "" + visible_group_name = (data.visible_group_name or data.group_name).strip() + if not visible_group_name: + raise ValueError("群聊显示名不能为空") date_line = f"统计日期:{report_date}" + period_line = f"{data.period_start} ~ {data.period_end}" + message_line = f"{data.message_count} 条消息" + speaker_line = f"{data.speaker_count} 人发言" messages = [self._to_prompt_message(message, index) for index, message in enumerate(data.messages, start=1)] messages = [message for message in messages if message.text] direct_chars = max(1_000, int(self.settings.max_context_chars or 50_000)) - chunks = segment_messages( - messages, - direct_chars=direct_chars, - target_chars=min(TARGET_CHUNK_CHARS, direct_chars), - hard_chars=max(HARD_CHUNK_CHARS, direct_chars), - session_gap_minutes=SESSION_GAP_MINUTES, - overlap_messages=OVERLAP_MESSAGES, - ) - if not chunks: - raise ValueError("没有可提交给总结模型的聊天文本") + chunks: list[ConversationChunk] = [] + if data.persisted_topic_selection is None: + chunks = segment_messages( + messages, + direct_chars=direct_chars, + target_chars=min(TARGET_CHUNK_CHARS, direct_chars), + hard_chars=max(HARD_CHUNK_CHARS, direct_chars), + session_gap_minutes=SESSION_GAP_MINUTES, + overlap_messages=OVERLAP_MESSAGES, + ) + if not chunks: + raise ValueError("没有可提交给总结模型的聊天文本") + elif not messages: + raise ValueError("messages.json 为空,无法回查已保存选题") meta: dict = { "template": data.template, "template_source": "group_override" if data.template_override else "global", @@ -287,8 +410,15 @@ def build(self, data: PromptInput) -> PromptOutput: "report_date": report_date, } meta.update(theme.to_meta()) - - if len(chunks) <= 1: + meta["style_intervention"] = theme.has_explicit_style + + if data.persisted_topic_selection is not None: + selection = _validated_persisted_selection(data.persisted_topic_selection, messages) + meta["mode"] = "persisted_topic_selection" + meta["topic_selection_reused"] = True + meta["reuse_source"] = "run.prompt_meta" + analysis_calls = 0 + elif len(chunks) <= 1: meta["mode"] = "direct" candidates, candidate_calls = self._topic_candidates_with_retry( TOPIC_CANDIDATE_SYSTEM, @@ -302,8 +432,11 @@ def build(self, data: PromptInput) -> PromptOutput: def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: idx, chunk = item + event_system = EVENT_ANALYZE_SYSTEM + if explicit_theme_text: + event_system += "\n\n" + self._theme_constraint(explicit_theme_text) return self._event_cards_with_retry( - EVENT_ANALYZE_SYSTEM + "\n\n" + self._theme_constraint(f"{theme.display_name}:{theme.prompt}"), + event_system, build_event_prompt(chunk, f"第 {idx}/{len(chunks)} 块"), chunk, ) @@ -326,7 +459,9 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: ) analysis_calls = event_calls + candidate_calls - selection = score_and_select_topics(candidates, messages) + if data.persisted_topic_selection is None: + selection = score_and_select_topics(candidates, messages) + meta["topic_selection_reused"] = False meta["topic_selection_version"] = selection["topic_selection_version"] meta["topic_selection"] = selection selected_payload = selected_topics_json(selection) @@ -336,7 +471,16 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: style_layout_locked = detect_explicit_style_layout(custom_style_text) preferred_layout = preferred_layout_from_style(custom_style_text) - if preferred_layout: + if data.persisted_topic_selection is not None: + layout = restored_layout_plan( + data.persisted_theme_meta, + topic_ids, + style_layout_locked=style_layout_locked, + ) + if layout is None: + raise ValueError("已保存的漫画分镜无法覆盖全部入选主题,已停止重建") + layout_calls = 0 + elif preferred_layout: layout = fixed_layout_plan( preferred_layout, topic_ids, @@ -355,7 +499,7 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: layout, layout_calls = self._layout_plan_with_retry( selected_payload, topic_ids, - theme_text=theme_text, + theme_text=explicit_theme_text, recent_history=recent_history, style_layout_locked=style_layout_locked, seed_text=f"{data.group_id or data.group_name}|{data.run_date}", @@ -373,7 +517,7 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: structure = render_image_prompt_template( template_text, { - "group_name": data.group_name, + "group_name": visible_group_name, "period_start": data.period_start, "period_end": data.period_end, "report_date": report_date, @@ -389,6 +533,15 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: if date_line not in structure: # 兼容没有新增占位符的旧/群级模板,同时保证每个最终 Prompt 都收到日期区块。 structure = f"【固定画面日期】\n{date_line}\n\n{structure}" + fixed_visibility_contract = ( + "【固定头尾可见合同|不得降级】\n" + f"顶部逐字清晰绘制群名称“{visible_group_name}”、完整统计时段“{period_line}”、" + "本次基于真实话题生成的主标题和副标题。\n" + f"底部逐字清晰绘制本次基于真实话题生成的一句总结,以及“{message_line}”“{speaker_line}”。\n" + "不得写任何不绘制、不设置、省略或仅作语境的相反指令;" + "空间不足只能减少装饰、次要气泡和次要反应细节。" + ) + structure = f"{structure}\n\n{fixed_visibility_contract}" final_user_prompt = ( "以下主题已经过原消息证据回查和喜剧优先固定评分。" @@ -403,48 +556,62 @@ def analyze(item: tuple[int, ConversationChunk]) -> tuple[list[dict], int]: ) text = "" final_calls = 0 + last_violations: list[str] = [] for attempt in range(FINAL_PROMPT_MAX_ATTEMPTS): - prompt = final_user_prompt if attempt == 0 else final_user_prompt + _FINAL_PROMPT_RETRY_INSTRUCTION + prompt = final_user_prompt + if attempt: + prompt += _FINAL_PROMPT_RETRY_INSTRUCTION + if last_violations: + prompt += "\n上次具体违反:" + ";".join(last_violations[:8]) candidate_text = self._chat( structure, prompt, - theme_text, + explicit_theme_text, layout_instruction, ) final_calls += 1 + if not theme.has_explicit_style: + candidate_text = _normalize_ai_free_style(candidate_text, theme_text) + mandatory_blocks: list[str] = [] + if story_material not in candidate_text: + mandatory_blocks.append(story_material) + if theme_text not in candidate_text: + heading = "【大主题】" if theme.has_explicit_style else "【视觉风格】" + suffix = "\n漫画分镜不得替换或削弱该指定风格。" if theme.has_explicit_style else "" + mandatory_blocks.append(heading + "\n" + theme_text + suffix) + if layout.layout_name not in candidate_text: + mandatory_blocks.append("【漫画分镜|整张图只使用一种骨架】\n" + layout_instruction) + if date_line not in candidate_text: + mandatory_blocks.append( + "【必须在画面中清晰绘制的固定文字】\n" + + date_line + + "\n该日期标识不得省略或改写。" + ) + if mandatory_blocks: + candidate_text = "\n\n".join((*mandatory_blocks, candidate_text)) + forbidden = [term for term in _FORBIDDEN_FINAL_PROMPT_TERMS if term in candidate_text] - if forbidden: - logger.warning("最终 Prompt 暴露内部字段或主题 ID(第 %s/%s 次):%s", attempt + 1, FINAL_PROMPT_MAX_ATTEMPTS, forbidden) + contract = _visible_contract_violations( + candidate_text, + group_name=visible_group_name, + period_line=period_line, + date_line=date_line, + message_line=message_line, + speaker_line=speaker_line, + ) + last_violations = [*(f"含内部词:{term}" for term in forbidden), *contract] + if last_violations: + logger.warning( + "最终 Prompt 合同校验失败(第 %s/%s 次):%s", + attempt + 1, + FINAL_PROMPT_MAX_ATTEMPTS, + last_violations, + ) continue text = candidate_text break if not text: - raise ValueError("最终生图 Prompt 连续暴露内部字段或主题 ID") - - mandatory_blocks: list[str] = [] - if story_material not in text: - mandatory_blocks.append(story_material) - if theme_text not in text: - mandatory_blocks.append( - "【大主题】\n" - + theme_text - + "\n漫画分镜不得替换或削弱该指定风格。" - ) - if layout.layout_name not in text: - mandatory_blocks.append("【漫画分镜|整张图只使用一种骨架】\n" + layout_instruction) - if date_line not in text: - mandatory_blocks.append( - "【必须在画面中清晰绘制的固定文字】\n" - + date_line - + "\n该日期标识必须位于海报顶部或底部,不得省略或改写。" - ) - if mandatory_blocks: - text = "\n\n".join((*mandatory_blocks, text)) - if date_line not in text: - raise ValueError("最终生图 Prompt 缺少准确统计日期") - forbidden = [term for term in _FORBIDDEN_FINAL_PROMPT_TERMS if term in text] - if forbidden: - raise ValueError("最终生图 Prompt 仍含内部字段或主题 ID:" + "、".join(forbidden)) + raise ValueError("最终生图 Prompt 未通过固定头尾合同:" + ";".join(last_violations[:8])) meta["api_call_count"] = analysis_calls + layout_calls + final_calls @@ -506,7 +673,7 @@ def _layout_constraint(layout_prompt: str) -> str: return ( "【漫画分镜约束|整张图只使用一种骨架】\n" + layout_prompt - + "\n漫画分镜只控制格子几何、阅读路径和镜头节拍;必须服从大主题。" + + "\n漫画分镜只控制格子几何、阅读路径和镜头节拍;不得改变当前视觉风格说明。" ) def _chat( diff --git a/app/ai/prompt_builder_types.py b/app/ai/prompt_builder_types.py index 1b5dfde..4d66c48 100644 --- a/app/ai/prompt_builder_types.py +++ b/app/ai/prompt_builder_types.py @@ -23,11 +23,13 @@ class PromptInput: template: str = "default" group_id: str = "" run_date: str = "" - image_theme: str = "random_preset" + visible_group_name: str = "" + image_theme: str = "ai_free" image_theme_custom: str = "" template_override: str = "" previous_theme_signature: str = "" persisted_theme_meta: dict[str, Any] | None = None + persisted_topic_selection: dict[str, Any] | None = None recent_layout_history: tuple[dict[str, Any], ...] = () diff --git a/app/ai/prompt_editing.py b/app/ai/prompt_editing.py index d8a90ce..d1596c2 100644 --- a/app/ai/prompt_editing.py +++ b/app/ai/prompt_editing.py @@ -9,7 +9,7 @@ MAX_PROMPT_CHARS = 50_000 _THEME_SECTION_RE = re.compile( - r"(?ms)^【大主题】\s*\n.*?(?=^【[^\n】]+】\s*$|\Z)" + r"(?ms)^【(?:大主题|视觉风格)】\s*\n.*?(?=^【[^\n】]+】\s*$|\Z)" ) @@ -31,12 +31,13 @@ def prompt_revision(text: str) -> str: def resolved_theme_text(theme: ResolvedImageTheme) -> str: - return f"{theme.display_name}:{theme.prompt}" + return theme.visible_text def replace_theme_section(prompt: str, theme: ResolvedImageTheme) -> str: """只替换规范主题段;不存在时在开头插入,不改写其他内容。""" - block = f"【大主题】\n{resolved_theme_text(theme)}\n\n" + heading = "大主题" if theme.has_explicit_style else "视觉风格" + block = f"【{heading}】\n{resolved_theme_text(theme)}\n\n" if _THEME_SECTION_RE.search(prompt): return _THEME_SECTION_RE.sub(block.rstrip(), prompt, count=1).strip() + "\n" return block + prompt.lstrip() diff --git a/app/ai/prompt_templates.py b/app/ai/prompt_templates.py index 09bd56a..cb811d0 100644 --- a/app/ai/prompt_templates.py +++ b/app/ai/prompt_templates.py @@ -37,7 +37,7 @@ {{message_count}} 条消息 {{speaker_count}} 人发言 -【大主题】 +【视觉风格】 {{image_theme}} 【漫画分镜】 @@ -61,21 +61,25 @@ 只有连续镜头确有需要时,才允许增加第二条不超过 22 个汉字的短气泡;完整真实姓名不得缩写、替换或省略。 【画面文字白名单】 -只清晰绘制:主标题、统计日期、给定数据、自然的话题短标题、短事实旁白、真实姓名和精选群聊气泡。 +只清晰绘制:群名称、完整统计时段、主标题、副标题、底部总结、统计日期、给定数据、自然的话题短标题、短事实旁白、真实姓名和精选群聊气泡。 每段指定文字逐字、恰好出现一次,不得重复;不得绘制程序字段、主题编号、说明性栏目名、JSON、自动创造的栏目名、英文装饰词、Logo、网址或额外标签。 【空间不足时的降级顺序】 -严格依次减少:装饰 → 底部总结 → 副标题 → 次要气泡。不得删除主标题、统计日期、两项统计数据、任何入选话题、完整真实姓名、事实短句或主气泡。 +严格依次减少:装饰 → 次要气泡 → 次要反应细节。不得删除或弱化群名称、完整统计时段、主标题、副标题、底部总结、统计日期、两项统计数据、任何入选话题、完整真实姓名、事实短句或主气泡。 【分镜表现】 整页至少有大、中、小三级格子尺寸差;使用嵌套反应小格、连续动作、局部特写或一次跨格主体建立节奏。 气泡尾巴、人物视线和动作线共同引导从上到下、从左到右阅读;禁止整齐两列等高矩形和重复模板块。 【底部总结】 -可用一句短文案回收当天讨论;不使用“信息量拉满”“一天顶一周”“比过山车还刺激”等通用套话。 +必须生成并清晰绘制一句基于当天真实讨论的短文案,回收当天内容;不使用“信息量拉满”“一天顶一周”“比过山车还刺激”等通用套话。 + +【固定头尾合同】 +海报顶部必须清晰绘制群名称“{{group_name}}”、完整统计时段“{{period_start}} ~ {{period_end}}”、当天生成的真实主标题和真实副标题。 +海报底部必须清晰绘制当天生成的一句底部总结,以及“{{message_count}} 条消息”“{{speaker_count}} 人发言”。这些内容不可降级、不可省略、不可改写。 【重新生图不变量】 -重新生图时只允许改变所选美术家族和当天已解析的画材、配色、纹理、光影细节;聊天事实、统计日期、数字、人物、逐字气泡、话题覆盖和既定漫画分镜不得改变。 +重新生图时只允许按当前视觉风格说明改变视觉表现;聊天事实、群名称、完整统计时段、主副标题、底部总结、统计数字、人物、逐字气泡、话题覆盖和既定漫画分镜不得改变。 【硬性要求】 1. 只使用聊天内容中真实存在的事件、人物、对话,禁止编造。 @@ -84,15 +88,15 @@ 4. 可以使用字面化、反差、回环、误会与反转、一本正经地荒诞,但不能改变事实。 5. 海报人物依据聊天事件中的真实人员,而不是发言排行榜 Top10;姓名只能使用程序回查得到的人员。 6. 数据(消息数、发言人数)必须使用给定数字,禁止自行计算。 -7. 【大主题】是全图最高视觉约束,控制配色、画材、造型、装饰、纹理、光影和画风;【漫画分镜】不得替换或削弱它。 +7. 仅当【视觉风格】提供了手动预设或自定义风格时,它才是全图最高视觉约束;默认 AI 自由发挥时,不得自行追加任何预设风格库词。 8. 【漫画分镜】只控制格子几何、阅读路径和镜头节拍;每张图只能使用给定的一种骨架。 9. 不得把法庭、菜单、地图、新闻台等无关主题包装强加给真实聊天。 10. 最终 Prompt 必须严格包含给定的 2~7 个入选主题且各使用一次;证据不足时由上游减少数量,不得编造。 11. 每个入选话题至少显示一个真实姓名、一句事实短句和一句给定气泡,不得用泛化头像替代人物。 12. 漫画主体与对话必须和对应聊天事实直接相关,视觉比喻只能放大已有笑点,不能另写故事。 -13. 必须把“统计日期:{{report_date}}”逐字作为清晰可见的画面文字,放在海报顶部或底部。 +13. 必须把群名称“{{group_name}}”、完整统计时段“{{period_start}} ~ {{period_end}}”和“统计日期:{{report_date}}”逐字作为清晰可见的画面文字。 14. 指定文字必须逐字且恰好出现一次;不得自行创造栏目名、英文装饰词、Logo、网址或说明性标签。 -15. 空间不足时只能按既定降级顺序缩减,日期、两项统计、全部话题、真实姓名、事实短句和主气泡是不可删除项。 +15. 空间不足时只能按既定降级顺序缩减;群名称、完整统计时段、主标题、副标题、底部总结、日期、两项统计、全部话题、真实姓名、事实短句和主气泡是不可删除项。 """ # 生图 Prompt 模板支持的变量 diff --git a/app/api/groups.py b/app/api/groups.py index 419ad31..fb57198 100644 --- a/app/api/groups.py +++ b/app/api/groups.py @@ -49,7 +49,7 @@ class GroupCreate(BaseModel): send_target: str = "" ranking_template: str = "default" image_prompt_template: str = "default" - image_theme: str = "random_preset" + image_theme: str = DEFAULT_IMAGE_THEME image_theme_custom: str = "" image_prompt_override: str = "" wechat_send_enabled: bool = False @@ -134,7 +134,7 @@ def _group_prompt_payload(group: Group) -> dict: preview = render_image_prompt_template( _strip_html_comments(content), { - "group_name": group.display_name or group.wechat_group_name, + "group_name": group.wechat_group_name or group.display_name, "period_start": "(生成时写入统计开始时间)", "period_end": "(生成时写入统计结束时间)", "message_count": "(生成时写入消息数)", @@ -206,7 +206,7 @@ def create_group(payload: GroupCreate, session: Session = Depends(repo.get_sessi ) values["send_target"] = str(values.get("send_target") or "").strip() values["image_theme"], values["image_theme_custom"] = _validate_group_theme( - values.get("image_theme", "random_preset"), values.get("image_theme_custom", "") + values.get("image_theme", DEFAULT_IMAGE_THEME), values.get("image_theme_custom", "") ) values["image_prompt_override"] = _validate_prompt_override(values.get("image_prompt_override", "")) wechat_group_id = str(values.get("wechat_group_id") or "").strip() diff --git a/app/db/models.py b/app/db/models.py index 380c837..89a215f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -38,7 +38,7 @@ class Group(SQLModel, table=True): send_target: str = "" # 可选人工发送目标;为空时自动跟随 wechat_group_name ranking_template: str = "default" # 排行榜模板名 image_prompt_template: str = "default" # 生图 Prompt 模板名 - image_theme: str = "random_preset" # 生图大主题键(默认每日随机) + image_theme: str = "ai_free" # 生图主题键(默认由 AI 按聊天内容自由发挥) image_theme_custom: str = "" # 自定义生图大主题(image_theme=custom 时使用) image_prompt_override: str = "" # 本群专属 Prompt 模板;为空时继承全局模板 wechat_send_enabled: bool = False # 独立于生成开关,默认禁止自动对外发送 diff --git a/app/db/repository.py b/app/db/repository.py index 26b89b2..e718387 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -175,7 +175,7 @@ def _ensure_relationship_schema_current() -> None: "send_target": "VARCHAR(256) NOT NULL DEFAULT ''", "ranking_template": "VARCHAR(64) NOT NULL DEFAULT 'default'", "image_prompt_template": "VARCHAR(64) NOT NULL DEFAULT 'default'", - "image_theme": "VARCHAR(64) NOT NULL DEFAULT 'random_preset'", + "image_theme": "VARCHAR(64) NOT NULL DEFAULT 'ai_free'", "image_theme_custom": "VARCHAR(80) NOT NULL DEFAULT ''", "image_prompt_override": "TEXT NOT NULL DEFAULT ''", "wechat_send_enabled": "BOOLEAN NOT NULL DEFAULT 0", diff --git a/app/pipeline/daily_pipeline.py b/app/pipeline/daily_pipeline.py index 3b5fb64..ab92632 100644 --- a/app/pipeline/daily_pipeline.py +++ b/app/pipeline/daily_pipeline.py @@ -302,6 +302,7 @@ def _generate_one( force: bool, *, refresh_messages: bool = False, + reuse_persisted_topic_selection: bool = False, ) -> dict: group_name = group.display_name or group.wechat_group_name store = self.store @@ -332,6 +333,7 @@ def finish(result: dict) -> dict: # 防重复:同一群同一周期已到终态 run = store.load_run(group_name, run_date) + persisted_prompt_meta = run.get("prompt_meta") if isinstance(run.get("prompt_meta"), dict) else {} if not force and not refresh_messages and run.get("status") in (IMAGE_READY, READY_TO_SEND, SENT): logger.info("群 %s %s 已到 %s,跳过生成", group_name, run_date, run.get("status")) return finish({"group_name": group_name, "status": "skipped", "detail": f"已{run.get('status')}"}) @@ -538,6 +540,11 @@ def finish(result: dict) -> dict: prompt_msgs = [m for m in messages if RankingEngine._countable(m)] prompt_input = PromptInput( group_name=group_name, + visible_group_name=str( + run.get("wechat_group_name") + or group.wechat_group_name + or group_name + ).strip(), group_id=str(group.id or group.wechat_group_id or group_name), run_date=run_date, period_start=period_start, @@ -551,7 +558,13 @@ def finish(result: dict) -> dict: image_theme_custom=group.image_theme_custom, template_override=getattr(group, "image_prompt_override", "") or "", previous_theme_signature=store.previous_theme_signature(group_name, run_date), - persisted_theme_meta=run.get("prompt_meta") if isinstance(run.get("prompt_meta"), dict) else None, + persisted_theme_meta=persisted_prompt_meta or None, + persisted_topic_selection=( + persisted_prompt_meta.get("topic_selection") + if reuse_persisted_topic_selection + and isinstance(persisted_prompt_meta.get("topic_selection"), dict) + else None + ), recent_layout_history=store.recent_layout_history(group_name, run_date, limit=3), ) prompt_started = perf_counter() @@ -1122,6 +1135,14 @@ def rebuild_prompt_from_snapshot( "error_type": "IMAGE_REGEN_BUSY", "detail": "该运行正在生图,请完成后再重建 Prompt", } + current_prompt_meta = current.get("prompt_meta") if isinstance(current.get("prompt_meta"), dict) else {} + if not isinstance(current_prompt_meta.get("topic_selection"), dict): + return { + "group_name": group_name, + "status": "failed", + "error_type": "TOPIC_SELECTION_SNAPSHOT_INVALID", + "detail": "run.json 缺少已校验选题总结;已停止且不会重新选题", + } keep_sent = current.get("status") == SENT self.store.update( @@ -1143,11 +1164,13 @@ def rebuild_prompt_from_snapshot( run_date, force=True, refresh_messages=False, + reuse_persisted_topic_selection=True, ) if result.get("status") == "failed": self.store.update( group_name, run_date, + status=SENT if keep_sent else FAILED, prompt_rebuild_status="failed", prompt_rebuild_error=str(result.get("detail") or result.get("error") or "重建失败")[:500], send_hold=True, @@ -1170,7 +1193,7 @@ def rebuild_prompt_from_snapshot( return { "group_name": group_name, "status": "prompt_ready", - "detail": "已从当天 messages.json 重建排行榜和 Prompt;未取数,未生图", + "detail": "已复用 run.json 中已校验选题和既定分镜重建 Prompt;未取数,未生图", } def force_send( diff --git a/frontend/src/components/ImageThemePicker.tsx b/frontend/src/components/ImageThemePicker.tsx index 729ab44..c608a2d 100644 --- a/frontend/src/components/ImageThemePicker.tsx +++ b/frontend/src/components/ImageThemePicker.tsx @@ -76,7 +76,9 @@ export function ImageThemePicker({ close(); }; - const status = current?.key === "random_preset" + const status = current?.key === "ai_free" + ? "不注入预设风格" + : current?.key === "random_preset" ? `每日随机 · ${current.variation_count} 种组合` : current?.key === "custom" ? "自定义描述" @@ -120,7 +122,7 @@ export function ImageThemePicker({ onClick={() => choose(theme.key)} > {theme.label}{theme.description} - {theme.key === "random_preset" ? `${theme.variation_count} 种` : "80 字内"} + {theme.key === "ai_free" ? "默认" : theme.key === "random_preset" ? `${theme.variation_count} 种` : "80 字内"} ))}
diff --git a/frontend/src/pages/v2/AIImages.tsx b/frontend/src/pages/v2/AIImages.tsx index 066e0ee..e9172da 100644 --- a/frontend/src/pages/v2/AIImages.tsx +++ b/frontend/src/pages/v2/AIImages.tsx @@ -146,7 +146,7 @@ export default function AIImages() { const [defaultConfig, setDefaultConfig] = useState(null); const [globalDefaultPrompt, setGlobalDefaultPrompt] = useState(""); const [defaultTemplateError, setDefaultTemplateError] = useState(""); - const [defaultTheme, setDefaultTheme] = useState("random_preset"); + const [defaultTheme, setDefaultTheme] = useState("ai_free"); const [defaultCustom, setDefaultCustom] = useState(""); const [defaultThemeText, setDefaultThemeText] = useState(""); const [defaultThemeError, setDefaultThemeError] = useState(""); @@ -160,7 +160,7 @@ export default function AIImages() { const [detail, setDetail] = useState(null); const [runPrompt, setRunPrompt] = useState(null); const [runDraft, setRunDraft] = useState(""); - const [runTheme, setRunTheme] = useState("random_preset"); + const [runTheme, setRunTheme] = useState("ai_free"); const [runCustom, setRunCustom] = useState(""); const [detailLoading, setDetailLoading] = useState(false); const [runSaving, setRunSaving] = useState(false); @@ -256,7 +256,7 @@ export default function AIImages() { if (cancelled) return; setDefaultConfig(config); if (!defaultStyleTouchedRef.current) { - setDefaultTheme(config.image_theme || "random_preset"); + setDefaultTheme(config.image_theme || "ai_free"); const savedCustom = config.image_theme === "custom" ? config.image_theme_custom || "" : ""; setDefaultCustom(savedCustom); setDefaultThemeText(config.resolved_theme?.theme_text || ""); @@ -354,7 +354,7 @@ export default function AIImages() { const prompt = promptResult.value; setRunPrompt(prompt); setRunDraft(prompt.content); - setRunTheme(prompt.image_theme || "random_preset"); + setRunTheme(prompt.image_theme || "ai_free"); setRunCustom(prompt.image_theme_custom || ""); } else { const message = describeLoadError("当天 Prompt", promptResult.reason); @@ -392,7 +392,7 @@ export default function AIImages() { }, [detail?.run.group_name, detail?.run.run_date, regenStatus]); const selectedDefaultGroup = groups.find((group) => group.id === defaultGroupId); - const savedDefaultTheme = defaultConfig?.image_theme || "random_preset"; + const savedDefaultTheme = defaultConfig?.image_theme || "ai_free"; const savedDefaultCustom = defaultConfig?.image_theme === "custom" ? defaultConfig.image_theme_custom.trim() : ""; @@ -441,7 +441,7 @@ export default function AIImages() { }); const refreshed = await getGroupImagePrompt(defaultGroupId); setDefaultConfig(refreshed); - setDefaultTheme(refreshed.image_theme || "random_preset"); + setDefaultTheme(refreshed.image_theme || "ai_free"); setDefaultCustom(refreshed.image_theme === "custom" ? refreshed.image_theme_custom || "" : ""); setDefaultThemeText(refreshed.resolved_theme?.theme_text || ""); defaultStyleTouchedRef.current = false; @@ -505,7 +505,7 @@ export default function AIImages() { const restored = await restoreRunPrompt(detail.run.group_name, detail.run.run_date); setRunPrompt(restored); setRunDraft(restored.content); - setRunTheme(restored.image_theme || "random_preset"); + setRunTheme(restored.image_theme || "ai_free"); setRunCustom(restored.image_theme_custom || ""); toast("已恢复首次编辑前的 Prompt"); } catch (reason) { @@ -528,10 +528,10 @@ export default function AIImages() { setDetail((current) => current ? { ...current, run: rebuilt.run } : current); setRunPrompt(prompt); setRunDraft(prompt.content); - setRunTheme(prompt.image_theme || "random_preset"); + setRunTheme(prompt.image_theme || "ai_free"); setRunCustom(prompt.image_theme_custom || ""); loadRuns(); - toast("已从当天 messages.json 重建 Prompt;没有重新读取微信,也没有生图"); + toast("已复用当天已校验选题和既定分镜重建 Prompt;没有重新读取微信,也没有生图"); } catch (reason) { toast(`Prompt 重建失败:${String(reason)}`); } finally { @@ -585,7 +585,7 @@ export default function AIImages() {
-

设置群聊生图风格

可每日随机,也可固定一个风格家族并保留每天的细微变化。

+

设置群聊生图风格

默认由 AI 按聊天内容自由发挥;手动选择后才注入预设或自定义风格。

{catalogLoading && !groups.length ? : groupsError && !groups.length ? 重新加载} /> : !groups.length ? : ( @@ -670,7 +670,7 @@ export default function AIImages() {
}

日报图片

daily_image.png
{detail.files.includes("daily_image.png") && !imageLoadError ? { setImageLoadError(true); setImageViewerOpen(false); }} onOpen={() => setImageViewerOpen(true)} /> : }
- {runPrompt ?

当天生图 Prompt