diff --git a/frontend/src/pages/v2/Groups.tsx b/frontend/src/pages/v2/Groups.tsx
index 028e007..da647de 100644
--- a/frontend/src/pages/v2/Groups.tsx
+++ b/frontend/src/pages/v2/Groups.tsx
@@ -323,7 +323,7 @@ export default function Groups() {
| 群名称 / 绑定信息 |
启用状态 |
统计规则 |
- 发送时间 |
+ 发送批次 |
排行榜配置 |
AI 图片 |
发送目标 |
@@ -349,8 +349,8 @@ export default function Groups() {
toggle(group, "enabled")} />
|
- {group.schedule_rule || "weekday_default"} |
- {group.send_time || "—"} |
+ {group.schedule_rule || "daily_previous_day"} |
+ {group.send_time || "08:30"} |
{group.ranking_template || "default"}
diff --git a/frontend/src/pages/v2/Ranking.tsx b/frontend/src/pages/v2/Ranking.tsx
index 13bc9c3..a12902f 100644
--- a/frontend/src/pages/v2/Ranking.tsx
+++ b/frontend/src/pages/v2/Ranking.tsx
@@ -21,6 +21,15 @@ import {
} from "../../components/common";
import { useToast } from "../../components/ui";
import { TemplateEditor } from "./Templates";
+import { shanghaiDateInputValue } from "../../date";
+import { ContentSwap } from "../../components/motion";
+import {
+ formatRankingCount,
+ INTERACTION_EXPLANATION,
+ isTextPrimaryRanking,
+ parseRanking,
+ type ParsedRankingSummary,
+} from "./rankingPolicy";
const STATUS_LABELS: Record = {
PENDING: "待生成",
@@ -33,25 +42,10 @@ const STATUS_LABELS: Record = {
FAILED: "失败",
};
-interface RankingSpeaker {
- rank: number;
- name: string;
- count: number;
-}
-
-interface RankingSummary {
- groupName: string;
- periodStart: string;
- periodEnd: string;
- messageCount: number | null;
- speakerCount: number | null;
- topSpeakers: RankingSpeaker[];
-}
-
interface RankingDetail {
run: V2Run;
files: string[];
- summary: RankingSummary | null;
+ summary: ParsedRankingSummary | null;
rankingText: string;
jsonError: string;
textError: string;
@@ -70,52 +64,6 @@ function StatusPill({ status }: { status: string }) {
return {STATUS_LABELS[normalized] || status || "未知"};
}
-function asRecord(value: unknown): Record | null {
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null;
-}
-
-function asText(value: unknown): string {
- return typeof value === "string" ? value : "";
-}
-
-function asCount(value: unknown): number | null {
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value);
- return null;
-}
-
-function parseRanking(value: unknown): { summary: RankingSummary | null; error: string } {
- const record = asRecord(value);
- if (!record) return { summary: null, error: "ranking.json 不是对象格式,无法解析排行榜。" };
- const rawSpeakers = record.top_speakers;
- if (!Array.isArray(rawSpeakers)) {
- return { summary: null, error: "ranking.json 缺少有效的 top_speakers 数组。" };
- }
- const topSpeakers: RankingSpeaker[] = [];
- let malformed = false;
- rawSpeakers.forEach((item, index) => {
- const speaker = asRecord(item);
- const name = speaker ? asText(speaker.name).trim() : "";
- const count = speaker ? asCount(speaker.count) : null;
- if (!name || count === null) {
- malformed = true;
- return;
- }
- const rank = asCount(speaker?.rank) || index + 1;
- topSpeakers.push({ rank, name, count });
- });
- return {
- summary: {
- groupName: asText(record.group_name),
- periodStart: asText(record.period_start),
- periodEnd: asText(record.period_end),
- messageCount: asCount(record.message_count),
- speakerCount: asCount(record.speaker_count),
- topSpeakers,
- },
- error: malformed ? "ranking.json 中有部分排行项格式异常,已跳过异常项。" : "",
- };
-}
-
function runKey(run: V2Run): string {
return `${run.group_name}\u0000${run.run_date}`;
}
@@ -128,7 +76,7 @@ function formatDateTime(value: unknown): string {
export default function Ranking() {
const { msg, toast } = useToast();
const [runs, setRuns] = useState([]);
- const [dateFilter, setDateFilter] = useState("");
+ const [dateFilter, setDateFilter] = useState(shanghaiDateInputValue);
const [groupFilter, setGroupFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [selectedKey, setSelectedKey] = useState("");
@@ -195,7 +143,7 @@ export default function Ranking() {
? readV2TextFile(selected.group_name, selected.run_date, "ranking.txt")
: Promise.resolve("");
return Promise.allSettled([jsonRequest, textRequest]).then(([jsonResult, textResult]) => {
- let summary: RankingSummary | null = null;
+ let summary: ParsedRankingSummary | null = null;
let jsonError = result.files.includes("ranking.json") ? "" : "未找到 ranking.json,无法显示结构化排行。";
let rankingText = "";
let textError = result.files.includes("ranking.txt") ? "" : "未找到 ranking.txt,无法显示排行榜文案。";
@@ -255,7 +203,8 @@ export default function Ranking() {
- {detailLoading ? : !detail ? : (
+
+ {detailLoading ? : !detail ? : (
<>
真实运行产物{detail.run.group_name} · {detail.run.run_date}状态 · 更新时间 {formatDateTime(detail.run.updated_at)}
@@ -267,12 +216,13 @@ export default function Ranking() {
{(detail.jsonError || detail.textError) && {detail.jsonError && {detail.jsonError} }{detail.textError && {detail.textError} } }
- Top 排名来自 ranking.json{detail.summary?.topSpeakers.length ? {detail.summary.topSpeakers.map((speaker) => - {speaker.rank}{speaker.name}{speaker.count} 条
)} : }
+ Top 排名来自 ranking.json{detail.summary?.topSpeakers.length ? <> {detail.summary.topSpeakers.map((speaker) => - {speaker.rank}{speaker.name}{formatRankingCount(detail.summary?.countPolicy || "all_messages", { count: speaker.count, text_count: speaker.textCount, interaction_count: speaker.interactionCount })}
)} {isTextPrimaryRanking(detail.summary.countPolicy) && {INTERACTION_EXPLANATION} }> : }
排行榜文案来自 ranking.txt{detail.rankingText.trim() ? {detail.rankingText} : }
{detail.run.error && 任务错误:{String(detail.run.error)} }
>
- )}
+ )}
+
diff --git a/frontend/src/pages/v2/Settings.tsx b/frontend/src/pages/v2/Settings.tsx
index eade8b8..5a96c40 100644
--- a/frontend/src/pages/v2/Settings.tsx
+++ b/frontend/src/pages/v2/Settings.tsx
@@ -22,17 +22,15 @@ import {
} from "../../api";
import { Button, EmptyState, LoadingState, PageHeader, StatusBadge, Toast } from "../../components/common";
import { useToast } from "../../components/ui";
+import { ContentSwap, m } from "../../components/motion";
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 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 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", "wechat_native_stage_timeout_seconds", "wechat_native_submit_timeout_seconds", "wechat_native_poll_interval_seconds", "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 +46,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(备用)",
@@ -58,6 +55,9 @@ const LABELS: Record = {
generation_group_concurrency: "群任务并发数",
wechat_fetch_concurrency: "微信取数并发数",
ai_request_concurrency: "DeepSeek 备用请求并发数",
+ wechat_native_stage_timeout_seconds: "微信粘贴暂存等待(秒)",
+ wechat_native_submit_timeout_seconds: "微信提交验证等待(秒)",
+ wechat_native_poll_interval_seconds: "微信 UI 轮询间隔(秒)",
email_enabled: "启用邮件(V1 兼容)",
email_recipient: "邮件收件人(V1 兼容)",
email_from: "发件地址(V1 兼容)",
@@ -78,9 +78,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,19 +94,19 @@ 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",
title: "高级并发设置",
description: "控制群任务、微信取数、Codex 总结和 DeepSeek 备用请求的并发上限。",
icon: GearSix,
- keys: ["generation_group_concurrency", "wechat_fetch_concurrency", "codex_summary_request_concurrency", "ai_request_concurrency"],
+ keys: ["generation_group_concurrency", "wechat_fetch_concurrency", "codex_summary_request_concurrency", "ai_request_concurrency", "wechat_native_stage_timeout_seconds", "wechat_native_submit_timeout_seconds", "wechat_native_poll_interval_seconds"],
},
{
id: "legacy",
title: "邮件与每日调度",
- description: "每日生成时间用于 V2 前一日群报;各群发送时间仍在群聊配置中管理。邮件字段保留兼容。",
+ description: "每日 00:15 生成前一日群报,08:30 按稳定群 ID 串行发送;邮件字段保留兼容。",
icon: Clock,
keys: ["email_enabled", "email_recipient", "email_from", "email_smtp_host", "email_smtp_port", "email_smtp_user", "email_smtp_password", "email_use_ssl", "email_send_partial_report", "schedule_generate_time", "schedule_email_time"],
},
@@ -315,13 +312,14 @@ export default function Settings() {
API Key、Token、密码等敏感值只显示掩码;留空或保持 ****** 不会修改现有密钥。
-
-
-
-
+
+
+
+
- {tab === "settings" && (
+
+ {tab === "settings" && (
{settingsError && {settingsError} }
{settingsLoading && }
@@ -341,6 +339,7 @@ export default function Settings() {
{tab === "health" && 系统健康状态来自本地 `/api/v2/system/health`,不可用依赖保持真实阻塞状态。 }
{tab === "startup" && 启动检查展示服务启动时的真实检查结果,不在浏览器端安装或修改自启动。 }
{tab === "recovery" && 恢复信息只读展示后端扫描到的未完成任务与输出完整性。 }
+
diff --git a/frontend/src/pages/v2/Tasks.test.ts b/frontend/src/pages/v2/Tasks.test.ts
new file mode 100644
index 0000000..279819a
--- /dev/null
+++ b/frontend/src/pages/v2/Tasks.test.ts
@@ -0,0 +1,50 @@
+// @vitest-environment jsdom
+
+import { act, createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+import { ConfirmDialog } from "../../components/common";
+import { sendResolutionDialogCopy } from "./Tasks";
+
+describe("send unknown resolution confirmations", () => {
+ beforeAll(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ });
+ vi.stubGlobal("cancelAnimationFrame", () => undefined);
+ });
+
+ afterAll(() => vi.unstubAllGlobals());
+
+ it.each([
+ ["text_sent" as const, "确认文字已经发送?", "之后只允许继续图片阶段"],
+ ["not_sent" as const, "确认文字没有发送?", "之后可重新执行完整发送"],
+ ])("renders a confirmation before writing %s", (resolution, title, consequence) => {
+ const copy = sendResolutionDialogCopy(resolution);
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(createElement(ConfirmDialog, {
+ open: true,
+ title: copy.title,
+ description: copy.description,
+ confirmLabel: "写入核对结论",
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ }));
+ });
+ const markup = container.innerHTML;
+
+ expect(markup).toContain('role="dialog"');
+ expect(markup).toContain(title);
+ expect(markup).toContain("不会立即发送");
+ expect(markup).toContain(consequence);
+ expect(markup).toContain("写入核对结论");
+ act(() => root.unmount());
+ container.remove();
+ });
+});
diff --git a/frontend/src/pages/v2/Tasks.tsx b/frontend/src/pages/v2/Tasks.tsx
index 6d86338..f32c93d 100644
--- a/frontend/src/pages/v2/Tasks.tsx
+++ b/frontend/src/pages/v2/Tasks.tsx
@@ -9,10 +9,14 @@ import {
import {
getRunDetail,
getRecoveryInfo,
+ getRecoveryBacklog,
+ confirmRecovery,
getRuns,
- getSystemHealth,
+ getSystemReadiness,
+ resolveSendUnknown,
retryFailed,
RecoveryInfo,
+ RecoveryBacklog,
V2Run,
} from "../../api";
import {
@@ -44,6 +48,20 @@ interface TaskEntry {
integrity?: RecoveryInfo["integrity"][number];
}
+type SendResolution = "text_sent" | "not_sent";
+
+export function sendResolutionDialogCopy(resolution: SendResolution | undefined) {
+ return resolution === "text_sent"
+ ? {
+ title: "确认文字已经发送?",
+ description: "系统将记录文字阶段已完成,解除未知锁;不会立即发送,之后只允许继续图片阶段。",
+ }
+ : {
+ title: "确认文字没有发送?",
+ description: "系统将重置文字阶段并解除未知锁;不会立即发送,之后可重新执行完整发送。",
+ };
+}
+
function runKey(run: V2Run): string {
return `${run.group_name}\u0000${run.run_date}`;
}
@@ -80,10 +98,14 @@ function runGroupId(run: V2Run): number | undefined {
export default function Tasks() {
const { msg, toast } = useToast();
- const health = useFetch(getSystemHealth);
+ const health = useFetch(getSystemReadiness);
const [entries, setEntries] = useState([]);
const [recovery, setRecovery] = useState(null);
const [recoveryError, setRecoveryError] = useState("");
+ const [backlog, setBacklog] = useState(null);
+ const [selectedBacklog, setSelectedBacklog] = useState>(new Set());
+ const [confirmingBacklog, setConfirmingBacklog] = useState(false);
+ const [showBacklogConfirm, setShowBacklogConfirm] = useState(false);
const [dateFilter, setDateFilter] = useState("");
const [groupFilter, setGroupFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
@@ -92,25 +114,45 @@ export default function Tasks() {
const [loadError, setLoadError] = useState("");
const [retryTarget, setRetryTarget] = useState(null);
const [retrying, setRetrying] = useState(false);
+ const [resolutionTarget, setResolutionTarget] = useState<{ entry: TaskEntry; resolution: SendResolution } | null>(null);
+ const [resolving, setResolving] = useState(false);
+ const resolutionDialogCopy = sendResolutionDialogCopy(resolutionTarget?.resolution);
const loadTasks = () => {
setLoading(true);
setLoadError("");
Promise.all([
- getRuns(dateFilter || undefined),
+ getRuns(dateFilter || undefined, { includeFiles: true }),
getRecoveryInfo().catch((reason: unknown) => {
setRecoveryError(`恢复完整性读取失败:${String(reason)}`);
return null;
}),
+ getRecoveryBacklog().catch((reason: unknown) => {
+ setRecoveryError(`历史恢复清单读取失败:${String(reason)}`);
+ return null;
+ }),
])
- .then(async ([runData, recoveryData]) => {
+ .then(async ([runData, recoveryData, backlogData]) => {
if (recoveryData) {
setRecovery(recoveryData);
setRecoveryError("");
}
+ if (backlogData) {
+ setBacklog(backlogData);
+ setSelectedBacklog((current) => new Set(
+ [...current].filter((key) => backlogData.items.some((item) => `${item.run_date}:${item.group_id}` === key && item.recoverable)),
+ ));
+ }
const integrityMap = new Map();
recoveryData?.integrity.forEach((item) => integrityMap.set(`${item.group_name}\u0000${item.run_date}`, item));
const detailed = await Promise.all(runData.runs.map(async (run): Promise => {
+ if (Array.isArray(run.files)) {
+ return {
+ run,
+ files: run.files.filter((file): file is string => typeof file === "string"),
+ integrity: integrityMap.get(runKey(run)),
+ };
+ }
try {
const detail = await getRunDetail(run.group_name, run.run_date);
return { run: detail.run, files: detail.files, integrity: integrityMap.get(runKey(detail.run)) || integrityMap.get(runKey(run)) };
@@ -173,6 +215,48 @@ export default function Tasks() {
.finally(() => setRetrying(false));
};
+ const confirmResolution = () => {
+ if (!resolutionTarget || resolving) return;
+ const groupId = runGroupId(resolutionTarget.entry.run);
+ const unknownAt = stringField(resolutionTarget.entry.run, "send_unknown_at");
+ if (!groupId || !unknownAt) {
+ toast("任务缺少群 ID 或未知状态时间戳,请刷新后重试");
+ return;
+ }
+ setResolving(true);
+ resolveSendUnknown({
+ group_id: groupId,
+ run_date: resolutionTarget.entry.run.run_date,
+ resolution: resolutionTarget.resolution,
+ expected_send_unknown_at: unknownAt,
+ })
+ .then((response) => {
+ toast(`${response.result.detail};下一阶段:${response.result.next_stage}`);
+ setResolutionTarget(null);
+ loadTasks();
+ })
+ .catch((reason: unknown) => toast(`人工核对写入失败:${String(reason)}`))
+ .finally(() => setResolving(false));
+ };
+
+ const confirmSelectedBacklog = () => {
+ if (!backlog || selectedBacklog.size === 0 || confirmingBacklog) return;
+ const tasks = backlog.items
+ .filter((item) => item.recoverable && item.group_id && selectedBacklog.has(`${item.run_date}:${item.group_id}`))
+ .map((item) => ({ run_date: item.run_date, group_id: item.group_id as number }));
+ if (tasks.length === 0) return;
+ setConfirmingBacklog(true);
+ confirmRecovery({ expected_version: backlog.version, tasks })
+ .then((response) => {
+ toast(`历史生成恢复完成:${response.status};接口确认未调用发送`);
+ setSelectedBacklog(new Set());
+ setShowBacklogConfirm(false);
+ loadTasks();
+ })
+ .catch((reason: unknown) => toast(`历史恢复失败:${String(reason)}`))
+ .finally(() => setConfirmingBacklog(false));
+ };
+
if (loading && entries.length === 0) return ;
if (loadError && entries.length === 0) {
return 重新加载} />;
@@ -193,6 +277,40 @@ export default function Tasks() {
未完成{waitingCount}不等同于实时进度
+ {backlog && backlog.items.length > 0 && (
+
+
+ 48 小时外恢复待核对这里只允许恢复生成;不会调用历史发送。结果未知、状态损坏或群已停用的项目只能人工查看。
+
+
+
+ {backlog.items.map((item) => {
+ const key = `${item.run_date}:${item.group_id}`;
+ return (
+
+ );
+ })}
+
+
+ )}
+
@@ -215,9 +333,10 @@ export default function Tasks() {
统计周期{selectedEntry.run.period_start || "—"} ~ {selectedEntry.run.period_end || "—"} 消息数{typeof selectedEntry.run.message_count === "number" ? selectedEntry.run.message_count : "—"} 发言人数{typeof selectedEntry.run.speaker_count === "number" ? selectedEntry.run.speaker_count : "—"}
当前后端未记录阶段事件;页面不显示虚构进度、耗时或事件时间线。
{String(selectedEntry.run.status).toUpperCase() === "PENDING" && selectedEntry.files.length === 0 && !selectedEntry.run.started_at && !selectedEntry.run.finished_at && !selectedEntry.run.updated_at && 该记录仅有 PENDING 状态,未发现文件或时间证据,不能确认任务已真实创建。 }
- {(stringField(selectedEntry.run, "error") || stringField(selectedEntry.run, "image_error") || stringField(selectedEntry.run, "error_type")) && 错误:{stringField(selectedEntry.run, "error") || stringField(selectedEntry.run, "image_error") || stringField(selectedEntry.run, "error_type")} }
+ {(stringField(selectedEntry.run, "error") || stringField(selectedEntry.run, "image_error") || stringField(selectedEntry.run, "send_error") || stringField(selectedEntry.run, "error_type")) && 错误:{stringField(selectedEntry.run, "error") || stringField(selectedEntry.run, "image_error") || stringField(selectedEntry.run, "send_error") || stringField(selectedEntry.run, "error_type")} }
输出文件来自运行详情与恢复完整性检查已发现{selectedEntry.files.length ? {selectedEntry.files.map((file) => - {file}
)} : 运行详情没有文件记录。 } 缺失{selectedEntry.integrity?.missing.length ? {selectedEntry.integrity.missing.map((file) => - {file}
)} : {selectedEntry.integrity ? "完整性检查未发现缺失文件。" : "暂无恢复完整性数据。"} }
{selectedEntry.detailError && {selectedEntry.detailError} }
+ {stringField(selectedEntry.run, "send_hold_reason") === "SEND_RESULT_UNKNOWN" && (stringField(selectedEntry.run, "send_unknown_stage") || (stringField(selectedEntry.run, "text_sent_at") ? "image" : "text")) === "text" && 文字发送结果待人工核对两种操作都只更新阶段检查点,不会立即发送微信;确认已发后后续发送只继续图片。 }
{String(selectedEntry.run.status).toUpperCase() === "FAILED" && 失败任务仅调用后端 retryFailed,不提供取消或虚构重试进度。 }
>
)}
@@ -225,6 +344,15 @@ export default function Tasks() {
{recovery && 恢复扫描:{recovery.incomplete.length} 条未完成任务 · 完整性记录 {recovery.integrity.length} 条。这里只展示后端返回的恢复信息。 }
+ !confirmingBacklog && setShowBacklogConfirm(false)}
+ />
!retrying && setRetryTarget(null)}
/>
+ !resolving && setResolutionTarget(null)}
+ />
);
diff --git a/frontend/src/pages/v2/ai-images/AIImageRunWorkspace.tsx b/frontend/src/pages/v2/ai-images/AIImageRunWorkspace.tsx
new file mode 100644
index 0000000..a259a19
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/AIImageRunWorkspace.tsx
@@ -0,0 +1,173 @@
+import {
+ ArrowCounterClockwise,
+ CaretDown,
+ CaretUp,
+ Copy,
+ FloppyDisk,
+ ImageSquare,
+ PaperPlaneTilt,
+ Play,
+ Sparkle,
+ WarningCircle,
+} from "@phosphor-icons/react";
+import { useEffect, useId, useState } from "react";
+
+import type { ImageThemeOption } from "../../../api";
+import {
+ Button,
+ ConfirmDialog,
+ EmptyState,
+ ImagePreviewTrigger,
+ ImageViewer,
+ LoadingState,
+ StatusBadge,
+} from "../../../components/common";
+import { ImageThemePicker } from "../../../components/ImageThemePicker";
+import { copyText } from "../../../components/ui";
+import { ContentSwap } from "../../../components/motion";
+import { formatDateTime, REGEN_LABELS, runKey, STATUS_LABELS, StatusPill } from "./model";
+import type { ToastFn } from "./useAIImageCatalogs";
+import type { AIImageRunsModel } from "./useAIImageRuns";
+
+interface AIImageRunWorkspaceProps {
+ model: AIImageRunsModel;
+ themes: ImageThemeOption[];
+ catalogLoading: boolean;
+ themesError: string;
+ toast: ToastFn;
+}
+
+export function AIImageRunWorkspace({
+ model,
+ themes,
+ catalogLoading,
+ themesError,
+ toast,
+}: AIImageRunWorkspaceProps) {
+ const {
+ runs,
+ dateFilter,
+ setDateFilter,
+ groupFilter,
+ setGroupFilter,
+ statusFilter,
+ setStatusFilter,
+ selectedKey,
+ setSelectedKey,
+ detail,
+ runPrompt,
+ runDraft,
+ setRunDraft,
+ runTheme,
+ runCustom,
+ detailLoading,
+ runSaving,
+ rebuildingPrompt,
+ regenerating,
+ restoring,
+ sending,
+ sendConfirmOpen,
+ setSendConfirmOpen,
+ imageLoadError,
+ setImageLoadError,
+ imageViewerOpen,
+ setImageViewerOpen,
+ detailError,
+ runPromptError,
+ regenPollError,
+ imageCandidates,
+ candidateLoading,
+ candidateClaiming,
+ setDetailReloadVersion,
+ filteredRuns,
+ regenStatus,
+ currentImageSrc,
+ runDirty,
+ applyRunTheme,
+ saveCurrentPrompt,
+ restoreCurrentPrompt,
+ rebuildCurrentPrompt,
+ regenerate,
+ claimCandidate,
+ confirmSend,
+ } = model;
+ const [topicsExpanded, setTopicsExpanded] = useState(false);
+ const topicScoreListId = useId();
+ const topicSelection = runPrompt?.topic_selection;
+ const topicCandidates = topicSelection?.candidates || [];
+ const hiddenTopicCount = Math.max(0, topicCandidates.length - 2);
+ const visibleTopicCandidates = topicsExpanded ? topicCandidates : topicCandidates.slice(0, 2);
+
+ useEffect(() => {
+ setTopicsExpanded(false);
+ }, [selectedKey]);
+
+ return (
+ <>
+
+
+
+
+
+ {!filteredRuns.length ? : {filteredRuns.map((run) => )} }
+
+
+
+
+ {detailLoading ? : detailError && !detail ? setDetailReloadVersion((current) => current + 1)}>重试} /> : !detail ? : (
+ <>
+ 当天真实运行{detail.run.group_name} · {detail.run.run_date} · 更新 {formatDateTime(detail.run.updated_at)}
+ {REGEN_LABELS[regenStatus] || regenStatus}{String(detail.run.image_regen_error || detail.run.image_regen_detail || "messages.json 已按运行日期保存;重建 Prompt 和重新生图都不会再次读取微信,也不会自动发送。")}
+ {regenPollError && {regenPollError} }
+ {topicSelection &&
+ 选题评分候选 {topicSelection.candidate_count} · 入选 {topicSelection.selected_count}v{topicSelection.topic_selection_version}{hiddenTopicCount > 0 && }
+ {visibleTopicCandidates.map((topic) =>
+ #{topic.rank}{topic.title}{topic.selected ? "已入选" : "候选"}{topic.scores.total.toFixed(1)}
+ {topic.summary}
+ 讨论 {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 &&
+ applyRunTheme(key, custom)} label="替换当天大主题" loading={catalogLoading} error={themesError} disabled={runSaving} />
+ }
+
+ 日报图片daily_image.png{detail.files.includes("daily_image.png") && !imageLoadError ? { setImageLoadError(true); setImageViewerOpen(false); }} onOpen={() => setImageViewerOpen(true)} /> : }
+ {runPrompt ? 当天生图 Prompt : setDetailReloadVersion((current) => current + 1)}>重新读取 Prompt} /> }
+
+ {["ambiguous_result", "result_unknown"].includes(regenStatus) &&
+ 本次任务候选图片只显示当前 job_id、群和日期都匹配的候选
+ {candidateLoading ? : !imageCandidates.length ? : {imageCandidates.map((candidate) =>
+
+ {candidate.group_name} · {candidate.run_date}
+ 任务 {candidate.job_id.slice(0, 12)} · 哈希 {candidate.sha256.slice(0, 12)}
+
+ )} }
+ }
+ {regenStatus === "ready_for_review" && 请先检查新图。只有再次确认后才会发送文字和图片。 }
+ {detail.run.error && 主任务错误:{String(detail.run.error)} }
+ >
+ )}
+
+
+
+
+ setImageViewerOpen(false)}
+ onDownloadError={toast}
+ />
+
+ setSendConfirmOpen(false)} onConfirm={confirmSend} />
+ >
+ );
+}
diff --git a/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx b/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx
new file mode 100644
index 0000000..31b3270
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/ImageStylePanel.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { FloppyDisk, Sparkle } from "@phosphor-icons/react";
+
+import {
+ getGroupImagePrompt,
+ GroupImagePromptConfig,
+ GroupV2,
+ ImageThemeOption,
+ resolveImageTheme,
+ updateGroup,
+} from "../../../api";
+import { Button, EmptyState, LoadingState } from "../../../components/common";
+import { ImageThemePicker } from "../../../components/ImageThemePicker";
+import { describeLoadError, renderGroupPreview } from "./model";
+import type { ToastFn } from "./useAIImageCatalogs";
+
+interface ImageStylePanelProps {
+ groups: GroupV2[];
+ themes: ImageThemeOption[];
+ catalogLoading: boolean;
+ groupsError: string;
+ themesError: string;
+ globalDefaultPrompt: string;
+ defaultTemplateError: string;
+ loadCatalogs: () => Promise;
+ toast: ToastFn;
+}
+
+export function ImageStylePanel({
+ groups,
+ themes,
+ catalogLoading,
+ groupsError,
+ themesError,
+ globalDefaultPrompt,
+ defaultTemplateError,
+ loadCatalogs,
+ toast,
+}: ImageStylePanelProps) {
+ const [defaultGroupId, setDefaultGroupId] = useState(null);
+ const [defaultConfig, setDefaultConfig] = useState(null);
+ const [defaultTheme, setDefaultTheme] = useState("ai_free");
+ const [defaultCustom, setDefaultCustom] = useState("");
+ const [defaultThemeText, setDefaultThemeText] = useState("");
+ const [defaultThemeError, setDefaultThemeError] = useState("");
+ const [defaultStyleTouched, setDefaultStyleTouched] = useState(false);
+ const defaultStyleTouchedRef = useRef(false);
+ const [defaultLoading, setDefaultLoading] = useState(false);
+ const [defaultSaving, setDefaultSaving] = useState(false);
+ const [defaultError, setDefaultError] = useState("");
+ const [defaultReloadVersion, setDefaultReloadVersion] = useState(0);
+
+ useEffect(() => {
+ setDefaultGroupId((current) => groups.some((group) => group.id === current)
+ ? current
+ : null);
+ }, [groups]);
+
+ useEffect(() => {
+ if (defaultGroupId === null) {
+ setDefaultConfig(null);
+ setDefaultError("");
+ return;
+ }
+ let cancelled = false;
+ setDefaultLoading(true);
+ setDefaultConfig(null);
+ setDefaultError("");
+ getGroupImagePrompt(defaultGroupId)
+ .then((config) => {
+ if (cancelled) return;
+ setDefaultConfig(config);
+ if (!defaultStyleTouchedRef.current) {
+ setDefaultTheme(config.image_theme || "ai_free");
+ const savedCustom = config.image_theme === "custom" ? config.image_theme_custom || "" : "";
+ setDefaultCustom(savedCustom);
+ setDefaultThemeText(config.resolved_theme?.theme_text || "");
+ }
+ })
+ .catch((reason: unknown) => {
+ if (cancelled) return;
+ const message = describeLoadError("群级 Prompt", reason);
+ setDefaultError(message);
+ toast(message);
+ })
+ .finally(() => {
+ if (!cancelled) setDefaultLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [defaultGroupId, defaultReloadVersion, toast]);
+
+ const selectedDefaultGroup = groups.find((group) => group.id === defaultGroupId);
+ const savedDefaultTheme = defaultConfig?.image_theme || "ai_free";
+ const savedDefaultCustom = defaultConfig?.image_theme === "custom"
+ ? defaultConfig.image_theme_custom.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 applyDefaultTheme = async (key: string, custom = "") => {
+ defaultStyleTouchedRef.current = true;
+ setDefaultStyleTouched(true);
+ setDefaultTheme(key);
+ setDefaultCustom(key === "custom" ? custom : "");
+ setDefaultThemeError("");
+ try {
+ const resolved = await resolveImageTheme({
+ image_theme: key,
+ image_theme_custom: key === "custom" ? custom : "",
+ 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 = defaultTheme === "custom" ? defaultCustom.trim() : "";
+ await updateGroup(defaultGroupId, {
+ image_theme: defaultTheme,
+ image_theme_custom: custom,
+ });
+ const refreshed = await getGroupImagePrompt(defaultGroupId);
+ setDefaultConfig(refreshed);
+ setDefaultTheme(refreshed.image_theme || "ai_free");
+ setDefaultCustom(refreshed.image_theme === "custom" ? refreshed.image_theme_custom || "" : "");
+ setDefaultThemeText(refreshed.resolved_theme?.theme_text || "");
+ defaultStyleTouchedRef.current = false;
+ setDefaultStyleTouched(false);
+ const selectedThemeLabel = themes.find((theme) => theme.key === defaultTheme)?.label || "生图风格";
+ toast(`已把「${selectedThemeLabel}」保存到「${selectedDefaultGroup?.display_name || selectedDefaultGroup?.wechat_group_name || `群 ${defaultGroupId}`}」`);
+ } catch (reason) {
+ toast(`生图风格保存失败:${String(reason)}`);
+ } finally {
+ setDefaultSaving(false);
+ }
+ };
+
+ return (
+
+
+ 设置群聊生图风格默认由 AI 按聊天内容自由发挥;手动选择后才注入预设或自定义风格。
+
+
+ {catalogLoading && !groups.length ? : groupsError && !groups.length ? 重新加载} /> : !groups.length ? : (
+ <>
+ applyDefaultTheme(key, custom)}
+ label="风格模式"
+ loading={catalogLoading}
+ error={themesError}
+ disabled={defaultSaving}
+ />
+
+ 生成时使用的 Prompt 预览
+ {defaultPreview || "Prompt 预览暂不可用"}
+
+
+
+
+
+ {defaultStyleTouched && defaultGroupId !== null && defaultDirty && 风格草稿已保留,切换目标群不会覆盖;明确保存后才会应用。 }
+ {groupsError && {groupsError} }
+ {themesError && {themesError} }
+ {defaultTemplateError && {defaultTemplateError} }
+ {defaultThemeError && {defaultThemeError} }
+ {defaultLoading && 正在读取所选群聊的 Prompt… }
+ {defaultError && {defaultError} }
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/v2/ai-images/model.test.ts b/frontend/src/pages/v2/ai-images/model.test.ts
new file mode 100644
index 0000000..738985b
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/model.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import type { GroupV2, V2Run } from "../../../api";
+import {
+ describeLoadError,
+ formatDateTime,
+ regenerationPollDelay,
+ renderGroupPreview,
+ runKey,
+} from "./model";
+
+describe("AI 图片视图模型", () => {
+ it("优先使用稳定群 ID、微信 ID 与日期组成运行键", () => {
+ const run = {
+ group_name: "测试群",
+ group_id: 23,
+ wechat_group_id: "wx-group-23",
+ run_date: "2026-08-25",
+ status: "PROMPT_READY",
+ } as V2Run;
+ expect(runKey(run)).toBe("23\u0000wx-group-23\u00002026-08-25");
+ });
+
+ it("把旧后端 404 转成可操作的提示", () => {
+ expect(describeLoadError("主题目录", new Error('{"detail":"Not Found"}')))
+ .toContain("请重启 GroupBrief 服务后重试");
+ expect(describeLoadError("主题目录", new Error("timeout")))
+ .toBe("主题目录加载失败:timeout");
+ });
+
+ it("移除内部注释并渲染群名和风格预览变量", () => {
+ const group = {
+ display_name: "测试群",
+ wechat_group_name: "",
+ } as GroupV2;
+ const preview = renderGroupPreview(
+ "\n{{group_name}} / {{image_theme}} / {{report_date}}",
+ group,
+ "低饱和黏土摄影",
+ );
+ expect(preview).not.toContain("internal");
+ expect(preview).toContain("测试群 / 低饱和黏土摄影 / 统计日期(从统计周期自动填入)");
+ });
+
+ it("将 ISO 时间压缩为页面使用的分钟精度", () => {
+ expect(formatDateTime("2026-08-25T08:12:59+08:00")).toBe("2026-08-25 08:12");
+ expect(formatDateTime(null)).toBe("—");
+ });
+
+ it("轮询失败时指数退避并限制在 30 秒", () => {
+ expect(regenerationPollDelay("running", 0)).toBe(2000);
+ expect(regenerationPollDelay("running", 1)).toBe(4000);
+ expect(regenerationPollDelay("running", 10)).toBe(30_000);
+ expect(regenerationPollDelay("fallback_queued", 1)).toBe(10_000);
+ });
+});
diff --git a/frontend/src/pages/v2/ai-images/model.tsx b/frontend/src/pages/v2/ai-images/model.tsx
new file mode 100644
index 0000000..b38116a
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/model.tsx
@@ -0,0 +1,117 @@
+import { StatusBadge } from "../../../components/common";
+import type { GroupV2, V2Run } from "../../../api";
+
+
+export const STATUS_LABELS: Record = {
+ PENDING: "待生成",
+ DATA_READY: "数据就绪",
+ RANKING_READY: "排行完成",
+ PROMPT_READY: "Prompt 完成",
+ IMAGE_READY: "图片完成",
+ READY_TO_SEND: "待发送",
+ SENT: "已发送",
+ FAILED: "失败",
+};
+
+export const REGEN_LABELS: Record = {
+ idle: "未重新生图",
+ queued: "已排队",
+ running: "生成中",
+ fallback_queued: "已转入 Codex Desktop 队列",
+ ambiguous_result: "发现候选,等待人工认领",
+ result_unknown: "结果未知,已停止重试",
+ ready_for_review: "新图待审核",
+ prompt_rebuilt: "Prompt 已重建,等待生图",
+ failed: "重新生图失败",
+ sent: "新图已发送",
+};
+
+export interface ImageDetail {
+ run: V2Run;
+ files: string[];
+}
+
+export function runKey(run: V2Run): string {
+ const stableId = run.group_id || run.wechat_group_id || run.group_name;
+ return `${stableId}\u0000${run.wechat_group_id || ""}\u0000${run.run_date}`;
+}
+
+export function formatDateTime(value: unknown): string {
+ if (!value) return "—";
+ return String(value).replace("T", " ").slice(0, 16);
+}
+
+export function regenerationPollDelay(
+ status: string,
+ consecutiveFailures: number,
+): number {
+ const base = status === "fallback_queued" ? 5000 : 2000;
+ return Math.min(base * (2 ** Math.max(0, consecutiveFailures)), 30_000);
+}
+
+export function describeLoadError(scope: string, reason: unknown): string {
+ const raw = reason instanceof Error ? reason.message : String(reason);
+ let detail = raw;
+ try {
+ const parsed = JSON.parse(raw) as { detail?: unknown };
+ if (typeof parsed.detail === "string") detail = parsed.detail;
+ } catch {
+ // 非 JSON 错误直接保留原始信息。
+ }
+ if (detail === "Not Found" || detail.includes('"detail":"Not Found"')) {
+ return `${scope}接口尚未加载。当前后端可能仍是旧版本,请重启 GroupBrief 服务后重试。`;
+ }
+ return `${scope}加载失败:${detail}`;
+}
+
+function statusTone(
+ status: string,
+): "success" | "warning" | "danger" | "info" | "neutral" {
+ if (["SENT", "IMAGE_READY", "READY_TO_SEND"].includes(status)) return "success";
+ if (status === "FAILED") return "danger";
+ if (["PROMPT_READY", "RANKING_READY"].includes(status)) return "info";
+ if (["PENDING", "DATA_READY"].includes(status)) return "warning";
+ return "neutral";
+}
+
+export function StatusPill({ status }: { status: string }) {
+ const normalized = status.toUpperCase();
+ return (
+
+ {STATUS_LABELS[normalized] || status || "未知"}
+
+ );
+}
+
+export function renderGroupPreview(
+ content: string,
+ group: GroupV2 | undefined,
+ themeText: string,
+): string {
+ const name = group?.display_name
+ || group?.wechat_group_name
+ || "群名称(选择目标群后填入)";
+ const variables: Record = {
+ "{{group_name}}": name,
+ "{{period_start}}": "统计开始时间(生成时自动填入)",
+ "{{period_end}}": "统计结束时间(生成时自动填入)",
+ "{{report_date}}": "统计日期(从统计周期自动填入)",
+ "{{message_count}}": "消息数(生成时自动填入)",
+ "{{speaker_count}}": "发言人数(生成时自动填入)",
+ "{{main_title}}": "当天真实主标题(生成时自动填入)",
+ "{{subtitle}}": "当天真实副标题(生成时自动填入)",
+ "{{overall_visual}}": themeText
+ ? `固定群聊漫画要求\n\n本次手动视觉风格:${themeText}`
+ : "固定群聊漫画要求\n\n根据当天真实聊天内容自由选择统一视觉风格。",
+ "{{panels}}": "【版面1】\n当天真实话题的完整漫画导演稿(生成时自动填入)",
+ "{{text_rules}}": "固定文字合同(生成时自动填入)",
+ "{{footer_summary}}": "当天真实底部总结(生成时自动填入)",
+ "{{image_theme}}": themeText || "(在上方输入指定风格后自动填入)",
+ "{{layout_name}}": "整张海报版式(生成时自动选择)",
+ "{{layout_instruction}}": "版式结构指令(生成时自动填入)",
+ };
+ return Object.entries(variables).reduce(
+ (preview, [token, value]) => preview.split(token).join(value),
+ content.replace(//g, "").trim(),
+ ).trim();
+}
diff --git a/frontend/src/pages/v2/ai-images/useAIImageCatalogs.ts b/frontend/src/pages/v2/ai-images/useAIImageCatalogs.ts
new file mode 100644
index 0000000..21f63ab
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/useAIImageCatalogs.ts
@@ -0,0 +1,71 @@
+import { useCallback, useEffect, useState } from "react";
+
+import {
+ getImagePromptTemplate,
+ GroupV2,
+ ImageThemeOption,
+ listGroups,
+ listImageThemes,
+} from "../../../api";
+import { describeLoadError } from "./model";
+
+export type ToastFn = (message: string) => void;
+
+export function useAIImageCatalogs(toast: ToastFn) {
+ const [groups, setGroups] = useState([]);
+ const [themes, setThemes] = useState([]);
+ const [catalogLoading, setCatalogLoading] = useState(true);
+ const [groupsError, setGroupsError] = useState("");
+ const [themesError, setThemesError] = useState("");
+ const [globalDefaultPrompt, setGlobalDefaultPrompt] = useState("");
+ const [defaultTemplateError, setDefaultTemplateError] = useState("");
+
+ const loadCatalogs = useCallback(async () => {
+ setCatalogLoading(true);
+ setGroupsError("");
+ setThemesError("");
+ setDefaultTemplateError("");
+ const [groupResult, themeResult, promptResult] = await Promise.allSettled([
+ listGroups(),
+ listImageThemes(),
+ getImagePromptTemplate("default"),
+ ]);
+ if (groupResult.status === "fulfilled") {
+ setGroups(groupResult.value);
+ } else {
+ const message = describeLoadError("群配置", groupResult.reason);
+ setGroupsError(message);
+ toast(message);
+ }
+ if (themeResult.status === "fulfilled") {
+ setThemes(themeResult.value.themes);
+ } else {
+ const message = describeLoadError("主题目录", themeResult.reason);
+ setThemesError(message);
+ toast(message);
+ }
+ if (promptResult.status === "fulfilled") {
+ setGlobalDefaultPrompt(promptResult.value.content);
+ } else {
+ const message = describeLoadError("默认 Prompt", promptResult.reason);
+ setDefaultTemplateError(message);
+ toast(message);
+ }
+ setCatalogLoading(false);
+ }, [toast]);
+
+ useEffect(() => {
+ void loadCatalogs();
+ }, [loadCatalogs]);
+
+ return {
+ groups,
+ themes,
+ catalogLoading,
+ groupsError,
+ themesError,
+ globalDefaultPrompt,
+ defaultTemplateError,
+ loadCatalogs,
+ };
+}
diff --git a/frontend/src/pages/v2/ai-images/useAIImageRuns.ts b/frontend/src/pages/v2/ai-images/useAIImageRuns.ts
new file mode 100644
index 0000000..0f60495
--- /dev/null
+++ b/frontend/src/pages/v2/ai-images/useAIImageRuns.ts
@@ -0,0 +1,431 @@
+import { useEffect, useMemo, useState } from "react";
+
+import {
+ claimRunImageCandidate,
+ getRunImageCandidates,
+ getRunDetail,
+ getRunPrompt,
+ getRuns,
+ getV2File,
+ GroupV2,
+ ImageCandidate,
+ pipelineSend,
+ rebuildRunPrompt,
+ regenerateRunImage,
+ resolveImageTheme,
+ restoreRunPrompt,
+ RunPromptConfig,
+ saveRunPrompt,
+ V2Run,
+} from "../../../api";
+import {
+ describeLoadError,
+ ImageDetail,
+ regenerationPollDelay,
+ runKey,
+} from "./model";
+import type { ToastFn } from "./useAIImageCatalogs";
+
+export function useAIImageRuns(groups: GroupV2[], toast: ToastFn) {
+ const [runs, setRuns] = useState([]);
+ const [dateFilter, setDateFilter] = useState("");
+ const [groupFilter, setGroupFilter] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [selectedKey, setSelectedKey] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [detail, setDetail] = useState(null);
+ const [runPrompt, setRunPrompt] = useState(null);
+ const [runDraft, setRunDraft] = useState("");
+ const [runTheme, setRunTheme] = useState("ai_free");
+ const [runCustom, setRunCustom] = useState("");
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [runSaving, setRunSaving] = useState(false);
+ const [rebuildingPrompt, setRebuildingPrompt] = useState(false);
+ const [regenerating, setRegenerating] = useState(false);
+ const [restoring, setRestoring] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
+ const [imageLoadError, setImageLoadError] = useState(false);
+ const [imageViewerOpen, setImageViewerOpen] = useState(false);
+ const [imageVersion, setImageVersion] = useState(0);
+ const [detailError, setDetailError] = useState("");
+ const [runPromptError, setRunPromptError] = useState("");
+ const [regenPollError, setRegenPollError] = useState("");
+ const [detailReloadVersion, setDetailReloadVersion] = useState(0);
+ const [imageCandidates, setImageCandidates] = useState([]);
+ const [candidateLoading, setCandidateLoading] = useState(false);
+ const [candidateClaiming, setCandidateClaiming] = useState("");
+
+ const loadRuns = () => {
+ setLoading(true);
+ setError("");
+ getRuns(dateFilter || undefined)
+ .then((data) => {
+ setRuns(data.runs);
+ setSelectedKey((current) => data.runs.some((run) => runKey(run) === current)
+ ? current
+ : data.runs[0] ? runKey(data.runs[0]) : "");
+ })
+ .catch((reason: unknown) => {
+ const message = `图片运行记录加载失败:${String(reason)}`;
+ setError(message);
+ toast(message);
+ })
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ loadRuns();
+ // loadRuns intentionally follows the selected date and the current toast handler.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dateFilter, toast]);
+
+ const filteredRuns = useMemo(() => runs.filter((run) => {
+ const query = groupFilter.trim().toLocaleLowerCase();
+ return (!query || run.group_name.toLocaleLowerCase().includes(query))
+ && (statusFilter === "all" || run.status.toUpperCase() === statusFilter);
+ }), [groupFilter, runs, statusFilter]);
+
+ useEffect(() => {
+ if (!filteredRuns.length) {
+ setSelectedKey("");
+ } else if (!filteredRuns.some((run) => runKey(run) === selectedKey)) {
+ setSelectedKey(runKey(filteredRuns[0]));
+ }
+ }, [filteredRuns, selectedKey]);
+
+ useEffect(() => {
+ if (!selectedKey) {
+ setDetail(null);
+ setRunPrompt(null);
+ setDetailError("");
+ setRunPromptError("");
+ return;
+ }
+ const selected = runs.find((run) => runKey(run) === selectedKey);
+ if (!selected) return;
+ let cancelled = false;
+ setDetailLoading(true);
+ setDetail(null);
+ setRunPrompt(null);
+ setDetailError("");
+ setRunPromptError("");
+ setImageLoadError(false);
+ setImageViewerOpen(false);
+ Promise.allSettled([
+ getRunDetail(selected.group_name, selected.run_date),
+ getRunPrompt(selected.group_name, selected.run_date),
+ ]).then(([detailResult, promptResult]) => {
+ if (cancelled) return;
+ if (detailResult.status === "fulfilled") {
+ setDetail(detailResult.value);
+ } else {
+ const message = describeLoadError("运行详情", detailResult.reason);
+ setDetailError(message);
+ toast(message);
+ }
+ if (promptResult.status === "fulfilled") {
+ const prompt = promptResult.value;
+ setRunPrompt(prompt);
+ setRunDraft(prompt.content);
+ setRunTheme(prompt.image_theme || "ai_free");
+ setRunCustom(prompt.image_theme_custom || "");
+ } else {
+ const message = describeLoadError("当天 Prompt", promptResult.reason);
+ setRunPromptError(message);
+ toast(message);
+ }
+ }).finally(() => {
+ if (!cancelled) setDetailLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [detailReloadVersion, runs, selectedKey, toast]);
+
+ const persistedRegenStatus = String(detail?.run.image_regen_status || "idle");
+ const imageJobStatus = String(
+ (detail?.run.image_job as { status?: unknown } | undefined)?.status || "",
+ );
+ const regenStatus = ["", "idle"].includes(persistedRegenStatus)
+ && ["ambiguous_result", "result_unknown"].includes(imageJobStatus)
+ ? imageJobStatus
+ : persistedRegenStatus;
+ const currentImageSrc = detail
+ ? `${getV2File(detail.run.group_name, detail.run.run_date, "daily_image.png")}?v=${imageVersion}`
+ : "";
+
+ useEffect(() => {
+ if (!detail || !["ambiguous_result", "result_unknown"].includes(regenStatus)) {
+ setImageCandidates([]);
+ return;
+ }
+ let cancelled = false;
+ setCandidateLoading(true);
+ getRunImageCandidates(detail.run.group_name, detail.run.run_date)
+ .then((result) => {
+ if (!cancelled) setImageCandidates(result.candidates);
+ })
+ .catch((reason: unknown) => {
+ if (!cancelled) toast(`候选图片加载失败:${String(reason)}`);
+ })
+ .finally(() => {
+ if (!cancelled) setCandidateLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [detail?.run.group_name, detail?.run.run_date, regenStatus, toast]);
+
+ useEffect(() => {
+ if (!detail || !["queued", "running", "fallback_queued"].includes(regenStatus)) {
+ setRegenPollError("");
+ return;
+ }
+ const groupName = detail.run.group_name;
+ const runDate = detail.run.run_date;
+ let cancelled = false;
+ let timer: number | undefined;
+ let consecutiveFailures = 0;
+
+ const schedule = (delay: number) => {
+ timer = window.setTimeout(poll, delay);
+ };
+ const poll = () => {
+ let continuePolling = true;
+ let nextDelay = regenerationPollDelay(regenStatus, consecutiveFailures);
+ getRunDetail(groupName, runDate)
+ .then((next) => {
+ if (cancelled) return;
+ consecutiveFailures = 0;
+ setRegenPollError("");
+ setDetail(next);
+ const nextStatus = String(next.run.image_regen_status || "idle");
+ continuePolling = ["queued", "running", "fallback_queued"].includes(nextStatus);
+ nextDelay = regenerationPollDelay(nextStatus, 0);
+ if (["ready_for_review", "failed", "ambiguous_result", "result_unknown"].includes(nextStatus)) {
+ setImageVersion((current) => current + 1);
+ loadRuns();
+ }
+ })
+ .catch((reason: unknown) => {
+ if (cancelled) return;
+ consecutiveFailures += 1;
+ nextDelay = regenerationPollDelay(regenStatus, consecutiveFailures);
+ setRegenPollError(
+ `重新生图状态刷新失败,将在 ${Math.round(nextDelay / 1000)} 秒后重试:${String(reason)}`,
+ );
+ })
+ .finally(() => {
+ if (!cancelled && continuePolling) schedule(nextDelay);
+ });
+ };
+
+ schedule(regenerationPollDelay(regenStatus, 0));
+ return () => {
+ cancelled = true;
+ if (timer !== undefined) window.clearTimeout(timer);
+ };
+ // Polling is keyed by the persisted run identity and regeneration state.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [detail?.run.group_name, detail?.run.run_date, regenStatus]);
+
+ const runDirty = Boolean(runPrompt) && runDraft !== runPrompt?.content;
+
+ const applyRunTheme = async (key: string, custom = runCustom) => {
+ setRunTheme(key);
+ setRunCustom(key === "custom" ? custom : "");
+ if (key === "custom" && !custom.trim()) return;
+ try {
+ const resolved = await resolveImageTheme({
+ image_theme: key,
+ 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
+ : undefined,
+ run_date: detail?.run.run_date,
+ });
+ 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)}`);
+ }
+ };
+
+ const saveCurrentPrompt = async () => {
+ if (!detail || !runPrompt) return;
+ setRunSaving(true);
+ try {
+ const saved = await saveRunPrompt(detail.run.group_name, detail.run.run_date, {
+ content: runDraft,
+ expected_revision: runPrompt.revision,
+ image_theme: runTheme,
+ image_theme_custom: runTheme === "custom" ? runCustom.trim() : "",
+ });
+ setRunPrompt(saved);
+ setRunDraft(saved.content);
+ toast("当天 Prompt 已保存;尚未重新生图,也不会自动发送");
+ } catch (reason) {
+ toast(`当天 Prompt 保存失败:${String(reason)}`);
+ } finally {
+ setRunSaving(false);
+ }
+ };
+
+ const restoreCurrentPrompt = async () => {
+ if (!detail) return;
+ setRestoring(true);
+ try {
+ const restored = await restoreRunPrompt(detail.run.group_name, detail.run.run_date);
+ setRunPrompt(restored);
+ setRunDraft(restored.content);
+ setRunTheme(restored.image_theme || "ai_free");
+ setRunCustom(restored.image_theme_custom || "");
+ toast("已恢复首次编辑前的 Prompt");
+ } catch (reason) {
+ toast(`恢复 Prompt 失败:${String(reason)}`);
+ } finally {
+ setRestoring(false);
+ }
+ };
+
+ const rebuildCurrentPrompt = async () => {
+ if (!detail) return;
+ if (runDirty) {
+ toast("当前 Prompt 有未保存修改,请先保存或恢复后再重建");
+ return;
+ }
+ setRebuildingPrompt(true);
+ try {
+ const rebuilt = await rebuildRunPrompt(detail.run.group_name, detail.run.run_date);
+ const prompt = await getRunPrompt(detail.run.group_name, detail.run.run_date);
+ setDetail((current) => current ? { ...current, run: rebuilt.run } : current);
+ setRunPrompt(prompt);
+ setRunDraft(prompt.content);
+ setRunTheme(prompt.image_theme || "ai_free");
+ setRunCustom(prompt.image_theme_custom || "");
+ loadRuns();
+ toast("已复用当天已校验选题和既定分镜重建 Prompt;没有重新读取微信,也没有生图");
+ } catch (reason) {
+ toast(`Prompt 重建失败:${String(reason)}`);
+ } finally {
+ setRebuildingPrompt(false);
+ }
+ };
+
+ const regenerate = async () => {
+ if (!detail || !runPrompt) return;
+ if (runDirty) {
+ toast("请先保存当天 Prompt,再重新生图");
+ return;
+ }
+ setRegenerating(true);
+ try {
+ const accepted = await regenerateRunImage(detail.run.group_name, detail.run.run_date);
+ setDetail((current) => current ? { ...current, run: accepted.run } : current);
+ toast("已加入 2 路受控队列;生成成功后会停在人工审核状态");
+ } catch (reason) {
+ toast(`重新生图请求失败:${String(reason)}`);
+ } finally {
+ setRegenerating(false);
+ }
+ };
+
+ const claimCandidate = async (candidate: ImageCandidate) => {
+ if (!detail) return;
+ setCandidateClaiming(candidate.candidate_id);
+ try {
+ const claimed = await claimRunImageCandidate(
+ detail.run.group_name,
+ detail.run.run_date,
+ { job_id: candidate.job_id, candidate_id: candidate.candidate_id },
+ );
+ setDetail((current) => current ? { ...current, run: claimed.run } : current);
+ setImageCandidates([]);
+ setImageVersion((current) => current + 1);
+ loadRuns();
+ toast("候选图片已按 job_id 和哈希认领,旧图已备份;仍需人工审核,不会自动发送");
+ } catch (reason) {
+ toast(`候选图片认领失败:${String(reason)}`);
+ } finally {
+ setCandidateClaiming("");
+ }
+ };
+
+ const confirmSend = async () => {
+ if (!detail) return;
+ const groupId = Number(detail.run.group_id || groups.find((group) => group.display_name === detail.run.group_name || group.wechat_group_name === detail.run.group_name)?.id || 0);
+ if (!groupId) {
+ toast("运行记录缺少可用群 ID,无法发送");
+ return;
+ }
+ setSending(true);
+ try {
+ const result = await pipelineSend({ group_id: groupId, run_date: detail.run.run_date, confirm_regenerated: true });
+ if (result.result.status !== "sent") throw new Error(String(result.result.error || result.result.detail || "发送未成功"));
+ setSendConfirmOpen(false);
+ toast("已确认并发送文字与图片");
+ loadRuns();
+ } catch (reason) {
+ toast(`发送失败:${String(reason)}`);
+ } finally {
+ setSending(false);
+ }
+ };
+
+ return {
+ runs,
+ dateFilter,
+ setDateFilter,
+ groupFilter,
+ setGroupFilter,
+ statusFilter,
+ setStatusFilter,
+ selectedKey,
+ setSelectedKey,
+ loading,
+ error,
+ detail,
+ runPrompt,
+ runDraft,
+ setRunDraft,
+ runTheme,
+ runCustom,
+ setRunCustom,
+ detailLoading,
+ runSaving,
+ rebuildingPrompt,
+ regenerating,
+ restoring,
+ sending,
+ sendConfirmOpen,
+ setSendConfirmOpen,
+ imageLoadError,
+ setImageLoadError,
+ imageViewerOpen,
+ setImageViewerOpen,
+ detailError,
+ runPromptError,
+ regenPollError,
+ imageCandidates,
+ candidateLoading,
+ candidateClaiming,
+ setDetailReloadVersion,
+ filteredRuns,
+ regenStatus,
+ currentImageSrc,
+ runDirty,
+ loadRuns,
+ applyRunTheme,
+ saveCurrentPrompt,
+ restoreCurrentPrompt,
+ rebuildCurrentPrompt,
+ regenerate,
+ claimCandidate,
+ confirmSend,
+ };
+}
+
+export type AIImageRunsModel = ReturnType;
diff --git a/frontend/src/pages/v2/dashboardRuntime.test.ts b/frontend/src/pages/v2/dashboardRuntime.test.ts
new file mode 100644
index 0000000..5556cc2
--- /dev/null
+++ b/frontend/src/pages/v2/dashboardRuntime.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { runtimeRefreshDelay } from "./dashboardRuntime";
+
+describe("Dashboard runtime refresh policy", () => {
+ it("polls active work every three seconds", () => {
+ expect(runtimeRefreshDelay("running", { isToday: true, visible: true })).toBe(3_000);
+ expect(runtimeRefreshDelay("retry_pending", { isToday: true, visible: true })).toBe(3_000);
+ });
+
+ it("checks waiting work every thirty seconds and wakes at 00:15", () => {
+ const now = Date.parse("2026-08-29T00:14:55+08:00");
+ expect(runtimeRefreshDelay("not_started", {
+ isToday: true,
+ visible: true,
+ scheduledAt: "2026-08-29T00:15:00+08:00",
+ now,
+ })).toBe(5_000);
+ expect(runtimeRefreshDelay("not_started", { isToday: true, visible: true, now })).toBe(30_000);
+ });
+
+ it("stops for history, hidden pages and terminal states", () => {
+ expect(runtimeRefreshDelay("running", { isToday: false, visible: true })).toBeNull();
+ expect(runtimeRefreshDelay("running", { isToday: true, visible: false })).toBeNull();
+ expect(runtimeRefreshDelay("complete", { isToday: true, visible: true })).toBeNull();
+ expect(runtimeRefreshDelay("blocked", { isToday: true, visible: true })).toBeNull();
+ expect(runtimeRefreshDelay("failed", { isToday: true, visible: true })).toBeNull();
+ });
+});
diff --git a/frontend/src/pages/v2/dashboardRuntime.ts b/frontend/src/pages/v2/dashboardRuntime.ts
new file mode 100644
index 0000000..2a8b204
--- /dev/null
+++ b/frontend/src/pages/v2/dashboardRuntime.ts
@@ -0,0 +1,36 @@
+import type { RuntimeNodeStatus, RuntimeOverallStatus } from "../../api";
+
+export const RUNTIME_NODE_META: Record = {
+ pending: { label: "等待", tone: "neutral" },
+ running: { label: "运行中", tone: "info" },
+ success: { label: "完成", tone: "success" },
+ retry_pending: { label: "等待重试", tone: "warning" },
+ held: { label: "需要处理", tone: "danger" },
+ failed: { label: "失败", tone: "danger" },
+};
+
+export function runtimeRefreshDelay(
+ status: RuntimeOverallStatus,
+ options: {
+ isToday: boolean;
+ visible: boolean;
+ scheduledAt?: string;
+ now?: number;
+ },
+): number | null {
+ if (!options.isToday || !options.visible) return null;
+ if (status === "running" || status === "retry_pending") return 3_000;
+ if (status !== "not_started") return null;
+
+ const scheduled = options.scheduledAt ? Date.parse(options.scheduledAt) : Number.NaN;
+ const now = options.now ?? Date.now();
+ if (Number.isFinite(scheduled) && scheduled > now) {
+ return Math.max(250, Math.min(30_000, scheduled - now));
+ }
+ return 30_000;
+}
+
+export function formatRuntimeTime(value?: string): string {
+ if (!value) return "—";
+ return value.replace("T", " ").slice(0, 19);
+}
diff --git a/frontend/src/pages/v2/rankingPolicy.test.ts b/frontend/src/pages/v2/rankingPolicy.test.ts
new file mode 100644
index 0000000..f0b23eb
--- /dev/null
+++ b/frontend/src/pages/v2/rankingPolicy.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ formatRankingCount,
+ INTERACTION_EXPLANATION,
+ isTextPrimaryRanking,
+ parseRanking,
+ TEXT_PRIMARY_WITH_INTERACTIONS,
+} from "./rankingPolicy";
+
+describe("ranking policy display", () => {
+ it("formats the text-primary policy and exposes the approved explanation", () => {
+ expect(isTextPrimaryRanking(TEXT_PRIMARY_WITH_INTERACTIONS)).toBe(true);
+ expect(formatRankingCount(TEXT_PRIMARY_WITH_INTERACTIONS, {
+ count: 55,
+ text_count: 55,
+ interaction_count: 34,
+ })).toBe("文字 55|互动 34");
+ expect(INTERACTION_EXPLANATION).toBe("说明:互动指图片、表情、引用等非文字消息,仅展示活跃度,不影响排名。");
+ });
+
+ it("keeps legacy rankings compatible", () => {
+ expect(isTextPrimaryRanking("all_messages")).toBe(false);
+ expect(formatRankingCount("all_messages", { count: 89 })).toBe("89 条");
+ });
+
+ it("falls back safely when an early strict record lacks extended counts", () => {
+ expect(formatRankingCount(TEXT_PRIMARY_WITH_INTERACTIONS, { count: 12 })).toBe("文字 12|互动 0");
+ });
+
+ it("parses extended strict ranking fields", () => {
+ const parsed = parseRanking({
+ group_name: "测试群",
+ count_policy: TEXT_PRIMARY_WITH_INTERACTIONS,
+ message_count: 3,
+ speaker_count: 1,
+ text_message_count: 1,
+ interaction_message_count: 2,
+ text_speaker_count: 1,
+ top_speakers: [{
+ rank: 1,
+ name: "群友",
+ count: 1,
+ text_count: 1,
+ interaction_count: 2,
+ name_source: "wechat_data_analysis",
+ }],
+ });
+
+ expect(parsed.error).toBe("");
+ expect(parsed.summary?.countPolicy).toBe(TEXT_PRIMARY_WITH_INTERACTIONS);
+ expect(parsed.summary?.topSpeakers[0]).toMatchObject({
+ textCount: 1,
+ interactionCount: 2,
+ nameSource: "wechat_data_analysis",
+ });
+ });
+
+ it("parses legacy ranking JSON without extended fields", () => {
+ const parsed = parseRanking({
+ group_name: "历史群",
+ message_count: 8,
+ speaker_count: 1,
+ top_speakers: [{ rank: 1, name: "旧群友", count: 8 }],
+ });
+
+ expect(parsed.error).toBe("");
+ expect(parsed.summary?.countPolicy).toBe("all_messages");
+ expect(parsed.summary?.topSpeakers[0]).toMatchObject({
+ count: 8,
+ textCount: 8,
+ interactionCount: 0,
+ nameSource: "",
+ });
+ });
+});
diff --git a/frontend/src/pages/v2/rankingPolicy.ts b/frontend/src/pages/v2/rankingPolicy.ts
new file mode 100644
index 0000000..19811ea
--- /dev/null
+++ b/frontend/src/pages/v2/rankingPolicy.ts
@@ -0,0 +1,101 @@
+export const TEXT_PRIMARY_WITH_INTERACTIONS = "text_primary_with_interactions";
+
+export const INTERACTION_EXPLANATION = "说明:互动指图片、表情、引用等非文字消息,仅展示活跃度,不影响排名。";
+
+export type RankingCountPolicy = "all_messages" | "text_primary_with_interactions";
+
+export interface RankingCountFields {
+ count: number;
+ text_count?: number;
+ interaction_count?: number;
+}
+
+export interface ParsedRankingSpeaker {
+ rank: number;
+ name: string;
+ count: number;
+ textCount: number;
+ interactionCount: number;
+ nameSource: string;
+}
+
+export interface ParsedRankingSummary {
+ groupName: string;
+ periodStart: string;
+ periodEnd: string;
+ messageCount: number | null;
+ speakerCount: number | null;
+ countPolicy: RankingCountPolicy;
+ textMessageCount: number | null;
+ interactionMessageCount: number | null;
+ textSpeakerCount: number | null;
+ topSpeakers: ParsedRankingSpeaker[];
+}
+
+function asRecord(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null;
+}
+
+function asText(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function asCount(value: unknown): number | null {
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value);
+ return null;
+}
+
+export function isTextPrimaryRanking(policy: string): boolean {
+ return policy === TEXT_PRIMARY_WITH_INTERACTIONS;
+}
+
+export function formatRankingCount(policy: string, speaker: RankingCountFields): string {
+ if (!isTextPrimaryRanking(policy)) return `${speaker.count} 条`;
+ return `文字 ${speaker.text_count ?? speaker.count}|互动 ${speaker.interaction_count ?? 0}`;
+}
+
+export function parseRanking(value: unknown): { summary: ParsedRankingSummary | null; error: string } {
+ const record = asRecord(value);
+ if (!record) return { summary: null, error: "ranking.json 不是对象格式,无法解析排行榜。" };
+ const rawSpeakers = record.top_speakers;
+ if (!Array.isArray(rawSpeakers)) {
+ return { summary: null, error: "ranking.json 缺少有效的 top_speakers 数组。" };
+ }
+ const countPolicy: RankingCountPolicy = asText(record.count_policy) === TEXT_PRIMARY_WITH_INTERACTIONS
+ ? TEXT_PRIMARY_WITH_INTERACTIONS
+ : "all_messages";
+ const topSpeakers: ParsedRankingSpeaker[] = [];
+ let malformed = false;
+ rawSpeakers.forEach((item, index) => {
+ const speaker = asRecord(item);
+ const name = speaker ? asText(speaker.name).trim() : "";
+ const count = speaker ? asCount(speaker.count) : null;
+ if (!name || count === null) {
+ malformed = true;
+ return;
+ }
+ topSpeakers.push({
+ rank: asCount(speaker?.rank) || index + 1,
+ name,
+ count,
+ textCount: asCount(speaker?.text_count) ?? count,
+ interactionCount: asCount(speaker?.interaction_count) ?? 0,
+ nameSource: asText(speaker?.name_source),
+ });
+ });
+ return {
+ summary: {
+ groupName: asText(record.group_name),
+ periodStart: asText(record.period_start),
+ periodEnd: asText(record.period_end),
+ messageCount: asCount(record.message_count),
+ speakerCount: asCount(record.speaker_count),
+ countPolicy,
+ textMessageCount: asCount(record.text_message_count),
+ interactionMessageCount: asCount(record.interaction_message_count),
+ textSpeakerCount: asCount(record.text_speaker_count),
+ topSpeakers,
+ },
+ error: malformed ? "ranking.json 中有部分排行项格式异常,已跳过异常项。" : "",
+ };
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index e44e54f..32a24d5 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1440,6 +1440,309 @@ textarea:focus-visible {
background: rgba(239, 85, 74, 0.08);
}
+.dashboard-runtime-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 3fr) minmax(320px, 2fr);
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.dashboard-runtime-panel {
+ min-height: 430px;
+}
+
+.dashboard-runtime-updated {
+ flex: 0 0 auto;
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.dashboard-runtime-nodes {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 18px;
+}
+
+.dashboard-runtime-node {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 9px;
+ padding: 12px;
+ border: 1px solid #e7ebf1;
+ border-radius: 12px;
+ background: #fafbfd;
+}
+
+.dashboard-runtime-node > .ui-status {
+ grid-column: 2;
+ justify-self: start;
+}
+
+.dashboard-runtime-node.is-running,
+.dashboard-runtime-node.is-retry_pending {
+ border-color: rgba(59, 130, 246, 0.24);
+ background: rgba(59, 130, 246, 0.05);
+}
+
+.dashboard-runtime-node.is-held,
+.dashboard-runtime-node.is-failed {
+ border-color: rgba(239, 85, 74, 0.24);
+ background: rgba(239, 85, 74, 0.05);
+}
+
+.dashboard-runtime-node-index {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border-radius: 999px;
+ color: #657287;
+ background: #e9eef5;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.dashboard-runtime-node.is-success .dashboard-runtime-node-index {
+ color: #147a43;
+ background: rgba(34, 169, 91, 0.14);
+}
+
+.dashboard-runtime-node.is-running .dashboard-runtime-node-index {
+ color: #245fbe;
+ background: rgba(59, 130, 246, 0.15);
+}
+
+.dashboard-runtime-node-main {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.dashboard-runtime-node-main strong {
+ overflow: hidden;
+ color: var(--text);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-runtime-node-main span {
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.dashboard-runtime-groups {
+ display: flex;
+ max-height: 260px;
+ flex-direction: column;
+ gap: 8px;
+ overflow: auto;
+ padding-right: 3px;
+}
+
+.dashboard-runtime-group {
+ border: 1px solid #e9edf3;
+ border-radius: 10px;
+ background: #fff;
+}
+
+.dashboard-runtime-group summary {
+ display: flex;
+ min-height: 54px;
+ cursor: pointer;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 12px;
+ list-style-position: inside;
+}
+
+.dashboard-runtime-group summary > span:first-of-type {
+ display: inline-flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.dashboard-runtime-group summary strong,
+.dashboard-runtime-group summary small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-runtime-group summary strong {
+ color: var(--text);
+ font-size: 12px;
+}
+
+.dashboard-runtime-group summary small {
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.dashboard-runtime-group-nodes {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 7px;
+ padding: 0 12px 12px;
+}
+
+.dashboard-runtime-group-nodes span {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 5px;
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.dashboard-runtime-group-nodes i {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: #bdc6d2;
+}
+
+.dashboard-runtime-group-nodes .is-success i { background: #22a95b; }
+.dashboard-runtime-group-nodes .is-running i { background: #3b82f6; }
+.dashboard-runtime-group-nodes .is-retry_pending i { background: #f59f2f; }
+.dashboard-runtime-group-nodes .is-held i,
+.dashboard-runtime-group-nodes .is-failed i { background: #ef554a; }
+
+.dashboard-runtime-group-error {
+ display: flex;
+ gap: 7px;
+ margin: 0 12px 12px;
+ padding: 8px 9px;
+ border-radius: 8px;
+ color: #a93531;
+ background: rgba(239, 85, 74, 0.07);
+ font-size: 10px;
+}
+
+.dashboard-runtime-group-error span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.dashboard-runtime-empty,
+.dashboard-log-state {
+ display: grid;
+ min-height: 110px;
+ place-items: center;
+ color: var(--text-secondary);
+ font-size: 12px;
+ text-align: center;
+}
+
+.dashboard-log-heading {
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.dashboard-log-controls {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.dashboard-log-controls label {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.dashboard-log-controls label > span {
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.dashboard-log-controls select {
+ width: 100%;
+ min-height: 32px;
+ padding: 0 8px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ background: #fff;
+ font-size: 11px;
+}
+
+.dashboard-log-list {
+ height: 310px;
+ overflow: auto;
+ border: 1px solid #e7ebf1;
+ border-radius: 11px;
+ background: #f7f9fc;
+}
+
+.dashboard-log-line {
+ padding: 10px 12px;
+ border-bottom: 1px solid #e8edf4;
+}
+
+.dashboard-log-line:last-child {
+ border-bottom: 0;
+}
+
+.dashboard-log-line header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin-bottom: 5px;
+ color: #69768a;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 9px;
+}
+
+.dashboard-log-source,
+.dashboard-log-level {
+ padding: 2px 5px;
+ border-radius: 5px;
+ background: #e9eef5;
+}
+
+.dashboard-log-line.is-warning .dashboard-log-level {
+ color: #9b620d;
+ background: rgba(245, 159, 47, 0.14);
+}
+
+.dashboard-log-line.is-error .dashboard-log-level,
+.dashboard-log-line.is-critical .dashboard-log-level {
+ color: #a93531;
+ background: rgba(239, 85, 74, 0.13);
+}
+
+.dashboard-log-line pre {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: #253044;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 10px;
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+
+.dashboard-log-state.is-error {
+ color: #a93531;
+}
+
+.dashboard-log-note {
+ margin-top: 8px;
+ color: var(--text-secondary);
+ font-size: 9px;
+}
+
.dashboard-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -2253,6 +2556,10 @@ textarea:focus-visible {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+ .dashboard-runtime-nodes {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.groups-bind-panel {
align-items: stretch;
flex-direction: column;
@@ -2264,10 +2571,15 @@ textarea:focus-visible {
}
@media (max-width: 820px) {
+ .dashboard-runtime-grid,
.dashboard-main-grid {
grid-template-columns: 1fr;
}
+ .dashboard-runtime-panel {
+ min-height: 0;
+ }
+
.dashboard-task-grid {
grid-template-columns: 1fr;
}
@@ -2304,6 +2616,24 @@ textarea:focus-visible {
width: 145px;
}
+ .dashboard-runtime-nodes,
+ .dashboard-runtime-group-nodes {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-log-controls {
+ align-items: stretch;
+ flex-wrap: wrap;
+ }
+
+ .dashboard-log-controls label {
+ flex-basis: calc(50% - 4px);
+ }
+
+ .dashboard-log-controls .ui-button {
+ width: 100%;
+ }
+
.dashboard-task-actions,
.dashboard-task-actions .ui-button,
.group-detail-footer-actions,
@@ -2673,11 +3003,12 @@ textarea:focus-visible {
}
.ranking-top-list li strong {
- overflow: hidden;
+ min-width: 0;
color: var(--text);
font-weight: 600;
- text-overflow: ellipsis;
- white-space: nowrap;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+ white-space: normal;
}
.ranking-top-list li > span:last-child {
@@ -2685,6 +3016,14 @@ textarea:focus-visible {
white-space: nowrap;
}
+.ranking-interaction-note,
+.dashboard-ranking-interaction-note {
+ margin: 10px 0 0;
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1.55;
+}
+
.ranking-text-block pre,
.template-preview-box pre,
.ai-images-prompt-card pre {
@@ -3632,27 +3971,74 @@ textarea:focus-visible {
font-size: 11px;
}
-@media (max-width: 1050px) {
- .tasks-kpi-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
+.tasks-backlog {
+ margin-bottom: 20px;
+ padding: 20px;
}
-@media (max-width: 900px) {
- .chat-records-workspace,
- .tasks-workspace {
- grid-template-columns: 1fr;
- }
-
- .chat-records-run-items,
- .tasks-list-items {
- max-height: 260px;
- }
+.tasks-backlog-list {
+ display: grid;
+ gap: 10px;
+ margin-top: 16px;
}
-@media (max-width: 680px) {
- .chat-records-filter-bar,
- .tasks-filter-bar {
+.tasks-backlog-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding: 12px 14px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ background: var(--surface-soft);
+}
+
+.tasks-backlog-item span:not(.status-badge) {
+ display: grid;
+ gap: 4px;
+}
+
+.tasks-backlog-item small {
+ color: var(--text-muted);
+}
+
+.tasks-backlog-item.is-disabled {
+ opacity: 0.72;
+}
+
+@media (max-width: 640px) {
+ .tasks-backlog .tasks-section-head,
+ .tasks-backlog-item {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .tasks-backlog .tasks-section-head .ui-button,
+ .tasks-backlog-item .status-badge {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 1050px) {
+ .tasks-kpi-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 900px) {
+ .chat-records-workspace,
+ .tasks-workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .chat-records-run-items,
+ .tasks-list-items {
+ max-height: 260px;
+ }
+}
+
+@media (max-width: 680px) {
+ .chat-records-filter-bar,
+ .tasks-filter-bar {
align-items: stretch;
flex-direction: column;
}
@@ -4305,6 +4691,81 @@ textarea:focus-visible {
margin-bottom: 14px;
}
+.weekly-archive {
+ margin-bottom: 20px;
+ padding: 22px;
+}
+
+.weekly-archive-layout {
+ display: grid;
+ grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.8fr);
+ gap: 18px;
+ margin-top: 18px;
+}
+
+.weekly-archive-list {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+}
+
+.weekly-archive-list button {
+ display: grid;
+ gap: 4px;
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ background: var(--surface-soft);
+ color: inherit;
+ text-align: left;
+}
+
+.weekly-archive-list button.is-selected {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.weekly-archive-list span,
+.weekly-archive-list small,
+.weekly-archive-detail p,
+.weekly-archive-meta {
+ color: var(--text-muted);
+}
+
+.weekly-archive-detail {
+ min-width: 0;
+}
+
+.weekly-archive-detail img {
+ display: block;
+ width: min(100%, 420px);
+ margin: 16px 0;
+ border-radius: 16px;
+ border: 1px solid var(--border-subtle);
+}
+
+.weekly-archive-detail pre {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ font: inherit;
+ line-height: 1.7;
+}
+
+.weekly-archive-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 14px;
+ font-size: 13px;
+}
+
+@media (max-width: 760px) {
+ .weekly-archive-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
.archive-catalog-head,
.archive-selected-group,
.archive-calendar-head,
@@ -5221,126 +5682,412 @@ textarea:focus-visible {
box-shadow: 0 0 0 3px var(--accent-soft);
}
-.ai-images-theme-options {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px;
+.image-theme-picker {
+ position: relative;
+ min-width: 0;
}
-.ai-images-theme-modes {
- grid-template-columns: repeat(2, minmax(0, 1fr));
+.image-theme-picker-label {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 650;
}
-.ai-images-theme-option {
- min-width: 0;
- min-height: 88px;
- padding: 13px 14px;
+.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: 12px;
+ border-radius: var(--radius-control);
color: var(--text);
background: var(--card);
text-align: left;
cursor: pointer;
- transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
-.ai-images-theme-option:hover {
- border-color: #b9cff5;
- background: #f8fbff;
- transform: translateY(-1px);
+.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;
}
-.ai-images-theme-option.is-active {
- border-color: var(--accent);
- background: var(--accent-soft);
- box-shadow: 0 0 0 2px rgba(36, 120, 245, 0.12);
+.image-theme-picker-trigger:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
}
-.ai-images-theme-option strong,
-.ai-images-theme-option span {
- display: block;
+.image-theme-picker-trigger > span:nth-child(2) {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 2px;
}
-.ai-images-theme-option strong {
+.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;
- font-weight: 700;
}
-.ai-images-theme-option span {
- margin-top: 7px;
+.image-theme-picker-trigger small {
color: var(--text-secondary);
- font-size: 11px;
- line-height: 1.5;
+ font-size: 10.5px;
}
-.ai-images-theme-custom-field {
+.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-width: none;
- margin: 0;
+ 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);
}
-.ai-images-style-target-row {
- display: grid;
- grid-template-columns: minmax(240px, 1fr) auto;
- align-items: end;
+.image-theme-picker-popover-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
gap: 12px;
- margin-top: 12px;
+ margin-bottom: 10px;
}
-.ai-images-style-target-row .ui-button {
- min-width: 148px;
+.image-theme-picker-popover-head > div {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
}
-.ai-images-style-draft-note,
-.ai-images-style-status {
- margin-top: 8px;
+.image-theme-picker-popover-head strong {
+ font-size: 14px;
+}
+
+.image-theme-picker-popover-head span {
color: var(--text-secondary);
- font-size: 11px;
+ font-size: 10.5px;
}
-.ai-images-style-draft-note {
- color: var(--accent);
+.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;
}
-.ai-images-theme-error {
- margin-top: 12px;
- padding: 10px 12px;
- border: 1px solid rgba(239, 85, 74, 0.26);
- border-radius: 9px;
- color: var(--danger);
- background: rgba(239, 85, 74, 0.07);
- font-size: 12px;
+.image-theme-mode-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
}
-.ai-images-theme-footer {
+.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;
- gap: 16px;
- margin-top: 16px;
- padding-top: 14px;
- border-top: 1px solid var(--border);
+ padding: 9px 10px;
}
-.ai-images-theme-footer > div {
+.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;
- align-items: center;
- gap: 10px;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 3px;
}
-.ai-images-theme-note {
- color: var(--text-secondary);
+.image-theme-mode-list b,
+.image-theme-preset-grid b {
font-size: 12px;
}
-.ai-images-theme-saved {
- color: var(--success);
- font-size: 12px;
- font-weight: 650;
+.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;
}
-@media (max-width: 1120px) {
- .ai-images-theme-options {
+.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));
+ gap: 10px;
+}
+
+.ai-images-theme-modes {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.ai-images-theme-option {
+ min-width: 0;
+ min-height: 88px;
+ padding: 13px 14px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ color: var(--text);
+ background: var(--card);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+
+.ai-images-theme-option:hover {
+ border-color: #b9cff5;
+ background: #f8fbff;
+ transform: translateY(-1px);
+}
+
+.ai-images-theme-option.is-active {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ box-shadow: 0 0 0 2px rgba(36, 120, 245, 0.12);
+}
+
+.ai-images-theme-option strong,
+.ai-images-theme-option span {
+ display: block;
+}
+
+.ai-images-theme-option strong {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.ai-images-theme-option span {
+ margin-top: 7px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.ai-images-theme-custom-field {
+ width: 100%;
+ max-width: none;
+ margin: 0;
+}
+
+.ai-images-style-target-row {
+ display: grid;
+ grid-template-columns: minmax(240px, 1fr) auto;
+ align-items: end;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.ai-images-style-target-row .ui-button {
+ min-width: 148px;
+}
+
+.ai-images-style-draft-note,
+.ai-images-style-status {
+ margin-top: 8px;
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.ai-images-style-draft-note {
+ color: var(--accent);
+}
+
+.ai-images-theme-error {
+ margin-top: 12px;
+ padding: 10px 12px;
+ border: 1px solid rgba(239, 85, 74, 0.26);
+ border-radius: 9px;
+ color: var(--danger);
+ background: rgba(239, 85, 74, 0.07);
+ font-size: 12px;
+}
+
+.ai-images-theme-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+
+.ai-images-theme-footer > div {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.ai-images-theme-note {
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.ai-images-theme-saved {
+ color: var(--success);
+ font-size: 12px;
+ font-weight: 650;
+}
+
+@media (max-width: 1120px) {
+ .ai-images-theme-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -5468,6 +6215,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);
@@ -5476,6 +6227,51 @@ textarea:focus-visible {
background: var(--surface);
}
+.ai-images-topic-score-card > .ai-images-content-heading > div:first-child {
+ min-width: 0;
+}
+
+.ai-images-topic-score-heading-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+}
+
+.ai-images-topic-score-heading-actions > span {
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.ai-images-topic-score-toggle {
+ display: inline-flex;
+ min-height: 30px;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ padding: 0 9px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--accent);
+ background: var(--surface-muted);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 650;
+ white-space: nowrap;
+}
+
+.ai-images-topic-score-toggle:hover {
+ border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
+ background: var(--accent-soft);
+}
+
+.ai-images-topic-score-toggle:focus-visible {
+ outline: 3px solid rgba(36, 120, 245, 0.28);
+ outline-offset: 2px;
+}
+
.ai-images-topic-score-list {
display: grid;
gap: 10px;
@@ -5516,7 +6312,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;
}
@@ -5592,6 +6388,47 @@ textarea:focus-visible {
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
}
+.ai-images-regen-state.ambiguous_result,
+.ai-images-regen-state.result_unknown {
+ border-color: color-mix(in srgb, var(--warning) 52%, var(--border));
+ background: color-mix(in srgb, var(--warning) 10%, var(--surface));
+}
+
+.ai-images-candidate-card {
+ margin-top: 18px;
+ padding: 18px;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--surface);
+}
+
+.ai-images-candidate-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 14px;
+}
+
+.ai-images-candidate-grid article {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+}
+
+.ai-images-candidate-grid img {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: contain;
+ border-radius: 10px;
+ background: var(--surface-muted);
+}
+
+.ai-images-candidate-grid small {
+ overflow-wrap: anywhere;
+ color: var(--text-muted);
+}
+
.ai-images-review-actions {
display: flex;
align-items: center;
@@ -5627,7 +6464,25 @@ textarea:focus-visible {
grid-template-columns: 1fr;
}
- .ai-images-run-actions,
+ .image-theme-preset-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ai-images-topic-score-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .ai-images-topic-score-card > .ai-images-content-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .ai-images-topic-score-heading-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .ai-images-run-actions,
.ai-images-review-actions,
.ai-images-theme-footer > div {
align-items: stretch;
@@ -5639,3 +6494,648 @@ textarea:focus-visible {
width: 100%;
}
}
+
+/* Motion 与状态闭环:所有位移和缩放都可由系统“减少动态效果”关闭。 */
+.app-page-motion {
+ width: 100%;
+ min-width: 0;
+}
+
+.motion-content-swap {
+ width: 100%;
+ min-width: 0;
+}
+
+.settings-tab {
+ position: relative;
+ isolation: isolate;
+}
+
+.settings-tab > :not(.settings-tab-indicator) {
+ position: relative;
+ z-index: 1;
+}
+
+.settings-tab-indicator {
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--accent-soft);
+}
+
+.settings-tab.is-active {
+ background: transparent;
+}
+
+.app-nav-item,
+.workspace-tabs button {
+ position: relative;
+ isolation: isolate;
+}
+
+.app-nav-item > :not(.app-nav-active-indicator),
+.workspace-tabs button > :not(.workspace-tab-indicator) {
+ position: relative;
+ z-index: 1;
+}
+
+.workspace-tabs button > .workspace-tab-label {
+ position: relative;
+ z-index: 1;
+}
+
+.app-nav-active-indicator,
+.workspace-tab-indicator {
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--accent-soft);
+}
+
+.app-nav-item.active,
+.workspace-tabs button.is-active {
+ background: transparent;
+}
+
+.dashboard-health-trigger {
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ cursor: pointer;
+}
+
+.dashboard-health-trigger:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.dashboard-health-details {
+ display: grid;
+ gap: 7px;
+ margin: -2px 0 16px;
+ padding: 14px 16px;
+ border: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border));
+ border-radius: var(--radius-control);
+ background: color-mix(in srgb, var(--warning) 5%, var(--card));
+}
+
+.dashboard-health-details > strong {
+ color: var(--text);
+ font-size: 13px;
+}
+
+.dashboard-health-details p {
+ display: grid;
+ grid-template-columns: minmax(120px, 0.2fr) 1fr;
+ gap: 12px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.dashboard-health-details b {
+ color: var(--text);
+}
+
+.dashboard-main-grid {
+ grid-template-columns: 1fr;
+}
+
+.dashboard-task-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.dashboard-task-card {
+ animation: dashboard-card-resolved 520ms ease-out both;
+ transition: border-color 180ms ease, background-color 180ms ease;
+}
+
+@keyframes dashboard-card-resolved {
+ 0% { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--card)); }
+ 100% { border-color: #e9edf3; background: #fbfcfe; }
+}
+
+.dashboard-task-content {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(180px, 1.1fr);
+ gap: 12px;
+}
+
+.dashboard-ranking-preview,
+.dashboard-image-preview {
+ min-width: 0;
+ padding: 11px;
+ border: 1px solid var(--border);
+ border-radius: 11px;
+ background: var(--card);
+}
+
+.dashboard-task-content-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 9px;
+}
+
+.dashboard-task-content-head strong {
+ color: var(--text);
+ font-size: 12px;
+}
+
+.dashboard-task-content-head span {
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.dashboard-ranking-preview ol {
+ display: grid;
+ gap: 4px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.dashboard-ranking-preview li {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 7px;
+ min-height: 23px;
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.dashboard-ranking-preview li > span {
+ display: grid;
+ width: 20px;
+ height: 20px;
+ place-items: center;
+ border-radius: 6px;
+ color: var(--accent);
+ background: var(--accent-soft);
+ font-weight: 700;
+}
+
+.dashboard-ranking-preview li strong {
+ min-width: 0;
+ color: var(--text);
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.dashboard-ranking-preview li em {
+ font-style: normal;
+ white-space: nowrap;
+}
+
+.dashboard-ranking-interaction-note {
+ padding-top: 8px;
+ border-top: 1px solid var(--border);
+}
+
+.dashboard-ranking-empty {
+ display: grid;
+ min-height: 142px;
+ place-items: center;
+ padding: 12px;
+ border: 1px dashed var(--border);
+ border-radius: 9px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ text-align: center;
+}
+
+.dashboard-manual-dialog {
+ width: min(100%, 560px);
+}
+
+.dashboard-manual-dialog fieldset {
+ display: grid;
+ gap: 8px;
+ margin: 17px 0 0;
+ padding: 0;
+ border: 0;
+}
+
+.dashboard-manual-dialog fieldset label {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 11px 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ cursor: pointer;
+}
+
+.dashboard-manual-dialog fieldset label:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.dashboard-manual-dialog fieldset input {
+ margin-top: 3px;
+ accent-color: var(--accent);
+}
+
+.dashboard-manual-dialog fieldset span {
+ display: grid;
+ gap: 3px;
+}
+
+.dashboard-manual-dialog fieldset b {
+ color: var(--text);
+ font-size: 13px;
+}
+
+.dashboard-manual-dialog fieldset small {
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
+
+.image-theme-center-backdrop {
+ position: fixed;
+ z-index: 140;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgba(17, 24, 39, 0.46);
+ backdrop-filter: blur(3px);
+}
+
+.image-theme-center {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ width: min(1060px, 100%);
+ height: min(760px, calc(100vh - 40px));
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--card);
+ box-shadow: 0 28px 80px rgba(17, 24, 39, 0.26);
+ outline: none;
+}
+
+.image-theme-center-head,
+.image-theme-center-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 16px 20px;
+}
+
+.image-theme-center-head {
+ border-bottom: 1px solid var(--border);
+}
+
+.image-theme-center-head h2 {
+ color: var(--text);
+ font-size: 18px;
+}
+
+.image-theme-center-head p,
+.image-theme-center-actions > span {
+ margin-top: 3px;
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.image-theme-center-head > button {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ place-items: center;
+ border: 0;
+ border-radius: 9px;
+ color: var(--text-secondary);
+ background: var(--surface-muted);
+ cursor: pointer;
+}
+
+.image-theme-center-tabs {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ padding: 8px 20px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.image-theme-center-tabs button {
+ min-height: 42px;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ color: var(--text-secondary);
+ background: transparent;
+ cursor: pointer;
+ font-weight: 650;
+ transition: color 160ms ease, border-color 160ms ease;
+}
+
+.image-theme-center-tabs button.is-active {
+ border-bottom-color: var(--accent);
+ color: var(--accent);
+}
+
+.image-theme-center-body {
+ min-height: 0;
+ overflow: auto;
+ padding: 16px 20px;
+}
+
+.image-theme-preset-layout {
+ display: grid;
+ grid-template-columns: minmax(310px, 0.85fr) minmax(360px, 1.15fr);
+ align-items: start;
+ min-height: 100%;
+ gap: 18px;
+}
+
+.image-theme-preset-browser {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ min-height: 0;
+}
+
+.image-theme-search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+}
+
+.image-theme-search input {
+ flex: 1;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.image-theme-preset-list {
+ display: grid;
+ align-content: start;
+ min-height: 0;
+ overflow: auto;
+ gap: 6px;
+ padding-right: 5px;
+}
+
+.image-theme-preset-list > button {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 9px;
+ min-height: 50px;
+ padding: 7px 9px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ color: var(--text);
+ background: var(--card);
+ text-align: left;
+ cursor: pointer;
+}
+
+.image-theme-preset-list > button:hover,
+.image-theme-preset-list > button.is-active {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.image-theme-preset-list > button > span:nth-child(2) {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+
+.image-theme-preset-list b {
+ overflow: hidden;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.image-theme-preset-list small {
+ color: var(--text-secondary);
+ font-size: 10px;
+}
+
+.image-theme-preview-panel {
+ display: grid;
+ grid-template-columns: minmax(220px, 300px) minmax(180px, 1fr);
+ align-items: start;
+ align-content: start;
+ align-self: start;
+ justify-content: center;
+ gap: 20px;
+ min-width: 0;
+ padding: 18px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface-muted);
+}
+
+.image-theme-preview-frame {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ aspect-ratio: 2 / 3;
+ border-radius: 12px;
+ box-shadow: 0 12px 32px rgba(17, 24, 39, 0.14);
+}
+
+.image-theme-preview-frame img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.image-theme-preview-placeholder {
+ display: grid;
+ width: 100%;
+ height: 100%;
+ place-content: center;
+ gap: 10px;
+ padding: 18px;
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(17, 24, 39, 0.25);
+ text-align: center;
+}
+
+.image-theme-preview-copy {
+ display: grid;
+ align-content: center;
+ gap: 10px;
+}
+
+.image-theme-preview-copy > span,
+.image-theme-mode-panel > span {
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.image-theme-preview-copy h3,
+.image-theme-mode-panel h3 {
+ color: var(--text);
+ font-size: 20px;
+}
+
+.image-theme-preview-copy p,
+.image-theme-mode-panel p,
+.image-theme-preview-copy small,
+.image-theme-mode-panel small {
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.image-theme-preview-copy .image-theme-swatches {
+ width: fit-content;
+}
+
+.image-theme-preview-copy .image-theme-swatches i {
+ width: 30px;
+ height: 26px;
+}
+
+.image-theme-mode-panel {
+ display: grid;
+ max-width: 680px;
+ min-height: 330px;
+ align-content: center;
+ gap: 11px;
+ margin: 0 auto;
+ padding: 32px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface-muted);
+}
+
+.image-theme-custom-panel label {
+ display: grid;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.image-theme-custom-panel label > span {
+ display: flex;
+ justify-content: space-between;
+ color: var(--text);
+ font-size: 12px;
+}
+
+.image-theme-custom-panel textarea {
+ min-height: 120px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ color: var(--text);
+ background: var(--card);
+ resize: vertical;
+ font: inherit;
+}
+
+.image-theme-center-actions {
+ border-top: 1px solid var(--border);
+}
+
+.image-theme-center-actions > div {
+ display: flex;
+ gap: 9px;
+}
+
+.groups-toggle,
+.groups-discovery-result,
+.group-detail-discovered,
+.tasks-run-item,
+.ranking-run-item,
+.chat-records-run-item,
+.archive-tab,
+.settings-tab,
+.ai-images-run-item,
+.ai-images-candidate-card,
+.ai-images-regen-state {
+ transition: color 180ms ease, background-color 180ms ease, border-color 180ms ease, opacity 180ms ease, transform 180ms ease;
+}
+
+@media (max-width: 900px) {
+ .dashboard-task-grid,
+ .image-theme-preset-layout,
+ .image-theme-preview-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .image-theme-center {
+ height: calc(100vh - 24px);
+ }
+
+ .image-theme-preview-frame {
+ width: min(320px, 100%);
+ justify-self: center;
+ }
+}
+
+@media (max-width: 640px) {
+ .image-theme-center-backdrop {
+ padding: 0;
+ }
+
+ .image-theme-center {
+ width: 100%;
+ height: 100vh;
+ border: 0;
+ border-radius: 0;
+ }
+
+ .image-theme-center-head,
+ .image-theme-center-actions,
+ .image-theme-center-body {
+ padding-right: 14px;
+ padding-left: 14px;
+ }
+
+ .image-theme-center-tabs {
+ overflow-x: auto;
+ grid-template-columns: repeat(4, minmax(112px, 1fr));
+ padding-right: 14px;
+ padding-left: 14px;
+ }
+
+ .image-theme-center-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .image-theme-center-actions > div,
+ .image-theme-center-actions .ui-button {
+ width: 100%;
+ }
+
+ .dashboard-task-content,
+ .dashboard-health-details p {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+
+ .spin {
+ animation: none !important;
+ }
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..36d518a
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ include: ["src/**/*.test.ts"],
+ restoreMocks: true,
+ clearMocks: true,
+ },
+});
diff --git a/requirements-dev.txt b/requirements-dev.txt
index df39e2a..931e785 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,2 +1,3 @@
pytest>=8.0.0
pytest-asyncio>=0.23.0
+pytest-cov>=7.1.0
diff --git a/requirements.lock b/requirements.lock
new file mode 100644
index 0000000..82bd161
--- /dev/null
+++ b/requirements.lock
@@ -0,0 +1,49 @@
+# 当前 Windows/Python 3.12 已验证组合。
+# 作为 constraints 使用,不直接安装:
+# python -m pip install -r requirements.txt -r requirements-dev.txt -c requirements.lock
+annotated-doc==0.0.5
+annotated-types==0.8.0
+anyio==4.14.2
+APScheduler==3.11.3
+certifi==2026.7.22
+click==8.4.2
+colorama==0.4.6
+coverage==7.15.4
+fastapi==0.141.1
+greenlet==3.5.5
+h11==0.16.0
+httpcore==1.0.9
+httptools==0.8.0
+httpx==0.28.1
+idna==3.18
+iniconfig==2.3.0
+packaging==26.3
+Pillow==12.3.0
+pluggy==1.6.0
+pydantic==2.13.4
+pydantic_core==2.46.4
+pydantic-settings==2.15.0
+Pygments==2.21.0
+pytest==9.1.1
+pytest-asyncio==1.4.0
+pytest-cov==7.1.0
+python-dotenv==1.2.3
+pywin32==312
+PyYAML==6.0.3
+SQLAlchemy==2.0.52
+sqlmodel==0.0.39
+starlette==1.6.0
+typing_extensions==4.16.0
+typing-inspection==0.4.4
+tzdata==2026.3
+tzlocal==5.4.4
+uvicorn==0.52.3
+watchfiles==1.2.0
+websockets==17.0.1
+winrt-runtime==3.2.1
+winrt-Windows.Foundation==3.2.1
+winrt-Windows.Foundation.Collections==3.2.1
+winrt-Windows.Globalization==3.2.1
+winrt-Windows.Graphics.Imaging==3.2.1
+winrt-Windows.Media.Ocr==3.2.1
+winrt-Windows.Storage.Streams==3.2.1
diff --git a/requirements.txt b/requirements.txt
index 2a59d2d..82d89e5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,10 +5,12 @@ pydantic-settings>=2.4.0
SQLModel>=0.0.22
APScheduler>=3.10.0
httpx>=0.27.0
+httpx2>=2.0.0
python-dotenv>=1.0.0
tzdata>=2024.1
Pillow>=11.0.0
pywin32>=310; sys_platform == "win32"
+pywinauto==0.6.9; sys_platform == "win32"
winrt-runtime>=3.2; sys_platform == "win32"
winrt-Windows.Foundation>=3.2; sys_platform == "win32"
winrt-Windows.Foundation.Collections>=3.2; sys_platform == "win32"
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/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/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/scripts/send_daily_email.py b/scripts/send_daily_email.py
index 504cf4b..6644e3b 100644
--- a/scripts/send_daily_email.py
+++ b/scripts/send_daily_email.py
@@ -14,9 +14,9 @@
import argparse
import json
-import smtplib
+import smtplib # 兼容旧测试注入;真实发送实现在 app.services.email_delivery
import sys
-import time
+import time # 兼容旧测试注入;真实退避实现在 app.services.email_delivery
from dataclasses import dataclass
from datetime import datetime
from email.message import EmailMessage
@@ -30,6 +30,13 @@
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.scheduler.outcome import ProcessExitCode
+from app.services.email_delivery import (
+ EmailDeliveryLedger,
+ EmailDeliveryResult,
+ deliver_email,
+)
+from app.services.email_service import email_delivery_config_error
from app.services.handoff_service import safe_dir_name
@@ -200,47 +207,40 @@ def collect_group_inputs(settings, groups, run_date: str) -> tuple[list[GroupMai
return blocks, skipped
-def _send_with_retry(message: EmailMessage, settings, max_attempts: int = 2) -> tuple[bool, str]:
- """单群最多重试两次;失败后交回调用方继续下一个群。"""
- last_error = ""
- for attempt in range(1, max_attempts + 1):
- server = None
- attempt_error: Exception | None = None
- try:
- if settings.email_use_ssl:
- server = smtplib.SMTP_SSL(
- settings.email_smtp_host,
- settings.email_smtp_port,
- timeout=30,
- )
- else:
- server = smtplib.SMTP(
- settings.email_smtp_host,
- settings.email_smtp_port,
- timeout=30,
- )
- server.starttls()
- if settings.email_smtp_user:
- server.login(settings.email_smtp_user, settings.email_smtp_password)
- server.send_message(message)
- except Exception as exc:
- attempt_error = exc
- last_error = str(exc)
- finally:
- if server is not None:
- try:
- quit_method = getattr(server, "quit", None)
- if quit_method is not None:
- quit_method()
- except Exception as exc:
- # send_message 已成功时,QUIT 异常不能触发重复发送。
- print(f" SMTP 连接关闭失败(邮件已提交): {str(exc)[:200]}")
- if attempt_error is None:
- return True, ""
- print(f" 发送 attempt {attempt} 失败: {last_error[:200]}")
- if attempt < max_attempts:
- time.sleep(3)
- return False, last_error
+def _send_with_retry(
+ message: EmailMessage,
+ settings,
+ max_attempts: int = 2,
+ *,
+ ledger: EmailDeliveryLedger | None = None,
+) -> EmailDeliveryResult:
+ """兼容旧函数名;真实重试所有权统一到 email_delivery。"""
+ return deliver_email(
+ message,
+ settings,
+ ledger=ledger,
+ max_attempts=max_attempts,
+ )
+
+
+def _delivery_exit_code(
+ *,
+ sent_count: int,
+ already_sent_count: int,
+ failed_count: int,
+ unknown_count: int,
+ skipped_count: int,
+) -> int:
+ """把逐群交付结果聚合为稳定退出码;跳过项不能伪装成全量成功。"""
+ if unknown_count:
+ return int(ProcessExitCode.BLOCKED)
+ completed_count = sent_count + already_sent_count
+ unsent_count = failed_count + skipped_count
+ if completed_count > 0 and unsent_count == 0:
+ return int(ProcessExitCode.SUCCESS)
+ if completed_count > 0 and unsent_count > 0:
+ return int(ProcessExitCode.PARTIAL)
+ return int(ProcessExitCode.FAILED)
def main() -> int:
@@ -259,9 +259,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()
@@ -301,8 +303,11 @@ def main() -> int:
return 0
sent_count = 0
+ already_sent_count = 0
failed_count = 0
+ unknown_count = 0
summary: list[str] = []
+ ledger = EmailDeliveryLedger(Path(settings.output_dir) / ".email-delivery")
for block in blocks:
try:
message = build_message(block, settings)
@@ -312,24 +317,36 @@ def main() -> int:
summary.append(detail)
print(f"❌ {detail}")
continue
- ok, detail = _send_with_retry(message, settings)
- if ok:
+ delivery = _send_with_retry(message, settings, ledger=ledger)
+ if delivery.status == "sent":
sent_count += 1
summary.append(f"{block.group_name}:发送成功")
print(f"✅ {block.group_name} 邮件发送成功")
+ elif delivery.status == "already_sent":
+ already_sent_count += 1
+ summary.append(f"{block.group_name}:已发送,幂等跳过")
+ print(f"✅ {block.group_name} 邮件已确认发送,本次跳过")
+ elif delivery.outcome_unknown:
+ unknown_count += 1
+ summary.append(f"{block.group_name}:结果未知({delivery.detail[:200]})")
+ print(f"⏸️ {block.group_name} 邮件结果未知,已禁止自动重发: {delivery.detail[:300]}")
else:
failed_count += 1
- summary.append(f"{block.group_name}:发送失败({detail[:200]})")
- print(f"❌ {block.group_name} 邮件发送失败: {detail[:300]}")
+ summary.append(f"{block.group_name}:提交前失败({delivery.detail[:200]})")
+ print(f"❌ {block.group_name} 邮件提交前失败: {delivery.detail[:300]}")
print("\n===== 逐群发送汇总 =====")
for item in summary:
print(f"- {item}")
if skipped:
print(f"- 跳过 {len(skipped)} 个不可发送群(不建立 SMTP 发送尝试)")
- if sent_count > 0 and failed_count == 0:
- return 0
- return 1
+ return _delivery_exit_code(
+ sent_count=sent_count,
+ already_sent_count=already_sent_count,
+ failed_count=failed_count,
+ unknown_count=unknown_count,
+ skipped_count=len(skipped),
+ )
if __name__ == "__main__":
diff --git a/scripts/simulate_reliability.py b/scripts/simulate_reliability.py
new file mode 100644
index 0000000..d57ac22
--- /dev/null
+++ b/scripts/simulate_reliability.py
@@ -0,0 +1,501 @@
+"""GroupBrief 30 天无人值守确定性故障注入仿真(不调用任何外部服务)。"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from collections import Counter, defaultdict
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+# 仿真即使在生产服务运行时也必须使用独立 Windows 命名锁。
+os.environ.setdefault(
+ "GROUPBRIEF_GENERATION_MUTEX_NAMESPACE",
+ f"simulation-{os.getpid()}-{hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]}",
+)
+
+from PIL import Image
+
+from app.ai.prompt_builder_types import PromptOutput
+from app.config.settings import Settings
+from app.data_sources.base import DataSourceStatus, FetchResult, V2Message, WeChatDataSource
+from app.db.models import Group
+from app.image.image_task import ImageTaskResult
+from app.pipeline.daily_pipeline import DailyPipeline
+from app.scheduler.daily_v2_job import (
+ DailyScheduleState,
+ _compact_results,
+ _generation_results_terminal,
+ _generation_status,
+)
+from app.sender.base import SendResult, WechatSender
+from app.services.group_name_sync import GroupNameSyncReport
+from app.v2.constants import SENT
+from app.v2.run_store import RunStore
+
+
+class FaultPlan:
+ RATES = {
+ "network_timeout": 0.05,
+ "ai_invalid_format": 0.05,
+ "image_generation_failed": 0.05,
+ "image_download_failed": 0.03,
+ "send_failed": 0.03,
+ "send_result_unknown": 0.02,
+ "program_interrupt": 0.03,
+ "duplicate_start": 0.08,
+ }
+
+ def __init__(self, seed: int):
+ self.seed = int(seed)
+ self.seen: Counter[tuple[str, str]] = Counter()
+ self.injected: Counter[str] = Counter()
+
+ def selected(self, stage: str, key: str) -> bool:
+ digest = hashlib.sha256(f"{self.seed}|{stage}|{key}".encode("utf-8")).digest()
+ value = int.from_bytes(digest[:8], "big") / float(2**64)
+ return value < self.RATES[stage]
+
+ def once(self, stage: str, key: str) -> bool:
+ identity = (stage, key)
+ self.seen[identity] += 1
+ if self.seen[identity] != 1 or not self.selected(stage, key):
+ return False
+ self.injected[stage] += 1
+ return True
+
+
+class SimulationSource(WeChatDataSource):
+ name = "simulation_source"
+
+ def __init__(self, faults: FaultPlan):
+ self.faults = faults
+ self.calls: Counter[str] = Counter()
+
+ def fetch_messages(self, group_id, start_time, end_time):
+ key = f"{end_time.date().isoformat()}|{group_id}"
+ self.calls[key] += 1
+ if self.faults.once("program_interrupt", f"fetch|{key}"):
+ raise RuntimeError("simulated process interruption after task checkpoint")
+ if self.faults.once("network_timeout", key):
+ return FetchResult(
+ [],
+ DataSourceStatus.READ_FAILED,
+ "simulated network timeout",
+ "MESSAGE_FETCH_FAILED",
+ )
+ return FetchResult(
+ [
+ V2Message(
+ message_id=f"{key}-1",
+ group_id=str(group_id),
+ group_name=str(group_id),
+ sender_id="member-a",
+ sender_name="群友甲",
+ timestamp=start_time + timedelta(hours=10),
+ content="今天讨论项目进展 500 万和稳定性",
+ ),
+ V2Message(
+ message_id=f"{key}-2",
+ group_id=str(group_id),
+ group_name=str(group_id),
+ sender_id="member-b",
+ sender_name="群友乙",
+ timestamp=start_time + timedelta(hours=11),
+ content="补充了第二个话题和验证结果",
+ ),
+ ],
+ DataSourceStatus.OK,
+ "simulation ok",
+ meta={"provider_chain": ["simulation"]},
+ )
+
+
+class SimulationPrompt:
+ def __init__(self, faults: FaultPlan):
+ self.faults = faults
+ self.calls: Counter[str] = Counter()
+
+ def build(self, data):
+ key = f"{data.run_date}|{data.group_id}"
+ self.calls[key] += 1
+ if self.faults.once("ai_invalid_format", key):
+ return PromptOutput(False, error="simulated invalid AI schema", model="simulation-ai")
+ selection = {
+ "topic_selection_version": "4.0",
+ "selected_topic_ids": ["topic-01"],
+ "selected_count": 1,
+ "candidates": [
+ {
+ "topic_id": "topic-01",
+ "selected": True,
+ "title": "稳定性进展",
+ "summary": "群友讨论当天进展和验证结果",
+ "message_ids": [data.messages[0].message_id],
+ "quotes": ["今天讨论项目进展 500 万和稳定性"],
+ "visible_participants": ["群友甲"],
+ }
+ ],
+ }
+ return PromptOutput(
+ True,
+ "【任务】\n生成群聊漫画\n【主标题】\n今日热聊",
+ model="simulation-ai",
+ meta={
+ "api_model": "simulation-ai",
+ "api_call_count": 1,
+ "chunk_count": 1,
+ "topic_selection": selection,
+ },
+ )
+
+
+class SimulationImageGenerator:
+ def __init__(self, faults: FaultPlan):
+ self.faults = faults
+ self.calls: Counter[str] = Counter()
+ self.successful_calls: Counter[str] = Counter()
+ self.settings = Settings(_env_file=None)
+
+ def generate(self, prompt_file: Path, output_path: Path, **_kwargs):
+ key = f"{output_path.parent.name}|{output_path.parent.parent.name}"
+ self.calls[key] += 1
+ if self.faults.once("image_generation_failed", key):
+ return ImageTaskResult(
+ False,
+ error="simulated image API 5xx",
+ detail={"error_code": "API_5XX", "outcome_unknown": False},
+ )
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ if self.faults.once("image_download_failed", key):
+ output_path.write_bytes(b"truncated-image")
+ return ImageTaskResult(True, image_path=output_path, detail={"attempt_count": 1})
+ Image.new("RGB", (64, 96), "white").save(output_path, format="PNG")
+ self.successful_calls[key] += 1
+ return ImageTaskResult(
+ True,
+ image_path=output_path,
+ detail={"attempt_count": 1, "receipt_source": "simulation"},
+ )
+
+
+class SimulationSender(WechatSender):
+ name = "simulation_sender"
+
+ def __init__(self, faults: FaultPlan):
+ self.faults = faults
+ self.text_calls: Counter[str] = Counter()
+ self.image_calls: Counter[str] = Counter()
+ self.text_submissions: Counter[str] = Counter()
+ self.image_submissions: Counter[str] = Counter()
+
+ @staticmethod
+ def _key(target: str, payload) -> str:
+ run_date = Path(payload).parent.name if not isinstance(payload, str) or Path(payload).is_file() else ""
+ return f"{run_date}|{target}" if run_date else str(target)
+
+ def health_check(self):
+ return True, "simulation ok"
+
+ def send_text(self, target: str, text: str):
+ match = re.search(r"\b20\d{2}-\d{2}-\d{2}\b", text)
+ run_date = match.group(0) if match else "unknown-date"
+ key = f"{run_date}|{target}"
+ self.text_calls[key] += 1
+ occurrence_key = f"text|{target}|{self.text_calls[key]}"
+ if self.faults.once("send_failed", occurrence_key):
+ return SendResult(False, "simulated pre-submit send failure", submitted=False)
+ self.text_submissions[key] += 1
+ if self.faults.once("send_result_unknown", key):
+ return SendResult(
+ False,
+ "simulated submitted result unknown",
+ datetime.now().astimezone().isoformat(),
+ submitted=True,
+ outcome_unknown=True,
+ )
+ return SendResult(
+ True,
+ "simulation text sent",
+ datetime.now().astimezone().isoformat(),
+ submitted=True,
+ verification_level="simulation",
+ )
+
+ def send_image(self, target: str, image_path):
+ key = f"{Path(image_path).parent.name}|{target}"
+ self.image_calls[key] += 1
+ if self.faults.once("send_failed", f"image|{key}"):
+ return SendResult(False, "simulated pre-submit image failure", submitted=False)
+ self.image_submissions[key] += 1
+ return SendResult(
+ True,
+ "simulation image sent",
+ datetime.now().astimezone().isoformat(),
+ submitted=True,
+ verification_level="simulation",
+ )
+
+
+def _pipeline(
+ settings: Settings,
+ store: RunStore,
+ groups: list[Group],
+ source: SimulationSource,
+ prompt: SimulationPrompt,
+ image: SimulationImageGenerator,
+ sender: SimulationSender,
+) -> DailyPipeline:
+ pipeline = DailyPipeline(
+ settings=settings,
+ data_source=source,
+ prompt_builder=prompt,
+ image_generator=image,
+ sender=sender,
+ store=store,
+ )
+ pipeline._load_groups = lambda group_ids=None: [
+ group for group in groups if not group_ids or group.id in group_ids
+ ]
+ pipeline._get_group = lambda group_id: next(
+ (group for group in groups if group.id == group_id),
+ None,
+ )
+ pipeline._sync_group_names_safe = lambda group_ids=None: GroupNameSyncReport(
+ status="cached",
+ source="simulation",
+ checked=len(group_ids or groups),
+ )
+ return pipeline
+
+
+def _scheduler_generate(
+ pipeline: DailyPipeline,
+ state: DailyScheduleState,
+ run_date: str,
+) -> list[dict]:
+ current = state.load(run_date)
+ fields = {
+ "generation_status": "resuming" if current.get("generation_started_at") else "running",
+ "generation_started_at": current.get("generation_started_at") or datetime.now().astimezone().isoformat(),
+ "generation_hold": False,
+ }
+ state.update(run_date, **fields)
+ results = pipeline.generate_all(run_date=run_date)
+ completion = {
+ "generation_status": _generation_status(results),
+ "generation_results": _compact_results(results),
+ "generation_invocation_completed_at": datetime.now().astimezone().isoformat(),
+ }
+ if _generation_results_terminal(results):
+ completion["generation_completed_at"] = datetime.now().astimezone().isoformat()
+ state.update(run_date, **completion)
+ pipeline._write_runtime_status_safe([run_date])
+ return results
+
+
+def run_simulation(
+ *,
+ days: int = 30,
+ groups_count: int = 6,
+ seed: int = 20260827,
+ workdir: Path,
+) -> dict:
+ faults = FaultPlan(seed)
+ settings = Settings(
+ _env_file=None,
+ app_timezone="Asia/Shanghai",
+ generation_group_concurrency=min(groups_count, 5),
+ image_generation_concurrency=2,
+ wechat_fetch_concurrency=2,
+ ai_request_concurrency=2,
+ wechat_late_send_window_minutes=30,
+ )
+ store = RunStore(workdir / "output")
+ state = DailyScheduleState(store.root)
+ groups = [
+ Group(
+ id=index,
+ display_name=f"sim-group-{index:02d}",
+ wechat_group_id=f"sim-{index}@chatroom",
+ wechat_group_name=f"sim-group-{index:02d}",
+ send_target=f"sim-target-{index:02d}",
+ enabled=True,
+ image_enabled=True,
+ wechat_send_enabled=True,
+ send_time="08:30",
+ schedule_rule="daily_previous_day",
+ )
+ for index in range(1, groups_count + 1)
+ ]
+ source = SimulationSource(faults)
+ prompt = SimulationPrompt(faults)
+ image = SimulationImageGenerator(faults)
+ sender = SimulationSender(faults)
+
+ import app.pipeline.generation_stages as generation_stages
+
+ original_retry_is_due = generation_stages.retry_is_due
+ generation_stages.retry_is_due = lambda _run: True
+ try:
+ start = date(2026, 8, 27) - timedelta(days=days - 1)
+ all_dates: list[str] = []
+ downtime_offsets = {10, 11} if days >= 14 else set()
+ downtime_dates: list[str] = []
+ for offset in range(days):
+ run_date = (start + timedelta(days=offset)).isoformat()
+ all_dates.append(run_date)
+ if offset in downtime_offsets:
+ downtime_dates.append(run_date)
+ continue
+
+ generate_dates = [*downtime_dates, run_date]
+ downtime_dates = []
+ for generate_date in generate_dates:
+ pipeline = _pipeline(settings, store, groups, source, prompt, image, sender)
+ for _ in range(5):
+ _scheduler_generate(pipeline, state, generate_date)
+ if state.load(generate_date).get("generation_completed_at"):
+ break
+ send_now = datetime.fromisoformat(f"{run_date}T09:00:00+08:00")
+ if generate_date == run_date:
+ # 同日中断由 48 小时窗口内的新实例继续;未知提交必须保持暂停。
+ if faults.once("program_interrupt", f"before-send|{run_date}"):
+ send_now = datetime.fromisoformat(f"{run_date}T09:10:00+08:00")
+ for send_attempt in range(4):
+ attempt_now = send_now + timedelta(minutes=15 * send_attempt)
+ pipeline.send_due_for_dates([generate_date], now=attempt_now, recovery=True)
+ day_runs = [
+ store.load_run(group.display_name, generate_date)
+ for group in groups
+ ]
+ if all(
+ item.get("status") == SENT or item.get("send_hold")
+ for item in day_runs
+ ):
+ break
+ else:
+ # 停机期间产生的历史任务只生成并进入人工清单,禁止自动补发。
+ pipeline.send_due_for_dates([generate_date], now=send_now, recovery=True)
+ if faults.once("duplicate_start", generate_date):
+ duplicate = _pipeline(settings, store, groups, source, prompt, image, sender)
+ _scheduler_generate(duplicate, state, generate_date)
+
+ # 天数很短时也确保尾部停机日被恢复;历史发送仍只进入人工待处理。
+ for generate_date in downtime_dates:
+ pipeline = _pipeline(settings, store, groups, source, prompt, image, sender)
+ for _ in range(5):
+ _scheduler_generate(pipeline, state, generate_date)
+ if state.load(generate_date).get("generation_completed_at"):
+ break
+ now = datetime.fromisoformat(f"{all_dates[-1]}T09:10:00+08:00")
+ pipeline.send_due_for_dates([generate_date], now=now, recovery=True)
+
+ runs = store.list_runs()
+ expected = days * groups_count
+ sent = [run for run in runs if run.get("status") == SENT]
+ manual_holds = [
+ run
+ for run in runs
+ if run.get("execution_state") == "HOLD_MANUAL" or run.get("send_hold")
+ ]
+ retry_pending = [run for run in runs if run.get("execution_state") == "WAIT_RETRY"]
+ failed_final = [run for run in runs if run.get("execution_state") == "FAILED_FINAL"]
+ task_loss = max(expected - len(runs), 0)
+ duplicate_images = sum(max(count - 1, 0) for count in image.successful_calls.values())
+ duplicate_image_sends = sum(max(count - 1, 0) for count in sender.image_submissions.values())
+ duplicate_text_sends = sum(max(count - 1, 0) for count in sender.text_submissions.values())
+ scheduler_incomplete = sum(
+ 1 for run_date in all_dates if not state.load(run_date).get("generation_completed_at")
+ )
+ runtime_reports = sum(
+ int((workdir / "runtime" / run_date / "status.json").is_file())
+ for run_date in all_dates
+ )
+ result = {
+ "seed": seed,
+ "days": days,
+ "groups": groups_count,
+ "expected_tasks": expected,
+ "runs_found": len(runs),
+ "sent": len(sent),
+ "accounted_terminal_tasks": len(sent) + len(manual_holds),
+ "manual_holds": len(manual_holds),
+ "failed_final": len(failed_final),
+ "retry_pending": len(retry_pending),
+ "task_loss": task_loss,
+ "duplicate_external_image_calls": duplicate_images,
+ "duplicate_successful_image_sends": duplicate_image_sends,
+ "duplicate_successful_text_sends": duplicate_text_sends,
+ "scheduler_incomplete_dates": scheduler_incomplete,
+ "runtime_reports": runtime_reports,
+ "downtime_dates": sorted(
+ (start + timedelta(days=value)).isoformat() for value in downtime_offsets
+ ),
+ "injected": dict(sorted(faults.injected.items())),
+ "source_max_attempts": max(source.calls.values(), default=0),
+ "prompt_max_attempts": max(prompt.calls.values(), default=0),
+ "image_max_attempts": max(image.calls.values(), default=0),
+ "ok": (
+ len(runs) == expected
+ and len(sent) + len(manual_holds) == expected
+ and not failed_final
+ and not retry_pending
+ and task_loss == 0
+ and duplicate_images == 0
+ and duplicate_image_sends == 0
+ and duplicate_text_sends == 0
+ and scheduler_incomplete == 0
+ and runtime_reports == days
+ and max(source.calls.values(), default=0) <= 5
+ and max(prompt.calls.values(), default=0) <= 5
+ and max(image.calls.values(), default=0) <= 5
+ ),
+ }
+ return result
+ finally:
+ generation_stages.retry_is_due = original_retry_is_due
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--days", type=int, default=30)
+ parser.add_argument("--groups", type=int, default=6)
+ parser.add_argument("--seed", type=int, default=20260827)
+ parser.add_argument("--workdir", type=Path)
+ args = parser.parse_args()
+ if args.days < 1 or args.groups < 1:
+ parser.error("--days 和 --groups 必须大于 0")
+
+ if args.workdir is not None:
+ args.workdir.mkdir(parents=True, exist_ok=True)
+ result = run_simulation(
+ days=args.days,
+ groups_count=args.groups,
+ seed=args.seed,
+ workdir=args.workdir.resolve(),
+ )
+ else:
+ with tempfile.TemporaryDirectory(prefix="groupbrief-reliability-") as temp:
+ result = run_simulation(
+ days=args.days,
+ groups_count=args.groups,
+ seed=args.seed,
+ workdir=Path(temp),
+ )
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if result["ok"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
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
diff --git a/templates/image_prompt/default.md b/templates/image_prompt/default.md
index 55fb90a..f6e8536 100644
--- a/templates/image_prompt/default.md
+++ b/templates/image_prompt/default.md
@@ -1,35 +1,28 @@
【任务】
生成一张竖版微信群日报漫画信息图。
-【创作优先级】
-事实真实性是准入门槛;通过真实性校验后,好玩程度、群内识别度和视觉笑点是第一优化目标。
-正常保留 5~7 个独立话题的密度,用漫画镜头表现“谁做了什么、别人怎样接话”,不要画成栏目列表。
-
【群名称】
{{group_name}}
-【固定画面日期】
-统计日期:{{report_date}}
-
【统计时间】
{{period_start}} ~ {{period_end}}
@@ -37,46 +30,28 @@ GroupBrief V2 生图 Prompt 默认模板(P4 ImagePromptBuilder 读取)。
{{message_count}} 条消息
{{speaker_count}} 人发言
-【大主题】
-{{image_theme}}
-
-【漫画分镜】
-{{layout_name}}
-{{layout_instruction}}
-
【主标题】
-(优先使用群聊原句、群内梗、反差或回环;建议不超过 18 个汉字)
+{{main_title}}
【副标题】
-(一句话概括当天讨论;建议不超过 26 个汉字)
+{{subtitle}}
-【剧情与镜头分配】
-按给定阅读顺序使用全部入选话题,不得遗漏、重复、增删或改选。
-一个话题不等于一个矩形模块;同一话题可以用连续的环境、动作、对白、反应或特写镜头展开。
-正常 5~7 个话题应形成 7~12 个视觉格,至少一个话题使用两个以上连续镜头。
+【整体视觉】
+{{overall_visual}}
-【画面文字白名单】
-只清晰绘制:主标题、统计日期、给定数据、自然的话题短标题、短事实旁白、真实姓名和精选群聊气泡。
-不得绘制程序字段、主题编号、说明性栏目名或 JSON;空间不足时先减少装饰和副标题,保留事实与气泡。
+【漫画分镜】
+每个话题先作为一个独立漫画框,框内再根据聊天内容自由拆成1~3个连续分镜,表现发言、动作、群友回应和表情反应。
+可自由使用:头条大格+反应小格、宽格+小格、纵向长镜头+侧挂格、大场景+表情特写、上下连续镜头、两个不等大场景、对角线动作跨格、自由切角拼贴。
+简单话题可以只画一个完整场景,不强制分镜。
+同一话题内可以出现多名真实群友,姓名贴近对应人物,真实聊天气泡放在说话人物附近。内部小镜头不要重复话题标题和姓名。
+分镜只在当前话题框内展开,不跨到其他话题;根据真实聊天强化误会、吐槽、动作和群友反应,不额外编造剧情。
+允许人物动作、道具、速度线或视觉效果在同一个话题框内部轻微跨越小分镜边界,以增加漫画连续感。
+漫画分镜负责强化真实聊天中的动作、误会、吐槽、反差和群友反应
-【分镜表现】
-整页至少有大、中、小三级格子尺寸差;使用嵌套反应小格、连续动作、局部特写或一次跨格主体建立节奏。
-气泡尾巴、人物视线和动作线共同引导从上到下、从左到右阅读;禁止整齐两列等高矩形和重复模板块。
+{{panels}}
-【底部总结】
-可用一句短文案回收当天讨论;不使用“信息量拉满”“一天顶一周”“比过山车还刺激”等通用套话。
+【文字规则】
+{{text_rules}}
-【硬性要求】
-1. 只使用聊天内容中真实存在的事件、人物、对话,禁止编造。
-2. 不得凭空补充金额、时间、地点、身份关系。
-3. 气泡文字必须来自程序给定的真实聊天,可缩短长度,但不能改写事实。
-4. 可以使用字面化、反差、回环、误会与反转、一本正经地荒诞,但不能改变事实。
-5. 海报人物依据聊天事件中的真实人员,而不是发言排行榜 Top10;姓名只能使用程序回查得到的人员。
-6. 数据(消息数、发言人数)必须使用给定数字,禁止自行计算。
-7. 【大主题】是全图最高视觉约束,控制配色、画材、造型、装饰、纹理、光影和画风;【漫画分镜】不得替换或削弱它。
-8. 【漫画分镜】只控制格子几何、阅读路径和镜头节拍;每张图只能使用给定的一种骨架。
-9. 不得把法庭、菜单、地图、新闻台等无关主题包装强加给真实聊天。
-10. 最终 Prompt 必须严格包含给定的 2~7 个入选主题且各使用一次;证据不足时由上游减少数量,不得编造。
-11. 每个入选话题至少显示一个真实姓名、一句事实短句和一句给定气泡,不得用泛化头像替代人物。
-12. 漫画主体与对话必须和对应聊天事实直接相关,视觉比喻只能放大已有笑点,不能另写故事。
-13. 必须把“统计日期:{{report_date}}”逐字作为清晰可见的画面文字,放在海报顶部或底部。
+【底部总结】
+{{footer_summary}}
diff --git a/templates/ranking/text_interactions.txt b/templates/ranking/text_interactions.txt
new file mode 100644
index 0000000..ebab1c4
--- /dev/null
+++ b/templates/ranking/text_interactions.txt
@@ -0,0 +1,17 @@
+===== {{group_name}} =====
+【文字发言排行榜】
+{{group_name}}
+消息统计
+------------
+时间起:{{period_start}}
+时间止:{{period_end}}
+------------
+活跃人数:{{speaker_count}}
+文字发言人数:{{text_speaker_count}}
+文字消息:{{text_message_count}}
+互动消息:{{interaction_message_count}}
+总消息:{{message_count}}
+------------
+文字发言 Top{{top_limit}}
+{{top_lines}}
+说明:互动指图片、表情、引用等非文字消息,仅展示活跃度,不影响排名。
diff --git a/tests/conftest.py b/tests/conftest.py
index 33fd9de..b69e823 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,8 +9,19 @@
"""
import os
+import random
+import shutil
+import tempfile
+import uuid
+from pathlib import Path
-os.environ["DATABASE_URL"] = "sqlite:///data/test_groupbrief.db"
+_TEST_ROOT = Path(
+ tempfile.mkdtemp(prefix=f"groupbrief-pytest-{os.getpid()}-")
+)
+_TEST_DB_PATH = _TEST_ROOT / "groupbrief-test.db"
+
+os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_DB_PATH.as_posix()}"
+os.environ["OUTPUT_ROOT_OVERRIDE"] = str(_TEST_ROOT / "output")
os.environ["GROUPBRIEF_NO_SCHEDULER"] = "1"
# 测试不读取真实微信联系人库(避免本机 APPDATA 下的 contact.db 影响断言)
os.environ["GROUPBRIEF_NO_CONTACT_DB"] = "1"
@@ -21,3 +32,57 @@
os.environ["AI_API_KEY"] = ""
# 默认集成测试强制使用无需外部调用的旧兼容分支;Codex 主备路由由专门单测覆盖。
os.environ["SUMMARY_PROVIDER_PRIMARY"] = "deepseek"
+# 旧 V1 单测需要显式进入兼容维护模式;生产默认仍是 read_only。
+os.environ["LEGACY_V1_WRITE_MODE"] = "maintenance"
+# 普通测试必须能在真实 8766 运行时执行,不能争抢生产生成锁。
+os.environ["GROUPBRIEF_GENERATION_MUTEX_NAMESPACE"] = (
+ f"pytest-{os.getpid()}-{uuid.uuid4().hex}"
+)
+
+
+def pytest_addoption(parser) -> None:
+ parser.addoption(
+ "--random-order-seed",
+ action="store",
+ type=int,
+ default=None,
+ help="使用给定整数 seed 随机重排测试收集顺序",
+ )
+
+
+def pytest_collection_modifyitems(config, items) -> None:
+ seed = config.getoption("--random-order-seed")
+ if seed is None:
+ return
+ random.Random(seed).shuffle(items)
+ reporter = config.pluginmanager.get_plugin("terminalreporter")
+ if reporter is not None:
+ reporter.write_line(f"random-order-seed={seed}")
+
+
+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
+ try:
+ shutil.rmtree(_TEST_ROOT)
+ except OSError:
+ pass
diff --git a/tests/test_codex_image_automation.py b/tests/test_codex_image_automation.py
index 4dda493..40b9d07 100644
--- a/tests/test_codex_image_automation.py
+++ b/tests/test_codex_image_automation.py
@@ -2,19 +2,21 @@
import json
import sqlite3
+from io import BytesIO
from types import SimpleNamespace
from pathlib import Path
+from PIL import Image
+
from app.v2.constants import FAILED, IMAGE_GENERATION_FAILED, PROMPT_READY, READY_TO_SEND
from app.v2.run_store import RunStore
from scripts import codex_image_automation as automation
from scripts.codex_image_automation import adopt_image, begin_task, collect_pending
-PNG_BYTES = bytes.fromhex(
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
- "0000000d4944415478da63f8cfc0f01f00050001fff83f240000000049454e44ae426082"
-)
+_png_buffer = BytesIO()
+Image.new("RGBA", (2, 2), (18, 52, 86, 255)).save(_png_buffer, format="PNG")
+PNG_BYTES = _png_buffer.getvalue()
def _make_run(store: RunStore, group: str = "测试群", date: str = "2026-08-20", **fields) -> None:
diff --git a/tests/test_codex_summary_provider.py b/tests/test_codex_summary_provider.py
index 44de49c..0c74473 100644
--- a/tests/test_codex_summary_provider.py
+++ b/tests/test_codex_summary_provider.py
@@ -6,6 +6,10 @@
import pytest
from app.config.settings import Settings
+from app.providers.ai.base import (
+ ExternalCallInvalidResponseError,
+ ExternalCallNotSubmittedError,
+)
from app.providers.ai.codex import CodexGPTProvider, build_summary_provider
@@ -51,6 +55,7 @@ def test_codex_success_uses_stdin_read_only_and_does_not_call_fallback(monkeypat
def fake_run(command, **kwargs):
captured["command"] = command
captured["input"] = kwargs["input"]
+ captured["timeout"] = kwargs["timeout"]
output_path = Path(command[command.index("--output-last-message") + 1])
output_path.write_text('{"events": []}', encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -68,24 +73,65 @@ def fake_run(command, **kwargs):
assert captured["command"][captured["command"].index("--model") + 1] == "gpt-5.6-sol"
assert captured["command"][captured["command"].index("--sandbox") + 1] == "read-only"
assert "--ephemeral" in captured["command"]
+ assert "--ignore-user-config" in captured["command"]
+ assert "--ignore-rules" in captured["command"]
+ assert 'model_reasoning_effort="medium"' in captured["command"]
+ assert captured["timeout"] == 30
-def test_codex_failure_uses_deepseek_fallback(monkeypatch):
+def test_codex_invalid_json_is_known_failure_without_fallback(monkeypatch):
+ provider = CodexGPTProvider(_settings(ai_api_key="fake"))
+ provider._resolved_binary = "codex.CMD"
+ fallback = _Fallback()
+ provider._fallback = fallback
+
+ def fake_run(command, **_kwargs):
+ output_path = Path(command[command.index("--output-last-message") + 1])
+ output_path.write_text("这不是 JSON", encoding="utf-8")
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr("app.providers.ai.codex.subprocess.run", fake_run)
+
+ with pytest.raises(ExternalCallInvalidResponseError, match="JSON 无效"):
+ provider._chat(
+ [{"role": "user", "content": "test"}],
+ response_format="json_object",
+ )
+ assert fallback.calls == 0
+
+
+def test_default_codex_summary_timeout_allows_long_structured_responses():
+ assert Settings(_env_file=None).codex_summary_timeout_seconds == 600
+
+
+def test_codex_confirmed_not_submitted_uses_deepseek_fallback(monkeypatch):
provider = CodexGPTProvider(_settings(ai_api_key="fake"))
fallback = _Fallback(result="备用成功")
provider._fallback = fallback
- monkeypatch.setattr(provider, "_codex_chat", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("主模型失败")))
+ monkeypatch.setattr(
+ provider,
+ "_codex_chat",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(
+ ExternalCallNotSubmittedError("主模型未提交")
+ ),
+ )
assert provider._chat([{"role": "user", "content": "test"}]) == "备用成功"
assert fallback.calls == 1
-def test_codex_and_deepseek_failure_returns_clear_error(monkeypatch):
+def test_codex_not_submitted_and_deepseek_failure_returns_clear_error(monkeypatch):
provider = CodexGPTProvider(_settings(ai_api_key="fake"))
provider._fallback = _Fallback(error=RuntimeError("备用失败"))
- monkeypatch.setattr(provider, "_codex_chat", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("主模型失败")))
+ monkeypatch.setattr(
+ provider,
+ "_codex_chat",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(
+ ExternalCallNotSubmittedError("主模型未提交")
+ ),
+ )
- with pytest.raises(RuntimeError, match="主模型与 DeepSeek 备用均失败"):
+ with pytest.raises(RuntimeError, match="未提交且 DeepSeek 备用失败"):
provider._chat([{"role": "user", "content": "test"}])
@@ -93,3 +139,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_contact_resolver.py b/tests/test_contact_resolver.py
index 03cd031..40d2705 100644
--- a/tests/test_contact_resolver.py
+++ b/tests/test_contact_resolver.py
@@ -22,6 +22,36 @@ def _make_db(tmp_path, rows: list[tuple[str, str, str]]) -> str:
return str(db)
+def _varint(value: int) -> bytes:
+ result = bytearray()
+ while True:
+ byte = value & 0x7F
+ value >>= 7
+ result.append(byte | 0x80 if value else byte)
+ if not value:
+ return bytes(result)
+
+
+def _field(number: int, value: str | bytes) -> bytes:
+ raw = value.encode("utf-8") if isinstance(value, str) else value
+ return _varint((number << 3) | 2) + _varint(len(raw)) + raw
+
+
+def _member(*fields: tuple[int, str]) -> bytes:
+ return _field(1, b"".join(_field(number, value) for number, value in fields))
+
+
+def _add_chat_room(db: str, chatroom: str, ext_buffer: bytes) -> None:
+ con = sqlite3.connect(db)
+ con.execute("CREATE TABLE chat_room (username TEXT, ext_buffer BLOB)")
+ con.execute(
+ "INSERT INTO chat_room (username, ext_buffer) VALUES (?, ?)",
+ (chatroom, ext_buffer),
+ )
+ con.commit()
+ con.close()
+
+
def test_prefers_remark_over_nickname(tmp_path):
db = _make_db(tmp_path, [("wxid_a", "备注名", "昵称名")])
r = ContactResolver(db)
@@ -59,3 +89,79 @@ def test_blank_names_skipped(tmp_path):
m = r.load()
assert "wxid_c" not in m
assert m["wxid_d"] == "有备注"
+
+
+def test_group_card_field4_inviter_does_not_leak_to_member_without_card(tmp_path):
+ db = _make_db(
+ tmp_path,
+ [("jiangzhema123", "春夏秋冬", ""), ("to1900", "", "罗斯")],
+ )
+ ext_buffer = _member(
+ (1, "jiangzhema123"), (2, "鲁布斯"), (4, "to1900")
+ ) + _member((1, "to1900"), (4, "wxid_inviter"))
+ _add_chat_room(db, "tea@chatroom", ext_buffer)
+
+ resolver = ContactResolver(db)
+
+ assert resolver.group_nicknames(
+ "tea@chatroom", ["jiangzhema123", "to1900"]
+ ) == {"jiangzhema123": "鲁布斯"}
+
+
+def test_group_card_owner_field_does_not_collapse_large_group(tmp_path):
+ db = _make_db(tmp_path, [("member_a123", "成员甲", ""), ("member_b123", "成员乙", "")])
+ ext_buffer = _member(
+ (1, "member_a123"), (2, "群名片甲"), (4, "c2341298")
+ ) + _member((1, "member_b123"), (4, "c2341298"))
+ _add_chat_room(db, "grok@chatroom", ext_buffer)
+
+ resolver = ContactResolver(db)
+
+ assert resolver.group_nicknames(
+ "grok@chatroom", ["member_a123", "member_b123", "c2341298"]
+ ) == {"member_a123": "群名片甲"}
+
+
+def test_group_card_preserves_short_and_long_values_exactly(tmp_path):
+ db = _make_db(tmp_path, [])
+ ext_buffer = _member((1, "member_short"), (2, "广州")) + _member(
+ (1, "member_long"), (2, "广州-U啥都行-好好上 b 班版")
+ )
+ _add_chat_room(db, "eason@chatroom", ext_buffer)
+
+ resolver = ContactResolver(db)
+
+ assert resolver.group_nicknames(
+ "eason@chatroom", ["member_short", "member_long"]
+ ) == {
+ "member_short": "广州",
+ "member_long": "广州-U啥都行-好好上 b 班版",
+ }
+
+
+def test_group_card_rejects_system_event_text_in_field2(tmp_path):
+ db = _make_db(tmp_path, [("member_event", "联系人名称", "")])
+ _add_chat_room(
+ db,
+ "dirty@chatroom",
+ _member((1, "member_event"), (2, "群主邀请了“景甜”进入群聊")),
+ )
+
+ resolver = ContactResolver(db)
+
+ assert resolver.group_nicknames("dirty@chatroom", ["member_event"]) == {}
+
+
+def test_group_card_keeps_legacy_field4_member_layout(tmp_path):
+ db = _make_db(tmp_path, [])
+ _add_chat_room(
+ db,
+ "legacy@chatroom",
+ _member((4, "legacy_member"), (1, "旧布局群名片")),
+ )
+
+ resolver = ContactResolver(db)
+
+ assert resolver.group_nicknames("legacy@chatroom", ["legacy_member"]) == {
+ "legacy_member": "旧布局群名片"
+ }
diff --git a/tests/test_cross_process_state.py b/tests/test_cross_process_state.py
new file mode 100644
index 0000000..a9f534b
--- /dev/null
+++ b/tests/test_cross_process_state.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import multiprocessing
+import os
+from pathlib import Path
+
+import pytest
+
+from app.scheduler.daily_v2_job import DailyScheduleState
+from app.v2.constants import PENDING
+from app.v2.run_store import RunStore, _atomic_write_text
+
+
+def test_atomic_write_retries_transient_windows_sharing_violation(tmp_path, monkeypatch):
+ target = tmp_path / "state.json"
+ original_replace = os.replace
+ attempts = 0
+
+ def flaky_replace(source, destination):
+ nonlocal attempts
+ attempts += 1
+ if attempts < 3:
+ raise PermissionError(5, "access denied", str(source), str(destination))
+ original_replace(source, destination)
+
+ monkeypatch.setattr(os, "replace", flaky_replace)
+ monkeypatch.setattr("app.v2.run_store.time.sleep", lambda _seconds: None)
+
+ _atomic_write_text(target, '{"ok": true}')
+
+ assert attempts == 3
+ assert target.read_text(encoding="utf-8") == '{"ok": true}'
+ assert not list(tmp_path.glob("*.tmp"))
+
+
+def test_atomic_write_does_not_hide_persistent_replace_failure(tmp_path, monkeypatch):
+ target = tmp_path / "state.json"
+
+ def blocked_replace(source, destination):
+ raise PermissionError(5, "access denied", str(source), str(destination))
+
+ monkeypatch.setattr(os, "replace", blocked_replace)
+ monkeypatch.setattr("app.v2.run_store.time.sleep", lambda _seconds: None)
+
+ with pytest.raises(PermissionError):
+ _atomic_write_text(target, '{"ok": false}')
+
+ assert not target.exists()
+ assert not list(tmp_path.glob("*.tmp"))
+
+
+def _run_store_writer(root: str, worker: int, count: int, queue) -> None:
+ try:
+ store = RunStore(Path(root))
+ for index in range(count):
+ store.update(
+ "并发群",
+ "2026-08-27",
+ **{f"worker_{worker}_{index}": index},
+ )
+ queue.put("")
+ except Exception as exc: # pragma: no cover - 返回给父进程精确失败证据
+ queue.put(f"{type(exc).__name__}: {exc}")
+
+
+def _schedule_state_writer(root: str, worker: int, count: int, queue) -> None:
+ try:
+ state = DailyScheduleState(Path(root))
+ for index in range(count):
+ state.update(
+ "2026-08-27",
+ generation_started_at="2026-08-27T00:15:00+08:00",
+ generation_status="running",
+ **{f"worker_{worker}_{index}": index},
+ )
+ queue.put("")
+ except Exception as exc: # pragma: no cover
+ queue.put(f"{type(exc).__name__}: {exc}")
+
+
+def _run_two_processes(target, root: Path, count: int = 12) -> None:
+ context = multiprocessing.get_context("spawn")
+ queue = context.Queue()
+ processes = [
+ context.Process(target=target, args=(str(root), worker, count, queue))
+ for worker in (1, 2)
+ ]
+ for process in processes:
+ process.start()
+ for process in processes:
+ process.join(timeout=20)
+ assert process.exitcode == 0
+ assert [queue.get(timeout=2) for _ in processes] == ["", ""]
+
+
+def test_run_store_cross_process_updates_do_not_lose_fields(tmp_path):
+ root = tmp_path / "output"
+ store = RunStore(root)
+ store.save_run("并发群", "2026-08-27", {"status": PENDING})
+
+ _run_two_processes(_run_store_writer, root)
+
+ run = store.load_run("并发群", "2026-08-27")
+ for worker in (1, 2):
+ for index in range(12):
+ assert run[f"worker_{worker}_{index}"] == index
+ assert run["state_version"] >= 25
+ assert not list(root.rglob("*.tmp"))
+
+
+def test_scheduler_state_cross_process_updates_do_not_lose_fields(tmp_path):
+ _run_two_processes(_schedule_state_writer, tmp_path)
+
+ state = DailyScheduleState(tmp_path).load("2026-08-27")
+ for worker in (1, 2):
+ for index in range(12):
+ assert state[f"worker_{worker}_{index}"] == index
+ assert state["state_version"] >= 24
+ assert not list(tmp_path.rglob("*.tmp"))
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_daily_random_theme.py b/tests/test_daily_random_theme.py
index 27c5a45..34730bf 100644
--- a/tests/test_daily_random_theme.py
+++ b/tests/test_daily_random_theme.py
@@ -1,18 +1,120 @@
-"""每日随机画风的确定性、换日排除和自定义覆盖。"""
+"""公开风格目录、每日确定性、历史恢复和旧主题兼容。"""
-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_three_modes_and_22_stable_presets():
+ validate_style_catalog()
+ options = public_image_theme_options()
+ assert [item["key"] for item in options[:3]] == ["ai_free", "random_preset", "custom"]
+ assert all(item["kind"] == "mode" for item in options[:3])
+ assert [item["key"] for item in options[3:]] == EXPECTED_PRESET_KEYS
+ assert all(item["kind"] == "preset" for item in options[3:])
+ assert len(options) == 25
+ assert options[0]["label"] == "AI 自由发挥"
+ assert options[1]["variation_count"] == 352
+ assert all(not item["preview_url"] for item in options[:3])
+ assert all(
+ item["preview_url"] == f"/assets/image-theme-previews/{item['key']}.webp"
+ for item in options[3:]
+ )
+
+
+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 +123,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 +158,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 +182,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_data_source_resilience.py b/tests/test_data_source_resilience.py
new file mode 100644
index 0000000..c8f10ad
--- /dev/null
+++ b/tests/test_data_source_resilience.py
@@ -0,0 +1,133 @@
+from datetime import datetime
+
+from app.config.settings import Settings
+from app.data_sources.base import (
+ DataSourceHealth,
+ DataSourceStatus,
+ FetchResult,
+ ResolvedGroup,
+ V2Message,
+ WeChatDataSource,
+)
+from app.data_sources.resilient import ProviderCircuitBreaker, ResilientWeChatDataSource
+
+
+def _message() -> V2Message:
+ return V2Message(
+ message_id="m1",
+ group_id="g1@chatroom",
+ group_name="测试群",
+ sender_id="u1",
+ sender_name="成员甲",
+ timestamp=datetime(2026, 8, 27, 10, 0),
+ content="hello",
+ )
+
+
+class ScriptedSource(WeChatDataSource):
+ name = "scripted"
+
+ def __init__(self, results):
+ self.results = list(results)
+ self.calls = 0
+
+ def health_check(self):
+ return DataSourceHealth(DataSourceStatus.OK, "ok")
+
+ def list_groups(self):
+ return [ResolvedGroup("g1@chatroom", "测试群")]
+
+ def resolve_group(self, group_name):
+ return self.list_groups()
+
+ def fetch_messages(self, group_id, start_time, end_time):
+ self.calls += 1
+ return self.results[min(self.calls - 1, len(self.results) - 1)]
+
+
+def test_transient_fetch_failure_retries_same_provider_then_succeeds():
+ failures = [
+ FetchResult([], DataSourceStatus.READ_FAILED, "timeout", "MESSAGE_FETCH_FAILED"),
+ FetchResult([], DataSourceStatus.UNAVAILABLE, "down", "WECHAT_DATA_UNAVAILABLE"),
+ FetchResult([_message()], DataSourceStatus.OK, "ok"),
+ ]
+ source = ScriptedSource(failures)
+ sleeps = []
+ settings = Settings(
+ _env_file=None,
+ wechat_fetch_max_attempts=3,
+ wechat_fetch_retry_backoff_seconds=1,
+ )
+ resilient = ResilientWeChatDataSource(
+ source,
+ settings,
+ sleep=sleeps.append,
+ jitter=lambda: 0,
+ )
+
+ result = resilient.fetch_messages("g1@chatroom", datetime(2026, 8, 27), datetime(2026, 8, 28))
+
+ assert result.status == DataSourceStatus.OK
+ assert source.calls == 3
+ assert sleeps == [1, 2]
+ assert result.meta["attempt_count"] == 3
+ assert result.meta["provider_chain"] == ["scripted"]
+
+
+def test_group_not_found_is_never_retried():
+ source = ScriptedSource(
+ [FetchResult([], DataSourceStatus.GROUP_NOT_FOUND, "missing", "GROUP_NOT_FOUND")]
+ )
+ resilient = ResilientWeChatDataSource(
+ source,
+ Settings(_env_file=None, wechat_fetch_max_attempts=3),
+ sleep=lambda _seconds: None,
+ )
+
+ result = resilient.fetch_messages("missing", datetime(2026, 8, 27), datetime(2026, 8, 28))
+
+ assert result.status == DataSourceStatus.GROUP_NOT_FOUND
+ assert source.calls == 1
+
+
+def test_circuit_opens_and_half_open_probe_can_recover():
+ clock = [100.0]
+ source = ScriptedSource(
+ [
+ FetchResult([], DataSourceStatus.READ_FAILED, "down", "MESSAGE_FETCH_FAILED"),
+ FetchResult([_message()], DataSourceStatus.OK, "ok"),
+ ]
+ )
+ settings = Settings(
+ _env_file=None,
+ wechat_fetch_max_attempts=1,
+ wechat_fetch_circuit_failure_threshold=1,
+ wechat_fetch_circuit_cooldown_seconds=10,
+ )
+ resilient = ResilientWeChatDataSource(
+ source,
+ settings,
+ sleep=lambda _seconds: None,
+ clock=lambda: clock[0],
+ )
+
+ first = resilient.fetch_messages("g1", datetime(2026, 8, 27), datetime(2026, 8, 28))
+ blocked = resilient.fetch_messages("g1", datetime(2026, 8, 27), datetime(2026, 8, 28))
+ clock[0] += 10
+ recovered = resilient.fetch_messages("g1", datetime(2026, 8, 27), datetime(2026, 8, 28))
+
+ assert first.status == DataSourceStatus.READ_FAILED
+ assert blocked.meta["circuit_open"] is True
+ assert recovered.status == DataSourceStatus.OK
+ assert source.calls == 2
+
+
+def test_half_open_circuit_allows_only_one_probe_until_result():
+ circuit = ProviderCircuitBreaker(threshold=1, cooldown_seconds=10)
+ circuit.failure(100.0)
+
+ assert circuit.allow(110.0) is True
+ assert circuit.allow(110.0) is False
+
+ circuit.success()
+ assert circuit.allow(110.0) is True
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"
diff --git a/tests/test_db_resilience.py b/tests/test_db_resilience.py
new file mode 100644
index 0000000..cc29ac5
--- /dev/null
+++ b/tests/test_db_resilience.py
@@ -0,0 +1,38 @@
+from sqlalchemy.exc import OperationalError
+
+from app.db.resilience import run_with_sqlite_retry
+
+
+def test_sqlite_busy_retries_with_finite_budget(monkeypatch):
+ calls = 0
+ sleeps = []
+
+ def operation():
+ nonlocal calls
+ calls += 1
+ if calls < 3:
+ raise OperationalError("select 1", {}, Exception("database is locked"))
+ return "ok"
+
+ monkeypatch.setattr("app.db.resilience.time.sleep", sleeps.append)
+
+ assert run_with_sqlite_retry(operation, max_attempts=3, base_delay_seconds=0.1) == "ok"
+ assert calls == 3
+ assert sleeps == [0.1, 0.2]
+
+
+def test_sqlite_non_busy_error_is_not_retried():
+ calls = 0
+
+ def operation():
+ nonlocal calls
+ calls += 1
+ raise OperationalError("select 1", {}, Exception("no such table"))
+
+ try:
+ run_with_sqlite_retry(operation, max_attempts=3)
+ except OperationalError:
+ pass
+ else:
+ raise AssertionError("expected OperationalError")
+ assert calls == 1
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_email.py b/tests/test_email.py
index 1adbc3a..f547158 100644
--- a/tests/test_email.py
+++ b/tests/test_email.py
@@ -4,12 +4,16 @@
from datetime import datetime
import app.services.email_service as email_module
+from PIL import Image
from sqlmodel import Session, select
from app.config.settings import Settings, get_settings
from app.db import repository as repo
from app.db.models import Group, GroupRun
+from app.services.email_delivery import EmailDeliveryLedger
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()
@@ -18,11 +22,25 @@
def _prepare_run(session: Session) -> int:
- group = repo.save_group(
- session,
- Group(display_name="示例UED-4群", wechat_group_id="group-a"),
+ group = repo.find_group_by_wechat_id(session, "group-a", include_deleted=False)
+ if group is None:
+ group = repo.save_group(
+ session,
+ Group(display_name="示例UED-4群", wechat_group_id="group-a"),
+ )
+ 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),
)
- service = ReportService()
run = service.generate(session, group=group, report_date="2026-08-13", force=True)
return run.id
@@ -78,6 +96,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,9 +127,66 @@ def fail_if_connected(*args, **kwargs):
assert not smtp_calls
-def test_email_quit_failure_does_not_retry(monkeypatch):
- settings2 = Settings(email_enabled=True, email_smtp_host="smtp.example.com")
+def test_email_partial_delivery_does_not_report_full_success(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=True,
+ )
service = EmailService(settings2)
+ block = GroupMailBlock(group_name="可发送群", ranking_text="排行榜")
+ monkeypatch.setattr(
+ service,
+ "build_email",
+ lambda session, run=None: EmailBuildResult(
+ subject="unused",
+ body="unused",
+ blocks=[block],
+ missing=["缺失群:报告数据缺失"],
+ ),
+ )
+ monkeypatch.setattr(service, "_send_group_message", lambda message: (True, ""))
+
+ with Session(repo.engine) as session:
+ ok, detail = service.send(session)
+
+ assert not ok
+ assert "成功 1 个群" in detail
+ assert "失败 1 个群" in detail
+
+
+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(tmp_path, monkeypatch):
+ settings2 = Settings(email_enabled=True, email_smtp_host="smtp.example.com")
+ service = EmailService(
+ settings2,
+ delivery_ledger=EmailDeliveryLedger(tmp_path / "ledger"),
+ )
calls = {"connect": 0, "send": 0, "sleep": 0}
class FakeSMTP:
@@ -142,14 +219,17 @@ def quit(self):
def test_email_send_is_per_group_and_attaches_valid_poster(tmp_path, monkeypatch):
poster = tmp_path / "poster.png"
- poster.write_bytes(b"\x89PNG\r\n\x1a\nminimal-png")
+ Image.new("RGBA", (2, 2), (20, 40, 60, 255)).save(poster, format="PNG")
settings2 = Settings(
email_enabled=True,
email_smtp_host="smtp.example.com",
email_recipient="to@example.com",
email_from="from@example.com",
)
- service = EmailService(settings2)
+ service = EmailService(
+ settings2,
+ delivery_ledger=EmailDeliveryLedger(tmp_path / "ledger"),
+ )
blocks = [
GroupMailBlock(
group_name="失败群",
@@ -193,7 +273,7 @@ def quit(self):
assert not ok
assert "成功 1 个群" in detail
all_messages = [message for instance in FakeSMTP.instances for message in instance.messages]
- assert sum("失败群" in str(message["Subject"]) for message in all_messages) == 2
+ assert sum("失败群" in str(message["Subject"]) for message in all_messages) == 1
successful = [message for message in all_messages if "成功群" in str(message["Subject"])]
assert len(successful) == 1
assert successful[0].get_body(preferencelist=("plain",)).get_content().strip() == "排行榜成功"
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_generation_concurrency.py b/tests/test_generation_concurrency.py
index 06755ad..48a5302 100644
--- a/tests/test_generation_concurrency.py
+++ b/tests/test_generation_concurrency.py
@@ -7,6 +7,7 @@
from datetime import datetime
import pytest
+from sqlmodel import Session, SQLModel, create_engine
from app.ai.concurrency import bounded_slot
from app.ai.prompt_builder_types import PromptOutput
@@ -21,19 +22,40 @@
from app.v2.run_store import RunStore
+@pytest.fixture(autouse=True)
+def _isolate_concurrency_tests_from_group_name_sync(monkeypatch):
+ """并发测试不依赖数据库初始化,也不验证群名同步。"""
+
+ monkeypatch.setattr(DailyPipeline, "_sync_group_names", lambda self, group_ids=None: None)
+
+
class DelayedSource(WeChatDataSource):
name = "delayed"
- def __init__(self):
+ def __init__(self, barrier_participants: int = 0):
self.lock = threading.Lock()
self.active = 0
self.maximum = 0
+ self.barrier = (
+ threading.Barrier(barrier_participants)
+ if barrier_participants > 1
+ else None
+ )
+ self.barrier_slots = barrier_participants
+ self.barrier_guard = threading.Lock()
def fetch_messages(self, group_id, start_time, end_time):
with self.lock:
self.active += 1
self.maximum = max(self.maximum, self.active)
- time.sleep(0.06)
+ with self.barrier_guard:
+ use_barrier = self.barrier is not None and self.barrier_slots > 0
+ if use_barrier:
+ self.barrier_slots -= 1
+ if use_barrier:
+ self.barrier.wait(timeout=5)
+ else:
+ time.sleep(0.06)
with self.lock:
self.active -= 1
if group_id == "group-3":
@@ -52,21 +74,49 @@ def fetch_messages(self, group_id, start_time, end_time):
class DelayedPrompt:
- def __init__(self, settings: Settings):
+ def __init__(self, settings: Settings, barrier_participants: int = 0):
self.settings = settings
self.lock = threading.Lock()
self.active = 0
self.maximum = 0
+ self.barrier = (
+ threading.Barrier(barrier_participants)
+ if barrier_participants > 1
+ else None
+ )
+ self.barrier_slots = barrier_participants
+ self.barrier_guard = threading.Lock()
+
+ def build(self, data):
+ from app.ai.speaker_attribution import build_attribution_contract
- def build(self, _data):
with bounded_slot("deepseek_request", self.settings.ai_request_concurrency):
with self.lock:
self.active += 1
self.maximum = max(self.maximum, self.active)
- time.sleep(0.06)
+ with self.barrier_guard:
+ use_barrier = self.barrier is not None and self.barrier_slots > 0
+ if use_barrier:
+ self.barrier_slots -= 1
+ if use_barrier:
+ self.barrier.wait(timeout=5)
+ else:
+ time.sleep(0.06)
with self.lock:
self.active -= 1
- return PromptOutput(True, "完整 Prompt", meta={"api_call_count": 1, "chunk_count": 1})
+ contract = build_attribution_contract(data.messages)
+ snapshot_hash = data.message_snapshot_sha256 or contract.message_snapshot_sha256
+ speaker_fingerprint = data.speaker_fingerprint or contract.speaker_fingerprint
+ return PromptOutput(
+ True,
+ "完整 Prompt",
+ meta={
+ "api_call_count": 1,
+ "chunk_count": 1,
+ "message_snapshot_sha256": snapshot_hash,
+ "speaker_fingerprint": speaker_fingerprint,
+ },
+ )
class PromptReadyOrder:
@@ -78,13 +128,24 @@ def __init__(self):
self.slow_saw_image_start = False
def build(self, data):
+ from app.ai.speaker_attribution import build_attribution_contract
+
+ contract = build_attribution_contract(data.messages)
+ meta = {
+ "api_call_count": 1,
+ "chunk_count": 1,
+ "message_snapshot_sha256": (
+ data.message_snapshot_sha256 or contract.message_snapshot_sha256
+ ),
+ "speaker_fingerprint": data.speaker_fingerprint or contract.speaker_fingerprint,
+ }
if data.group_name == "快群":
self.fast_prompt_ready.set()
- return PromptOutput(True, "Prompt 快群", meta={"api_call_count": 1, "chunk_count": 1})
+ return PromptOutput(True, "Prompt 快群", meta=meta)
assert self.fast_prompt_ready.wait(timeout=1)
self.slow_saw_image_start = self.image_started.wait(timeout=2)
- return PromptOutput(True, "Prompt 慢群", meta={"api_call_count": 1, "chunk_count": 1})
+ return PromptOutput(True, "Prompt 慢群", meta=meta)
class ImmediateFakeImageGenerator:
@@ -94,6 +155,7 @@ def __init__(self, prompt_order: PromptReadyOrder):
self.active = 0
self.maximum = 0
self.lock = threading.Lock()
+ self.barrier = threading.Barrier(2)
def generate(self, prompt_file, output_path):
from app.image.image_task import ImageTaskResult
@@ -105,14 +167,16 @@ def generate(self, prompt_file, output_path):
self.calls.append(prompt)
if prompt == "Prompt 快群":
self.prompt_order.image_started.set()
- time.sleep(0.02)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_bytes(bytes.fromhex(
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
- "0000000d4944415478da63f8cfc0f80100050001fff83f240000000049454e44ae426082"
- ))
- with self.lock:
- self.active -= 1
+ try:
+ self.barrier.wait(timeout=5)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(bytes.fromhex(
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
+ "0000000d4944415478da63f8cfc0f80100050001fff83f240000000049454e44ae426082"
+ ))
+ finally:
+ with self.lock:
+ self.active -= 1
return ImageTaskResult(True, image_path=output_path)
@@ -123,8 +187,8 @@ def test_five_groups_overlap_with_limits_order_and_failure_isolation(tmp_path, m
wechat_fetch_concurrency=3,
ai_request_concurrency=2,
)
- source = DelayedSource()
- prompt = DelayedPrompt(settings)
+ source = DelayedSource(barrier_participants=3)
+ prompt = DelayedPrompt(settings, barrier_participants=2)
groups = [
Group(
display_name=f"群{index}",
@@ -143,16 +207,13 @@ def test_five_groups_overlap_with_limits_order_and_failure_isolation(tmp_path, m
)
monkeypatch.setattr(pipeline, "_load_groups", lambda group_ids=None: groups)
- started = time.perf_counter()
results = pipeline.generate_all(run_date="2026-08-21")
- elapsed = time.perf_counter() - started
assert [item["group_name"] for item in results] == [f"群{index}" for index in range(5)]
assert results[3]["status"] == "failed"
assert all(item["status"] == "ready_to_send" for index, item in enumerate(results) if index != 3)
assert 1 < source.maximum <= 3
assert 1 < prompt.maximum <= 2
- assert elapsed < 0.45 # 串行基线约 0.54 秒,延迟 Fake 不访问真实服务。
def test_prompt_ready_group_starts_image_before_other_prompts_finish(tmp_path, monkeypatch):
@@ -192,11 +253,66 @@ def test_prompt_ready_group_starts_image_before_other_prompts_finish(tmp_path, m
assert prompt_order.slow_saw_image_start is True
assert generator.calls == ["Prompt 快群", "Prompt 慢群"]
- assert generator.maximum == 1
+ assert generator.maximum == 2
assert [item["group_name"] for item in results] == ["慢群", "快群"]
assert all(item["status"] == "ready_to_send" for item in results)
+def test_group_overrides_rebuild_loaded_group_with_live_sqlalchemy_state(tmp_path, monkeypatch):
+ engine = create_engine(
+ f"sqlite:///{tmp_path / 'group-overrides.db'}",
+ connect_args={"check_same_thread": False},
+ )
+ SQLModel.metadata.create_all(engine)
+ monkeypatch.setattr(repo, "engine", engine)
+ with Session(engine) as session:
+ group = repo.save_group(
+ session,
+ Group(
+ display_name="覆盖配置群",
+ wechat_group_id="override-group",
+ image_enabled=False,
+ image_theme="random_preset",
+ ),
+ )
+ group_id = int(group.id)
+
+ settings = Settings(
+ _env_file=None,
+ generation_group_concurrency=1,
+ wechat_fetch_concurrency=1,
+ ai_request_concurrency=1,
+ )
+ pipeline = DailyPipeline(
+ settings=settings,
+ data_source=DelayedSource(),
+ prompt_builder=DelayedPrompt(settings),
+ store=RunStore(tmp_path / "output"),
+ dry_run=True,
+ )
+
+ results = pipeline.generate_all(
+ run_date="2026-08-21",
+ group_overrides={
+ group_id: {
+ "image_enabled": False,
+ "image_theme": "ai_free",
+ }
+ },
+ )
+
+ assert results == [
+ {
+ "group_name": "覆盖配置群",
+ "status": "ready_to_send",
+ "detail": "未启用生图",
+ }
+ ]
+ run = pipeline.store.load_run("覆盖配置群", "2026-08-21")
+ assert run["image_enabled"] is False
+ assert run["image_theme"] == "ai_free"
+
+
def test_unexpected_worker_and_image_errors_are_isolated(tmp_path, monkeypatch):
settings = Settings(_env_file=None, generation_group_concurrency=3)
groups = [
diff --git a/tests/test_group_provider_config.py b/tests/test_group_provider_config.py
new file mode 100644
index 0000000..eb36be3
--- /dev/null
+++ b/tests/test_group_provider_config.py
@@ -0,0 +1,48 @@
+from types import SimpleNamespace
+
+import pytest
+
+from app.config.settings import Settings
+from app.db.models import Group
+from app.services.group_provider_config import (
+ normalize_history_provider,
+ resolve_group_ai_settings,
+ validate_group_provider_values,
+)
+
+
+def test_history_provider_alias_is_normalized_and_unknown_is_rejected():
+ assert normalize_history_provider("wechat-cli") == "wechat_cli"
+ with pytest.raises(ValueError, match="不支持"):
+ normalize_history_provider("free-form-provider")
+
+
+def test_group_ai_config_inherits_global_and_rejects_unknown_model():
+ settings = Settings(
+ _env_file=None,
+ summary_provider_primary="codex",
+ codex_summary_model="gpt-5.6-sol",
+ )
+ group = Group(display_name="群", prompt_provider="", prompt_model="")
+ resolved, meta = resolve_group_ai_settings(settings, group, capability="prompt")
+ assert meta == {
+ "provider": "codex",
+ "model": "gpt-5.6-sol",
+ "inherited": True,
+ "capability": "prompt",
+ }
+ assert resolved.summary_provider_primary == "codex"
+
+ group.prompt_provider = "codex"
+ group.prompt_model = "unknown-model"
+ with pytest.raises(ValueError, match="白名单"):
+ resolve_group_ai_settings(settings, group, capability="prompt")
+
+
+def test_group_configuration_accepts_only_supported_schedule_rules():
+ settings = Settings(_env_file=None)
+ assert validate_group_provider_values(
+ {"schedule_rule": "daily_previous_day"}, settings
+ )["schedule_rule"] == "daily_previous_day"
+ with pytest.raises(ValueError, match="统计周期"):
+ validate_group_provider_values({"schedule_rule": "cron:*"}, settings)
diff --git a/tests/test_group_resolve.py b/tests/test_group_resolve.py
index bead431..099420b 100644
--- a/tests/test_group_resolve.py
+++ b/tests/test_group_resolve.py
@@ -7,7 +7,7 @@
import pytest
from fastapi import FastAPI
from sqlmodel import SQLModel, Session, create_engine, select
-from starlette.testclient import TestClient
+from fastapi.testclient import TestClient
from app.api import groups as groups_api
from app.data_sources.base import DataSourceHealth, DataSourceStatus, ResolvedGroup
diff --git a/tests/test_handoff.py b/tests/test_handoff.py
index 51457ff..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,20 +19,49 @@
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,
+ 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")
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():
with Session(repo.engine) as session:
- group = repo.save_group(
- session,
- Group(display_name="示例UED-4群", wechat_group_id="group-a"),
- )
- service = ReportService()
+ group = _get_or_create_group(session, "示例UED-4群", "group-a")
+ service = _test_report_service()
run = service.generate(session, group=group, report_date="2026-08-13", force=True)
assert run.status == "success"
@@ -75,11 +106,8 @@ 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"),
- )
- service = ReportService()
+ _get_or_create_group(session, "产品经理交流群", "group-b")
+ 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_image_fact_verification.py b/tests/test_image_fact_verification.py
new file mode 100644
index 0000000..5ab152b
--- /dev/null
+++ b/tests/test_image_fact_verification.py
@@ -0,0 +1,150 @@
+"""严格图片 OCR 事实校验。"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from PIL import Image
+
+from app.ai.strict_prompt_contract import append_strict_image_fact_contract
+from app.image.fact_verification import review_image_facts
+
+
+def _evidence(tmp_path: Path) -> tuple[Path, Path]:
+ prompt = tmp_path / "image_prompt.txt"
+ prompt.write_text(
+ "只允许引用:深圳-UI-白白 78.8;群友甲 61;群友乙 66。",
+ encoding="utf-8",
+ )
+ (tmp_path / "messages.json").write_text(
+ json.dumps(
+ [
+ {
+ "group_name": "Eason张UED-4群🤘",
+ "sender_name": "深圳-UI-白白",
+ "content": "今天 78.8万元",
+ },
+ {"sender_name": "群友甲", "content": "61"},
+ {"sender_name": "群友乙", "content": "66"},
+ ],
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+ image = tmp_path / "daily_image.png"
+ Image.new("RGB", (16, 16), "white").save(image)
+ return prompt, image
+
+
+def test_rejects_unverified_numbers_and_factual_phrases(tmp_path):
+ prompt, image = _evidence(tmp_path)
+
+ review = review_image_facts(
+ prompt,
+ image,
+ ocr_text="体脂率 12%\n90÷1.72²\nBMI 30.4\n连续下雨 120天",
+ )
+
+ assert not review.ok
+ assert {"12%", "90", "1.72", "30.4", "120天"}.issubset(
+ set(review.unknown_numeric)
+ )
+ assert review.unknown_text
+
+
+def test_allows_numbers_and_text_present_in_evidence(tmp_path):
+ prompt, image = _evidence(tmp_path)
+
+ review = review_image_facts(
+ prompt,
+ image,
+ ocr_text="深圳-UI-白白 78.8\n群友甲 61\n群友乙 66",
+ )
+
+ assert review.ok
+ assert review.unknown_numeric == ()
+ assert review.unknown_text == ()
+
+
+def test_allows_ocr_fragments_of_known_numbers_and_ignores_name_garble(tmp_path):
+ prompt, image = _evidence(tmp_path)
+
+ review = review_image_facts(
+ prompt,
+ image,
+ ocr_text="写着 78 和 8万 的瓜藤\n罰 一 U 《 一 白 白\n面板 8",
+ )
+
+ assert review.ok
+ assert review.unknown_numeric == ()
+ assert review.unknown_text == ()
+
+
+def test_numeric_units_do_not_join_across_ocr_lines(tmp_path):
+ prompt, image = _evidence(tmp_path)
+
+ review = review_image_facts(
+ prompt,
+ image,
+ ocr_text="0\n米游涩泛二次元同好摸鱼群",
+ )
+
+ assert review.ok
+ assert review.unknown_numeric == ()
+
+
+def test_allows_deterministic_header_numbers_and_currency_alias(tmp_path):
+ prompt = tmp_path / "image_prompt.txt"
+ prompt.write_text(
+ """【群名称】
+Eason张UED-4.1群
+【统计时间】
+2026-08-30 00:00:00 ~ 2026-08-30 23:59:59
+【数据】
+138 条消息
+20 人发言
+【版面1】
+38元魔法战神
+""",
+ encoding="utf-8",
+ )
+ (tmp_path / "messages.json").write_text(
+ json.dumps(
+ [{"sender_name": "符号昵称", "content": "38块的游戏"}],
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+ image = tmp_path / "daily_image.png"
+ Image.new("RGB", (16, 16), "white").save(image)
+
+ review = review_image_facts(
+ prompt,
+ image,
+ ocr_text=(
+ "Eason张UED-4.1群\n"
+ "2026-08-30 00:00:00 ~ 2026-08-30 23:59:59\n"
+ "138 条消息 20 人发言\n38元魔法战神\n符号昵称 0000"
+ ),
+ )
+
+ assert review.ok
+ assert review.unknown_numeric == ()
+
+
+def test_strict_contract_removes_bmi_display_instructions():
+ prompt = """【版面4】
+手指一路猜到BMI
+话题延伸到身高、BMI和婚后发福。
+旁边的计算器连续弹出BMI窗口。
+人物说:我的BMI 很标准啊
+"""
+
+ strict_prompt = append_strict_image_fact_contract(prompt)
+
+ assert "BMI" not in strict_prompt.upper()
+ assert "从手指猜体重" in strict_prompt
+ assert "话题延伸到身高和婚后发福" in strict_prompt
+ assert "猜体重" in strict_prompt
+ assert "我的" not in strict_prompt
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_p14_email_idempotency.py b/tests/test_p14_email_idempotency.py
new file mode 100644
index 0000000..13c8760
--- /dev/null
+++ b/tests/test_p14_email_idempotency.py
@@ -0,0 +1,162 @@
+"""P1.4 邮件幂等账本测试;不连接真实 SMTP。"""
+
+from __future__ import annotations
+
+import json
+from email.message import EmailMessage
+from types import SimpleNamespace
+
+from app.config.settings import Settings
+from app.scheduler.outcome import ProcessExitCode
+from app.services.email_delivery import (
+ EmailDeliveryLedger,
+ deliver_email,
+ ensure_email_identity,
+)
+
+
+def _settings(tmp_path) -> Settings:
+ return Settings(
+ _env_file=None,
+ output_dir=tmp_path / "output",
+ email_enabled=True,
+ email_smtp_host="smtp.example.com",
+ email_smtp_port=465,
+ email_recipient="to@example.com",
+ email_from="from@example.com",
+ email_use_ssl=True,
+ )
+
+
+def _message() -> EmailMessage:
+ message = EmailMessage()
+ message["Subject"] = "群报 GroupBrief|测试群|2026-08-24"
+ message["From"] = "from@example.com"
+ message["To"] = "to@example.com"
+ message.set_content("排行榜正文")
+ return message
+
+
+def test_sent_message_is_skipped_with_same_stable_message_id(tmp_path, monkeypatch):
+ calls = []
+
+ class FakeSMTP:
+ def __init__(self, *args, **kwargs):
+ calls.append("connect")
+
+ def send_message(self, message):
+ calls.append(str(message["Message-ID"]))
+ return {}
+
+ def quit(self):
+ return None
+
+ monkeypatch.setattr("app.services.email_delivery.smtplib.SMTP_SSL", FakeSMTP)
+ ledger = EmailDeliveryLedger(tmp_path / "ledger")
+ first = deliver_email(_message(), _settings(tmp_path), ledger=ledger)
+ second_message = _message()
+ second = deliver_email(second_message, _settings(tmp_path), ledger=ledger)
+
+ assert first.status == "sent"
+ assert second.status == "already_sent"
+ assert first.message_id == second.message_id == str(second_message["Message-ID"])
+ assert calls.count("connect") == 1
+
+
+def test_send_message_exception_becomes_unknown_and_never_retries(tmp_path, monkeypatch):
+ calls = []
+
+ class FakeSMTP:
+ def __init__(self, *args, **kwargs):
+ calls.append("connect")
+
+ def send_message(self, message):
+ calls.append("submit")
+ raise RuntimeError("connection lost after submit")
+
+ def quit(self):
+ return None
+
+ monkeypatch.setattr("app.services.email_delivery.smtplib.SMTP_SSL", FakeSMTP)
+ ledger = EmailDeliveryLedger(tmp_path / "ledger")
+ first = deliver_email(_message(), _settings(tmp_path), ledger=ledger, max_attempts=3)
+ second = deliver_email(_message(), _settings(tmp_path), ledger=ledger, max_attempts=3)
+
+ assert first.status == "unknown"
+ assert second.status == "unknown"
+ assert calls == ["connect", "submit"]
+
+
+def test_crash_after_submitting_marker_is_held_without_smtp(tmp_path, monkeypatch):
+ ledger = EmailDeliveryLedger(tmp_path / "ledger")
+ message = _message()
+ identity = ensure_email_identity(message)
+ with ledger.transaction(identity) as transaction:
+ transaction.mark_submitting()
+
+ def fail_connect(*args, **kwargs):
+ raise AssertionError("submitting 状态恢复时不得连接 SMTP")
+
+ monkeypatch.setattr("app.services.email_delivery.smtplib.SMTP_SSL", fail_connect)
+ result = deliver_email(_message(), _settings(tmp_path), ledger=ledger)
+ assert result.status == "unknown"
+
+
+def test_connection_failure_retries_only_before_submit(tmp_path, monkeypatch):
+ calls = []
+
+ def fail_connect(*args, **kwargs):
+ calls.append("connect")
+ raise OSError("cannot connect")
+
+ monkeypatch.setattr("app.services.email_delivery.smtplib.SMTP_SSL", fail_connect)
+ monkeypatch.setattr("app.services.email_delivery.time.sleep", lambda _seconds: None)
+ ledger = EmailDeliveryLedger(tmp_path / "ledger")
+ message = _message()
+ identity = ensure_email_identity(message)
+ result = deliver_email(message, _settings(tmp_path), ledger=ledger, max_attempts=2)
+
+ assert result.status == "failed_before_submit"
+ assert calls == ["connect", "connect"]
+ record = json.loads(ledger.path_for(identity).read_text(encoding="utf-8"))
+ assert record["state"] == "failed_before_submit"
+
+
+def test_scheduler_maps_email_blocked_exit_to_unknown_hold(tmp_path, monkeypatch):
+ from app.scheduler import daily_v2_job as daily
+
+ settings = _settings(tmp_path)
+ 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": "测试群", "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: SimpleNamespace(
+ returncode=int(ProcessExitCode.BLOCKED),
+ stdout="one group unknown",
+ stderr="",
+ ),
+ )
+
+ result = daily.run_daily_v2_job("2026-08-25", settings=settings)
+
+ assert result["status"] == "blocked"
+ assert result["email_status"] == "unknown"
+ assert result["error_type"] == "EMAIL_RESULT_UNKNOWN"
+ state = TempState(tmp_path).load("2026-08-25")
+ assert state["email_hold"] is True
diff --git a/tests/test_p14_generation_idempotency.py b/tests/test_p14_generation_idempotency.py
new file mode 100644
index 0000000..8de50ff
--- /dev/null
+++ b/tests/test_p14_generation_idempotency.py
@@ -0,0 +1,229 @@
+"""P1.4 AI/Prompt 幂等测试;所有外部调用均由 Fake 替代。"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import httpx
+import pytest
+
+from app.config.settings import Settings
+from app.providers.ai.base import (
+ ExternalCallNotSubmittedError,
+ ExternalCallResultUnknownError,
+)
+from app.providers.ai.codex import CodexGPTProvider
+from app.providers.ai.deepseek import DeepSeekV4FlashProvider
+from app.v2.constants import RANKING_READY
+from app.v2.recovery import scan_incomplete
+from app.v2.run_store import RunStore
+
+
+def test_recorded_prompt_result_is_recovered_without_second_external_call(tmp_path):
+ store = RunStore(tmp_path)
+ store.update("测试群", "2026-08-25", status=RANKING_READY)
+ operation_id, _, reason = store.claim_prompt_operation(
+ "测试群", "2026-08-25", input_hash="input-v1"
+ )
+ assert reason == "claimed"
+ assert operation_id
+
+ store.record_prompt_result(
+ "测试群",
+ "2026-08-25",
+ operation_id,
+ prompt="已付费生成的 Prompt",
+ meta={"api_call_count": 1},
+ )
+
+ recovered_id, recovered, recovered_reason = store.claim_prompt_operation(
+ "测试群", "2026-08-25", input_hash="input-v1"
+ )
+ assert recovered_id is None
+ assert recovered_reason == "result_recorded"
+ store.commit_recorded_prompt(
+ "测试群",
+ "2026-08-25",
+ recovered["prompt_operation_id"],
+ )
+
+ assert store.prompt_path("测试群", "2026-08-25").read_text(encoding="utf-8") == "已付费生成的 Prompt"
+ run = store.load_run("测试群", "2026-08-25")
+ assert run["prompt_operation_status"] == "succeeded"
+ assert run["prompt_operation_result"] is None
+
+
+def test_unfinished_prompt_operation_becomes_manual_hold_even_with_force(tmp_path):
+ store = RunStore(tmp_path)
+ store.update("测试群", "2026-08-25", status=RANKING_READY)
+ operation_id, _, reason = store.claim_prompt_operation(
+ "测试群", "2026-08-25", input_hash="input-v1"
+ )
+ assert operation_id and reason == "claimed"
+
+ next_id, run, next_reason = store.claim_prompt_operation(
+ "测试群", "2026-08-25", input_hash="input-v1", force=True
+ )
+ assert next_id is None
+ assert next_reason == "result_unknown"
+ assert run["prompt_hold"] is True
+ assert run["prompt_operation_finished_at"] == ""
+ assert scan_incomplete(store, "2026-08-25")[0]["recovery_type"] == "manual_review"
+
+ third_id, _, third_reason = store.claim_prompt_operation(
+ "测试群", "2026-08-25", input_hash="input-v1", force=True
+ )
+ assert third_id is None
+ assert third_reason == "result_unknown"
+
+
+def test_prompt_unknown_requires_matching_operation_id_before_retry(tmp_path):
+ store = RunStore(tmp_path)
+ store.update("测试群", "2026-08-27", status=RANKING_READY)
+ operation_id, _, reason = store.claim_prompt_operation(
+ "测试群", "2026-08-27", input_hash="input-v1"
+ )
+ assert operation_id and reason == "claimed"
+ store.mark_prompt_result_unknown(
+ "测试群",
+ "2026-08-27",
+ operation_id,
+ error="Codex GPT 超时且结果未知",
+ )
+
+ resolved, _, stale_reason = store.resolve_prompt_result_unknown(
+ "测试群",
+ "2026-08-27",
+ expected_operation_id="stale-operation",
+ now=datetime.now().astimezone(),
+ )
+ assert resolved is False
+ assert stale_reason == "stale"
+
+ resolved, run, resolved_reason = store.resolve_prompt_result_unknown(
+ "测试群",
+ "2026-08-27",
+ expected_operation_id=operation_id,
+ now=datetime.now().astimezone(),
+ )
+ assert resolved is True
+ assert resolved_reason == "resolved"
+ assert run["prompt_hold"] is False
+ assert run["prompt_operation_status"] == "failed"
+ assert run["prompt_last_resolution"] == "discard_and_retry"
+
+ next_id, _, next_reason = store.claim_prompt_operation(
+ "测试群", "2026-08-27", input_hash="input-v1", force=True
+ )
+ assert next_reason == "claimed"
+ assert next_id and next_id != operation_id
+
+
+class _Fallback:
+ model = "fake"
+
+ def __init__(self):
+ self.calls = 0
+
+ def health_check(self):
+ return True, "ok"
+
+ def _chat(self, *args, **kwargs):
+ self.calls += 1
+ return "fallback-result"
+
+
+def _codex_provider() -> CodexGPTProvider:
+ settings = Settings(
+ _env_file=None,
+ summary_provider_primary="codex",
+ summary_provider_fallback="deepseek",
+ ai_api_key="test-only",
+ )
+ provider = CodexGPTProvider(settings)
+ provider._fallback = _Fallback()
+ return provider
+
+
+def test_codex_unknown_result_never_calls_fallback(monkeypatch):
+ provider = _codex_provider()
+
+ def unknown(*args, **kwargs):
+ raise ExternalCallResultUnknownError("result unknown")
+
+ monkeypatch.setattr(provider, "_codex_chat", unknown)
+ with pytest.raises(ExternalCallResultUnknownError):
+ provider._chat([{"role": "user", "content": "test"}])
+ assert provider._fallback.calls == 0
+
+
+def test_codex_confirmed_not_submitted_can_use_fallback(monkeypatch):
+ provider = _codex_provider()
+
+ def not_submitted(*args, **kwargs):
+ raise ExternalCallNotSubmittedError("not submitted")
+
+ monkeypatch.setattr(provider, "_codex_chat", not_submitted)
+ assert provider._chat([{"role": "user", "content": "test"}]) == "fallback-result"
+ assert provider._fallback.calls == 1
+
+
+def _deepseek_settings(**overrides) -> Settings:
+ values = {
+ "_env_file": None,
+ "summary_provider_primary": "deepseek",
+ "ai_api_key": "test-only",
+ "ai_max_retries": 3,
+ }
+ values.update(overrides)
+ return Settings(**values)
+
+
+def test_deepseek_read_timeout_is_unknown_and_not_retried(monkeypatch):
+ calls = []
+
+ def timeout(*args, **kwargs):
+ calls.append(1)
+ request = httpx.Request("POST", "https://api.example.test/chat/completions")
+ raise httpx.ReadTimeout("timeout", request=request)
+
+ monkeypatch.setattr(httpx, "post", timeout)
+ provider = DeepSeekV4FlashProvider(_deepseek_settings())
+ with pytest.raises(ExternalCallResultUnknownError):
+ provider._chat([{"role": "user", "content": "test"}])
+ assert len(calls) == 1
+
+
+def test_deepseek_explicit_429_can_retry(monkeypatch):
+ calls = []
+ responses = [
+ httpx.Response(429),
+ httpx.Response(
+ 200,
+ json={"choices": [{"message": {"content": "ok"}}]},
+ ),
+ ]
+
+ def respond(*args, **kwargs):
+ calls.append(1)
+ return responses.pop(0)
+
+ monkeypatch.setattr(httpx, "post", respond)
+ monkeypatch.setattr("app.providers.ai.deepseek.time.sleep", lambda _seconds: None)
+ provider = DeepSeekV4FlashProvider(_deepseek_settings())
+ assert provider._chat([{"role": "user", "content": "test"}]) == "ok"
+ assert len(calls) == 2
+
+
+def test_deepseek_500_is_unknown_and_not_retried(monkeypatch):
+ calls = []
+
+ def respond(*args, **kwargs):
+ calls.append(1)
+ return httpx.Response(500)
+
+ monkeypatch.setattr(httpx, "post", respond)
+ provider = DeepSeekV4FlashProvider(_deepseek_settings())
+ with pytest.raises(ExternalCallResultUnknownError):
+ provider._chat([{"role": "user", "content": "test"}])
+ assert len(calls) == 1
diff --git a/tests/test_p15_v1_freeze.py b/tests/test_p15_v1_freeze.py
new file mode 100644
index 0000000..75c53f1
--- /dev/null
+++ b/tests/test_p15_v1_freeze.py
@@ -0,0 +1,140 @@
+"""P1.5 旧 V1 双轨冻结测试。"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.api import email as email_api
+from app.api import reports as reports_api
+from app.config.settings import Settings, get_settings
+from app.main import app
+from app.scheduler.email_job import run_email_job
+from app.scheduler.generate_job import run_generate_job
+from app.services.email_service import EmailService
+from app.services.legacy_v1_policy import (
+ LEGACY_V1_WRITE_BLOCKED,
+ LegacyV1WriteBlockedError,
+)
+from app.services.report_service import ReportService
+
+
+def _read_only_settings(tmp_path=None) -> Settings:
+ values = {
+ "_env_file": None,
+ "legacy_v1_write_mode": "read_only",
+ "email_enabled": True,
+ "email_smtp_host": "smtp.example.com",
+ "email_recipient": "to@example.com",
+ "email_from": "from@example.com",
+ }
+ if tmp_path is not None:
+ values["database_url"] = f"sqlite:///{(tmp_path / 'blocked.db').as_posix()}"
+ return Settings(**values)
+
+
+def test_legacy_v1_write_mode_defaults_to_read_only(monkeypatch):
+ monkeypatch.delenv("LEGACY_V1_WRITE_MODE", raising=False)
+ assert Settings(_env_file=None).legacy_v1_write_mode == "read_only"
+
+
+def test_report_service_blocks_before_generation_mutex(monkeypatch):
+ monkeypatch.setattr(
+ "app.services.report_service.generation_mutex",
+ lambda: pytest.fail("只读模式不得进入生成锁"),
+ )
+ service = ReportService(settings=_read_only_settings())
+
+ with pytest.raises(LegacyV1WriteBlockedError):
+ service.generate(None)
+
+
+def test_email_service_blocks_before_build_or_smtp(monkeypatch):
+ service = EmailService(_read_only_settings())
+ monkeypatch.setattr(
+ service,
+ "build_email",
+ lambda *args, **kwargs: pytest.fail("只读模式不得读取并发送旧报告"),
+ )
+
+ with pytest.raises(LegacyV1WriteBlockedError):
+ service.send(None)
+
+
+def test_legacy_write_apis_return_410_without_calling_services(monkeypatch):
+ settings = _read_only_settings()
+ app.dependency_overrides[get_settings] = lambda: settings
+ monkeypatch.setattr(
+ reports_api.ReportService,
+ "generate",
+ lambda *args, **kwargs: pytest.fail("冻结 API 不得调用 ReportService"),
+ )
+ monkeypatch.setattr(
+ email_api.EmailService,
+ "send",
+ lambda *args, **kwargs: pytest.fail("冻结 API 不得调用 EmailService"),
+ )
+ try:
+ with TestClient(app) as client:
+ generated = client.post("/api/reports/generate", json={})
+ updated = client.put("/api/reports/999/prompt", json={"text": "不应写入"})
+ sent = client.post("/api/email/send")
+ finally:
+ app.dependency_overrides.clear()
+
+ for response in (generated, updated, sent):
+ assert response.status_code == 410
+ assert response.json()["detail"]["code"] == LEGACY_V1_WRITE_BLOCKED
+
+
+def test_legacy_read_apis_remain_available():
+ with TestClient(app) as client:
+ assert client.get("/api/reports/latest").status_code == 200
+ assert client.get("/api/runs").status_code == 200
+ assert client.get("/api/email/preview").status_code == 200
+
+
+def test_legacy_scheduler_jobs_return_blocked_without_services(monkeypatch):
+ monkeypatch.setattr(
+ "app.scheduler.generate_job.Session",
+ lambda *args, **kwargs: pytest.fail("冻结 job 不得打开数据库 Session"),
+ )
+ monkeypatch.setattr(
+ "app.scheduler.email_job.Session",
+ lambda *args, **kwargs: pytest.fail("冻结 job 不得打开数据库 Session"),
+ )
+ settings = _read_only_settings()
+
+ generate_result = run_generate_job(settings)
+ email_result = run_email_job(settings)
+
+ for result in (generate_result, email_result):
+ assert result["status"] == "blocked"
+ assert result["error_type"] == LEGACY_V1_WRITE_BLOCKED
+ assert result["exit_code"] == 3
+
+
+def test_maintenance_mode_is_explicit_and_not_settings_api_editable():
+ from app.api.settings import EDITABLE_KEYS
+
+ settings = Settings(_env_file=None, legacy_v1_write_mode="maintenance")
+ assert settings.legacy_v1_write_mode == "maintenance"
+ assert "legacy_v1_write_mode" not in EDITABLE_KEYS
+
+ read_only = _read_only_settings()
+ assert read_only.apply_runtime_values({"legacy_v1_write_mode": "maintenance"}) == []
+ assert read_only.legacy_v1_write_mode == "read_only"
+
+
+def test_system_status_exposes_freeze_mode():
+ settings = _read_only_settings()
+ app.dependency_overrides[get_settings] = lambda: settings
+ try:
+ with TestClient(app) as client:
+ response = client.get("/api/system/status")
+ finally:
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ assert response.json()["legacy_v1_write_mode"] == "read_only"
+ assert response.json()["legacy_v1_writes_active"] is False
diff --git a/tests/test_p24_observability.py b/tests/test_p24_observability.py
new file mode 100644
index 0000000..df45636
--- /dev/null
+++ b/tests/test_p24_observability.py
@@ -0,0 +1,316 @@
+from __future__ import annotations
+
+import logging
+from logging.handlers import RotatingFileHandler
+from types import SimpleNamespace
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from sqlalchemy.pool import StaticPool
+from sqlmodel import Session, SQLModel, create_engine, select
+
+from app.api import system
+from app.api import v2_ui
+from app.config.settings import get_settings
+from app.core.logging import setup_logging
+from app.db import repository as repo
+from app.db.models import ProviderHealth
+from app.main import _capture_startup_checks
+from app.image.codex_generator import CodexImageGenerator
+from app.providers.ai.codex import CodexGPTProvider
+from app.sender.wechat_native import WechatNativeSender
+
+
+def test_setup_logging_configures_category_files_when_root_has_handler(tmp_path):
+ root = logging.getLogger()
+ root_handler = logging.NullHandler()
+ root.addHandler(root_handler)
+ target = str((tmp_path / "scheduler.log").resolve())
+ category_names = (
+ "app",
+ "groupbrief.providers",
+ "groupbrief.ai",
+ "groupbrief.scheduler",
+ "groupbrief.email",
+ )
+ before = {
+ name: list(logging.getLogger(name).handlers) for name in category_names
+ }
+ scheduler_logger = logging.getLogger("groupbrief.scheduler")
+ try:
+ setup_logging(tmp_path)
+ matching = [
+ handler
+ for handler in scheduler_logger.handlers
+ if isinstance(handler, RotatingFileHandler)
+ and handler.baseFilename == target
+ ]
+ assert len(matching) == 1
+
+ setup_logging(tmp_path)
+ matching = [
+ handler
+ for handler in scheduler_logger.handlers
+ if isinstance(handler, RotatingFileHandler)
+ and handler.baseFilename == target
+ ]
+ assert len(matching) == 1
+ finally:
+ root.removeHandler(root_handler)
+ for name in category_names:
+ logger = logging.getLogger(name)
+ for handler in list(logger.handlers):
+ if handler not in before[name]:
+ logger.removeHandler(handler)
+ handler.close()
+
+
+def _readiness_client(tmp_path):
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ SQLModel.metadata.create_all(engine)
+ output_dir = tmp_path / "output"
+ output_dir.mkdir()
+
+ def override_session():
+ with Session(engine) as session:
+ yield session
+
+ api = FastAPI()
+ api.include_router(system.router)
+ api.state.startup_check_error = ""
+ api.state.startup_checks = []
+ api.state.startup_checks_at = "2026-08-27T00:00:00+08:00"
+ api.state.scheduler_owner = "windows"
+ api.state.scheduler_active = False
+ api.dependency_overrides[repo.get_session] = override_session
+ api.dependency_overrides[get_settings] = lambda: SimpleNamespace(
+ output_dir=output_dir,
+ scheduler_owner="windows",
+ app_timezone="Asia/Shanghai",
+ schedule_generate_time="23:59",
+ reliability_watchdog_interval_minutes=10,
+ scheduler_heartbeat_stale_seconds=300,
+ )
+ return TestClient(api), api
+
+
+def test_readiness_is_local_read_only_and_exposes_startup_capture_error(tmp_path):
+ client, api = _readiness_client(tmp_path)
+ with client:
+ ready = client.get("/api/system/ready")
+ assert ready.status_code == 200
+ assert ready.json()["ready"] is True
+ assert set(ready.json()["checks"]) == {
+ "database",
+ "output",
+ "templates",
+ "startup_capture",
+ "wechat_data",
+ "wechat_client",
+ "summary_provider",
+ "scheduler_heartbeat",
+ }
+ assert ready.json()["daily_result"]["status"] == "NOT_DUE"
+
+ api.state.startup_check_error = "startup probe crashed"
+ failed = client.get("/api/system/ready")
+ assert failed.status_code == 503
+ assert failed.json()["ready"] is False
+ assert failed.json()["checks"]["startup_capture"]["status"] == "ERROR"
+
+
+def test_provider_health_retention_keeps_latest_one_hundred_per_provider():
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ SQLModel.metadata.create_all(engine)
+ with Session(engine) as session:
+ for index in range(105):
+ session.add(
+ ProviderHealth(
+ provider="provider-a",
+ status="OK",
+ detail=str(index),
+ )
+ )
+ session.flush()
+ assert system._prune_provider_health(session) == 5
+ session.commit()
+ rows = session.exec(
+ select(ProviderHealth).where(
+ ProviderHealth.provider == "provider-a"
+ )
+ ).all()
+ assert len(rows) == 100
+
+
+def test_liveness_remains_side_effect_free():
+ payload = system.health()
+ assert payload["status"] == "ok"
+ assert payload["service"] == "groupbrief"
+ assert payload["timestamp"]
+
+
+def test_readiness_degrades_when_required_wechat_dependency_failed(tmp_path):
+ client, api = _readiness_client(tmp_path)
+ from app.db.models import Group
+
+ api.state.startup_checks = [
+ {"name": "WeChatDataAnalysis 数据源", "ok": False, "status": "UNAVAILABLE", "detail": "MCP offline"},
+ {"name": "Codex GPT 群聊总结", "ok": True, "status": "OK", "detail": "ok"},
+ {"name": "DeepSeek V4 Flash(备用)", "ok": False, "status": "UNAVAILABLE", "detail": "not configured"},
+ ]
+ override = api.dependency_overrides[repo.get_session]
+ session_iterator = override()
+ session = next(session_iterator)
+ try:
+ session.add(Group(display_name="群A", wechat_group_id="g1", enabled=True))
+ session.commit()
+ finally:
+ session_iterator.close()
+
+ with client:
+ response = client.get("/api/system/ready")
+
+ assert response.status_code == 503
+ assert response.json()["status"] == "degraded"
+ assert response.json()["checks"]["wechat_data"]["status"] == "UNAVAILABLE"
+
+
+def test_idle_scheduler_stays_ready_even_when_last_event_is_old(tmp_path, monkeypatch):
+ from datetime import datetime, timedelta
+
+ from app.scheduler.heartbeat import record_scheduler_heartbeat
+ from app.scheduler import manager
+
+ client, api = _readiness_client(tmp_path)
+ settings = api.dependency_overrides[get_settings]()
+ settings.scheduler_owner = "fastapi"
+ api.dependency_overrides[get_settings] = lambda: settings
+ api.state.scheduler_owner = "fastapi"
+ api.state.scheduler_active = True
+ record_scheduler_heartbeat(
+ settings,
+ job="send_batch",
+ status="success",
+ now=datetime.now().astimezone() - timedelta(minutes=10),
+ )
+
+ class RunningScheduler:
+ running = True
+
+ def get_jobs(self):
+ return []
+
+ monkeypatch.setattr(manager, "get_scheduler", lambda: RunningScheduler())
+
+ with client:
+ live = client.get("/api/system/health")
+ ready = client.get("/api/system/ready")
+
+ assert live.status_code == 200
+ assert live.json()["status"] == "ok"
+ assert ready.status_code == 200
+ assert ready.json()["status"] == "ready"
+ assert ready.json()["checks"]["scheduler_heartbeat"]["status"] == "OK"
+
+
+def test_startup_check_exception_is_preserved_as_explicit_failure(caplog):
+ def fail(_settings):
+ raise RuntimeError("startup probe crashed")
+
+ with caplog.at_level(logging.ERROR, logger="app"):
+ checks, error = _capture_startup_checks(object(), runner=fail)
+
+ assert error == "startup probe crashed"
+ assert checks == [
+ {
+ "name": "启动检查执行",
+ "ok": False,
+ "status": "ERROR",
+ "detail": "startup probe crashed",
+ }
+ ]
+ assert "启动检查执行失败" in caplog.text
+
+
+def test_deep_health_formatting_reuses_existing_reports():
+ summary = object.__new__(CodexGPTProvider)
+ summary.model = "gpt-test"
+ summary_report = {
+ "ok": True,
+ "version": {"value": "codex 1", "detail": "ok"},
+ "fallback": {"configured": False},
+ }
+ assert summary.health_check(summary_report) == (
+ True,
+ "主模型 gpt-test(codex 1);DeepSeek 备用未配置",
+ )
+
+ image = object.__new__(CodexImageGenerator)
+ image_report = {
+ "binary": {"ok": True},
+ "version": {"ok": True, "value": "codex 1", "detail": "ok"},
+ "last_image_smoke": {"ok": False},
+ }
+ assert image.health_check(image_report) == (
+ True,
+ "codex 可执行:codex 1;图片能力尚未实测",
+ )
+
+ sender = object.__new__(WechatNativeSender)
+ sender.dry_run = False
+ sender_report = {
+ "ok": False,
+ "dependencies": {"ok": True, "detail": "ok"},
+ "desktop": {"ok": False, "detail": "桌面已锁定"},
+ "ocr": {"ok": False, "detail": "未检查"},
+ "clipboard": {"ok": False, "detail": "未检查"},
+ "window": {"ok": False, "detail": "未检查"},
+ }
+ assert sender.health_check(sender_report) == (False, "桌面已锁定")
+
+
+def test_recovery_endpoint_reuses_one_run_snapshot(monkeypatch):
+ class CountingStore:
+ def __init__(self):
+ self.calls = 0
+
+ def list_runs(self, _run_date=None):
+ self.calls += 1
+ return []
+
+ store = CountingStore()
+ monkeypatch.setattr(v2_ui, "_store", lambda _settings: store)
+
+ assert v2_ui.recovery_info(settings=object()) == {
+ "incomplete": [],
+ "integrity": [],
+ }
+ assert store.calls == 1
+
+
+def test_v2_startup_endpoint_returns_saved_snapshot_without_rerun():
+ api = FastAPI()
+ api.include_router(v2_ui.router)
+ api.state.startup_checks = [
+ {"name": "cached", "ok": True, "status": "OK", "detail": "saved"}
+ ]
+ api.state.startup_check_error = ""
+
+ with TestClient(api) as client:
+ response = client.get("/api/v2/system/startup")
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "checks": [
+ {"name": "cached", "ok": True, "status": "OK", "detail": "saved"}
+ ],
+ "error": "",
+ }
diff --git a/tests/test_path_security.py b/tests/test_path_security.py
new file mode 100644
index 0000000..ed1792b
--- /dev/null
+++ b/tests/test_path_security.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from app.core.path_security import (
+ PathBoundaryError,
+ _strip_windows_extended_prefix,
+ 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)
+
+
+def test_windows_extended_path_prefix_is_normalized_without_changing_location():
+ assert _strip_windows_extended_prefix(r"\\?\C:\output\群A") == r"C:\output\群A"
+ assert _strip_windows_extended_prefix(r"\\?\UNC\server\share\群A") == r"\\server\share\群A"
+ assert _strip_windows_extended_prefix(r"C:\output\群A") == r"C:\output\群A"
+
+
+@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_period_rules.py b/tests/test_period_rules.py
new file mode 100644
index 0000000..d825058
--- /dev/null
+++ b/tests/test_period_rules.py
@@ -0,0 +1,48 @@
+from datetime import date
+
+import pytest
+
+from app.scheduler.period import PeriodResolver
+
+
+def test_weekday_default_monday_covers_friday_through_sunday():
+ window = PeriodResolver().resolve(
+ date(2026, 8, 31), schedule_rule="weekday_default"
+ )
+
+ assert window.should_run is True
+ assert [item.isoformat() for item in window.covered_dates] == [
+ "2026-08-28",
+ "2026-08-29",
+ "2026-08-30",
+ ]
+ assert window.period_start.date().isoformat() == "2026-08-28"
+ assert window.period_end.date().isoformat() == "2026-08-30"
+
+
+def test_weekday_default_skips_weekend():
+ window = PeriodResolver().resolve(
+ date(2026, 8, 29), schedule_rule="weekday_default"
+ )
+ assert window.should_run is False
+
+
+def test_daily_previous_day_runs_on_weekend():
+ window = PeriodResolver().resolve(
+ date(2026, 8, 29), schedule_rule="daily_previous_day"
+ )
+ assert window.should_run is True
+ assert [item.isoformat() for item in window.covered_dates] == ["2026-08-28"]
+
+
+def test_default_rule_runs_every_day_for_previous_day():
+ window = PeriodResolver().resolve(date(2026, 8, 29))
+
+ assert window.rule == "daily_previous_day"
+ assert window.should_run is True
+ assert [item.isoformat() for item in window.covered_dates] == ["2026-08-28"]
+
+
+def test_unknown_schedule_rule_is_rejected():
+ with pytest.raises(NotImplementedError, match="暂不支持"):
+ PeriodResolver().resolve(date(2026, 8, 27), schedule_rule="custom")
diff --git a/tests/test_pipeline_stages.py b/tests/test_pipeline_stages.py
new file mode 100644
index 0000000..806eb29
--- /dev/null
+++ b/tests/test_pipeline_stages.py
@@ -0,0 +1,26 @@
+"""P2.2 Pipeline 阶段协议测试。"""
+
+import pytest
+
+from app.pipeline.stage_result import StageDisposition, StageResult
+
+
+def test_stage_result_proceed_exposes_only_next_value():
+ result = StageResult.proceed({"messages": 3})
+
+ assert result.disposition is StageDisposition.CONTINUE
+ assert result.is_terminal is False
+ assert result.next_value() == {"messages": 3}
+ with pytest.raises(RuntimeError, match="没有终止响应"):
+ result.terminal_response()
+
+
+def test_stage_result_stop_exposes_only_terminal_response():
+ response = {"status": "held", "error_type": "RESULT_UNKNOWN"}
+ result = StageResult.stop(response)
+
+ assert result.disposition is StageDisposition.TERMINAL
+ assert result.is_terminal is True
+ assert result.terminal_response() == response
+ with pytest.raises(RuntimeError, match="没有下一阶段输入"):
+ result.next_value()
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_prompt_safety.py b/tests/test_prompt_safety.py
new file mode 100644
index 0000000..2b88089
--- /dev/null
+++ b/tests/test_prompt_safety.py
@@ -0,0 +1,48 @@
+from app.ai.prompt_safety import enforce_prompt_budget, sanitize_prompt_text
+
+
+def test_sanitize_prompt_text_removes_controls_and_normalizes_newlines():
+ value = "群\u202e名\r\n第一行\x00\n\n\n\n第二行"
+
+ cleaned, removed = sanitize_prompt_text(value)
+
+ assert "\u202e" not in cleaned
+ assert "\x00" not in cleaned
+ assert "\r" not in cleaned
+ assert removed == 2
+ assert "\n\n\n\n" not in cleaned
+
+
+def test_enforce_prompt_budget_preserves_all_section_headings():
+ prompt = "\n".join(
+ [
+ "【任务】",
+ "A" * 5000,
+ "【主标题】",
+ "今日热聊 2026-08-18",
+ "【分镜一】",
+ "B" * 5000 + " 票房 500 万",
+ "【硬约束】",
+ "C" * 5000 + " 1024×1536",
+ ]
+ )
+
+ result, meta = enforce_prompt_budget(prompt, max_chars=1800, max_bytes=4000)
+
+ assert len(result) <= 1800
+ assert len(result.encode("utf-8")) <= 4000
+ assert meta["prompt_compacted"] is True
+ for heading in ("【任务】", "【主标题】", "【分镜一】", "【硬约束】"):
+ assert heading in result
+ assert "2026-08-18" in result
+ assert "1024×1536" in result
+
+
+def test_enforce_prompt_budget_is_deterministic():
+ prompt = "【任务】\n" + ("群聊内容🙂" * 3000)
+
+ first, first_meta = enforce_prompt_budget(prompt, max_chars=2000, max_bytes=5000)
+ second, second_meta = enforce_prompt_budget(prompt, max_chars=2000, max_bytes=5000)
+
+ assert first == second
+ assert first_meta == second_meta
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_recovery_planner.py b/tests/test_recovery_planner.py
new file mode 100644
index 0000000..9d4d5fa
--- /dev/null
+++ b/tests/test_recovery_planner.py
@@ -0,0 +1,231 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import pytest
+from sqlmodel import Session
+
+from app.config.settings import Settings
+from app.db import repository as repo
+from app.db.models import Group
+from app.scheduler.recovery_planner import (
+ RecoveryPlanChangedError,
+ RecoveryPlanner,
+ RecoverySelectionError,
+)
+from app.scheduler.daily_v2_job import (
+ DailyScheduleState,
+ ScheduleStateVersionConflictError,
+)
+from app.scheduler.period import PeriodResolver
+from app.scheduler.task_manifest import manifest_fields
+from app.v2.run_store import RunStore
+
+
+def _settings(tmp_path):
+ return Settings(
+ _env_file=None,
+ database_url=f"sqlite:///{(tmp_path / 'recovery.db').as_posix()}",
+ reliability_lookback_days=2,
+ )
+
+
+def _planner(settings, tmp_path):
+ store = RunStore(tmp_path / "output")
+ return RecoveryPlanner(
+ settings,
+ store=store,
+ state_store=DailyScheduleState(store.root),
+ )
+
+
+def test_preview_lists_old_missing_tasks_without_invoking_generation(tmp_path):
+ settings = _settings(tmp_path)
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ repo.save_group(
+ session,
+ Group(
+ display_name="恢复群",
+ wechat_group_id="recover@chatroom",
+ schedule_rule="daily_previous_day",
+ image_enabled=True,
+ ),
+ )
+
+ planner = _planner(settings, tmp_path)
+ preview = planner.preview(
+ now=datetime(2026, 8, 27, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+ lookback_days=4,
+ )
+
+ assert preview["automatic_recovery_dates"] == ["2026-08-26", "2026-08-27"]
+ assert [item["run_date"] for item in preview["items"]] == ["2026-08-24", "2026-08-25"]
+ assert all(item["safe_stage"] == "generation_only" for item in preview["items"])
+ assert len(preview["version"]) == 64
+ assert not list(planner.store.root.rglob("run.json"))
+
+
+def test_confirm_rejects_stale_version_before_generation(tmp_path):
+ settings = _settings(tmp_path)
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ group = repo.save_group(
+ session,
+ Group(
+ display_name="恢复群",
+ wechat_group_id="recover@chatroom",
+ schedule_rule="daily_previous_day",
+ ),
+ )
+
+ with pytest.raises(RecoveryPlanChangedError, match="已变化"):
+ _planner(settings, tmp_path).confirm_generation(
+ [{"run_date": "2026-08-24", "group_id": group.id}],
+ expected_version="0" * 64,
+ now=datetime(2026, 8, 27, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+ )
+
+
+def test_daily_schedule_state_compare_and_update_is_cas(tmp_path):
+ state_store = DailyScheduleState(tmp_path)
+ state = state_store.update(
+ "2026-08-29",
+ **manifest_fields([]),
+ generation_started_at="2026-08-29T00:15:00+08:00",
+ generation_status="not_run",
+ generation_completed_at="2026-08-29T00:15:01+08:00",
+ )
+
+ updated = state_store.compare_and_update(
+ "2026-08-29",
+ expected_state_version=state["state_version"],
+ generation_status="running",
+ )
+
+ assert updated["state_version"] == state["state_version"] + 1
+ with pytest.raises(ScheduleStateVersionConflictError, match="状态已变化"):
+ state_store.compare_and_update(
+ "2026-08-29",
+ expected_state_version=state["state_version"],
+ generation_status="running",
+ )
+
+
+def test_repair_empty_manifest_rejects_stale_version_before_generation(tmp_path):
+ settings = _settings(tmp_path)
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ group = repo.save_group(
+ session,
+ Group(
+ display_name="恢复群",
+ wechat_group_id="recover@chatroom",
+ schedule_rule="daily_previous_day",
+ ),
+ )
+ planner = _planner(settings, tmp_path)
+ planner.state_store.update(
+ "2026-08-29",
+ **manifest_fields([]),
+ generation_started_at="2026-08-29T00:15:00+08:00",
+ generation_status="not_run",
+ generation_completed_at="2026-08-29T00:15:01+08:00",
+ )
+
+ with pytest.raises(RecoveryPlanChangedError, match="版本已变化"):
+ planner.repair_empty_manifest_and_generate(
+ "2026-08-29",
+ expected_state_version=99,
+ expected_group_ids=[group.id],
+ )
+
+
+def test_repair_empty_manifest_rebuilds_manifest_and_generates_only(
+ tmp_path, monkeypatch
+):
+ settings = _settings(tmp_path)
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ group = repo.save_group(
+ session,
+ Group(
+ display_name="恢复群",
+ wechat_group_id="recover@chatroom",
+ schedule_rule="daily_previous_day",
+ wechat_send_enabled=True,
+ ),
+ )
+ planner = _planner(settings, tmp_path)
+ state = planner.state_store.update(
+ "2026-08-29",
+ **manifest_fields([]),
+ generation_started_at="2026-08-29T00:15:00+08:00",
+ generation_status="not_run",
+ generation_completed_at="2026-08-29T00:15:01+08:00",
+ )
+ calls = []
+
+ class FakePipeline:
+ def __init__(self, settings):
+ self.settings = settings
+ self.period_resolver = PeriodResolver()
+
+ def generate_all(self, **kwargs):
+ calls.append(kwargs)
+ return [
+ {
+ "group_id": group.id,
+ "group_name": "恢复群",
+ "status": "ready_to_send",
+ }
+ ]
+
+ monkeypatch.setattr("app.scheduler.recovery_planner.DailyPipeline", FakePipeline)
+ result = planner.repair_empty_manifest_and_generate(
+ "2026-08-29",
+ expected_state_version=state["state_version"],
+ expected_group_ids=[group.id],
+ )
+
+ repaired = planner.state_store.load("2026-08-29")
+ assert result["status"] == "success"
+ assert result["generation_only"] is True
+ assert result["send_invoked"] is False
+ assert repaired["expected_group_count"] == 1
+ assert repaired["expected_groups"][0]["schedule_rule"] == "daily_previous_day"
+ assert repaired["expected_groups"][0]["expected_terminal"] == "SENT"
+ assert repaired["generation_status"] == "success"
+ assert repaired["email_status"] == "skipped_by_recovery_request"
+ assert repaired["manifest_source"] == "manual_empty_manifest_repair_current_config"
+ assert calls[0]["group_ids"] == [group.id]
+ assert calls[0]["acquire_lock"] is False
+
+
+def test_repair_empty_manifest_rejects_existing_group_run(tmp_path):
+ settings = _settings(tmp_path)
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ group = repo.save_group(
+ session,
+ Group(display_name="恢复群", wechat_group_id="recover@chatroom"),
+ )
+ planner = _planner(settings, tmp_path)
+ state = planner.state_store.update(
+ "2026-08-29",
+ **manifest_fields([]),
+ generation_started_at="2026-08-29T00:15:00+08:00",
+ generation_status="not_run",
+ generation_completed_at="2026-08-29T00:15:01+08:00",
+ )
+ planner.store.save_run(
+ "恢复群",
+ "2026-08-29",
+ {"group_id": str(group.id), "status": "PENDING"},
+ )
+
+ with pytest.raises(RecoverySelectionError, match="已存在群级运行记录"):
+ planner.repair_empty_manifest_and_generate(
+ "2026-08-29",
+ expected_state_version=state["state_version"],
+ expected_group_ids=[group.id],
+ )
diff --git a/tests/test_reliability_simulation.py b/tests/test_reliability_simulation.py
new file mode 100644
index 0000000..bbd622a
--- /dev/null
+++ b/tests/test_reliability_simulation.py
@@ -0,0 +1,20 @@
+from scripts.simulate_reliability import run_simulation
+
+
+def test_deterministic_reliability_simulation_has_no_loss_or_duplicates(tmp_path):
+ result = run_simulation(
+ days=5,
+ groups_count=3,
+ seed=20260827,
+ workdir=tmp_path,
+ )
+
+ assert result["ok"] is True
+ assert result["runs_found"] == 15
+ assert result["sent"] + result["manual_holds"] == 15
+ assert result["task_loss"] == 0
+ assert result["retry_pending"] == 0
+ assert result["duplicate_external_image_calls"] == 0
+ assert result["duplicate_successful_image_sends"] == 0
+ assert result["duplicate_successful_text_sends"] == 0
+ assert result["runtime_reports"] == 5
diff --git a/tests/test_reliability_state.py b/tests/test_reliability_state.py
new file mode 100644
index 0000000..f7eff46
--- /dev/null
+++ b/tests/test_reliability_state.py
@@ -0,0 +1,155 @@
+from datetime import datetime, timedelta, timezone
+
+from app.v2.constants import (
+ EXECUTION_COMPLETE,
+ EXECUTION_FAILED_FINAL,
+ EXECUTION_HOLD_MANUAL,
+ EXECUTION_WAIT_RETRY,
+ FAILED,
+ MESSAGE_FETCH_FAILED,
+ SENT,
+)
+from app.v2.reliability import enrich_run_state, retry_is_due
+from app.v2.run_store import RunStore
+
+
+def test_retryable_failure_gets_checkpoint_budget_and_due_time():
+ now = datetime(2026, 8, 27, 0, 15, tzinfo=timezone.utc)
+ run = enrich_run_state(
+ {
+ "group_name": "群A",
+ "run_date": "2026-08-27",
+ "status": FAILED,
+ "failed_stage": "data",
+ "error_type": MESSAGE_FETCH_FAILED,
+ "error": "temporary timeout",
+ },
+ now=now,
+ )
+
+ assert run["execution_state"] == EXECUTION_WAIT_RETRY
+ assert run["retry_attempt_count"] == 1
+ assert run["retry_budget"] == 3
+ assert run["next_retry_at"] == (now + timedelta(seconds=60)).isoformat()
+ assert retry_is_due(run, now=now) is False
+ assert retry_is_due(run, now=now + timedelta(seconds=60)) is True
+
+
+def test_same_failure_snapshot_does_not_consume_retry_budget_twice():
+ now = datetime(2026, 8, 27, 0, 15, tzinfo=timezone.utc)
+ first = enrich_run_state(
+ {
+ "group_name": "群A",
+ "run_date": "2026-08-27",
+ "status": FAILED,
+ "failed_stage": "prompt",
+ "error_type": "PROMPT_FAILED",
+ "error": "schema invalid",
+ },
+ now=now,
+ )
+ second = enrich_run_state(first, first, now=now + timedelta(seconds=5))
+
+ assert second["retry_attempt_count"] == 1
+ assert second["last_failure_at"] == first["last_failure_at"]
+ assert second["next_retry_at"] == first["next_retry_at"]
+ assert retry_is_due(second, now=now + timedelta(seconds=60)) is True
+
+
+def test_same_failure_after_a_real_retry_consumes_next_budget_slot():
+ now = datetime(2026, 8, 27, 0, 15, tzinfo=timezone.utc)
+ first = enrich_run_state(
+ {
+ "group_name": "群A",
+ "run_date": "2026-08-27",
+ "status": FAILED,
+ "failed_stage": "data",
+ "error_type": MESSAGE_FETCH_FAILED,
+ "error": "same timeout",
+ },
+ now=now,
+ )
+ retry_started = enrich_run_state(
+ {
+ **first,
+ "status": "PENDING",
+ "failure_fingerprint": "",
+ "retry_started_at": (now + timedelta(seconds=60)).isoformat(),
+ },
+ first,
+ now=now + timedelta(seconds=60),
+ )
+ second = enrich_run_state(
+ {
+ **retry_started,
+ "status": FAILED,
+ "failed_stage": "data",
+ "error_type": MESSAGE_FETCH_FAILED,
+ "error": "same timeout",
+ },
+ retry_started,
+ now=now + timedelta(seconds=61),
+ )
+
+ assert second["retry_attempt_count"] == 2
+ assert len(second["attempt_ledger"]) == 2
+
+
+def test_unknown_external_result_is_always_manual_hold():
+ run = enrich_run_state(
+ {
+ "group_name": "群A",
+ "run_date": "2026-08-27",
+ "status": FAILED,
+ "failed_stage": "image",
+ "error_type": "IMAGE_GENERATION_FAILED",
+ "error": "receipt missing",
+ "image_job": {"status": "result_unknown"},
+ }
+ )
+
+ assert run["execution_state"] == EXECUTION_HOLD_MANUAL
+ assert run["retryable"] is False
+ assert run["next_retry_at"] == ""
+
+
+def test_retry_budget_exhaustion_becomes_final():
+ previous = {
+ "retry_attempt_count": 2,
+ "retry_budget": 3,
+ "failure_fingerprint": "old",
+ }
+ run = enrich_run_state(
+ {
+ "group_name": "群A",
+ "run_date": "2026-08-27",
+ "status": FAILED,
+ "failed_stage": "data",
+ "error_type": MESSAGE_FETCH_FAILED,
+ "error": "third distinct failure",
+ "retry_budget": 3,
+ },
+ previous,
+ )
+
+ assert run["retry_attempt_count"] == 3
+ assert run["execution_state"] == EXECUTION_FAILED_FINAL
+
+
+def test_run_store_lazily_enriches_old_run_and_sent_is_complete(tmp_path):
+ store = RunStore(tmp_path)
+ failed = store.update(
+ "群A",
+ "2026-08-27",
+ status=FAILED,
+ failed_stage="data",
+ error_type=MESSAGE_FETCH_FAILED,
+ error="temporary timeout",
+ )
+ completed = store.update("群A", "2026-08-27", status=SENT)
+
+ assert failed["execution_state"] == EXECUTION_WAIT_RETRY
+ assert failed["last_successful_checkpoint"] == "TASK_CREATED"
+ assert completed["execution_state"] == EXECUTION_COMPLETE
+ assert completed["last_successful_checkpoint"] == "SENT_CONFIRMED"
+ assert completed["next_retry_at"] == ""
diff --git a/tests/test_reliability_watchdog.py b/tests/test_reliability_watchdog.py
new file mode 100644
index 0000000..5bda9ea
--- /dev/null
+++ b/tests/test_reliability_watchdog.py
@@ -0,0 +1,99 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+
+def test_recovery_dates_are_oldest_first_and_capped_at_two_days():
+ from app.scheduler.reliability_watchdog import recovery_dates
+
+ now = datetime(2026, 8, 27, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+
+ assert recovery_dates(now, 3) == ["2026-08-26", "2026-08-27"]
+ assert recovery_dates(now, 999) == ["2026-08-26", "2026-08-27"]
+
+
+def test_watchdog_backfills_in_date_order_and_scans_same_dates_for_send(tmp_path, monkeypatch):
+ from app.config.settings import Settings
+ from app.scheduler import reliability_watchdog as watchdog
+
+ settings = Settings(
+ _env_file=None,
+ reliability_watchdog_enabled=True,
+ reliability_lookback_days=3,
+ )
+ real_state_class = watchdog.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",
+ )
+ generated = []
+ sent = []
+
+ def fake_daily(run_date, *, settings, skip_email):
+ generated.append((run_date, skip_email))
+ return {"run_date": run_date, "status": "success"}
+
+ class FakePipeline:
+ def __init__(self, settings):
+ pass
+
+ def send_due_for_dates(self, run_dates, *, now, recovery):
+ sent.append((list(run_dates), recovery))
+ return []
+
+ monkeypatch.setattr(watchdog, "DailyScheduleState", TempState)
+ monkeypatch.setattr(watchdog, "run_daily_v2_job", fake_daily)
+ monkeypatch.setattr(watchdog, "DailyPipeline", FakePipeline)
+
+ now = datetime(2026, 8, 27, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+ result = watchdog.run_reliability_watchdog(settings=settings, now=now)
+
+ assert generated == [("2026-08-26", True), ("2026-08-27", True)]
+ assert sent == [(["2026-08-27"], False)]
+ assert result["status"] == "success"
+
+
+def test_watchdog_does_not_generate_today_before_schedule(tmp_path, monkeypatch):
+ from app.config.settings import Settings
+ from app.scheduler import reliability_watchdog as watchdog
+
+ settings = Settings(
+ _env_file=None,
+ reliability_watchdog_enabled=True,
+ reliability_lookback_days=1,
+ schedule_generate_time="08:30",
+ )
+ calls = []
+
+ class TempState(watchdog.DailyScheduleState):
+ def __init__(self, _output_root):
+ super().__init__(tmp_path)
+
+ class FakePipeline:
+ def __init__(self, settings):
+ pass
+
+ def send_due_for_dates(self, run_dates, *, now, recovery):
+ return []
+
+ monkeypatch.setattr(watchdog, "DailyScheduleState", TempState)
+ monkeypatch.setattr(watchdog, "DailyPipeline", FakePipeline)
+ monkeypatch.setattr(
+ watchdog,
+ "run_daily_v2_job",
+ lambda *args, **kwargs: calls.append((args, kwargs)),
+ )
+
+ watchdog.run_reliability_watchdog(
+ settings=settings,
+ now=datetime(2026, 8, 27, 8, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+ )
+
+ assert calls == []
diff --git a/tests/test_runtime_logs.py b/tests/test_runtime_logs.py
new file mode 100644
index 0000000..9410f15
--- /dev/null
+++ b/tests/test_runtime_logs.py
@@ -0,0 +1,88 @@
+from fastapi import HTTPException
+import pytest
+
+from app.api.v2_ui_read import runtime_logs
+from app.config.settings import Settings
+from app.services.runtime_logs import read_runtime_logs
+
+
+def test_runtime_logs_filters_sorts_redacts_and_truncates(tmp_path):
+ logs_dir = tmp_path / "logs"
+ logs_dir.mkdir()
+ (logs_dir / "scheduler.log").write_text(
+ "2026-08-27 00:15:03,001 [INFO] groupbrief.scheduler: 调度启动 token=secret-token\n"
+ "2026-08-27 00:15:05,001 [ERROR] groupbrief.scheduler: Prompt=完整敏感正文\n"
+ "Traceback: password=hunter2\n"
+ "2026-08-28 00:15:00,001 [INFO] groupbrief.scheduler: 另一天\n",
+ encoding="utf-8",
+ )
+ (logs_dir / "provider.log").write_text(
+ "2026-08-27 00:15:04,001 [WARNING] groupbrief.providers: 数据源响应较慢\n",
+ encoding="utf-8",
+ )
+
+ result = read_runtime_logs(logs_dir, "2026-08-27", tail=2)
+
+ assert [(item["source"], item["level"]) for item in result["items"]] == [
+ ("provider", "WARNING"),
+ ("scheduler", "ERROR"),
+ ]
+ assert result["truncated"] is True
+ serialized = str(result)
+ assert "secret-token" not in serialized
+ assert "完整敏感正文" not in serialized
+ assert "hunter2" not in serialized
+ assert "另一天" not in serialized
+
+
+def test_runtime_logs_honors_source_and_level_filters(tmp_path):
+ logs_dir = tmp_path / "logs"
+ logs_dir.mkdir()
+ (logs_dir / "ai.log").write_text(
+ "2026-08-27 00:15:03,001 [INFO] groupbrief.ai: 开始摘要\n"
+ "2026-08-27 00:15:04,001 [ERROR] groupbrief.ai: 摘要失败\n",
+ encoding="utf-8",
+ )
+
+ result = read_runtime_logs(
+ logs_dir,
+ "2026-08-27",
+ sources="ai",
+ levels="error",
+ )
+
+ assert len(result["items"]) == 1
+ assert result["items"][0]["message"] == "摘要失败"
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"tail": 0}, "tail"),
+ ({"tail": 201}, "tail"),
+ ({"sources": "../../.env"}, "不支持"),
+ ({"levels": "TRACE"}, "不支持"),
+ ],
+)
+def test_runtime_logs_rejects_out_of_contract_filters(tmp_path, kwargs, message):
+ with pytest.raises(ValueError, match=message):
+ read_runtime_logs(tmp_path, "2026-08-27", **kwargs)
+
+
+def test_runtime_logs_api_returns_422_for_invalid_filter(tmp_path):
+ settings = Settings(
+ _env_file=None,
+ logs_dir=tmp_path / "logs",
+ output_dir=tmp_path / "output",
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ runtime_logs(
+ run_date="2026-08-27",
+ tail=100,
+ sources="secrets",
+ levels=None,
+ settings=settings,
+ )
+
+ assert exc_info.value.status_code == 422
diff --git a/tests/test_runtime_status.py b/tests/test_runtime_status.py
new file mode 100644
index 0000000..3f844f4
--- /dev/null
+++ b/tests/test_runtime_status.py
@@ -0,0 +1,218 @@
+import json
+
+from app.scheduler.daily_v2_job import DailyScheduleState
+from app.scheduler.runtime_status import build_daily_status, write_daily_status
+from app.v2.constants import READY_TO_SEND, SENT
+from app.v2.run_store import RunStore
+
+
+def test_runtime_status_summarizes_groups_without_business_payload(tmp_path):
+ store = RunStore(tmp_path / "output")
+ DailyScheduleState(store.root).update(
+ "2026-08-27",
+ manifest_version=1,
+ manifest_created_at="2026-08-27T00:14:00+08:00",
+ expected_group_count=2,
+ expected_groups=[
+ {"group_id": 1, "expected_terminal": SENT},
+ {"group_id": 2, "expected_terminal": SENT},
+ ],
+ generation_started_at="2026-08-27T00:15:00+08:00",
+ generation_status="success",
+ generation_completed_at="2026-08-27T00:16:00+08:00",
+ )
+ store.save_run(
+ "群A",
+ "2026-08-27",
+ {
+ "group_id": "1",
+ "status": SENT,
+ "sent_at": "2026-08-27T08:45:00+08:00",
+ "prompt_meta": {"api_model": "model-a"},
+ "messages": "不得写入报告",
+ },
+ )
+ store.save_run("群B", "2026-08-27", {"group_id": "2", "status": READY_TO_SEND})
+
+ path = write_daily_status(store, "2026-08-27")
+ payload = json.loads(path.read_text(encoding="utf-8"))
+
+ assert path == tmp_path / "runtime" / "2026-08-27" / "status.json"
+ assert payload["run_id"].startswith("groupbrief:2026-08-27:")
+ assert payload["overall_status"] == "partial"
+ assert payload["summary"]["expected_group_count"] == 2
+ assert payload["summary"]["completed_group_count"] == 1
+ assert [item["group_task_id"] for item in payload["groups"]] == [
+ "groupbrief:2026-08-27:group-1",
+ "groupbrief:2026-08-27:group-2",
+ ]
+ assert payload["groups"][0]["send"]["status"] == "success"
+ serialized = json.dumps(payload, ensure_ascii=False)
+ assert "不得写入报告" not in serialized
+ assert not list(path.parent.glob("*.tmp"))
+
+
+def test_runtime_status_exposes_send_retry_and_final_hold(tmp_path):
+ store = RunStore(tmp_path / "output")
+ DailyScheduleState(store.root).update(
+ "2026-08-27",
+ manifest_version=1,
+ manifest_created_at="2026-08-27T00:14:00+08:00",
+ expected_group_count=2,
+ expected_groups=[
+ {"group_id": 3, "expected_terminal": SENT},
+ {"group_id": 4, "expected_terminal": SENT},
+ ],
+ )
+ store.save_run(
+ "重试群",
+ "2026-08-27",
+ {
+ "group_id": "3",
+ "status": READY_TO_SEND,
+ "send_state": "ready",
+ "send_retry_attempt_count": 1,
+ "send_retry_budget": 3,
+ "send_next_retry_at": "2026-08-27T08:46:00+08:00",
+ },
+ )
+ store.save_run(
+ "终止群",
+ "2026-08-27",
+ {
+ "group_id": "4",
+ "status": READY_TO_SEND,
+ "send_state": "failed_final",
+ "send_retry_attempt_count": 3,
+ "send_retry_budget": 3,
+ "send_hold": True,
+ "send_hold_reason": "SEND_RETRY_EXHAUSTED",
+ },
+ )
+
+ payload = json.loads(write_daily_status(store, "2026-08-27").read_text(encoding="utf-8"))
+ by_name = {row["group_name"]: row for row in payload["groups"]}
+
+ assert by_name["重试群"]["send"]["status"] == "retry_pending"
+ assert by_name["重试群"]["send"]["attempts"] == 1
+ assert by_name["终止群"]["send"]["status"] == "held"
+ assert by_name["终止群"]["send"]["hold_reason"] == "SEND_RETRY_EXHAUSTED"
+ assert payload["overall_status"] == "blocked"
+
+
+def test_runtime_status_never_completes_when_manifest_group_is_missing(tmp_path):
+ store = RunStore(tmp_path / "output")
+ DailyScheduleState(store.root).update(
+ "2026-08-27",
+ manifest_version=1,
+ manifest_created_at="2026-08-27T00:14:00+08:00",
+ expected_group_count=2,
+ expected_groups=[
+ {"group_id": 1, "expected_terminal": SENT},
+ {"group_id": 2, "expected_terminal": SENT},
+ ],
+ )
+ store.save_run(
+ "仅完成群",
+ "2026-08-27",
+ {"group_id": "1", "status": SENT, "sent_at": "2026-08-27T08:30:00+08:00"},
+ )
+
+ payload = json.loads(write_daily_status(store, "2026-08-27").read_text(encoding="utf-8"))
+
+ assert payload["overall_status"] == "needs_attention"
+ assert payload["summary"]["missing_expected_group_ids"] == ["2"]
+ assert payload["summary"]["manifest_complete"] is False
+
+
+def test_build_daily_status_projects_live_nodes_without_writing_runtime_file(tmp_path):
+ store = RunStore(tmp_path / "output")
+ DailyScheduleState(store.root).update(
+ "2026-08-27",
+ manifest_version=1,
+ manifest_created_at="2026-08-27T00:14:00+08:00",
+ expected_group_count=2,
+ expected_groups=[
+ {"group_id": 1, "expected_terminal": SENT},
+ {"group_id": 2, "expected_terminal": SENT},
+ ],
+ generation_started_at="2026-08-27T00:15:00+08:00",
+ generation_status="running",
+ )
+ store.save_run("群A", "2026-08-27", {"group_id": "1", "status": "DATA_READY"})
+ runs = [
+ store.load_run("群A", "2026-08-27"),
+ {"group_id": "2", "group_name": "群B", "run_date": "2026-08-27", "status": "PENDING"},
+ ]
+
+ payload = build_daily_status(store, "2026-08-27", runs=runs)
+ by_node = {item["id"]: item for item in payload["nodes"]}
+ by_group = {item["group_name"]: item for item in payload["groups"]}
+
+ assert payload["overall_status"] == "running"
+ assert payload["scheduler"]["scheduled_at"].startswith("2026-08-27T00:15:00")
+ assert payload["scheduler"]["send_scheduled_at"].startswith("2026-08-27T08:30:00")
+ assert payload["scheduler"]["next_generate_at"]
+ assert payload["scheduler"]["next_send_at"]
+ assert by_node["scheduler"]["status"] == "success"
+ assert by_node["ranking"]["status"] == "running"
+ assert by_group["群A"]["current_node"] == "ranking"
+ assert by_group["群A"]["node_status"] == "running"
+ assert by_group["群B"]["node_status"] == "pending"
+ assert not (tmp_path / "runtime" / "2026-08-27" / "status.json").exists()
+
+
+def test_build_daily_status_marks_unknown_send_and_corrupt_scheduler_fail_closed(tmp_path):
+ store = RunStore(tmp_path / "output")
+ scheduler_path = store.root / ".scheduler" / "2026-08-27.json"
+ scheduler_path.parent.mkdir(parents=True)
+ scheduler_path.write_text("not-json", encoding="utf-8")
+ store.save_run(
+ "暂停群",
+ "2026-08-27",
+ {
+ "group_id": "1",
+ "status": READY_TO_SEND,
+ "send_state": "unknown",
+ "send_hold": True,
+ "send_hold_reason": "SEND_RESULT_UNKNOWN",
+ },
+ )
+
+ payload = build_daily_status(store, "2026-08-27")
+
+ assert payload["overall_status"] == "needs_attention"
+ assert payload["scheduler"]["state_status"] == "corrupt"
+ assert payload["nodes"][0]["status"] == "held"
+ assert payload["groups"][0]["send"]["status"] == "held"
+ assert payload["groups"][0]["last_error_summary"] == "SEND_RESULT_UNKNOWN"
+
+
+def test_build_daily_status_keeps_queued_image_pending_instead_of_claiming_running(tmp_path):
+ store = RunStore(tmp_path / "output")
+ DailyScheduleState(store.root).update(
+ "2026-08-27",
+ manifest_version=1,
+ manifest_created_at="2026-08-27T00:14:00+08:00",
+ expected_group_count=1,
+ expected_groups=[{"group_id": 1, "expected_terminal": SENT}],
+ generation_started_at="2026-08-27T00:15:00+08:00",
+ generation_status="running",
+ )
+ store.save_run(
+ "排队群",
+ "2026-08-27",
+ {
+ "group_id": "1",
+ "status": "PROMPT_READY",
+ "image_job": {"status": "queued"},
+ },
+ )
+
+ payload = build_daily_status(store, "2026-08-27")
+ group = payload["groups"][0]
+
+ assert group["current_node"] == "image"
+ assert group["image"]["job_status"] == "queued"
+ assert group["image"]["status"] == "pending"
+ assert group["node_status"] == "pending"
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 9952884..3c1e498 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -1,11 +1,14 @@
"""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,
+ _schedule_startup_recovery,
get_scheduler,
start_scheduler,
stop_scheduler,
@@ -20,14 +23,19 @@ def test_scheduler_jobs_configured():
from app.config.settings import Settings
# 这里只验证 Cron 注册;禁止启动补偿在测试后台触发真实生成链路。
- settings = Settings(_env_file=None, schedule_startup_catchup_enabled=False)
+ settings = Settings(
+ _env_file=None,
+ reliability_watchdog_enabled=False,
+ )
start_scheduler(settings)
scheduler = get_scheduler()
assert scheduler is not None
jobs = scheduler.get_jobs()
ids = [j.id for j in jobs]
assert "daily_v2_generate_email" in ids
- assert "send_wechat_due" in ids
+ assert "daily_wechat_send_batch" in ids
+ assert "reliability_watchdog" not in ids
+ assert len(jobs) == 2
assert "generate_daily" not in ids
assert "send_daily_email" not in ids
for job in jobs:
@@ -36,13 +44,36 @@ def test_scheduler_jobs_configured():
fields = {field.name: str(field) for field in job.trigger.fields}
assert fields["hour"] == "0"
assert fields["minute"] == "15"
- if job.id == "send_wechat_due":
- assert job.name == "SendWechatDue"
+ if job.id == "daily_wechat_send_batch":
+ assert job.name == "DailyWechatSendBatch"
+ fields = {field.name: str(field) for field in job.trigger.fields}
+ assert fields["hour"] == "8"
+ assert fields["minute"] == "30"
+ assert job.trigger.__class__.__name__ == "CronTrigger"
assert start_scheduler(settings) is scheduler
stop_scheduler()
assert get_scheduler() is None
+def test_scheduler_registers_only_one_startup_recovery_job():
+ from app.config.settings import Settings
+
+ settings = Settings(
+ _env_file=None,
+ reliability_watchdog_enabled=True,
+ reliability_watchdog_interval_minutes=7,
+ )
+ start_scheduler(settings)
+ scheduler = get_scheduler()
+ assert scheduler is not None
+ jobs = {job.id: job for job in scheduler.get_jobs()}
+ assert "reliability_watchdog" not in jobs
+ assert "startup_recovery" in jobs
+ assert jobs["startup_recovery"].name == "StartupRecovery"
+ assert jobs["startup_recovery"].trigger.__class__.__name__ == "DateTrigger"
+ stop_scheduler()
+
+
def test_generate_job_runs_every_day():
"""兼容自动任务每天都执行(使用 mock 数据)。"""
from sqlmodel import Session, select
@@ -59,6 +90,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
@@ -97,11 +140,78 @@ def generate_all(self, run_date, acquire_lock=True):
assert state["email_completed_at"]
+def test_completed_legacy_day_lazily_adds_manifest_without_regeneration(
+ tmp_path,
+ monkeypatch,
+):
+ from app.config.settings import Settings
+ from app.db.models import Group
+ from app.scheduler import daily_v2_job as daily
+ from app.scheduler.period import PeriodResolver
+
+ 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)
+ 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=[{"group_name": "测试群", "status": "ready_to_send"}],
+ email_started_at="2026-08-25T00:21:00+08:00",
+ email_completed_at="2026-08-25T00:21:01+08:00",
+ email_status="skipped_disabled",
+ )
+
+ class CompletedPipeline:
+ def __init__(self, settings):
+ self.period_resolver = PeriodResolver()
+
+ def _load_groups(self):
+ return [
+ Group(
+ id=7,
+ display_name="测试群",
+ wechat_group_id="test@chatroom",
+ wechat_group_name="测试群",
+ image_enabled=False,
+ wechat_send_enabled=False,
+ )
+ ]
+
+ def generate_all(self, **_kwargs):
+ pytest.fail("已完成旧任务只能补清单,不得重新生成")
+
+ monkeypatch.setattr(daily, "DailyScheduleState", TempState)
+ monkeypatch.setattr(daily, "DailyPipeline", CompletedPipeline)
+ monkeypatch.setattr(daily.repo, "init_db", lambda settings: None)
+ monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: [])
+
+ result = daily.run_daily_v2_job("2026-08-25", settings=settings)
+ saved = state.load("2026-08-25")
+
+ assert result["status"] == "already_completed"
+ assert saved["manifest_source"] == "legacy_current_config_compat"
+ assert saved["expected_group_count"] == 1
+ assert saved["expected_groups"][0]["group_id"] == 7
+
+
def test_daily_v2_job_resumes_interrupted_generation_without_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):
@@ -109,7 +219,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,40 +283,638 @@ def generate_all(self, run_date, acquire_lock=True):
assert state["generation_hold"] is True
-def test_startup_catchup_is_added_only_when_today_is_incomplete(monkeypatch):
+def test_retryable_generation_result_does_not_seal_batch(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)
+
+ calls = []
+
+ class RecoveringPipeline:
+ def __init__(self, settings):
+ pass
+
+ def generate_all(self, run_date, acquire_lock=True):
+ calls.append(run_date)
+ if len(calls) == 1:
+ return [
+ {
+ "group_name": "群A",
+ "status": "failed",
+ "error_type": "MESSAGE_FETCH_FAILED",
+ }
+ ]
+ return [{"group_name": "群A", "status": "ready_to_send"}]
+
+ monkeypatch.setattr(daily, "DailyScheduleState", TempState)
+ monkeypatch.setattr(daily, "DailyPipeline", RecoveringPipeline)
+ monkeypatch.setattr(daily.repo, "init_db", lambda settings: None)
+ monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: [])
+
+ first = daily.run_daily_v2_job("2026-08-27", settings=settings, skip_email=True)
+ after_first = TempState(tmp_path).load("2026-08-27")
+ second = daily.run_daily_v2_job("2026-08-27", settings=settings, skip_email=True)
+ after_second = TempState(tmp_path).load("2026-08-27")
+
+ assert first["status"] == "failed"
+ assert after_first["generation_invocation_completed_at"]
+ assert "generation_completed_at" not in after_first
+ assert after_first["generation_hold"] is True
+ assert second["status"] == "success"
+ assert after_second["generation_completed_at"]
+ assert calls == ["2026-08-27", "2026-08-27"]
+
+
+def test_daily_v2_job_reports_busy_as_not_executed(tmp_path, 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
+
+ real_state_class = daily.DailyScheduleState
+
+ class TempState(real_state_class):
+ def __init__(self, _output_root):
+ super().__init__(tmp_path)
+
+ monkeypatch.setattr(daily, "_daily_mutex", lambda: BusyContext())
+ monkeypatch.setattr(daily, "DailyScheduleState", TempState)
+
+ 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
+ state = TempState(tmp_path).load("2026-08-25")
+ assert state["owner_busy_count"] == 1
+ assert state["owner_busy_at"]
+ assert state["next_retry_at"]
+ assert state["last_invocation_status"] == "already_running"
+
+
+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_recipient="to@example.com",
+ email_from="from@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_completed_partial_run_reconciles_thread_image_and_emails_only_recovered_group(
+ tmp_path, monkeypatch
+):
+ from app.config.settings import Settings
+ from app.scheduler import daily_v2_job as daily
+ from app.v2.constants import IMAGE_GENERATION_FAILED
+
+ 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):
+ def __init__(self, _output_root):
+ super().__init__(tmp_path)
+
+ run_date = "2026-08-25"
+ state = TempState(tmp_path)
+ state.update(
+ run_date,
+ generation_started_at="2026-08-25T00:15:00+08:00",
+ generation_completed_at="2026-08-25T00:20:00+08:00",
+ generation_status="partial",
+ generation_results=[
+ {"group_name": "群A", "status": "ready_to_send"},
+ {
+ "group_name": "群B",
+ "status": "failed",
+ "error_type": IMAGE_GENERATION_FAILED,
+ "detail": "原始失败必须保留",
+ },
+ ],
+ email_started_at="2026-08-25T00:21:00+08:00",
+ email_completed_at="2026-08-25T00:22:00+08:00",
+ email_status="sent",
+ email_detail="群A 已发送,群B 当时没有图片且不在发送集合",
+ )
+ prompt_path = tmp_path / "群B" / run_date / "image_prompt.txt"
+ prompt_path.parent.mkdir(parents=True)
+ prompt_path.write_text("test prompt", encoding="utf-8")
+ generation_calls = []
+ commands = []
+
+ class FakeGenerator:
+ def can_reconcile_without_generation(self, candidate_prompt, job_id):
+ assert candidate_prompt == prompt_path
+ assert job_id == "recovery_job_123"
+ return True
+
+ class FakeStore:
+ def load_run(self, group_name, candidate_date):
+ assert (group_name, candidate_date) == ("群B", run_date)
+ return {"image_job": {"job_id": "recovery_job_123"}}
+
+ def prompt_path(self, group_name, candidate_date):
+ assert (group_name, candidate_date) == ("群B", run_date)
+ return prompt_path
+
+ class RecoveryPipeline:
+ def __init__(self, settings):
+ self.image_generator = FakeGenerator()
+ self.store = FakeStore()
+
+ def _load_groups(self):
+ return [SimpleNamespace(id=2, display_name="群B", wechat_group_name="")]
+
+ def generate_all(self, run_date, group_ids=None, force=False, acquire_lock=True):
+ generation_calls.append((run_date, group_ids, force, acquire_lock))
+ return [
+ {
+ "group_name": "群B",
+ "status": "ready_to_send",
+ "receipt_source": "codex_thread_scan",
+ "recovery_status": "recovered_from_result_unknown",
+ "codex_thread_id": "thread-12345678",
+ }
+ ]
+
+ def fake_subprocess_run(command, **_kwargs):
+ commands.append(command)
+ return SimpleNamespace(returncode=0, stdout="sent", stderr="")
+
+ monkeypatch.setattr(daily, "DailyScheduleState", TempState)
+ monkeypatch.setattr(daily, "DailyPipeline", RecoveryPipeline)
+ monkeypatch.setattr(daily.repo, "init_db", lambda settings: None)
+ monkeypatch.setattr(daily.repo, "apply_db_settings", lambda settings: [])
+ monkeypatch.setattr(daily.subprocess, "run", fake_subprocess_run)
+
+ result = daily.run_daily_v2_job(run_date, settings=settings)
+ saved = state.load(run_date)
+
+ assert result["status"] == "success"
+ assert result["email_status"] == "sent"
+ assert generation_calls == [(run_date, [2], False, False)]
+ assert commands and commands[0][-2:] == ["--group", "群B"]
+ assert commands[0].count("--group") == 1
+ assert saved["generation_original_status"] == "partial"
+ assert saved["generation_history"][-1]["results"][1]["detail"] == "原始失败必须保留"
+ assert saved["generation_results"][1]["receipt_source"] == "codex_thread_scan"
+ assert saved["email_history"][-1]["status"] == "sent"
+ assert saved["email_recovery_required"] is False
+ assert saved["email_recovered_at"]
+
+
+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",
+ email_recipient="to@example.com",
+ 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 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
+
+ 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, schedule_startup_catchup_enabled=True)
+ 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",
+ email_recipient="to@example.com",
+ email_from="from@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_recovery_is_single_date_trigger():
+ from app.config.settings import Settings
+
+ settings = Settings(_env_file=None, reliability_watchdog_enabled=True)
captured = []
class FakeScheduler:
def add_job(self, *args, **kwargs):
captured.append((args, kwargs))
- class IncompleteState:
- def __init__(self, output_root):
- pass
-
- def load(self, run_date):
- return {"run_date": run_date}
-
- monkeypatch.setattr(manager, "DailyScheduleState", IncompleteState)
before = datetime(2026, 8, 21, 0, 14, 59, tzinfo=ZoneInfo("Asia/Shanghai"))
- assert _schedule_startup_catchup(FakeScheduler(), settings, now=before) is False
- assert captured == []
+ added = _schedule_startup_recovery(FakeScheduler(), settings, now=before)
- now = datetime(2026, 8, 21, 0, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
+ assert added is True
+ assert captured[0][1]["id"] == "startup_recovery"
+ assert captured[0][1]["name"] == "StartupRecovery"
+ assert captured[0][1]["trigger"].__class__.__name__ == "DateTrigger"
- added = _schedule_startup_catchup(FakeScheduler(), settings, now=now)
+ disabled = Settings(_env_file=None, reliability_watchdog_enabled=False)
+ assert _schedule_startup_recovery(FakeScheduler(), disabled, now=before) is False
+ assert len(captured) == 1
- assert added is True
- assert captured[0][1]["id"] == "daily_v2_startup_catchup"
- assert captured[0][1]["kwargs"] == {"skip_email": True}
- class CompletedState(IncompleteState):
- def load(self, run_date):
- return {"run_date": run_date, "generation_completed_at": "done"}
+def test_on_demand_ready_send_is_deduplicated_and_unknown_is_not_rescheduled(tmp_path):
+ from app.config.settings import Settings
+ from app.scheduler.manager import _schedule_on_demand_jobs
+ from app.v2.constants import READY_TO_SEND
+ from app.v2.run_store import RunStore
+
+ settings = Settings(_env_file=None, output_root_override=str(tmp_path / "output"))
+ store = RunStore(settings.output_dir)
+ run_date = "2026-08-29"
+ now = datetime(2026, 8, 29, 8, 40, tzinfo=ZoneInfo("Asia/Shanghai"))
+ store.save_run(
+ "群一",
+ run_date,
+ {
+ "group_id": "1",
+ "status": READY_TO_SEND,
+ "wechat_send_enabled": True,
+ "send_hold": False,
+ },
+ )
- monkeypatch.setattr(manager, "DailyScheduleState", CompletedState)
- assert _schedule_startup_catchup(FakeScheduler(), settings, now=now) is False
+ class FakeScheduler:
+ def __init__(self):
+ self.jobs = {}
+
+ def add_job(self, func, **kwargs):
+ self.jobs[kwargs["id"]] = (func, kwargs)
+
+ scheduler = FakeScheduler()
+ first = _schedule_on_demand_jobs(
+ scheduler,
+ settings,
+ now=now,
+ run_dates=[run_date],
+ )
+ second = _schedule_on_demand_jobs(
+ scheduler,
+ settings,
+ now=now,
+ run_dates=[run_date],
+ )
+
+ assert first == ["daily_send_once_20260829"]
+ assert second == first
+ assert list(scheduler.jobs) == ["daily_send_once_20260829"]
+ assert scheduler.jobs[first[0]][1]["trigger"].__class__.__name__ == "DateTrigger"
+
+ store.update(
+ "群一",
+ run_date,
+ send_hold=True,
+ send_state="unknown",
+ send_hold_reason="SEND_RESULT_UNKNOWN",
+ )
+ held_scheduler = FakeScheduler()
+ assert _schedule_on_demand_jobs(
+ held_scheduler,
+ settings,
+ now=now,
+ run_dates=[run_date],
+ ) == []
+ assert held_scheduler.jobs == {}
+
+
+def test_on_demand_rebuilds_only_persisted_generation_and_send_retries(tmp_path):
+ from app.config.settings import Settings
+ from app.scheduler.daily_v2_job import DailyScheduleState
+ from app.scheduler.manager import _schedule_on_demand_jobs
+ from app.v2.constants import READY_TO_SEND
+ from app.v2.run_store import RunStore
+
+ settings = Settings(_env_file=None, output_root_override=str(tmp_path / "output"))
+ state = DailyScheduleState(settings.output_dir)
+ store = RunStore(settings.output_dir)
+ now = datetime(2026, 8, 29, 8, 40, tzinfo=ZoneInfo("Asia/Shanghai"))
+ state.update(
+ "2026-08-28",
+ generation_started_at="2026-08-28T00:15:00+08:00",
+ generation_status="retry_pending",
+ next_retry_at="2026-08-29T08:45:00+08:00",
+ )
+ store.save_run(
+ "群二",
+ "2026-08-29",
+ {
+ "group_id": "2",
+ "status": READY_TO_SEND,
+ "wechat_send_enabled": True,
+ "send_next_retry_at": "2026-08-29T08:46:00+08:00",
+ },
+ )
+
+ class FakeScheduler:
+ def __init__(self):
+ self.jobs = {}
+
+ def add_job(self, func, **kwargs):
+ self.jobs[kwargs["id"]] = (func, kwargs)
+
+ scheduler = FakeScheduler()
+ scheduled = _schedule_on_demand_jobs(
+ scheduler,
+ settings,
+ now=now,
+ run_dates=["2026-08-28", "2026-08-29"],
+ include_newly_ready_send=False,
+ )
+
+ assert scheduled == ["daily_v2_retry_20260828", "daily_send_once_20260829"]
+ generation_at = scheduler.jobs[scheduled[0]][1]["trigger"].run_date
+ send_at = scheduler.jobs[scheduled[1]][1]["trigger"].run_date
+ assert generation_at.isoformat() == "2026-08-29T08:45:00+08:00"
+ assert send_at.isoformat() == "2026-08-29T08:46:00+08:00"
diff --git a/tests/test_scheduler_outcome.py b/tests/test_scheduler_outcome.py
new file mode 100644
index 0000000..9ec1df7
--- /dev/null
+++ b/tests/test_scheduler_outcome.py
@@ -0,0 +1,142 @@
+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
+ assert result["next_generate_at"]
+ assert result["next_send_at"]
+
+
+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
diff --git a/tests/test_send_daily_email.py b/tests/test_send_daily_email.py
index 131e76c..1117c4a 100644
--- a/tests/test_send_daily_email.py
+++ b/tests/test_send_daily_email.py
@@ -1,11 +1,20 @@
"""V2 逐群邮件测试:所有 SMTP 均使用内存 fake,不触发真实网络。"""
import json
+from io import BytesIO
from types import SimpleNamespace
+from PIL import Image
+
from scripts import send_daily_email as mail_script
+def _png_bytes() -> bytes:
+ buffer = BytesIO()
+ Image.new("RGBA", (2, 2), (20, 40, 60, 255)).save(buffer, format="PNG")
+ return buffer.getvalue()
+
+
def _settings(output_dir, *, enabled=True):
return SimpleNamespace(
output_dir=output_dir,
@@ -67,7 +76,7 @@ def test_collect_group_inputs_skips_empty_and_invalid_image(tmp_path):
def test_build_message_uses_raw_ranking_and_one_image_attachment(tmp_path):
run_date = "2026-08-21"
- image = b"\x89PNG\r\n\x1a\nminimal-png"
+ image = _png_bytes()
group_dir = _write_group(
tmp_path,
"示例群 A",
@@ -97,7 +106,7 @@ def test_build_message_uses_raw_ranking_and_one_image_attachment(tmp_path):
def test_collect_group_inputs_prefers_current_image_setting_over_stale_run_snapshot(tmp_path):
run_date = "2026-08-21"
- image = b"\x89PNG\r\n\x1a\nminimal-png"
+ image = _png_bytes()
_write_group(
tmp_path,
"后来开启图片的群",
@@ -163,7 +172,7 @@ def quit(self):
rc = mail_script.main()
- assert rc == 1
+ assert rc == 3
sent_subjects = [
str(message["Subject"])
for instance in FakeSMTP.instances
@@ -177,10 +186,20 @@ def quit(self):
if "失败群" in str(message["Subject"])
]
assert len(sent_subjects) == 1
- assert len(failed_attempts) == 2
+ assert len(failed_attempts) == 1
+
+
+def test_delivery_exit_code_treats_skipped_group_as_partial():
+ assert mail_script._delivery_exit_code(
+ sent_count=1,
+ already_sent_count=0,
+ failed_count=0,
+ unknown_count=0,
+ skipped_count=1,
+ ) == 2
-def test_send_quit_failure_does_not_retry(monkeypatch):
+def test_send_quit_failure_does_not_retry(tmp_path, monkeypatch):
settings = _settings(None)
calls = {"connect": 0, "send": 0, "sleep": 0}
@@ -202,10 +221,14 @@ def quit(self):
message = mail_script.EmailMessage()
message["Subject"] = "测试群"
- ok, detail = mail_script._send_with_retry(message, settings)
+ result = mail_script._send_with_retry(
+ message,
+ settings,
+ ledger=mail_script.EmailDeliveryLedger(tmp_path / "ledger"),
+ )
- assert ok
- assert detail == ""
+ assert result.success
+ assert result.detail == ""
assert calls == {"connect": 1, "send": 1, "sleep": 0}
@@ -282,3 +305,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_sender_name_policy.py b/tests/test_sender_name_policy.py
new file mode 100644
index 0000000..80f8015
--- /dev/null
+++ b/tests/test_sender_name_policy.py
@@ -0,0 +1,147 @@
+"""群级 WeChatDataAnalysis 展示名策略。"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from app.data_sources.wechat_data_analysis import _to_v2_message
+from app.providers.history.wechat_data_analysis import _mcp_to_raw, _to_raw
+from app.services.sender_name_policy import apply_sender_name_policy
+
+
+NOW = datetime(2026, 8, 28, 8, 0, 0)
+
+
+def test_mcp_and_export_keep_same_sender_display_name():
+ export = _to_raw(
+ {
+ "group_id": "group@chatroom",
+ "group_name": "测试群",
+ "sender_id": "wxid-a",
+ "senderDisplayName": " 深\t圳-UI-白白\u200b ",
+ "content": "早",
+ },
+ NOW,
+ )
+ mcp = _mcp_to_raw(
+ {
+ "id": "mcp-1",
+ "senderUsername": "wxid-a",
+ "senderDisplayName": " 深\t圳-UI-白白\u200b ",
+ "renderType": "text",
+ "content": "早",
+ },
+ "group@chatroom",
+ NOW,
+ )
+ messages = [_to_v2_message(export), _to_v2_message(mcp)]
+
+ apply_sender_name_policy(messages, "wechat_data_analysis")
+
+ assert [message.sender_name for message in messages] == [
+ "深 圳-UI-白白",
+ "深 圳-UI-白白",
+ ]
+ assert {message.sender_name_source for message in messages} == {
+ "wechat_data_analysis"
+ }
+
+
+def test_contact_resolution_is_not_overwritten_by_upstream_name():
+ older = _to_v2_message(
+ _mcp_to_raw(
+ {
+ "id": "m1",
+ "senderUsername": "wxid-a",
+ "senderDisplayName": "旧昵称",
+ "renderType": "text",
+ },
+ "group@chatroom",
+ NOW,
+ )
+ )
+ newer = _to_v2_message(
+ _mcp_to_raw(
+ {
+ "id": "m2",
+ "senderUsername": "wxid-a",
+ "senderDisplayName": "最新昵称",
+ "renderType": "image",
+ },
+ "group@chatroom",
+ NOW + timedelta(minutes=1),
+ )
+ )
+ older.sender_name = "联系人备注"
+ newer.sender_name = "联系人备注"
+ older.sender_name_source = newer.sender_name_source = "contact"
+
+ apply_sender_name_policy([older, newer], "wechat_data_analysis")
+
+ assert older.sender_name == newer.sender_name == "联系人备注"
+ assert older.sender_name_source == newer.sender_name_source == "contact"
+
+
+def test_missing_names_are_stable_and_same_names_are_disambiguated():
+ raw_messages = [
+ _mcp_to_raw(
+ {
+ "id": "m1",
+ "senderUsername": "wxid-a",
+ "senderDisplayName": "同名",
+ "renderType": "text",
+ },
+ "group@chatroom",
+ NOW,
+ ),
+ _mcp_to_raw(
+ {
+ "id": "m2",
+ "senderUsername": "wxid-b",
+ "senderDisplayName": "同名",
+ "renderType": "text",
+ },
+ "group@chatroom",
+ NOW,
+ ),
+ _mcp_to_raw(
+ {
+ "id": "m3",
+ "senderUsername": "wxid-missing",
+ "senderDisplayName": "",
+ "renderType": "text",
+ },
+ "group@chatroom",
+ NOW,
+ ),
+ ]
+ messages = [_to_v2_message(message) for message in raw_messages]
+
+ apply_sender_name_policy(messages, "wechat_data_analysis")
+
+ assert messages[0].sender_name == "同名(同名 1)"
+ assert messages[1].sender_name == "同名(同名 2)"
+ assert messages[2].sender_name.startswith("未命名成员-")
+ assert messages[2].sender_name_source == "anonymous"
+
+
+def test_trusted_contact_name_matching_sender_id_casefold_is_preserved():
+ message = _to_v2_message(
+ _mcp_to_raw(
+ {
+ "id": "m-contact",
+ "senderUsername": "exalex",
+ "senderDisplayName": "exalex",
+ "renderType": "text",
+ },
+ "group@chatroom",
+ NOW,
+ )
+ )
+ message.sender_name = "EXALEX"
+ message.sender_name_source = "contact"
+
+ apply_sender_name_policy([message], "wechat_data_analysis")
+
+ assert message.sender_name == "EXALEX"
+ assert message.sender_name_source == "contact"
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_speaker_attribution.py b/tests/test_speaker_attribution.py
new file mode 100644
index 0000000..b70871b
--- /dev/null
+++ b/tests/test_speaker_attribution.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from app.ai.speaker_attribution import build_attribution_contract
+from app.data_sources.base import V2Message
+
+
+def _message(
+ message_id: str,
+ sender_id: str,
+ sender_name: str,
+ upstream_sender_name: str,
+ *,
+ content: str = "测试消息",
+) -> V2Message:
+ return V2Message(
+ message_id=message_id,
+ group_id="group@chatroom",
+ group_name="测试群",
+ sender_id=sender_id,
+ sender_name=sender_name,
+ upstream_sender_name=upstream_sender_name,
+ sender_name_source="wechat_data_analysis",
+ timestamp=datetime(2026, 8, 29, 9, 0),
+ content=content,
+ )
+
+
+def test_unique_chat_time_name_wins_over_current_resolved_name():
+ contract = build_attribution_contract(
+ [_message("m1", "wxid-a", "狗莓是a仔", "杭州-UI-呱呱")]
+ )
+
+ assert contract.names[0].display_name == "杭州-UI-呱呱"
+ assert contract.names[0].source == "upstream_sender_name"
+
+
+def test_shared_bad_upstream_name_falls_back_to_resolved_names():
+ contract = build_attribution_contract(
+ [
+ _message("m1", "wxid-a", "Alice", "c2341298"),
+ _message("m2", "wxid-b", "Bob", "c2341298"),
+ ]
+ )
+
+ assert [item.display_name for item in contract.names] == ["Alice", "Bob"]
+
+
+def test_same_sender_keeps_each_messages_chat_time_name():
+ contract = build_attribution_contract(
+ [
+ _message("m1", "wxid-a", "当前名", "当天早些时候"),
+ _message("m2", "wxid-a", "当前名", "当天后来改名"),
+ ]
+ )
+
+ assert [item.display_name for item in contract.names] == [
+ "当天早些时候",
+ "当天后来改名",
+ ]
+
+
+def test_same_sender_id_is_case_insensitive_for_upstream_collision_check():
+ contract = build_attribution_contract(
+ [
+ _message("m1", "WXID-A", "当前名", "聊天名"),
+ _message("m2", "wxid-a", "当前名", "聊天名"),
+ ]
+ )
+
+ assert [item.display_name for item in contract.names] == ["聊天名", "聊天名"]
+
+
+def test_snapshot_and_speaker_hashes_track_different_changes():
+ original = [_message("m1", "wxid-a", "当前名", "聊天名", content="原文")]
+ content_changed = [
+ _message("m1", "wxid-a", "当前名", "聊天名", content="修改后的原文")
+ ]
+ name_changed = [
+ _message("m1", "wxid-a", "当前名", "新的聊天名", content="原文")
+ ]
+
+ first = build_attribution_contract(original)
+ second = build_attribution_contract(content_changed)
+ third = build_attribution_contract(name_changed)
+
+ assert first.message_snapshot_sha256 != second.message_snapshot_sha256
+ assert first.speaker_fingerprint == second.speaker_fingerprint
+ assert first.message_snapshot_sha256 != third.message_snapshot_sha256
+ assert first.speaker_fingerprint != third.speaker_fingerprint
diff --git a/tests/test_topic_selection.py b/tests/test_topic_selection.py
index 03b28d8..abd4e59 100644
--- a/tests/test_topic_selection.py
+++ b/tests/test_topic_selection.py
@@ -70,9 +70,9 @@ def test_score_weights_total_100_and_log_normalization():
@pytest.mark.parametrize("candidate_count", [5, 6, 7])
def test_selection_keeps_five_to_seven_high_quality_topics(candidate_count: int):
- messages = _messages(candidate_count)
+ messages = _messages(candidate_count * 2)
candidates = [
- _candidate(index, [f"m{index - 1}"], 36 - index, 18, 18)
+ _candidate(index, [f"m{(index - 1) * 2}", f"m{(index - 1) * 2 + 1}"], 36 - index, 18, 18)
for index in range(1, candidate_count + 1)
]
selection = score_and_select_topics(candidates, messages)
@@ -84,9 +84,12 @@ def test_selection_keeps_five_to_seven_high_quality_topics(candidate_count: int)
def test_selection_stops_at_seven_topics():
- messages = _messages(10)
+ messages = _messages(20)
selection = score_and_select_topics(
- [_candidate(index, [f"m{index - 1}"], 38, 18, 18) for index in range(1, 11)],
+ [
+ _candidate(index, [f"m{(index - 1) * 2}", f"m{(index - 1) * 2 + 1}"], 38, 18, 18)
+ for index in range(1, 11)
+ ],
messages,
)
assert selection["candidate_count"] == 10
@@ -105,7 +108,7 @@ def test_two_or_three_real_candidates_are_all_selected_without_padding():
def test_high_volume_chat_does_not_reduce_topic_density():
messages = _messages(205)
candidates = [
- _candidate(index, [f"m{index - 1}"], comedy=36 - index, visual=18, recognition=18)
+ _candidate(index, [f"m{(index - 1) * 2}", f"m{(index - 1) * 2 + 1}"], comedy=36 - index, visual=18, recognition=18)
for index in range(1, 8)
]
selection = score_and_select_topics(candidates, messages)
@@ -149,7 +152,7 @@ def test_selected_topics_json_contains_only_selected_candidates():
assert '"evidence_dialogue"' in payload
-def test_fabricated_quote_is_replaced_with_exact_evidence_text():
+def test_model_supplied_quotes_are_ignored_and_rebuilt_from_message_ids():
candidates = [_candidate(1, ["m0"]), _candidate(2, ["m1"])]
candidates[0]["quotes"] = ["这句根本没有出现在聊天里"]
candidates[1]["quotes"] = ["消息,1!"]
@@ -158,12 +161,31 @@ def test_fabricated_quote_is_replaced_with_exact_evidence_text():
assert by_id["topic-01"]["quotes"] == ["消息0"]
assert by_id["topic-01"]["evidence_dialogue"] == [
- {"message_id": "m0", "speaker": "成员0", "text": "消息0"}
+ {
+ "message_id": "m0",
+ "sender_id": "wxid-0",
+ "speaker": "成员0",
+ "text": "消息0",
+ "original_text": "消息0",
+ }
]
- assert by_id["topic-02"]["quotes"] == ["消息,1!"]
+ assert by_id["topic-02"]["quotes"] == ["消息1"]
assert by_id["topic-02"]["people"] == ["成员1"]
+def test_candidate_prompt_requires_message_ids_not_model_supplied_names_or_quotes():
+ from app.ai.topic_selection import TOPIC_CANDIDATE_SYSTEM, build_direct_candidate_prompt
+ from app.ai.conversation_segments import ConversationChunk
+
+ prompt = build_direct_candidate_prompt(
+ ConversationChunk("聊天内容", ("m1",), "开始", "结束", 4)
+ )
+
+ assert "不得输出 people、quotes 或任何人物姓名" in TOPIC_CANDIDATE_SYSTEM
+ assert '"people"' not in prompt
+ assert '"quotes"' not in prompt
+
+
def test_visible_participants_are_derived_from_evidence_and_names_are_not_truncated():
start = datetime(2026, 8, 21, 9, 0)
messages = [
@@ -180,7 +202,7 @@ def test_visible_participants_are_derived_from_evidence_and_names_are_not_trunca
first = selection["candidates"][0]
assert first["visible_participants"][0] == "很长但必须完整保留的群友姓名"
assert "很长但必须完整保留的群友姓名" in first["participant_label"]
- assert first["participant_label"].endswith("等 4 人")
+ assert first["participant_label"] == "很长但必须完整保留的群友姓名、李四、王五、赵六"
assert set(first["visible_participants"]).issubset(set(first["participants"]))
@@ -200,8 +222,8 @@ def test_visible_participants_skip_an_over_budget_name_and_keep_scanning():
)
first = selection["candidates"][0]
- assert first["visible_participants"] == ["c2341298", "Max"]
- assert first["participant_label"] == "c2341298、Max等 3 人"
+ assert first["visible_participants"] == ["c2341298", "这是一个特别特别特别长的完整群友姓名", "Max"]
+ assert first["participant_label"] == "c2341298、这是一个特别特别特别长的完整群友姓名、Max"
def test_same_display_name_with_different_ids_keeps_all_participant_fields_consistent():
@@ -225,6 +247,25 @@ def test_same_display_name_with_different_ids_keeps_all_participant_fields_consi
assert "等 2 人" not in first["participant_label"]
+def test_sender_id_case_variants_are_one_identity_in_topic_stats():
+ start = datetime(2026, 8, 21, 9, 0)
+ messages = [
+ PromptMessage("m0", start, "同一人", "第一条", "WXID-A"),
+ PromptMessage("m1", start + timedelta(minutes=1), "同一人", "第二条", "wxid-a"),
+ PromptMessage("m2", start + timedelta(minutes=2), "另一人", "第三条", "wxid-b"),
+ ]
+ selection = score_and_select_topics(
+ [_candidate(1, ["m0", "m1"]), _candidate(2, ["m2"])],
+ messages,
+ )
+ first = next(
+ item for item in selection["candidates"] if item["topic_id"] == "topic-01"
+ )
+
+ assert first["participant_count"] == 1
+ assert first["participants"] == ["同一人"]
+
+
def test_unresolved_participant_uses_explicit_fallback_instead_of_fake_name():
messages = [
PromptMessage("m0", datetime(2026, 8, 21, 9, 0), "(未知)", "发言", "wxid-a"),
@@ -238,3 +279,20 @@ def test_unresolved_participant_uses_explicit_fallback_instead_of_fake_name():
item["participant_label"] == "群友(昵称未识别)"
for item in selection["candidates"]
)
+
+
+def test_duplicate_message_id_is_rejected_instead_of_overwriting_evidence():
+ messages = _messages(2)
+ messages[1] = PromptMessage(
+ "m0",
+ messages[1].timestamp,
+ "另一成员",
+ "另一条消息",
+ "wxid-other",
+ )
+
+ with pytest.raises(TopicSelectionError, match="重复 message_id"):
+ score_and_select_topics(
+ [_candidate(1, ["m0"]), _candidate(2, ["m0"])],
+ messages,
+ )
diff --git a/tests/test_ui_api.py b/tests/test_ui_api.py
index b353eef..a5d4f2a 100644
--- a/tests/test_ui_api.py
+++ b/tests/test_ui_api.py
@@ -4,11 +4,11 @@
import pytest
from fastapi.testclient import TestClient
-from sqlmodel import Session
+from sqlmodel import Session, select
from app.main import app
from app.db import repository as repo
-from app.db.models import Group
+from app.db.models import Group, ProviderHealth
client = TestClient(app)
@@ -40,13 +40,24 @@ def test_run_detail_endpoint():
assert "group_runs" in data
-def test_providers_writes_health_db():
+def test_provider_health_refresh_is_explicit_and_get_is_passive():
with client:
- resp = client.get("/api/system/providers")
- assert resp.status_code == 200
- data = resp.json()
+ with Session(repo.engine) as session:
+ before_count = len(session.exec(select(ProviderHealth)).all())
+ before = client.get("/api/system/providers")
+ assert before.status_code == 200
+ with Session(repo.engine) as session:
+ assert len(session.exec(select(ProviderHealth)).all()) == before_count
+
+ refreshed = client.post("/api/system/providers/refresh")
+ assert refreshed.status_code == 200
+ data = refreshed.json()
assert "wechat_data_analysis" in data
- assert "mock" in data
+ assert "mock" not in data
+
+ cached = client.get("/api/system/providers")
+ assert cached.status_code == 200
+ assert cached.json()["wechat_data_analysis"]["checked_at"]
def test_resolve_api():
@@ -56,6 +67,25 @@ 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[:3]] == ["ai_free", "random_preset", "custom"]
+ assert len(themes) == 25
+ assert sum(item["kind"] == "preset" for item in themes) == 22
+ assert themes[3]["key"] == "silkscreen_editorial"
+ assert themes[-1]["key"] == "mineral_pigment"
+ assert all(
+ set(item)
+ == {"key", "label", "description", "kind", "category", "swatches", "variation_count", "preview_url"}
+ for item in themes
+ )
+ assert themes[0]["preview_url"] == ""
+ assert themes[3]["preview_url"] == "/assets/image-theme-previews/silkscreen_editorial.webp"
+
+
@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:
@@ -64,6 +94,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:
@@ -84,15 +121,19 @@ def test_group_image_theme_roundtrip_and_validation():
group_id = created.json()["id"]
try:
listed = next(item for item in client.get("/api/groups").json() if item["id"] == group_id)
+ assert listed["schedule_rule"] == "daily_previous_day"
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"},
@@ -113,3 +154,32 @@ def test_group_image_theme_roundtrip_and_validation():
assert ordinary_update.status_code == 200
finally:
client.delete(f"/api/groups/{group_id}")
+
+
+def test_group_send_time_is_global_and_non_default_values_are_rejected():
+ display_name = "全局发送批次测试群"
+ with client:
+ created = client.post("/api/groups", json={"display_name": display_name})
+ assert created.status_code == 200
+ group_id = created.json()["id"]
+ try:
+ listed = next(
+ item for item in client.get("/api/groups").json()
+ if item["id"] == group_id
+ )
+ assert listed["send_time"] == "08:30"
+
+ rejected = client.put(
+ f"/api/groups/{group_id}",
+ json={"send_time": "09:15"},
+ )
+ assert rejected.status_code == 422
+ assert "固定为 08:30" in rejected.json()["detail"]
+
+ create_rejected = client.post(
+ "/api/groups",
+ json={"display_name": display_name + "-拒绝", "send_time": "07:00"},
+ )
+ assert create_rejected.status_code == 422
+ finally:
+ client.delete(f"/api/groups/{group_id}")
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(
diff --git a/tests/test_v2_batch_image_workflow.py b/tests/test_v2_batch_image_workflow.py
new file mode 100644
index 0000000..04a88c3
--- /dev/null
+++ b/tests/test_v2_batch_image_workflow.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import threading
+import time
+
+from app.config.settings import Settings
+from app.db.models import Group
+from app.pipeline.daily_pipeline import DailyPipeline
+from app.v2.run_store import RunStore
+
+
+def test_snapshot_prompt_batch_runs_in_parallel_and_preserves_input_order(tmp_path, monkeypatch):
+ settings = Settings(
+ _env_file=None,
+ output_dir=tmp_path / "output",
+ generation_group_concurrency=1,
+ )
+ store = RunStore(tmp_path / "output")
+ groups = {
+ group_id: Group(
+ id=group_id,
+ display_name=f"群{index}",
+ wechat_group_id=f"wx-{index}",
+ )
+ for index, group_id in enumerate(range(23, 29), start=1)
+ }
+ for group in groups.values():
+ store.save_run(
+ group.display_name,
+ "2026-08-25",
+ {
+ "group_name": group.display_name,
+ "group_id": group.id,
+ "wechat_group_id": group.wechat_group_id,
+ "status": "SENT",
+ },
+ )
+
+ pipeline = DailyPipeline(settings=settings, store=store, dry_run=True)
+ monkeypatch.setattr(pipeline, "_get_group", lambda group_id: groups.get(group_id))
+ guard = threading.Lock()
+ active = 0
+ maximum = 0
+
+ def fake_rebuild(group_id, run_date, *, acquire_lock=True):
+ nonlocal active, maximum
+ with guard:
+ active += 1
+ maximum = max(maximum, active)
+ time.sleep(0.08)
+ with guard:
+ active -= 1
+ return {"group_name": groups[group_id].display_name, "status": "prompt_ready"}
+
+ monkeypatch.setattr(pipeline, "rebuild_prompt_from_snapshot", fake_rebuild)
+ targets = [
+ (group.id, group.wechat_group_id, "2026-08-25")
+ for group in groups.values()
+ ]
+ results = pipeline.rebuild_prompts_from_snapshots(
+ targets,
+ acquire_lock=False,
+ )
+
+ assert maximum == 6
+ assert [item["group_id"] for item in results] == list(range(23, 29))
+ assert [item["group_name"] for item in results] == [f"群{index}" for index in range(1, 7)]
+
+
+def test_snapshot_prompt_batch_rejects_group_identity_mismatch(tmp_path, monkeypatch):
+ settings = Settings(_env_file=None, output_dir=tmp_path / "output")
+ store = RunStore(tmp_path / "output")
+ group = Group(id=23, display_name="群A", wechat_group_id="wx-a")
+ store.save_run(
+ "群A",
+ "2026-08-25",
+ {
+ "group_name": "群A",
+ "group_id": 23,
+ "wechat_group_id": "wx-a",
+ "status": "SENT",
+ },
+ )
+ pipeline = DailyPipeline(settings=settings, store=store, dry_run=True)
+ monkeypatch.setattr(pipeline, "_get_group", lambda _group_id: group)
+
+ result = pipeline.rebuild_prompts_from_snapshots(
+ [(23, "wrong-wechat-id", "2026-08-25")],
+ acquire_lock=False,
+ )[0]
+
+ assert result["status"] == "failed"
+ assert result["error_type"] == "GROUP_IDENTITY_MISMATCH"
diff --git a/tests/test_v2_data_source.py b/tests/test_v2_data_source.py
index 8ca01ca..c0297f0 100644
--- a/tests/test_v2_data_source.py
+++ b/tests/test_v2_data_source.py
@@ -10,7 +10,9 @@
import pytest
+from app.config.settings import Settings
from app.data_sources.base import DataSourceStatus
+from app.data_sources.history_provider import _message as adapt_history_message
from app.data_sources.wechat_data_analysis import WeChatDataAnalysisSource
from app.providers.history.base import (
FetchResult,
@@ -135,6 +137,20 @@ def test_fetch_message_id_fallback_to_hash():
assert result.messages[0].message_id # 非空(确定性 hash)
+def test_history_provider_adapter_preserves_sender_name_provenance():
+ message = adapt_history_message(
+ _raw(
+ sender_name="联系人解析名",
+ upstream_sender_name="消息当时显示名",
+ sender_name_source="upstream_sender_name",
+ )
+ )
+
+ assert message.sender_name == "联系人解析名"
+ assert message.upstream_sender_name == "消息当时显示名"
+ assert message.sender_name_source == "upstream_sender_name"
+
+
# ---------- 错误类型 ----------
@@ -179,3 +195,87 @@ def test_fetch_unavailable_maps_to_wechat_data_unavailable():
result = _make_source(fake).fetch_messages("g1@chatroom", datetime(2026, 8, 17), datetime(2026, 8, 17))
assert result.status == DataSourceStatus.READ_FAILED
assert result.error_type == WECHAT_DATA_UNAVAILABLE
+
+
+def test_mcp_read_failure_can_switch_to_complete_export_without_mixing(tmp_path):
+ fake = FakeProvider()
+ fake._mcp_client = object()
+ fake.export_dir = tmp_path
+ fake.fetch = FetchResult(
+ "fake",
+ "g1@chatroom",
+ [],
+ ProviderStatus.READ_FAILED,
+ "MCP timeout",
+ )
+ export_message = _raw(source="export", source_message_id="export-1")
+ fake._fetch_messages_export = lambda group_id, start, end: FetchResult(
+ "fake",
+ group_id,
+ [export_message],
+ ProviderStatus.OK,
+ "export ok",
+ )
+ source = WeChatDataAnalysisSource(
+ settings=Settings(
+ _env_file=None,
+ wechat_runtime_export_fallback_enabled=True,
+ ),
+ provider=fake,
+ )
+
+ result = source.fetch_messages(
+ "g1@chatroom",
+ datetime(2026, 8, 17),
+ datetime(2026, 8, 18),
+ )
+
+ assert result.status == DataSourceStatus.OK
+ assert [message.message_id for message in result.messages] == ["export-1"]
+ assert result.meta["fallback_used"] is True
+ assert result.meta["provider_chain"] == [
+ "wechat_data_analysis_mcp",
+ "wechat_data_analysis_export",
+ ]
+ assert result.meta["primary_error"] == "MCP timeout"
+
+
+def test_mcp_read_failure_accepts_complete_empty_export_as_authoritative(tmp_path):
+ fake = FakeProvider()
+ fake._mcp_client = object()
+ fake.export_dir = tmp_path
+ fake.fetch = FetchResult(
+ "fake",
+ "g1@chatroom",
+ [],
+ ProviderStatus.READ_FAILED,
+ "MCP timeout",
+ )
+ fake._fetch_messages_export = lambda group_id, start, end: FetchResult(
+ "fake",
+ group_id,
+ [],
+ ProviderStatus.EMPTY_RESULT,
+ "export complete but empty",
+ )
+ source = WeChatDataAnalysisSource(
+ settings=Settings(
+ _env_file=None,
+ wechat_runtime_export_fallback_enabled=True,
+ ),
+ provider=fake,
+ )
+
+ result = source.fetch_messages(
+ "g1@chatroom",
+ datetime(2026, 8, 17),
+ datetime(2026, 8, 18),
+ )
+
+ assert result.status == DataSourceStatus.EMPTY_RESULT
+ assert result.messages == []
+ assert result.meta["fallback_used"] is True
+ assert result.meta["provider_chain"] == [
+ "wechat_data_analysis_mcp",
+ "wechat_data_analysis_export",
+ ]
diff --git a/tests/test_v2_group_migration.py b/tests/test_v2_group_migration.py
index b546ee1..e8f101e 100644
--- a/tests/test_v2_group_migration.py
+++ b/tests/test_v2_group_migration.py
@@ -81,7 +81,7 @@ def test_five_legacy_defaults_migrate_once_without_overwriting_custom(tmp_path,
rows = connection.exec_driver_sql(
"SELECT id, image_theme, image_theme_custom FROM groups ORDER BY id"
).all()
- assert [row[1] for row in rows[:5]] == ["random_preset"] * 5
+ assert [row[1] for row in rows[:5]] == ["ai_free"] * 5
assert tuple(rows[5][1:]) == ("blue_white", "指定手账")
connection.exec_driver_sql("UPDATE groups SET image_theme='blue_white' WHERE id=1")
with Session(engine) as session:
diff --git a/tests/test_v2_group_prompt_api.py b/tests/test_v2_group_prompt_api.py
index 135fb09..ffd97cd 100644
--- a/tests/test_v2_group_prompt_api.py
+++ b/tests/test_v2_group_prompt_api.py
@@ -3,6 +3,7 @@
from fastapi.testclient import TestClient
from app.main import app
+from app.ai.prompt_templates import DEFAULT_IMAGE_PROMPT_TEMPLATE
client = TestClient(app)
@@ -23,8 +24,13 @@ def test_group_prompt_override_is_isolated_and_not_exposed_in_list():
first_config = client.get(f"/api/groups/{first}/image-prompt").json()
second_config = client.get(f"/api/groups/{second}/image-prompt").json()
assert first_config["source"] == second_config["source"] == "global"
+ assert first_config["content"].count("【漫画分镜】") == 1
+ assert first_config["preview"].count("【漫画分镜】") == 1
- custom = "【任务】\n为 {{group_name}} 生成真实群报。\n【大主题】\n{{image_theme}}\n"
+ custom = DEFAULT_IMAGE_PROMPT_TEMPLATE.replace(
+ "生成一张竖版微信群日报漫画信息图。",
+ "为 {{group_name}} 生成一张竖版微信群日报漫画信息图。",
+ )
saved = client.put(
f"/api/groups/{first}/image-prompt",
json={
@@ -79,3 +85,26 @@ 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 = DEFAULT_IMAGE_PROMPT_TEMPLATE.replace(
+ "{{overall_visual}}",
+ "固定群聊漫画要求。\n\n根据当天真实聊天内容自由选择统一视觉风格。",
+ ).replace("{{panels}}", "【版面1】\n张三说今天完成 3 项工作。")
+ 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 "张三说今天完成 3 项工作。" in resolved["prompt"]
+ assert resolved["prompt"].count("【漫画分镜】") == 1
diff --git a/tests/test_v2_image_regeneration.py b/tests/test_v2_image_regeneration.py
index e7961b6..6ae5ed5 100644
--- a/tests/test_v2_image_regeneration.py
+++ b/tests/test_v2_image_regeneration.py
@@ -2,16 +2,32 @@
import threading
import time
+import hashlib
+from io import BytesIO
from types import SimpleNamespace
import pytest
+from PIL import Image
+from app.image import regeneration
from app.image.image_task import ImageTaskResult
from app.image.regeneration import enqueue_regeneration, run_regeneration_now
+from app.image.regeneration import claim_regeneration_candidate, list_regeneration_candidates
from app.v2.run_store import RunStore
-OLD_PNG = b"\x89PNG\r\n\x1a\nold-image"
-NEW_PNG = b"\x89PNG\r\n\x1a\nnew-image"
+
+def _png_bytes(
+ color: tuple[int, int, int, int],
+ size: tuple[int, int] = (2, 2),
+) -> bytes:
+ buffer = BytesIO()
+ Image.new("RGBA", size, color).save(buffer, format="PNG")
+ return buffer.getvalue()
+
+
+OLD_PNG = _png_bytes((20, 40, 60, 255))
+NEW_PNG = _png_bytes((80, 100, 120, 255))
+FLEXIBLE_SIZE_PNG = _png_bytes((100, 120, 140, 255), (864, 1821))
class SuccessGenerator:
@@ -43,7 +59,7 @@ def generate(self, prompt_path, output_path):
def _run(tmp_path, *, status="SENT"):
- settings = SimpleNamespace(output_dir=tmp_path)
+ settings = SimpleNamespace(output_dir=tmp_path, image_generation_concurrency=2)
store = RunStore(tmp_path)
group, run_date = "重新生图测试群", "2026-08-21"
store.save_run(group, run_date, {"status": status, "sent_at": "2026-08-21 09:00:00"})
@@ -54,6 +70,14 @@ def _run(tmp_path, *, status="SENT"):
def test_success_atomically_replaces_image_backs_up_old_and_holds_send(tmp_path):
settings, store, group, run_date = _run(tmp_path)
+ store.update(
+ group,
+ run_date,
+ image_fallback_level=3,
+ image_fallback_reason="PROMPT_FAILED",
+ image_variant="pillow",
+ image_force_local_fallback=True,
+ )
run = run_regeneration_now(settings, group, run_date, SuccessGenerator())
@@ -64,17 +88,21 @@ def test_success_atomically_replaces_image_backs_up_old_and_holds_send(tmp_path)
assert run["send_hold"] is True
assert run["needs_manual_send"] is True
assert run["sent_at"] == "2026-08-21 09:00:00"
+ assert run["image_fallback_level"] == 0
+ assert run["image_fallback_reason"] == ""
+ assert run["image_variant"] == "normal"
+ assert run["image_force_local_fallback"] is False
-def test_cli_failure_keeps_old_image_and_marks_desktop_fallback(tmp_path):
+def test_cli_failure_keeps_old_image_and_fails_closed(tmp_path):
settings, store, group, run_date = _run(tmp_path)
run = run_regeneration_now(settings, group, run_date, FailureGenerator("codex CLI 不可用", "health"))
assert store.image_path(group, run_date).read_bytes() == OLD_PNG
assert not store.previous_image_path(group, run_date).exists()
- assert run["image_regen_status"] == "fallback_queued"
- assert run["desktop_regen_requested"] is True
+ assert run["image_regen_status"] == "failed"
+ assert run["desktop_regen_requested"] is False
assert run["send_hold"] is True
@@ -89,6 +117,75 @@ def test_policy_rejection_does_not_fallback_and_keeps_old_image(tmp_path):
assert run["send_hold"] is True
+def test_strict_fact_review_retries_once_then_promotes(tmp_path, monkeypatch):
+ settings, store, group, run_date = _run(tmp_path)
+ store.update(
+ group,
+ run_date,
+ ranking_count_policy="text_primary_with_interactions",
+ )
+
+ class CountingGenerator:
+ def __init__(self):
+ self.calls = 0
+
+ def generate(self, prompt_path, output_path):
+ self.calls += 1
+ output_path.write_bytes(NEW_PNG)
+ return ImageTaskResult(True, image_path=output_path)
+
+ checks = iter(
+ [
+ (False, "图片事实校验失败:无证据数字 12%"),
+ (True, "事实校验通过"),
+ (True, "提升前复核通过"),
+ ]
+ )
+ monkeypatch.setattr(regeneration, "verify_image_contract", lambda *_args: next(checks))
+ generator = CountingGenerator()
+
+ run = run_regeneration_now(settings, group, run_date, generator)
+
+ assert generator.calls == 2
+ assert store.image_path(group, run_date).read_bytes() == NEW_PNG
+ assert run["image_regen_status"] == "ready_for_review"
+ assert run["image_regen_job"]["receipt"]["success"] is True
+
+
+def test_strict_fact_review_fails_closed_after_two_attempts(tmp_path, monkeypatch):
+ settings, store, group, run_date = _run(tmp_path)
+ store.update(
+ group,
+ run_date,
+ ranking_count_policy="text_primary_with_interactions",
+ )
+
+ class CountingGenerator:
+ def __init__(self):
+ self.calls = 0
+
+ def generate(self, prompt_path, output_path):
+ self.calls += 1
+ output_path.write_bytes(NEW_PNG)
+ return ImageTaskResult(True, image_path=output_path)
+
+ monkeypatch.setattr(
+ regeneration,
+ "verify_image_contract",
+ lambda *_args: (False, "图片事实校验失败:无证据文字"),
+ )
+ generator = CountingGenerator()
+
+ run = run_regeneration_now(settings, group, run_date, generator)
+
+ assert generator.calls == 2
+ assert store.image_path(group, run_date).read_bytes() == OLD_PNG
+ assert not store.previous_image_path(group, run_date).exists()
+ assert run["image_regen_status"] == "failed"
+ assert run["send_hold"] is True
+ assert run["needs_manual_send"] is True
+
+
def test_duplicate_click_is_rejected_while_same_run_is_active(tmp_path):
settings, store, group, run_date = _run(tmp_path, status="READY_TO_SEND")
generator = BlockingGenerator()
@@ -105,3 +202,131 @@ def test_duplicate_click_is_rejected_while_same_run_is_active(tmp_path):
break
time.sleep(0.05)
assert store.load_run(group, run_date)["image_regen_status"] == "ready_for_review"
+
+
+def test_two_different_runs_can_regenerate_concurrently(tmp_path):
+ settings = SimpleNamespace(output_dir=tmp_path, image_generation_concurrency=2)
+ store = RunStore(tmp_path)
+ guard = threading.Lock()
+
+ class ConcurrentGenerator:
+ def __init__(self):
+ self.active = 0
+ self.maximum = 0
+
+ def generate(self, prompt_path, output_path):
+ with guard:
+ self.active += 1
+ self.maximum = max(self.maximum, self.active)
+ time.sleep(0.12)
+ output_path.write_bytes(NEW_PNG)
+ with guard:
+ self.active -= 1
+ return ImageTaskResult(True, image_path=output_path)
+
+ generator = ConcurrentGenerator()
+ for group in ("并发群A", "并发群B"):
+ store.save_run(group, "2026-08-21", {"status": "SENT"})
+ store.prompt_path(group, "2026-08-21").write_text("真实 Prompt", encoding="utf-8")
+ store.image_path(group, "2026-08-21").write_bytes(OLD_PNG)
+ enqueue_regeneration(settings, group, "2026-08-21", generator=generator)
+
+ deadline = time.time() + 3
+ while time.time() < deadline:
+ states = [
+ store.load_run(group, "2026-08-21").get("image_regen_status")
+ for group in ("并发群A", "并发群B")
+ ]
+ if states == ["ready_for_review", "ready_for_review"]:
+ break
+ time.sleep(0.03)
+ assert generator.maximum == 2
+ assert states == ["ready_for_review", "ready_for_review"]
+
+
+def test_non_default_size_candidate_requires_exact_claim_and_keeps_send_hold(tmp_path):
+ settings = SimpleNamespace(
+ output_dir=tmp_path,
+ codex_path="",
+ codex_timeout_seconds=1200,
+ codex_home=str(tmp_path / "codex-home"),
+ codex_generated_images_dir="",
+ image_generation_concurrency=2,
+ )
+ store = RunStore(tmp_path)
+ group, run_date, job_id = "候选认领群", "2026-08-21", "job-candidate-001"
+ store.save_run(
+ group,
+ run_date,
+ {
+ "status": "SENT",
+ "sent_at": "2026-08-21 09:00:00",
+ "group_id": 23,
+ "wechat_group_id": "wx-group-23",
+ "failed_stage": "image",
+ "error": "旧的固定尺寸失败",
+ "error_type": "IMAGE_GENERATION_FAILED",
+ },
+ )
+ store.prompt_path(group, run_date).write_text(
+ "优先使用 1024×1536;其他完整可读的竖版尺寸也可接受",
+ encoding="utf-8",
+ )
+ store.image_path(group, run_date).write_bytes(OLD_PNG)
+ candidate = store.group_dir(group, run_date) / ".imagegen-jobs" / job_id / "candidate.png"
+ candidate.parent.mkdir(parents=True)
+ candidate.write_bytes(FLEXIBLE_SIZE_PNG)
+ digest = hashlib.sha256(FLEXIBLE_SIZE_PNG).hexdigest().upper()
+ store.update(
+ group,
+ run_date,
+ image_regen_status="ambiguous_result",
+ image_regen_job={
+ "job_id": job_id,
+ "revision": 1,
+ "prompt_sha256": "a" * 64,
+ "status": "ambiguous_result",
+ "candidates": [{
+ "candidate_id": digest.lower(),
+ "sha256": digest,
+ "root": "task",
+ "relative_path": f".imagegen-jobs/{job_id}/candidate.png",
+ "size_bytes": len(FLEXIBLE_SIZE_PNG),
+ "sources": ["scan"],
+ }],
+ },
+ )
+
+ candidates = list_regeneration_candidates(settings, group, run_date)
+ assert [item["candidate_id"] for item in candidates] == [digest.lower()]
+ with pytest.raises(ValueError, match="job_id"):
+ claim_regeneration_candidate(
+ settings,
+ group,
+ run_date,
+ job_id="wrong-job",
+ candidate_id=digest.lower(),
+ )
+
+ run = claim_regeneration_candidate(
+ settings,
+ group,
+ run_date,
+ job_id=job_id,
+ candidate_id=digest.lower(),
+ )
+ assert store.image_path(group, run_date).read_bytes() == FLEXIBLE_SIZE_PNG
+ assert store.previous_image_path(group, run_date).read_bytes() == OLD_PNG
+ with Image.open(store.image_path(group, run_date)) as image:
+ image.load()
+ assert image.size == (864, 1821)
+ assert run["status"] == "SENT"
+ assert run["sent_at"] == "2026-08-21 09:00:00"
+ assert run["image_regen_status"] == "ready_for_review"
+ assert run["send_hold"] is True
+ assert run["needs_manual_send"] is True
+ assert run["failed_stage"] is None
+ assert run["error"] is None
+ assert run["error_type"] is None
+ assert run["image_regen_job"]["claimed_candidate"]["candidate_id"] == digest.lower()
+ assert run["image_regen_job"]["candidates"] == []
diff --git a/tests/test_v2_image_task.py b/tests/test_v2_image_task.py
index eee2f39..83399b7 100644
--- a/tests/test_v2_image_task.py
+++ b/tests/test_v2_image_task.py
@@ -6,6 +6,9 @@
from __future__ import annotations
+from contextlib import nullcontext
+import hashlib
+import io
import json
from pathlib import Path
import subprocess
@@ -13,6 +16,7 @@
import time
import pytest
+from PIL import Image
from app.image import codex_generator
from app.image.codex_generator import CodexImageGenerator
@@ -24,11 +28,10 @@
verify_image,
)
-# 1x1 透明 PNG
-_PNG_1PX = bytes.fromhex(
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
- "0000000d4944415478da63f8cfc0f01f00050001fff83f240000000049454e44ae426082"
-)
+# 1x1 透明且可被 Pillow 完整解码的 PNG。
+_png_buffer = io.BytesIO()
+Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(_png_buffer, format="PNG")
+_PNG_1PX = _png_buffer.getvalue()
def test_detect_png(tmp_path):
@@ -76,7 +79,17 @@ def test_verify_image_not_image(tmp_path):
p.write_bytes(b"\x00" * 10)
ok, detail = verify_image(p)
assert ok is False
- assert "不是可识别的图片格式" in detail
+ assert "无法完整解码" in detail
+
+
+def test_verify_image_rejects_truncated_png_with_valid_signature(tmp_path):
+ p = tmp_path / "truncated.png"
+ p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"broken")
+
+ ok, detail = verify_image(p)
+
+ assert ok is False
+ assert "无法完整解码" in detail
class FakeGenerator:
@@ -125,6 +138,20 @@ def test_serial_queue_sequential(tmp_path):
assert ok is True
+def test_queue_surfaces_result_persistence_hook_failure(tmp_path):
+ def broken_hook(_job, _result):
+ raise OSError("disk unavailable")
+
+ job = _job(tmp_path, "状态落盘失败群", FakeGenerator())
+ result = SerialImageQueue(run_hook=broken_hook).run_all([job])[0]
+
+ assert result["status"] == "failed"
+ assert result["success"] is False
+ assert result["error_type"] == "IMAGE_STATE_PERSIST_FAILED"
+ assert result["hook_error"] is True
+ assert "OSError" in result["detail"]
+
+
def test_single_failure_does_not_block_others(tmp_path):
queue = SerialImageQueue()
jobs = [
@@ -133,11 +160,174 @@ def test_single_failure_does_not_block_others(tmp_path):
_job(tmp_path, "群3", FakeGenerator(fail=True)),
]
results = queue.run_all(jobs)
- assert [r["status"] for r in results] == ["failed", "success", "failed"]
- assert results[0]["error_type"] == "IMAGE_GENERATION_FAILED"
- # 失败的群不产出图片,其他群不受影响
- ok2, _ = verify_image(jobs[1].output_path)
- assert ok2 is True
+ assert [r["status"] for r in results] == ["success", "success", "success"]
+ assert results[0]["generator_detail"]["fallback_level"] == 3
+ # 外部失败的群得到本地信息图,其他群也不受影响。
+ for job in jobs:
+ ok, _ = verify_image(job.output_path)
+ assert ok is True
+
+
+def test_policy_rejection_uses_safe_prompt_once(tmp_path):
+ from app.image.image_task import ImageTaskResult
+
+ class PolicyThenSuccess:
+ def __init__(self):
+ self.prompts = []
+
+ def generate(self, prompt_file, output_path):
+ self.prompts.append(prompt_file.read_text(encoding="utf-8"))
+ if len(self.prompts) == 1:
+ return ImageTaskResult(
+ False,
+ error="blocked",
+ detail={"error_code": "CONTENT_FILTER"},
+ )
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(_PNG_1PX)
+ return ImageTaskResult(True, image_path=output_path)
+
+ generator = PolicyThenSuccess()
+ prompt = tmp_path / "image_prompt.txt"
+ prompt.write_text("测试群 张三 讨论当天票房 500 万", encoding="utf-8")
+ ranking = tmp_path / "ranking.json"
+ ranking.write_text(
+ json.dumps({"top_speakers": [{"name": "张三", "count": 3}]}, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ job = ImageJob(
+ group_name="测试群",
+ prompt_file=prompt,
+ output_path=tmp_path / "daily_image.png",
+ generator=generator,
+ )
+
+ result = job.run()
+
+ assert result["status"] == "success"
+ assert result["generator_detail"]["fallback_level"] == 2
+ assert len(generator.prompts) == 2
+ assert "测试群" not in generator.prompts[1]
+ assert "张三" not in generator.prompts[1]
+ assert "500" in generator.prompts[1]
+
+
+def test_unknown_image_result_never_calls_safe_or_local_fallback(tmp_path):
+ from app.image.image_task import ImageTaskResult
+
+ class UnknownGenerator:
+ def __init__(self):
+ self.calls = 0
+
+ def generate(self, prompt_file, output_path):
+ self.calls += 1
+ return ImageTaskResult(
+ False,
+ error="receipt missing",
+ detail={"outcome_unknown": True, "recovery_status": "result_unknown"},
+ )
+
+ generator = UnknownGenerator()
+ job = _job(tmp_path, "群1", generator)
+
+ result = job.run()
+
+ assert result["status"] == "failed"
+ assert generator.calls == 1
+ assert not job.output_path.exists()
+
+
+def test_strict_verification_known_failure_uses_local_fallback(tmp_path, monkeypatch):
+ from app.image.image_task import ImageTaskResult
+
+ class QualityRetryFailsKnown:
+ def __init__(self):
+ self.calls = 0
+
+ def generate(self, _prompt_file, output_path, **_kwargs):
+ self.calls += 1
+ if self.calls == 1:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(_PNG_1PX)
+ return ImageTaskResult(True, image_path=output_path)
+ return ImageTaskResult(
+ False,
+ error="network failure",
+ detail={"outcome_unknown": False},
+ )
+
+ generator = QualityRetryFailsKnown()
+ job = _job(tmp_path, "群1", generator)
+ fallback_calls = []
+ monkeypatch.setattr(
+ "app.image.image_task.verify_image_contract",
+ lambda *_args, **_kwargs: (False, "图片事实校验失败"),
+ )
+ monkeypatch.setattr(
+ "app.image.fact_verification.strict_fact_verification_enabled",
+ lambda _path: True,
+ )
+ monkeypatch.setattr(
+ ImageJob,
+ "_local_fallback",
+ lambda self, reason: fallback_calls.append(reason)
+ or {
+ "group_name": self.group_name,
+ "status": "success",
+ "success": True,
+ "detail": "local fallback",
+ "error_type": "",
+ "generator_detail": {"fallback_level": 3},
+ },
+ )
+
+ result = job.run()
+
+ assert result["status"] == "success"
+ assert generator.calls == 2
+ assert fallback_calls == ["IMAGE_CONTENT_VERIFICATION_FAILED"]
+
+
+def test_strict_verification_unknown_retry_stays_failed_closed(tmp_path, monkeypatch):
+ from app.image.image_task import ImageTaskResult
+
+ class QualityRetryUnknown:
+ def __init__(self):
+ self.calls = 0
+
+ def generate(self, _prompt_file, output_path, **_kwargs):
+ self.calls += 1
+ if self.calls == 1:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(_PNG_1PX)
+ return ImageTaskResult(True, image_path=output_path)
+ return ImageTaskResult(
+ False,
+ error="receipt missing",
+ detail={"outcome_unknown": True},
+ )
+
+ generator = QualityRetryUnknown()
+ job = _job(tmp_path, "群1", generator)
+ monkeypatch.setattr(
+ "app.image.image_task.verify_image_contract",
+ lambda *_args, **_kwargs: (False, "图片事实校验失败"),
+ )
+ monkeypatch.setattr(
+ "app.image.fact_verification.strict_fact_verification_enabled",
+ lambda _path: True,
+ )
+ monkeypatch.setattr(
+ ImageJob,
+ "_local_fallback",
+ lambda *_args, **_kwargs: pytest.fail("结果未知时不得进入本地兜底"),
+ )
+
+ result = job.run()
+
+ assert result["status"] == "failed"
+ assert result["error_type"] == "IMAGE_GENERATION_FAILED"
+ assert generator.calls == 2
def test_skip_when_image_exists(tmp_path):
@@ -188,10 +378,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,16 +412,58 @@ 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
def _attempt_paths(command: list[str]) -> tuple[Path, Path]:
result_path = Path(command[command.index("--output-last-message") + 1])
- staging_path = result_path.with_name(result_path.name.replace(".result.json", ".png"))
+ staging_path = result_path.with_name(
+ result_path.name.replace("receipt-", "candidate-").replace(".json", ".png")
+ )
return staging_path, result_path
+def _write_receipt(
+ result_path: Path,
+ image_path: Path | str,
+ *,
+ legacy: bool = False,
+) -> None:
+ payload = {
+ "job_id": result_path.parent.name,
+ "status": "success",
+ "image_path": str(image_path),
+ "error": "",
+ }
+ if legacy:
+ payload = {"job_id": result_path.parent.name, "image_path": str(image_path)}
+ result_path.write_text(
+ json.dumps(payload),
+ encoding="utf-8",
+ )
+
+
+def test_codex_prompt_hash_compares_raw_crlf_bytes(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ prompt.write_bytes("第一行\r\n第二行\r\n".encode("utf-8"))
+ expected_hash = hashlib.sha256(prompt.read_bytes()).hexdigest()
+
+ result = generator._generate_locked(
+ prompt,
+ prompt.parent / "daily_image.png",
+ force=True,
+ job_id="invalid!",
+ prompt_sha256=expected_hash,
+ )
+
+ assert result.success is False
+ assert "job_id" in result.error
+ assert "Prompt 内容已变化" not in result.error
+
+
def test_codex_prompt_uses_stdin_and_explicit_output_contract(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
captured = {}
@@ -232,8 +472,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)
+ _write_receipt(result_path, generated.resolve())
+ assert not staging.exists()
return _Proc()
monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
@@ -246,11 +489,110 @@ 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 "优先使用 1024×1536" in captured["input"]
+ assert "其他竖版尺寸也可以直接采用" in captured["input"]
+ assert "不要为了匹配尺寸裁切或拉伸" in captured["input"]
+ assert "绝对路径" in captured["input"]
+ assert "status=failed" in captured["input"]
+ assert "禁止把错误说明伪装成 image_path" in captured["input"]
assert result.detail["attempt_count"] == 1
assert result.detail["recovery_status"] == "completed"
-def test_codex_stdout_old_paths_are_not_candidates_and_retry_once(tmp_path, monkeypatch):
+def test_codex_accepts_legacy_success_receipt(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+
+ def fake_run(command, **kwargs):
+ _, result_path = _attempt_paths(command)
+ generated = tmp_path / "generated_images" / "legacy-success" / "final.png"
+ generated.parent.mkdir()
+ generated.write_bytes(_PNG_1PX)
+ _write_receipt(result_path, generated.resolve(), legacy=True)
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ result = generator.generate(prompt, prompt.parent / "daily_image.png")
+
+ assert result.success is True
+ assert result.detail["receipt_source"] == "structured_receipt"
+
+
+def test_codex_explicit_failure_receipt_is_known_failure_without_retry(
+ 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(
+ {
+ "job_id": result_path.parent.name,
+ "status": "failed",
+ "image_path": "",
+ "error": "connection failed: remote reset",
+ }
+ ),
+ encoding="utf-8",
+ )
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ job_id = "job-explicit-failure-001"
+ result = generator.generate(
+ prompt,
+ prompt.parent / "daily_image.png",
+ job_id=job_id,
+ )
+ manifest = json.loads(
+ (prompt.parent / ".imagegen-jobs" / job_id / "attempt.json").read_text(
+ encoding="utf-8"
+ )
+ )
+
+ assert result.success is False
+ assert calls == 1
+ assert "connection failed" in result.error
+ assert result.detail["outcome_unknown"] is False
+ assert result.detail["recovery_status"] == "explicit_generation_failure"
+ assert result.detail["candidate_diagnostics"] == []
+ assert manifest["state"] == "exhausted"
+ assert manifest["outcome"] == "explicit_failure"
+
+
+def test_codex_keeps_success_after_post_promote_smoke_write_failure(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+
+ def fake_run(command, **kwargs):
+ _, result_path = _attempt_paths(command)
+ generated = tmp_path / "generated_images" / "post-promote" / "final.png"
+ generated.parent.mkdir()
+ generated.write_bytes(_PNG_1PX)
+ _write_receipt(result_path, generated.resolve())
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ monkeypatch.setattr(generator, "_is_project_output", lambda _path: True)
+ monkeypatch.setattr(
+ generator,
+ "_save_last_smoke",
+ lambda *_args: (_ for _ in ()).throw(OSError("disk busy")),
+ )
+ output = prompt.parent / "daily_image.png"
+
+ result = generator.generate(prompt, output)
+
+ assert result.success is True
+ assert output.is_file()
+ assert result.detail["post_promote_warnings"] == ["smoke_state:OSError"]
+
+
+def test_codex_stdout_old_paths_are_not_candidates_and_hold_unknown(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
old = tmp_path / "generated_images" / "old.png"
old.write_bytes(_PNG_1PX)
@@ -267,8 +609,9 @@ def fake_run(command, **kwargs):
result = generator.generate(prompt, prompt.parent / "daily_image.png")
assert result.success is False
- assert len(calls) == 2
- assert result.detail["attempt_count"] == 2
+ assert len(calls) == 1
+ assert result.detail["attempt_count"] == 1
+ assert result.detail["outcome_unknown"] is True
assert result.detail["candidate_diagnostics"] == []
@@ -282,14 +625,60 @@ def fake_run(command, **kwargs):
nonlocal calls
calls += 1
_, result_path = _attempt_paths(command)
- result_path.write_text(json.dumps({"image_path": str(outside)}), encoding="utf-8")
+ _write_receipt(result_path, outside)
+ 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 == 1
+ assert result.detail["outcome_unknown"] is True
+ 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)
+ _write_receipt(result_path, "final.png")
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 calls == 1
+ assert result.detail["outcome_unknown"] is True
+ 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)
+ _write_receipt(result_path, candidate.resolve())
+ 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 == 1
+ assert result.detail["outcome_unknown"] is True
assert result.detail["candidate_diagnostics"] == []
@@ -310,12 +699,40 @@ def fake_run(command, **kwargs):
result = generator.generate(prompt, prompt.parent / "daily_image.png")
assert result.success is False
- assert calls == 2
+ assert calls == 1
+ assert result.detail["outcome_unknown"] is True
assert result.detail["stage"] == "ambiguous"
assert len(result.detail["candidate_diagnostics"]) == 2
-def test_codex_deduplicates_staging_and_generated_copy_by_hash(tmp_path, monkeypatch):
+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")
+ _write_receipt(result_path, selected.resolve())
+ 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_keeps_distinct_paths_even_when_candidate_hashes_match(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
def fake_run(command, **kwargs):
@@ -324,18 +741,21 @@ def fake_run(command, **kwargs):
generated.parent.mkdir()
generated.write_bytes(_PNG_1PX)
staging.write_bytes(_PNG_1PX)
- result_path.write_text(json.dumps({"image_path": str(generated)}), encoding="utf-8")
+ _write_receipt(result_path, generated)
return _Proc()
monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
result = generator.generate(prompt, prompt.parent / "daily_image.png")
assert result.success is True
- assert len(result.detail["candidate_diagnostics"]) == 1
- assert result.detail["candidate_diagnostics"][0]["sources"] == ["staging", "structured", "scan"]
+ assert len(result.detail["candidate_diagnostics"]) == 2
+ assert sum(
+ "structured" in item["sources"]
+ for item in result.detail["candidate_diagnostics"]
+ ) == 1
-def test_codex_recovers_single_staged_image_after_timeout(tmp_path, monkeypatch):
+def test_codex_staged_image_without_matching_receipt_is_not_auto_claimed(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
calls = 0
@@ -349,48 +769,189 @@ def fake_run(command, **kwargs):
monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
result = generator.generate(prompt, prompt.parent / "daily_image.png")
- assert result.success is True
+ assert result.success is False
assert calls == 1
assert result.detail["attempt_count"] == 1
+ assert result.detail["outcome_unknown"] is True
+ assert len(result.detail["candidate_diagnostics"]) == 1
+
+
+def test_codex_timeout_recovers_unique_candidate_from_same_thread(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ thread_id = "01a03-safe-thread"
+ calls = 0
+
+ def fake_run(command, **kwargs):
+ nonlocal calls
+ calls += 1
+ kwargs["on_start"](12345)
+ kwargs["on_event"]({"type": "thread.started", "thread_id": thread_id})
+ generated = tmp_path / "generated_images" / thread_id / "only.png"
+ generated.parent.mkdir()
+ generated.write_bytes(_PNG_1PX)
+ raise subprocess.TimeoutExpired(command, generator.timeout, stderr="token=secret-value")
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ monkeypatch.setattr(generator, "_pid_is_running", lambda _pid: False)
+
+ result = generator.generate(
+ prompt,
+ prompt.parent / "daily_image.png",
+ job_id="job-thread-timeout-001",
+ )
+
+ assert result.success is True
+ assert calls == 1
+ assert result.detail["codex_thread_id"] == thread_id
+ assert result.detail["receipt_source"] == "codex_thread_scan"
assert result.detail["recovery_status"] == "recovered_after_timeout"
+ assert "secret-value" not in result.detail["codex_stderr_tail"]
-def test_codex_retry_budget_survives_next_call(tmp_path, monkeypatch):
+def test_codex_thread_candidate_outside_matching_directory_stays_unknown(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
calls = 0
def fake_run(command, **kwargs):
nonlocal calls
calls += 1
+ kwargs["on_event"]({"type": "thread.started", "thread_id": "thread-expected-001"})
+ generated = tmp_path / "generated_images" / "thread-other-001" / "only.png"
+ generated.parent.mkdir()
+ generated.write_bytes(_PNG_1PX)
return _Proc()
monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
- first = generator.generate(prompt, prompt.parent / "daily_image.png")
- second = generator.generate(prompt, prompt.parent / "daily_image.png")
- manifest = json.loads((prompt.parent / ".codex-image-attempt.json").read_text(encoding="utf-8"))
+
+ result = generator.generate(
+ prompt,
+ prompt.parent / "daily_image.png",
+ job_id="job-thread-mismatch-001",
+ )
+
+ assert result.success is False
+ assert calls == 1
+ assert result.detail["outcome_unknown"] is True
+
+
+def test_codex_multiple_candidates_in_same_thread_stay_unknown(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ thread_id = "thread-multiple-001"
+
+ def fake_run(command, **kwargs):
+ kwargs["on_event"]({"type": "thread.started", "thread_id": thread_id})
+ generated = tmp_path / "generated_images" / thread_id
+ generated.mkdir()
+ (generated / "first.png").write_bytes(_PNG_1PX)
+ (generated / "second.png").write_bytes(_PNG_1PX)
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+
+ result = generator.generate(
+ prompt,
+ prompt.parent / "daily_image.png",
+ job_id="job-thread-multiple-001",
+ )
+
+ assert result.success is False
+ assert result.detail["stage"] == "ambiguous"
+ assert result.detail["outcome_unknown"] is True
+
+
+def test_codex_modified_old_file_in_thread_directory_is_not_new_candidate(
+ tmp_path, monkeypatch
+):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ thread_id = "thread-existing-001"
+ generated = tmp_path / "generated_images" / thread_id / "old.png"
+ generated.parent.mkdir()
+ generated.write_bytes(_PNG_1PX)
+ calls = 0
+
+ def fake_run(command, **kwargs):
+ nonlocal calls
+ calls += 1
+ kwargs["on_event"]({"type": "thread.started", "thread_id": thread_id})
+ generated.write_bytes(_PNG_1PX + b"modified")
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+
+ result = generator.generate(
+ prompt,
+ prompt.parent / "daily_image.png",
+ job_id="job-thread-existing-001",
+ )
+
+ assert result.success is False
+ assert calls == 1
+ assert result.detail["outcome_unknown"] is True
+ assert all(
+ "thread" not in item["sources"]
+ for item in result.detail["candidate_diagnostics"]
+ )
+
+
+def test_codex_unknown_result_survives_next_call_without_second_process(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ calls = 0
+
+ def fake_run(command, **kwargs):
+ nonlocal calls
+ calls += 1
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ job_id = "job-repeat-001"
+ first = generator.generate(prompt, prompt.parent / "daily_image.png", job_id=job_id)
+ second = generator.generate(prompt, prompt.parent / "daily_image.png", job_id=job_id)
+ manifest = json.loads((prompt.parent / ".imagegen-jobs" / job_id / "attempt.json").read_text(encoding="utf-8"))
assert first.success is False
assert second.success is False
- assert calls == 2
- assert manifest["state"] == "exhausted"
+ assert calls == 1
+ assert manifest["state"] == "result_unknown"
assert "包含引号" not in json.dumps(manifest, ensure_ascii=False)
-def test_codex_recovers_interrupted_attempt_before_new_process(tmp_path, monkeypatch):
+def test_codex_completed_manifest_with_missing_output_requires_explicit_force(tmp_path, monkeypatch):
generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
- attempt = generator._new_attempt(prompt.parent, 1, [])
+ job_id = "job-complete-001"
+ job_dir = prompt.parent / ".imagegen-jobs" / job_id
+ attempt = generator._new_attempt(prompt.parent, 1, [], job_id=job_id, job_dir=job_dir)
+ attempt.update(state="completed", outcome="completed", finished_at="2026-08-25T12:00:00+08:00")
+ generator._write_attempt_manifest(job_dir / "attempt.json", attempt)
+ monkeypatch.setattr(
+ codex_generator,
+ "_run_codex_process",
+ lambda *args, **kwargs: pytest.fail("已完成产物缺失时不得自动再次调用"),
+ )
+
+ result = generator.generate(prompt, prompt.parent / "daily_image.png", job_id=job_id)
+
+ assert result.success is False
+ assert result.detail["recovery_status"] == "completed_output_missing"
+
+
+def test_codex_interrupted_staging_without_receipt_stays_result_unknown(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ job_id = "job-interrupted-001"
+ job_dir = prompt.parent / ".imagegen-jobs" / job_id
+ attempt = generator._new_attempt(prompt.parent, 1, [], job_id=job_id, job_dir=job_dir)
Path(attempt["staging_path"]).write_bytes(_PNG_1PX)
- generator._write_attempt_manifest(prompt.parent / ".codex-image-attempt.json", attempt)
+ generator._write_attempt_manifest(job_dir / "attempt.json", attempt)
monkeypatch.setattr(
codex_generator,
"_run_codex_process",
lambda *args, **kwargs: pytest.fail("已恢复中断产物,不应启动新进程"),
)
- result = generator.generate(prompt, prompt.parent / "daily_image.png")
+ result = generator.generate(prompt, prompt.parent / "daily_image.png", job_id=job_id)
- assert result.success is True
- assert result.detail["recovery_status"] == "recovered_after_interruption"
+ assert result.success is False
+ assert result.detail["outcome_unknown"] is True
+ assert result.detail["recovery_status"] == "interrupted_result_unknown"
def test_codex_rejects_corrupt_image_without_overwriting_existing(tmp_path, monkeypatch):
@@ -405,25 +966,58 @@ def fake_run(command, **kwargs):
return _Proc()
monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
- result = generator.generate(prompt, output)
+ result = generator.generate(prompt, output, force=True)
assert result.success is False
assert output.read_bytes() == original
+def test_codex_accepts_valid_non_default_dimensions_without_second_generation(tmp_path, monkeypatch):
+ generator, prompt = _codex_test_generator(tmp_path, monkeypatch)
+ prompt.write_text("优先生成 1024×1536 群聊漫画", encoding="utf-8")
+ calls = 0
+
+ def fake_run(command, **kwargs):
+ nonlocal calls
+ calls += 1
+ _, result_path = _attempt_paths(command)
+ generated = tmp_path / "generated_images" / f"flexible-size-{calls}" / "final.png"
+ generated.parent.mkdir()
+ Image.new("RGB", (864, 1821), (30, 60, 90)).save(generated, format="PNG")
+ _write_receipt(result_path, generated.resolve())
+ return _Proc()
+
+ monkeypatch.setattr(codex_generator, "_run_codex_process", fake_run)
+ output = prompt.parent / "daily_image.png"
+ result = generator.generate(prompt, output, job_id="job-flexible-size-001")
+
+ assert result.success is True
+ assert calls == 1
+ assert output.exists()
+ with Image.open(output) as image:
+ image.load()
+ assert image.size == (864, 1821)
+ assert result.detail["recovery_status"] == "completed"
+ assert result.detail["width"] == 864
+ assert result.detail["height"] == 1821
+
+
def test_codex_timeout_terminates_process_tree(monkeypatch):
class FakeProcess:
pid = 12345
returncode = None
def __init__(self):
- self.communicate_calls = 0
-
- def communicate(self, **kwargs):
- self.communicate_calls += 1
- if self.communicate_calls == 1:
+ self.wait_calls = 0
+ self.stdin = io.StringIO()
+ self.stdout = io.StringIO("")
+ self.stderr = io.StringIO("")
+
+ def wait(self, **kwargs):
+ self.wait_calls += 1
+ if self.wait_calls == 1:
raise subprocess.TimeoutExpired("codex", 1)
- return ("", "")
+ return 1
def poll(self):
return None
@@ -446,10 +1040,17 @@ def kill(self):
)
assert terminated == [process]
- assert process.communicate_calls == 2
+ assert process.wait_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_period.py b/tests/test_v2_period.py
index 119645d..092e20f 100644
--- a/tests/test_v2_period.py
+++ b/tests/test_v2_period.py
@@ -23,7 +23,10 @@
],
)
def test_every_day_covers_only_previous_natural_day(run_date, target_date):
- window = resolver.resolve(run_date=run_date)
+ window = resolver.resolve(
+ run_date=run_date,
+ schedule_rule="daily_previous_day",
+ )
assert window.should_run is True
assert window.period_start == datetime.combine(target_date, datetime.min.time())
diff --git a/tests/test_v2_pipeline.py b/tests/test_v2_pipeline.py
index 93f6883..f90aa47 100644
--- a/tests/test_v2_pipeline.py
+++ b/tests/test_v2_pipeline.py
@@ -10,6 +10,7 @@
import json
from datetime import date, datetime, time, timedelta
from pathlib import Path
+from zoneinfo import ZoneInfo
import pytest
@@ -24,7 +25,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 +41,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()
@@ -105,17 +107,65 @@ def fetch_messages(self, group_id, start_time, end_time) -> FetchResult:
class FakePrompt:
- def __init__(self, fail=False):
+ def __init__(self, fail=False, failure_meta=None):
self.fail = fail
+ self.failure_meta = failure_meta
self.inputs = []
def build(self, data):
from app.ai.prompt_builder_types import PromptOutput
+ from app.ai.speaker_attribution import build_attribution_contract
self.inputs.append(data)
if self.fail:
- return PromptOutput(False, error="DeepSeek 失败")
- return PromptOutput(True, "【任务】\n生成图片\n【主标题】今天热聊", meta={"mode": "single"})
+ return PromptOutput(
+ False,
+ error="DeepSeek 失败",
+ meta=self.failure_meta,
+ )
+ topic_selection = data.persisted_topic_selection or {
+ "topic_selection_version": "4.0",
+ "selected_topic_ids": ["topic-01", "topic-02"],
+ "selected_count": 2,
+ "candidates": [
+ {
+ "topic_id": "topic-01", "selected": True, "title": "票房",
+ "summary": "张三聊票房", "message_ids": ["m1"],
+ "quotes": ["今天聊了票房"], "visible_participants": ["张三"],
+ },
+ {
+ "topic_id": "topic-02", "selected": True, "title": "牛来",
+ "summary": "李四回应票房", "message_ids": ["m2"],
+ "quotes": ["《牛来》破500万"], "visible_participants": ["李四"],
+ },
+ ],
+ }
+ attribution = build_attribution_contract(data.messages)
+ snapshot_hash = data.message_snapshot_sha256 or attribution.message_snapshot_sha256
+ speaker_fingerprint = data.speaker_fingerprint or attribution.speaker_fingerprint
+ topic_selection = dict(topic_selection)
+ topic_selection.setdefault("message_snapshot_sha256", snapshot_hash)
+ topic_selection.setdefault("speaker_fingerprint", speaker_fingerprint)
+ return PromptOutput(
+ True,
+ "【任务】\n生成图片\n【主标题】今天热聊",
+ meta={
+ "mode": "persisted_topic_selection" if data.persisted_topic_selection else "single",
+ "topic_selection": topic_selection,
+ "message_snapshot_sha256": snapshot_hash,
+ "speaker_fingerprint": speaker_fingerprint,
+ "speaker_bindings": [],
+ "layout_catalog_version": "comic-panels-v3",
+ "layout_id": "split_focus",
+ "structure_mode": "dual_rhythm",
+ "featured_topic_ids": ["topic-01", "topic-02"],
+ "topic_order": ["topic-01", "topic-02"],
+ "panel_beats": [
+ {"topic_id": "topic-01", "shots": ["establishing", "reaction"]},
+ {"topic_id": "topic-02", "shots": ["dialogue"]},
+ ],
+ },
+ )
class FakeGenerator:
@@ -130,10 +180,9 @@ def generate(self, prompt_file: Path, output_path: Path):
if self.fail:
return ImageTaskResult(False, error="生图失败")
output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_bytes(
- bytes.fromhex("89504e470d0a1a0a0000000d4948445200000001000000010806"
- "0000001f15c4890000000d4944415478da63f8cfc0f80100050001fff83f240000000049454e44ae426082")
- )
+ from PIL import Image
+
+ Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(output_path, format="PNG")
return ImageTaskResult(True, image_path=output_path)
@@ -175,7 +224,24 @@ def send_text(self, target: str, text: str):
)
-def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, image_enabled=True, send_time="08:30", image_theme="blue_white", image_theme_custom=""):
+class SubmittedFailureTextSender(FakeSender):
+ def send_text(self, target: str, text: str):
+ from app.sender.base import SendResult
+
+ self.text_calls.append((target, text))
+ return SendResult(
+ False,
+ "提交后提供方返回失败",
+ datetime.now().isoformat(),
+ submitted=True,
+ verification_level="unknown",
+ outcome_unknown=False,
+ )
+
+
+def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, image_enabled=True, send_time="08:30", image_theme="blue_white", image_theme_custom="", schedule_rule="daily_previous_day"):
+ from app.config.settings import get_settings
+
source = source or FakeSource()
prompt = prompt or FakePrompt()
gen = gen or FakeGenerator()
@@ -193,6 +259,7 @@ def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, im
wechat_group_id="g@chatroom",
wechat_group_name="测试群",
enabled=True,
+ schedule_rule=schedule_rule,
send_time=send_time,
image_enabled=image_enabled,
image_theme=image_theme,
@@ -203,6 +270,7 @@ def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, im
group.wechat_group_id = "g@chatroom"
group.wechat_group_name = "测试群"
group.send_target = ""
+ group.schedule_rule = schedule_rule
group.image_enabled = image_enabled
group.send_time = send_time
group.image_theme = image_theme
@@ -211,6 +279,7 @@ def _make_pipeline(tmp_path, source=None, prompt=None, gen=None, sender=None, im
group = repo.save_group(session, group)
return DailyPipeline(
+ settings=get_settings().model_copy(deep=True),
data_source=source,
prompt_builder=prompt,
image_generator=gen,
@@ -249,6 +318,26 @@ def test_generate_skip_when_already_ready(tmp_path):
assert results[0]["status"] == "skipped"
+def test_historical_recovery_requires_confirmation_and_never_auto_sends(tmp_path):
+ pipeline, group = _make_pipeline(tmp_path, send_time="08:30")
+ pipeline.generate_all(run_date="2026-08-18")
+ sender = FakeSender()
+ pipeline.sender = sender
+ now = datetime(2026, 8, 19, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+
+ first = pipeline.send_due_for_dates(["2026-08-18"], now=now, recovery=True)
+ second = pipeline.send_due_for_dates(["2026-08-18"], now=now, recovery=True)
+
+ assert first[0]["status"] == "held"
+ assert first[0]["error_type"] == "HISTORICAL_SEND_REQUIRES_CONFIRMATION"
+ assert second == []
+ assert sender.text_calls == []
+ assert sender.image_calls == []
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["status"] == READY_TO_SEND
+ assert run["send_hold_reason"] == "HISTORICAL_SEND_REQUIRES_CONFIRMATION"
+
+
def test_generate_force_regenerates(tmp_path):
pipeline, group = _make_pipeline(tmp_path)
pipeline.generate_all(run_date="2026-08-18")
@@ -301,7 +390,60 @@ def test_generate_explicit_refresh_replaces_saved_snapshot(tmp_path):
assert run["speaker_count"] == ranking["speaker_count"] == 1
assert run["prompt_meta"] == prompt_meta_before
assert run["prompt_rebuild_status"] == "required"
+ assert run["prompt_stale"] is True
+ assert run["image_stale"] is True
+ assert run["artifact_stale_reason"] == "MESSAGE_SNAPSHOT_REFRESHED"
+ assert run["message_snapshot_sha256"] != prompt_meta_before["message_snapshot_sha256"]
+ assert run["speaker_fingerprint"] != prompt_meta_before["speaker_fingerprint"]
+ assert run["send_hold"] is True
+
+
+def test_refresh_blocks_stale_selection_until_explicit_reselection(tmp_path):
+ pipeline, group = _make_pipeline(tmp_path)
+ pipeline.generate_all(run_date="2026-08-18")
+
+ changed_first = _msg("王五", "同一个 message_id 已更换说话人和原文", i=1)
+ changed_first.upstream_sender_name = "聊天时-王五"
+ refreshed = FakeSource(
+ messages=[
+ changed_first,
+ _msg("李四", "第二条消息仍可从当前快照回查", i=2),
+ ]
+ )
+ prompt = FakePrompt()
+ generator = FakeGenerator()
+ pipeline2, _ = _make_pipeline(
+ tmp_path,
+ source=refreshed,
+ prompt=prompt,
+ gen=generator,
+ )
+
+ refresh_result = pipeline2.generate_all(
+ run_date="2026-08-18",
+ refresh_messages=True,
+ )
+ blocked = pipeline2.rebuild_prompt_from_snapshot(group.id, "2026-08-18")
+ rebuilt = pipeline2.rebuild_prompt_from_snapshot(
+ group.id,
+ "2026-08-18",
+ allow_topic_reselection=True,
+ )
+ run = pipeline2.store.load_run("测试群", "2026-08-18")
+
+ assert refresh_result[0]["status"] == "data_ready"
+ assert blocked["error_type"] == "TOPIC_SELECTION_SNAPSHOT_INVALID"
+ assert rebuilt["status"] == "prompt_ready"
+ assert refreshed.fetch_calls == 1
+ assert len(prompt.inputs) == 1
+ assert prompt.inputs[0].persisted_topic_selection is None
+ assert generator.calls == []
+ assert run["prompt_topic_reselected"] is True
+ assert run["prompt_stale"] is False
+ assert run["image_stale"] is True
assert run["send_hold"] is True
+ assert run["message_snapshot_sha256"] == run["prompt_meta"]["message_snapshot_sha256"]
+ assert run["speaker_fingerprint"] == run["prompt_meta"]["speaker_fingerprint"]
def test_refresh_preserves_sent_status_and_sent_at_without_prompt_image_or_send(tmp_path):
@@ -332,6 +474,8 @@ def test_refresh_preserves_sent_status_and_sent_at_without_prompt_image_or_send(
def test_rebuild_prompt_uses_saved_snapshot_without_fetch_or_image(tmp_path):
pipeline, group = _make_pipeline(tmp_path)
pipeline.generate_all(run_date="2026-08-18")
+ sent_at = "2026-08-18T09:00:00+08:00"
+ pipeline.store.update("测试群", "2026-08-18", status=SENT, sent_at=sent_at)
snapshot_path = pipeline.store.messages_path("测试群", "2026-08-18")
image_path = pipeline.store.image_path("测试群", "2026-08-18")
snapshot_before = snapshot_path.read_bytes()
@@ -346,17 +490,94 @@ def test_rebuild_prompt_uses_saved_snapshot_without_fetch_or_image(tmp_path):
assert result["status"] == "prompt_ready"
assert source.fetch_calls == 0
assert len(prompt.inputs) == 1
+ assert prompt.inputs[0].persisted_topic_selection["selected_topic_ids"] == ["topic-01", "topic-02"]
assert generator.calls == []
assert snapshot_path.read_bytes() == snapshot_before
assert image_path.read_bytes() == image_before
run = pipeline2.store.load_run("测试群", "2026-08-18")
- assert run["status"] == PROMPT_READY
+ assert run["status"] == SENT
+ assert run["sent_at"] == sent_at
assert run["message_snapshot_reused"] is True
assert run["prompt_rebuild_status"] == "ready_for_review"
assert run["image_regen_status"] == "prompt_rebuilt"
assert run["send_hold"] is True
+def test_rebuild_failure_preserves_sent_status_and_sent_at(tmp_path):
+ pipeline, group = _make_pipeline(tmp_path)
+ pipeline.generate_all(run_date="2026-08-18")
+ sent_at = "2026-08-18T09:00:00+08:00"
+ pipeline.store.update("测试群", "2026-08-18", status=SENT, sent_at=sent_at)
+
+ pipeline2, _ = _make_pipeline(
+ tmp_path,
+ source=FakeSource(fail=True),
+ prompt=FakePrompt(fail=True),
+ gen=FakeGenerator(),
+ )
+ result = pipeline2.rebuild_prompt_from_snapshot(group.id, "2026-08-18")
+ run = pipeline2.store.load_run("测试群", "2026-08-18")
+
+ assert result["status"] == "failed"
+ assert pipeline2.data_source.fetch_calls == 0
+ assert run["status"] == SENT
+ assert run["sent_at"] == sent_at
+ assert run["prompt_rebuild_status"] == "failed"
+ assert run["send_hold"] is True
+
+
+def test_rebuild_missing_topics_requires_explicit_reselection_and_preserves_user_hold(
+ tmp_path,
+):
+ pipeline, group = _make_pipeline(tmp_path)
+ pipeline.generate_all(run_date="2026-08-18")
+ sent_at = "2026-08-18T09:00:00+08:00"
+ pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ status=SENT,
+ sent_at=sent_at,
+ prompt_meta={"mode": "local_infographic", "fallback_level": 3},
+ send_hold=True,
+ send_hold_reason="USER_REQUEST_NO_SEND_2026_08_18",
+ needs_manual_send=True,
+ image_force_local_fallback=True,
+ )
+
+ source = FakeSource(fail=True)
+ prompt = FakePrompt()
+ generator = FakeGenerator()
+ pipeline2, _ = _make_pipeline(
+ tmp_path,
+ source=source,
+ prompt=prompt,
+ gen=generator,
+ )
+
+ blocked = pipeline2.rebuild_prompt_from_snapshot(group.id, "2026-08-18")
+ rebuilt = pipeline2.rebuild_prompt_from_snapshot(
+ group.id,
+ "2026-08-18",
+ allow_topic_reselection=True,
+ )
+ run = pipeline2.store.load_run("测试群", "2026-08-18")
+
+ assert blocked["error_type"] == "TOPIC_SELECTION_SNAPSHOT_INVALID"
+ assert rebuilt["status"] == "prompt_ready"
+ assert source.fetch_calls == 0
+ assert len(prompt.inputs) == 1
+ assert prompt.inputs[0].persisted_topic_selection is None
+ assert generator.calls == []
+ assert run["status"] == SENT
+ assert run["sent_at"] == sent_at
+ assert run["prompt_topic_reselected"] is True
+ assert run["prompt_meta"]["topic_selection"]["selected_count"] == 2
+ assert run["image_force_local_fallback"] is False
+ assert run["send_hold"] is True
+ assert run["needs_manual_send"] is True
+ assert run["send_hold_reason"] == "USER_REQUEST_NO_SEND_2026_08_18"
+
+
def test_generate_corrupt_snapshot_fails_without_hidden_refetch(tmp_path):
source = FakeSource()
pipeline, _ = _make_pipeline(tmp_path, source=source)
@@ -450,6 +671,27 @@ def test_pipeline_passes_group_theme_and_records_request_metadata(tmp_path):
assert run["image_theme_custom"] == "可切回的旧主题"
+def test_prompt_visible_group_name_prefers_name_saved_in_run(tmp_path):
+ prompt = FakePrompt()
+ pipeline, group = _make_pipeline(tmp_path, prompt=prompt, image_enabled=False)
+ group.wechat_group_name = "数据库实时名 V5"
+ pipeline.store.save_run(
+ "测试群",
+ "2026-08-18",
+ {"status": "PENDING", "wechat_group_name": "运行已同步名 V4"},
+ )
+ window = pipeline.period_resolver.resolve(
+ run_date=date(2026, 8, 18),
+ timezone=pipeline.settings.app_timezone,
+ )
+
+ result = pipeline._generate_one(group, window, "2026-08-18", force=True)
+
+ assert result["status"] == "ready_to_send"
+ assert prompt.inputs[0].group_name == "测试群"
+ assert prompt.inputs[0].visible_group_name == "运行已同步名 V4"
+
+
def test_generate_one_refreshes_current_image_switch_before_decision(tmp_path):
pipeline, group = _make_pipeline(tmp_path, image_enabled=False)
assert group.image_enabled is False
@@ -481,14 +723,86 @@ 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_image_failure_returns_failed_state(tmp_path):
+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_uses_local_fallback(tmp_path):
gen = FakeGenerator(fail=True)
pipeline, group = _make_pipeline(tmp_path, gen=gen)
result = pipeline.force_generate(group.id, "2026-08-18")
- assert result["status"] == "failed"
- assert result["error_type"] == IMAGE_GENERATION_FAILED
+ assert result["status"] == "ready_to_send"
run = pipeline.store.load_run("测试群", "2026-08-18")
- assert run["status"] == FAILED
+ assert run["status"] == READY_TO_SEND
+ assert run["image_fallback_level"] == 3
+ assert run["image_variant"] == "pillow"
+
+
+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_image_job_refuses_stale_prompt_contract(tmp_path):
+ pipeline, group = _make_pipeline(tmp_path)
+ pipeline.generate_all(run_date="2026-08-18")
+ pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ prompt_stale=True,
+ artifact_stale_reason="MESSAGE_SNAPSHOT_REFRESHED",
+ )
+
+ with pytest.raises(ValueError, match="归属契约不一致"):
+ pipeline._make_image_job(group, "2026-08-18", force=True)
def test_generate_data_failure_marks_failed(tmp_path):
@@ -502,13 +816,26 @@ def test_generate_data_failure_marks_failed(tmp_path):
assert run["failed_stage"] == "data"
-def test_generate_prompt_failure_marks_failed(tmp_path):
- pipeline, group = _make_pipeline(tmp_path, prompt=FakePrompt(fail=True))
+def test_generate_prompt_failure_uses_local_infographic(tmp_path):
+ preserved_meta = {
+ "topic_selection": {"selected_topic_ids": ["topic-01"]},
+ "layout_id": "split_focus",
+ }
+ pipeline, group = _make_pipeline(
+ tmp_path,
+ prompt=FakePrompt(fail=True, failure_meta=preserved_meta),
+ )
results = pipeline.generate_all(run_date="2026-08-18")
- assert results[0]["status"] == "failed"
- assert results[0]["error_type"] == "PROMPT_FAILED"
+ assert results[0]["status"] == "ready_to_send"
run = pipeline.store.load_run("测试群", "2026-08-18")
- assert run["status"] == FAILED
+ assert run["status"] == READY_TO_SEND
+ assert run["prompt_fallback_level"] == 3
+ assert run["image_fallback_level"] == 3
+ assert run["image_variant"] == "pillow"
+ assert run["prompt_meta"]["topic_selection"] == preserved_meta["topic_selection"]
+ assert run["prompt_meta"]["layout_id"] == "split_focus"
+ assert run["prompt_meta"]["mode"] == "local_infographic"
+ assert pipeline.store.image_path("测试群", "2026-08-18").is_file()
def test_image_disabled_goes_ready_to_send_without_image(tmp_path):
@@ -527,13 +854,115 @@ def test_image_serial_order(tmp_path):
assert len(gen.calls) == 1
-def test_saturday_generates_previous_day(tmp_path):
+def test_existing_valid_image_reconciles_failed_state_without_regeneration(tmp_path):
pipeline, group = _make_pipeline(tmp_path)
+ pipeline.generate_all(run_date="2026-08-18")
+ previous = pipeline.store.load_run("测试群", "2026-08-18")
+ pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ status=FAILED,
+ failed_stage="image",
+ error="模拟图片落盘后进程中断",
+ image_job=previous["image_job"],
+ )
+ generator = FakeGenerator()
+ pipeline.image_generator = generator
+
+ job = pipeline._make_image_job(group, "2026-08-18", force=False)
+ result = pipeline._run_image_jobs([job], "2026-08-18")
+
+ assert result[0]["status"] == "ready_to_send"
+ assert generator.calls == []
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["status"] == READY_TO_SEND
+ assert run["image_job"]["prompt_sha256"] == previous["image_job"]["prompt_sha256"]
+
+
+def test_identical_generation_failure_exhausts_budget_after_real_retries(tmp_path, monkeypatch):
+ source = FakeSource(fail=True)
+ pipeline, _ = _make_pipeline(tmp_path, source=source, image_enabled=False)
+ monkeypatch.setattr("app.pipeline.generation_stages.retry_is_due", lambda _run: True)
+
+ first = pipeline.generate_all(run_date="2026-08-18")
+ second = pipeline.generate_all(run_date="2026-08-18")
+ third = pipeline.generate_all(run_date="2026-08-18")
+ final = pipeline.generate_all(run_date="2026-08-18")
+
+ assert [first[0]["status"], second[0]["status"], third[0]["status"]] == [
+ "failed",
+ "failed",
+ "failed",
+ ]
+ assert final[0]["status"] == "failed_final"
+ assert source.fetch_calls == 3
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["retry_attempt_count"] == 3
+ assert run["execution_state"] == "FAILED_FINAL"
+ assert len(run["attempt_ledger"]) == 3
+
+
+def test_force_generate_recovers_failed_final_without_erasing_retry_history(tmp_path, monkeypatch):
+ source = FakeSource(fail=True)
+ pipeline, group = _make_pipeline(tmp_path, source=source, image_enabled=False)
+ monkeypatch.setattr("app.pipeline.generation_stages.retry_is_due", lambda _run: True)
+
+ pipeline.generate_all(run_date="2026-08-18")
+ pipeline.generate_all(run_date="2026-08-18")
+ pipeline.generate_all(run_date="2026-08-18")
+ exhausted = pipeline.store.load_run("测试群", "2026-08-18")
+ assert exhausted["execution_state"] == "FAILED_FINAL"
+ assert exhausted["retry_attempt_count"] == 3
+
+ source.fail = False
+ recovered = pipeline.force_generate(group.id, "2026-08-18")
+
+ assert recovered["status"] == "ready_to_send"
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["status"] == READY_TO_SEND
+ assert run["retry_attempt_count"] == 3
+ assert len(run["attempt_ledger"]) == 3
+ assert source.fetch_calls == 4
+
+
+def test_force_generate_bypasses_retry_delay_but_not_manual_hold(tmp_path):
+ source = FakeSource(fail=True)
+ pipeline, group = _make_pipeline(tmp_path, source=source, image_enabled=False)
+
+ first = pipeline.generate_all(run_date="2026-08-18")
+ assert first[0]["status"] == "failed"
+ waiting = pipeline.store.load_run("测试群", "2026-08-18")
+ assert waiting["execution_state"] == "WAIT_RETRY"
+
+ source.fail = False
+ recovered = pipeline.force_generate(group.id, "2026-08-18")
+ assert recovered["status"] == "ready_to_send"
+
+ pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ status=FAILED,
+ failed_stage="prompt",
+ error_type="PROMPT_RESULT_UNKNOWN",
+ error="结果未知,需人工核对",
+ prompt_hold=True,
+ manual_hold=True,
+ )
+ source.fetch_calls = 0
+
+ held = pipeline.force_generate(group.id, "2026-08-18")
+
+ assert held["status"] == "held"
+ assert held["error_type"] == "PROMPT_RESULT_UNKNOWN"
+ assert source.fetch_calls == 0
+
+
+def test_weekday_default_skips_saturday(tmp_path):
+ pipeline, group = _make_pipeline(tmp_path, schedule_rule="weekday_default")
results = pipeline.generate_all(run_date="2026-08-22") # 周六
- assert results[0]["status"] == "ready_to_send"
+ assert results == [{"status": "no_groups", "reason": "当日没有符合群级统计规则的任务"}]
run = pipeline.store.load_run("测试群", "2026-08-22")
- assert run["period_start"] == "2026-08-21 00:00:00"
- assert run["period_end"] == "2026-08-21 23:59:59"
+ assert run["status"] == "PENDING"
# ---------- 发送阶段 ----------
@@ -545,6 +974,23 @@ def _ready_to_send(tmp_path, image_enabled=True, send_time="08:30") -> DailyPipe
return pipeline
+def _ready_run_contract(*, image_enabled: bool = False) -> dict:
+ snapshot_hash = "a" * 64
+ speaker_fingerprint = "b" * 64
+ return {
+ "status": READY_TO_SEND,
+ "image_enabled": image_enabled,
+ "message_snapshot_sha256": snapshot_hash,
+ "speaker_fingerprint": speaker_fingerprint,
+ "prompt_meta": {
+ "message_snapshot_sha256": snapshot_hash,
+ "speaker_fingerprint": speaker_fingerprint,
+ },
+ "prompt_stale": False,
+ "image_stale": False,
+ }
+
+
def test_send_due_sends_text_then_image(tmp_path):
pipeline = _ready_to_send(tmp_path)
now = datetime(2026, 8, 18, 9, 0, 0) # 超过 08:30
@@ -559,9 +1005,209 @@ def test_send_due_sends_text_then_image(tmp_path):
assert run["image_attempt_started_at"]
assert run["image_attempt_finished_at"]
assert run["verification_level"] == "provider_reported"
+ evidence = run["delivery_evidence"]
+ assert evidence["target"] == "测试群"
+ assert evidence["result"] == "sent"
+ assert evidence["verification_level"] == "provider_reported"
+ assert len(evidence["text_sha256"]) == 64
+ assert len(evidence["image_sha256"]) == 64
+ sender = pipeline.sender
+ assert len(sender.text_calls) == 1
+ assert len(sender.image_calls) == 1
+
+
+def test_explicit_text_failures_use_backoff_and_stop_after_send_retry_budget(tmp_path):
+ sender = FakeSender(fail_text=True)
+ pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=False)
+ pipeline.generate_all(run_date="2026-08-18")
+
+ first = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ assert first[0]["status"] == "retry_scheduled"
+ assert len(sender.text_calls) == 1
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["send_retry_attempt_count"] == 1
+ assert run["send_next_retry_at"] == "2026-08-18T08:32:00"
+
+ early = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 30))
+ assert early[0]["status"] == "retry_scheduled"
+ assert len(sender.text_calls) == 1
+
+ second = pipeline.send_due(now=datetime(2026, 8, 18, 8, 32, 0))
+ assert second[0]["status"] == "retry_scheduled"
+ assert len(sender.text_calls) == 2
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["send_next_retry_at"] == "2026-08-18T08:37:00"
+
+ third = pipeline.send_due(now=datetime(2026, 8, 18, 8, 38, 0))
+ assert third[0]["status"] == "failed_final"
+ assert len(sender.text_calls) == 3
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["send_state"] == "failed_final"
+ assert run["send_hold"] is True
+ assert run["send_hold_reason"] == "SEND_RETRY_EXHAUSTED"
+ assert run["send_retry_attempt_count"] == 3
+ assert len(run["send_failure_ledger"]) == 3
+
+ assert pipeline.send_due(now=datetime(2026, 8, 18, 8, 39, 0)) == []
+ assert len(sender.text_calls) == 3
+
+
+def test_explicit_unsubmitted_final_failure_can_be_reset_without_sending(tmp_path):
+ pipeline = _ready_to_send(tmp_path, image_enabled=False)
sender = pipeline.sender
+ failed = pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ send_state="failed_final",
+ send_hold=True,
+ send_hold_reason="SEND_RETRY_EXHAUSTED",
+ needs_manual_send=True,
+ send_retry_attempt_count=3,
+ send_next_retry_at="",
+ send_last_failure_at="2026-08-18T08:38:00",
+ send_error="目标匹配数 0",
+ send_error_type="SEND_TEXT_FAILED",
+ send_failure_ledger=[{"attempt": 3, "error_type": "SEND_TEXT_FAILED"}],
+ text_attempt_started_at="2026-08-18T08:38:00",
+ text_attempt_finished_at="2026-08-18T08:38:01",
+ text_submitted_at="",
+ text_verified_at="",
+ text_sent_at="",
+ image_submitted_at="",
+ image_verified_at="",
+ image_sent_at="",
+ )
+ calls_before = (len(sender.text_calls), len(sender.image_calls))
+
+ reset = pipeline.reset_explicit_send_failure(
+ 1,
+ "2026-08-18",
+ expected_updated_at=failed["updated_at"],
+ expected_state_version=failed["state_version"],
+ )
+ stale = pipeline.reset_explicit_send_failure(
+ 1,
+ "2026-08-18",
+ expected_updated_at=failed["updated_at"],
+ expected_state_version=failed["state_version"],
+ )
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+
+ assert reset["status"] == "prepared"
+ assert stale["status"] == "conflict"
+ assert stale["reason"] == "stale"
+ assert run["send_state"] == "ready"
+ assert run["send_hold"] is False
+ assert run["send_retry_attempt_count"] == 0
+ assert run["send_failure_ledger"] == failed["send_failure_ledger"]
+ assert run["send_retry_reset_history"][-1]["previous_attempt_count"] == 3
+ assert (len(sender.text_calls), len(sender.image_calls)) == calls_before
+
+
+@pytest.mark.parametrize(
+ "evidence_field",
+ [
+ "sent_at",
+ "send_unknown_at",
+ "text_submitted_at",
+ "text_verified_at",
+ "text_sent_at",
+ "image_submitted_at",
+ "image_verified_at",
+ "image_sent_at",
+ ],
+)
+def test_final_failure_reset_rejects_any_submission_or_delivery_evidence(
+ tmp_path, evidence_field
+):
+ pipeline = _ready_to_send(tmp_path, image_enabled=False)
+ failed = pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ send_state="failed_final",
+ send_hold=True,
+ send_hold_reason="SEND_RETRY_EXHAUSTED",
+ send_retry_attempt_count=3,
+ **{evidence_field: "2026-08-18T08:38:00"},
+ )
+
+ result = pipeline.reset_explicit_send_failure(
+ 1,
+ "2026-08-18",
+ expected_updated_at=failed["updated_at"],
+ expected_state_version=failed["state_version"],
+ )
+
+ assert result["status"] == "conflict"
+ assert result["reason"] == "submission_evidence"
+
+
+def test_final_failure_reset_rejects_unknown_state(tmp_path):
+ pipeline = _ready_to_send(tmp_path, image_enabled=False)
+ unknown = pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ send_state="unknown",
+ send_hold=True,
+ send_hold_reason="SEND_RESULT_UNKNOWN",
+ send_unknown_at="2026-08-18T08:38:00",
+ )
+
+ result = pipeline.reset_explicit_send_failure(
+ 1,
+ "2026-08-18",
+ expected_updated_at=unknown["updated_at"],
+ expected_state_version=unknown["state_version"],
+ )
+
+ assert result["status"] == "conflict"
+ assert result["reason"] == "not_explicit_failure"
+
+
+def test_final_failure_reset_rejects_non_exhausted_retry_budget(tmp_path):
+ pipeline = _ready_to_send(tmp_path, image_enabled=False)
+ inconsistent = pipeline.store.update(
+ "测试群",
+ "2026-08-18",
+ send_state="failed_final",
+ send_hold=True,
+ send_hold_reason="SEND_RETRY_EXHAUSTED",
+ send_retry_attempt_count=1,
+ send_retry_budget=3,
+ )
+
+ result = pipeline.reset_explicit_send_failure(
+ 1,
+ "2026-08-18",
+ expected_updated_at=inconsistent["updated_at"],
+ expected_state_version=inconsistent["state_version"],
+ )
+
+ assert result["status"] == "conflict"
+ assert result["reason"] == "not_explicit_failure"
+
+
+def test_image_retry_preserves_confirmed_text_checkpoint(tmp_path):
+ sender = FakeSender(fail_image=True)
+ pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=True)
+ pipeline.generate_all(run_date="2026-08-18")
+
+ first = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ assert first[0]["status"] == "retry_scheduled"
assert len(sender.text_calls) == 1
assert len(sender.image_calls) == 1
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["text_sent_at"]
+ assert not run.get("image_sent_at")
+
+ sender.fail_image = False
+ second = pipeline.send_due(now=datetime(2026, 8, 18, 8, 32, 0))
+ assert second[0]["status"] == "sent"
+ assert len(sender.text_calls) == 1
+ assert len(sender.image_calls) == 2
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["status"] == SENT
+ assert run["send_next_retry_at"] == ""
def test_send_due_refreshes_renamed_group_by_stable_id_without_changing_archive_name(tmp_path):
@@ -625,7 +1271,21 @@ def test_send_due_uses_cached_name_when_live_sync_is_unavailable(tmp_path):
def test_send_due_processes_same_time_groups_in_stable_order(tmp_path, monkeypatch):
- sender = FakeSender()
+ class SerialGuardSender(FakeSender):
+ def __init__(self):
+ super().__init__()
+ self.active_calls = 0
+ self.max_active_calls = 0
+
+ def send_text(self, target: str, text: str):
+ self.active_calls += 1
+ self.max_active_calls = max(self.max_active_calls, self.active_calls)
+ try:
+ return super().send_text(target, text)
+ finally:
+ self.active_calls -= 1
+
+ sender = SerialGuardSender()
pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=False)
groups = [
Group(
@@ -649,7 +1309,11 @@ def test_send_due_processes_same_time_groups_in_stable_order(tmp_path, monkeypat
]
monkeypatch.setattr(pipeline, "_load_groups", lambda group_ids=None: groups)
for group in groups:
- pipeline.store.save_run(group.display_name, "2026-08-18", {"status": READY_TO_SEND})
+ pipeline.store.save_run(
+ group.display_name,
+ "2026-08-18",
+ _ready_run_contract(),
+ )
ranking_path = pipeline.store.ranking_txt_path(group.display_name, "2026-08-18")
ranking_path.parent.mkdir(parents=True, exist_ok=True)
ranking_path.write_text(f"{group.display_name}总结", encoding="utf-8")
@@ -658,6 +1322,122 @@ def test_send_due_processes_same_time_groups_in_stable_order(tmp_path, monkeypat
assert [result["group_name"] for result in results] == ["顺序群一", "顺序群二"]
assert [target for target, _ in sender.text_calls] == ["目标一", "目标二"]
+ assert sender.max_active_calls == 1
+
+
+def test_send_due_isolates_one_group_pre_submit_exception(tmp_path, monkeypatch):
+ sender = FakeSender()
+ pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=False)
+ groups = [
+ Group(id=111, display_name="异常群", wechat_group_name="异常群", send_target="目标一", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ Group(id=112, display_name="正常群", wechat_group_name="正常群", send_target="目标二", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ ]
+ monkeypatch.setattr(pipeline, "_load_groups", lambda group_ids=None: groups)
+ for group in groups:
+ pipeline.store.save_run(
+ group.display_name,
+ "2026-08-18",
+ _ready_run_contract(),
+ )
+ path = pipeline.store.ranking_txt_path(group.display_name, "2026-08-18")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("日报", encoding="utf-8")
+ original = pipeline._send_one
+
+ def isolated_send(group, *args, **kwargs):
+ if group.display_name == "异常群":
+ raise RuntimeError("pre-submit")
+ return original(group, *args, **kwargs)
+
+ monkeypatch.setattr(pipeline, "_send_one", isolated_send)
+ results = pipeline.send_due(now=datetime(2026, 8, 18, 8, 30, 0))
+
+ assert [item["status"] for item in results] == ["failed", "sent"]
+ assert [target for target, _ in sender.text_calls] == ["目标二"]
+
+
+def test_send_due_aborts_batch_when_desktop_submission_state_is_unknown(tmp_path, monkeypatch):
+ sender = FakeSender()
+ pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=False)
+ groups = [
+ Group(id=121, display_name="未知群", wechat_group_name="未知群", send_target="目标一", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ Group(id=122, display_name="不应继续群", wechat_group_name="不应继续群", send_target="目标二", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ ]
+ monkeypatch.setattr(pipeline, "_load_groups", lambda group_ids=None: groups)
+ for group in groups:
+ pipeline.store.save_run(
+ group.display_name,
+ "2026-08-18",
+ _ready_run_contract(),
+ )
+ path = pipeline.store.ranking_txt_path(group.display_name, "2026-08-18")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("日报", encoding="utf-8")
+
+ def unresolved_send(group, group_name, run, run_date, now, **kwargs):
+ if group.display_name != "未知群":
+ return pytest.fail("桌面发送状态未知后不得继续其他群")
+ claim_id, _, _ = pipeline.store.claim_send(
+ group_name,
+ run_date,
+ now=now,
+ lease_seconds=60,
+ )
+ pipeline.store.update_send_claim(
+ group_name,
+ run_date,
+ claim_id,
+ send_state="sending_text",
+ text_attempt_started_at=now.isoformat(),
+ text_attempt_finished_at="",
+ )
+ raise RuntimeError("desktop crashed after submit")
+
+ monkeypatch.setattr(pipeline, "_send_one", unresolved_send)
+ results = pipeline.send_due(now=datetime(2026, 8, 18, 8, 30, 0))
+
+ assert len(results) == 1
+ assert results[0]["status"] == "held"
+ assert results[0]["error_type"] == "SEND_RESULT_UNKNOWN"
+ assert pipeline.store.load_run("未知群", "2026-08-18")["send_state"] == "unknown"
+ assert pipeline.store.load_run("不应继续群", "2026-08-18")["status"] == READY_TO_SEND
+
+
+def test_send_due_aborts_batch_when_sender_returns_unknown_result(tmp_path, monkeypatch):
+ sender = UnknownTextSender()
+ pipeline, _ = _make_pipeline(tmp_path, sender=sender, image_enabled=False)
+ groups = [
+ Group(id=131, display_name="未知返回群", wechat_group_name="未知返回群", send_target="目标一", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ Group(id=132, display_name="后续禁止群", wechat_group_name="后续禁止群", send_target="目标二", send_time="08:30", image_enabled=False, wechat_send_enabled=True),
+ ]
+ monkeypatch.setattr(pipeline, "_load_groups", lambda group_ids=None: groups)
+ for group in groups:
+ pipeline.store.save_run(group.display_name, "2026-08-18", _ready_run_contract())
+ path = pipeline.store.ranking_txt_path(group.display_name, "2026-08-18")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("日报", encoding="utf-8")
+
+ results = pipeline.send_due(now=datetime(2026, 8, 18, 8, 30, 0))
+
+ assert len(results) == 1
+ assert results[0]["error_type"] == "SEND_RESULT_UNKNOWN"
+ assert [target for target, _ in sender.text_calls] == ["目标一"]
+ assert pipeline.store.load_run("后续禁止群", "2026-08-18")["status"] == READY_TO_SEND
+
+
+def test_send_due_uses_cached_groups_when_sync_itself_raises(tmp_path, monkeypatch):
+ pipeline = _ready_to_send(tmp_path, image_enabled=False)
+ monkeypatch.setattr(
+ pipeline,
+ "_sync_group_names",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("mcp unavailable")),
+ )
+
+ result = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+
+ assert result[0]["status"] == "sent"
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["name_sync_status"] == "cached"
def test_send_due_skips_group_when_wechat_send_is_not_enabled(tmp_path):
@@ -676,11 +1456,20 @@ def test_send_due_skips_group_when_wechat_send_is_not_enabled(tmp_path):
def test_send_not_due_yet(tmp_path):
pipeline = _ready_to_send(tmp_path, send_time="12:00")
+ pipeline.settings.schedule_send_time = "12:00"
now = datetime(2026, 8, 18, 9, 0, 0)
results = pipeline.send_due(now=now)
assert results == []
+def test_group_send_time_is_ignored_in_favor_of_global_batch_time(tmp_path):
+ pipeline = _ready_to_send(tmp_path, send_time="12:00")
+
+ results = pipeline.send_due(now=datetime(2026, 8, 18, 8, 30, 0))
+
+ assert results[0]["status"] == "sent"
+
+
def test_send_no_duplicate_after_sent(tmp_path):
pipeline = _ready_to_send(tmp_path)
now = datetime(2026, 8, 18, 9, 0, 0)
@@ -694,7 +1483,7 @@ def test_send_text_failure(tmp_path):
pipeline.sender = FakeSender(fail_text=True)
now = datetime(2026, 8, 18, 9, 0, 0)
results = pipeline.send_due(now=now)
- assert results[0]["status"] == "failed"
+ assert results[0]["status"] == "retry_scheduled"
assert results[0]["error_type"] == "SEND_TEXT_FAILED"
run = pipeline.store.load_run("测试群", "2026-08-18")
assert run["status"] == READY_TO_SEND
@@ -754,7 +1543,7 @@ def test_send_image_failure(tmp_path):
pipeline.sender = FakeSender(fail_image=True)
now = datetime(2026, 8, 18, 9, 0, 0)
results = pipeline.send_due(now=now)
- assert results[0]["status"] == "failed"
+ assert results[0]["status"] == "retry_scheduled"
assert results[0]["error_type"] == "SEND_IMAGE_FAILED"
run = pipeline.store.load_run("测试群", "2026-08-18")
assert run["status"] == READY_TO_SEND
@@ -770,7 +1559,11 @@ def test_send_image_retry_does_not_repeat_text(tmp_path):
retry_sender = FakeSender()
pipeline.sender = retry_sender
- result = pipeline.send_due(now=datetime(2026, 8, 18, 9, 0, 0))
+ result = pipeline.send_due_for_dates(
+ ["2026-08-18"],
+ now=datetime(2026, 8, 18, 9, 1, 0),
+ recovery=True,
+ )
assert result[0]["status"] == "sent"
assert retry_sender.text_calls == []
@@ -866,9 +1659,233 @@ def test_submitted_but_unverified_text_is_held_without_retry(tmp_path):
assert run["send_hold"] is True
+def test_submitted_failure_is_still_held_as_unknown(tmp_path):
+ pipeline = _ready_to_send(tmp_path)
+ sender = SubmittedFailureTextSender()
+ pipeline.sender = sender
+
+ result = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+
+ assert result[0]["status"] == "held"
+ assert result[0]["error_type"] == "SEND_RESULT_UNKNOWN"
+ assert len(sender.text_calls) == 1
+ assert sender.image_calls == []
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["send_state"] == "unknown"
+
+
+def test_text_success_persistence_failure_holds_before_image(tmp_path, monkeypatch):
+ pipeline = _ready_to_send(tmp_path)
+ original = pipeline.store.update_send_claim
+ calls = 0
+
+ def flaky_update(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ # 第 1 次先原子保存送达证据,第 2 次标记文字提交中,
+ # 第 3 次模拟文字已成功但成功检查点无法落盘。
+ if calls == 3:
+ return False, pipeline.store.load_run("测试群", "2026-08-18")
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(pipeline.store, "update_send_claim", flaky_update)
+ result = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+
+ assert result[0]["status"] == "held"
+ assert result[0]["error_type"] == "SEND_RESULT_UNKNOWN"
+ assert len(pipeline.sender.text_calls) == 1
+ assert pipeline.sender.image_calls == []
+ assert pipeline.store.load_run("测试群", "2026-08-18")["send_state"] == "unknown"
+
+
+def test_image_claim_update_failure_stops_before_external_submit(tmp_path, monkeypatch):
+ pipeline = _ready_to_send(tmp_path)
+ original = pipeline.store.update_send_claim
+ calls = 0
+
+ def flaky_update(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ # 保存证据、文字提交中、文字成功检查点之后,
+ # 第 4 次在图片外部提交前模拟 claim 丢失。
+ if calls == 4:
+ return False, pipeline.store.load_run("测试群", "2026-08-18")
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(pipeline.store, "update_send_claim", flaky_update)
+ result = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+
+ assert result[0]["status"] == "skipped"
+ assert result[0]["error_type"] == "SEND_CLAIM_LOST"
+ assert len(pipeline.sender.text_calls) == 1
+ assert pipeline.sender.image_calls == []
+
+
+def test_sent_terminal_persistence_failure_becomes_unknown_hold(tmp_path, monkeypatch):
+ pipeline = _ready_to_send(tmp_path)
+ original = pipeline.store.finish_send_claim
+
+ def flaky_finish(*args, **kwargs):
+ if kwargs.get("send_state") == "sent":
+ return False, pipeline.store.load_run("测试群", "2026-08-18")
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(pipeline.store, "finish_send_claim", flaky_finish)
+ result = pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+
+ assert result[0]["status"] == "held"
+ assert result[0]["error_type"] == "SEND_RESULT_UNKNOWN"
+ assert len(pipeline.sender.text_calls) == 1
+ assert len(pipeline.sender.image_calls) == 1
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+ assert run["send_state"] == "unknown"
+ assert run["status"] == READY_TO_SEND
+
+
+def test_manual_text_sent_resolution_continues_image_without_resending_text(tmp_path):
+ pipeline = _ready_to_send(tmp_path)
+ pipeline.sender = UnknownTextSender()
+ pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ unknown = pipeline.store.load_run("测试群", "2026-08-18")
+ sender = FakeSender()
+ pipeline.sender = sender
+
+ resolved = pipeline.resolve_send_unknown(
+ 1,
+ "2026-08-18",
+ resolution="text_sent",
+ expected_send_unknown_at=unknown["send_unknown_at"],
+ )
+
+ assert resolved["status"] == "resolved"
+ assert resolved["next_stage"] == "image"
+ assert sender.text_calls == []
+ assert sender.image_calls == []
+ sent = pipeline.force_send(1, "2026-08-18", confirm_late_send=True)
+ assert sent["status"] == "sent"
+ assert sender.text_calls == []
+ assert len(sender.image_calls) == 1
+
+
+def test_manual_not_sent_resolution_allows_one_fresh_full_send(tmp_path):
+ pipeline = _ready_to_send(tmp_path)
+ pipeline.sender = UnknownTextSender()
+ pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ unknown = pipeline.store.load_run("测试群", "2026-08-18")
+ sender = FakeSender()
+ pipeline.sender = sender
+
+ resolved = pipeline.resolve_send_unknown(
+ 1,
+ "2026-08-18",
+ resolution="not_sent",
+ expected_send_unknown_at=unknown["send_unknown_at"],
+ )
+ stale = pipeline.resolve_send_unknown(
+ 1,
+ "2026-08-18",
+ resolution="not_sent",
+ expected_send_unknown_at=unknown["send_unknown_at"],
+ )
+
+ assert resolved["status"] == "resolved"
+ assert stale["status"] == "conflict"
+ sent = pipeline.force_send(1, "2026-08-18", confirm_late_send=True)
+ assert sent["status"] == "sent"
+ assert len(sender.text_calls) == 1
+ assert len(sender.image_calls) == 1
+
+
+def test_manual_all_sent_resolution_is_cas_protected_and_never_calls_sender(tmp_path):
+ pipeline = _ready_to_send(tmp_path)
+ unknown_sender = UnknownTextSender()
+ pipeline.sender = unknown_sender
+ pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ held = pipeline.store.load_run("测试群", "2026-08-18")
+
+ resolved = pipeline.resolve_manual_send(
+ 1,
+ "2026-08-18",
+ resolution="all_sent",
+ expected_updated_at=held["updated_at"],
+ )
+ stale = pipeline.resolve_manual_send(
+ 1,
+ "2026-08-18",
+ resolution="all_sent",
+ expected_updated_at=held["updated_at"],
+ )
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+
+ assert resolved["status"] == "resolved"
+ assert resolved["next_stage"] == "complete"
+ assert stale["status"] == "conflict"
+ assert run["status"] == SENT
+ assert run["send_state"] == "sent"
+ assert run["send_hold"] is False
+ assert run["verification_level"] == "manual_user_confirmed"
+ assert run["send_resolution_history"][-1]["resolution"] == "all_sent"
+ assert len(unknown_sender.text_calls) == 1
+ assert unknown_sender.image_calls == []
+
+
+def test_manual_resolution_rejects_missing_image_without_mutating_hold(tmp_path):
+ pipeline = _ready_to_send(tmp_path)
+ pipeline.sender = UnknownTextSender()
+ pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ held = pipeline.store.load_run("测试群", "2026-08-18")
+ pipeline.store.image_path("测试群", "2026-08-18").unlink()
+
+ rejected = pipeline.resolve_manual_send(
+ 1,
+ "2026-08-18",
+ resolution="all_sent",
+ expected_updated_at=held["updated_at"],
+ )
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+
+ assert rejected["status"] == "conflict"
+ assert rejected["reason"] == "image_missing"
+ assert run["send_hold"] is True
+
+
+@pytest.mark.parametrize(
+ ("resolution", "expected_text_sent", "expected_next_stage"),
+ [
+ ("text_sent", True, "image"),
+ ("not_sent", False, "text"),
+ ],
+)
+def test_manual_partial_resolutions_update_only_run_state(
+ tmp_path, resolution, expected_text_sent, expected_next_stage
+):
+ pipeline = _ready_to_send(tmp_path)
+ sender = UnknownTextSender()
+ pipeline.sender = sender
+ pipeline.send_due(now=datetime(2026, 8, 18, 8, 31, 0))
+ held = pipeline.store.load_run("测试群", "2026-08-18")
+ calls_before = (len(sender.text_calls), len(sender.image_calls))
+
+ resolved = pipeline.resolve_manual_send(
+ 1,
+ "2026-08-18",
+ resolution=resolution,
+ expected_updated_at=held["updated_at"],
+ )
+ run = pipeline.store.load_run("测试群", "2026-08-18")
+
+ assert resolved["status"] == "resolved"
+ assert resolved["next_stage"] == expected_next_stage
+ assert run["status"] == READY_TO_SEND
+ assert run["send_hold"] is False
+ assert bool(run.get("text_sent_at")) is expected_text_sent
+ assert run["send_resolution_history"][-1]["resolution"] == resolution
+ assert (len(sender.text_calls), len(sender.image_calls)) == calls_before
+
+
def test_run_store_send_claim_prevents_duplicate_and_marks_expired_attempt_unknown(tmp_path):
store = RunStore(tmp_path / "output")
- store.save_run("测试群", "2026-08-18", {"status": READY_TO_SEND})
+ store.save_run("测试群", "2026-08-18", _ready_run_contract())
now = datetime(2026, 8, 18, 8, 30, 0)
claim_id, _, reason = store.claim_send(
@@ -893,3 +1910,18 @@ def test_run_store_send_claim_prevents_duplicate_and_marks_expired_attempt_unkno
assert recovered is None
assert recovered_reason == "result_unknown"
assert run["send_state"] == "unknown"
+
+
+def test_run_store_send_claim_treats_legacy_artifact_without_contract_as_stale(tmp_path):
+ store = RunStore(tmp_path / "output")
+ store.save_run("测试群", "2026-08-18", {"status": READY_TO_SEND})
+
+ claim_id, _, reason = store.claim_send(
+ "测试群",
+ "2026-08-18",
+ now=datetime(2026, 8, 18, 8, 30, 0),
+ lease_seconds=60,
+ )
+
+ assert claim_id is None
+ assert reason == "artifact_stale"
diff --git a/tests/test_v2_prompt_builder.py b/tests/test_v2_prompt_builder.py
index ca41bfd..75531a1 100644
--- a/tests/test_v2_prompt_builder.py
+++ b/tests/test_v2_prompt_builder.py
@@ -1,8 +1,4 @@
-"""V2 P4:ImagePromptBuilder 单元测试。
-
-通过注入 FakeDeepSeek(实现 _chat 契约)隔离真实 API,
-验证:模板渲染 / 输入组装 / 分块策略 / 元数据 / 失败路径。
-"""
+"""固定群聊漫画 Prompt:证据编辑、固定结构、风格与快照复用。"""
from __future__ import annotations
@@ -12,459 +8,621 @@
import pytest
+from app.ai.image_themes import STYLE_FAMILY_KEYS
+from app.ai.layouts import LayoutPlan, PanelBeat
+from app.ai.poster_copy import (
+ PosterCopyError,
+ build_poster_editor_prompt,
+ build_poster_editor_source,
+ parse_poster_copy,
+ validate_fixed_prompt_contract,
+)
from app.ai.prompt_builder import DeepSeekImagePromptBuilder
from app.ai.prompt_builder_types import PromptInput
-from app.ai.prompt_templates import (
- DEFAULT_IMAGE_PROMPT_TEMPLATE,
- ImagePromptTemplateError,
- ImagePromptTemplateService,
-)
+from app.ai.prompt_templates import DEFAULT_IMAGE_PROMPT_TEMPLATE, ImagePromptTemplateService
+from app.config.settings import PROJECT_ROOT
from app.data_sources.base import V2Message
-def _msg(sender: str, content: str = "hi", mtype: str = "text", i: int = 0) -> V2Message:
+def _msg(sender: str, content: str, i: int) -> V2Message:
return V2Message(
message_id=f"m{i}",
group_id="g1@chatroom",
group_name="测试群",
sender_id=f"wxid_{sender}",
sender_name=sender,
- timestamp=datetime(2026, 8, 17, 10, 30, 0),
- message_type=mtype,
+ timestamp=datetime(2026, 8, 17, 10, 30, i),
+ message_type="text",
content=content,
)
-def _input(
- messages=None,
- template="default",
- image_theme="blue_white",
- image_theme_custom="",
- recent_layout_history=(),
- persisted_theme_meta=None,
-) -> PromptInput:
- return PromptInput(
- group_name="示例交流群 A",
- group_id="group-1",
- run_date="2026-08-18",
- period_start="2026-08-17 00:00:00",
- period_end="2026-08-17 23:59:59",
- message_count=3,
- speaker_count=2,
- messages=messages or [_msg("张三", "今天群里聊了票房", i=1), _msg("李四", "《牛来》破500万了", i=2)],
- template=template,
- image_theme=image_theme,
- image_theme_custom=image_theme_custom,
- recent_layout_history=tuple(recent_layout_history),
- persisted_theme_meta=persisted_theme_meta,
- )
+def _input(**changes) -> PromptInput:
+ values = {
+ "group_name": "示例交流群 A",
+ "visible_group_name": "示例交流群 A(实时名)",
+ "group_id": "group-1",
+ "run_date": "2026-08-18",
+ "period_start": "2026-08-17 00:00:00",
+ "period_end": "2026-08-17 23:59:59",
+ "message_count": 4,
+ "speaker_count": 2,
+ "messages": [
+ _msg("张三", "今天群里聊了票房", 1),
+ _msg("李四", "《牛来》破500万了", 2),
+ _msg("张三", "那就把票房画成火箭", 3),
+ _msg("李四", "我在下面接着", 4),
+ ],
+ "template": "default",
+ "image_theme": "ai_free",
+ "image_theme_custom": "",
+ }
+ values.update(changes)
+ return PromptInput(**values)
-class FakeDeepSeek:
- """模拟 DeepSeek 底层 _chat。"""
+def _raw_json_from_user(user: str) -> dict:
+ start = user.index('{"copy_version"')
+ payload, _ = json.JSONDecoder().raw_decode(user[start:])
+ return payload
- def __init__(self, model="deepseek-v4-flash", raise_on: Exception | None = None):
- self.model = model
+
+class FakeSummaryProvider:
+ model = "gpt-5.6-sol"
+
+ def __init__(self, fail: Exception | None = None):
self.calls: list[tuple[str, str]] = []
- self.raise_on = raise_on
+ self.fail = fail
def _chat(self, messages: list[dict], **kwargs) -> str:
- if self.raise_on:
- raise self.raise_on
+ if self.fail:
+ raise self.fail
system = messages[0]["content"]
user = messages[1]["content"]
self.calls.append((system, user))
- if kwargs.get("response_format") == "json_object":
- matches = list(dict.fromkeys(re.findall(r"消息ID:([^\]#]+)(?:#片段\d+/\d+)?\]", user)))
- if "可选分镜骨架" in user and "已入选主题" in user:
- topic_ids = list(dict.fromkeys(re.findall(r'"topic_id":"(topic-[^"]+)"', user)))
- history_text = user.split("最近分镜历史:", 1)[-1].split("\n可选分镜骨架:", 1)[0]
- layout_id = "cinematic_strips" if '"layout_id":"hero_with_insets"' in history_text else "hero_with_insets"
- return json.dumps(
- {
- "layout_id": layout_id,
- "structure_mode": "dual_rhythm" if len(topic_ids) == 2 else "hero_rhythm",
- "featured_topic_ids": topic_ids[:2] if len(topic_ids) == 2 else topic_ids[:1],
- "topic_order": topic_ids,
- "panel_beats": [
- {
- "topic_id": topic_id,
- "shots": ["establishing", "punchline"] if index == 0 else ["dialogue"],
- }
- for index, topic_id in enumerate(topic_ids)
- ],
- "comedy_device": "反差",
- "layout_reason": "用大小格和连续镜头呈现真实对话",
- },
- ensure_ascii=False,
- )
- if '"candidates"' in user:
- ids = matches or re.findall(r'"message_ids":\["([^"]+)', user) or ["m1", "m2"]
- if len(ids) == 1:
- ids = [ids[0], ids[0]]
- return json.dumps({
- "candidates": [
- {
- "topic_id": "topic-01",
- "title": "真实事件发起",
- "summary": "聊天中真实发生的事件发起",
- "people": ["张三"],
- "quotes": ["真实原话"],
- "start_time": "2026-08-17 10:30",
- "end_time": "2026-08-17 10:30",
- "message_ids": [ids[0]],
- "comedy_score": 35,
- "group_recognition_score": 18,
- "visual_score": 18,
- "comedy_angle": "真实原话形成反差",
- "visual_gag": "把原话字面化",
- "score_reason": "讨论有趣且适合画面",
- },
- {
- "topic_id": "topic-02",
- "title": "真实事件回应",
- "summary": "聊天中真实发生的群友回应",
- "people": ["李四"],
- "quotes": ["回应原话"],
- "start_time": "2026-08-17 10:30",
- "end_time": "2026-08-17 10:30",
- "message_ids": [ids[1]],
- "comedy_score": 32,
- "group_recognition_score": 16,
- "visual_score": 16,
- "comedy_angle": "群友回应形成回环",
- "visual_gag": "用回应做成回环气泡",
- "score_reason": "多人回应",
- },
- ]
- }, ensure_ascii=False)
- match = matches[0] if matches else "m1"
+ if '"copy_version":"fixed-chat-comic-v2"' in user:
+ source = _raw_json_from_user(user)
+ panels = []
+ for topic in source["topics"]:
+ options = topic["participant_options"]
+ bindings = topic["speaker_bindings"]
+ binding_by_name = {}
+ for entry in bindings:
+ binding_by_name.setdefault(entry["speaker"], entry)
+ participants = []
+ for name in options[:4]:
+ binding = binding_by_name[name]
+ participants.append({
+ "message_id": binding["message_id"],
+ "action": "站在同一张桌边接话并看向其他群友",
+ "quote": binding["text"],
+ })
+ panels.append({
+ "topic_id": topic["topic_id"],
+ "title": topic["source_title"][:18],
+ "event_summary": topic["source_summary"],
+ "composition": "采用大小错落的群聊场景,人物围绕同一事件形成前后接话",
+ "participants": participants,
+ "visual_gag": topic["source_visual_gag"] or "用人物反应形成不改变事实的视觉反差",
+ "fact_line": topic["source_summary"][:72],
+ })
+ return json.dumps({
+ "title": "票房火箭与群友接力",
+ "subtitle": "张三抛梗,李四把真实对话接住",
+ "panels": panels,
+ "footer_summary": "票房变火箭,群友接话让讨论停不下来",
+ }, ensure_ascii=False)
+
+ ids = list(dict.fromkeys(re.findall(r"消息ID:([^\]#]+)(?:#片段\d+/\d+)?\]", user)))
+ if "可选分镜骨架" in user and "已入选主题" in user:
+ topic_ids = list(dict.fromkeys(re.findall(r'"topic_id":"(topic-[^"]+)"', user)))
return json.dumps({
- "events": [{
- "title": "真实事件",
- "people": ["张三"],
- "content": "聊天中真实发生的连续事件",
- "quotes": ["真实原话"],
+ "layout_id": "hero_with_insets",
+ "structure_mode": "dual_rhythm",
+ "featured_topic_ids": topic_ids[:2],
+ "topic_order": topic_ids,
+ "panel_beats": [
+ {"topic_id": topic_id, "shots": ["dialogue", "reaction"]}
+ for topic_id in topic_ids
+ ],
+ "comedy_device": "接话反差",
+ "layout_reason": "用大小格呈现真实多人对话",
+ }, ensure_ascii=False)
+ if '"candidates"' in user:
+ ids = ids or ["m1", "m2", "m3", "m4"]
+ return json.dumps({"candidates": [
+ {
+ "topic_id": "topic-01",
+ "title": "票房火箭",
+ "summary": "张三和李四把票房讨论接成火箭笑点。",
+ "people": ["张三", "李四"],
+ "quotes": ["今天群里聊了票房", "《牛来》破500万了"],
"start_time": "2026-08-17 10:30",
"end_time": "2026-08-17 10:30",
- "message_ids": [match],
- }]
- }, ensure_ascii=False)
- # 模拟:按输出结构返回
- structure_block = system.rsplit("【输出结构】", 1)[-1]
- return f"生成成功\n{structure_block[:60]}"
+ "message_ids": ids[:2],
+ "comedy_score": 35,
+ "group_recognition_score": 18,
+ "visual_score": 18,
+ "comedy_angle": "真实接话形成反差",
+ "visual_gag": "把票房走势画成火箭",
+ "score_reason": "多人连续回应",
+ },
+ {
+ "topic_id": "topic-02",
+ "title": "群友接力",
+ "summary": "两位群友继续围绕怎么画展开连续回应。",
+ "people": ["张三", "李四"],
+ "quotes": ["那就把票房画成火箭", "我在下面接着"],
+ "start_time": "2026-08-17 10:30",
+ "end_time": "2026-08-17 10:30",
+ "message_ids": ids[2:4] or ids[:2],
+ "comedy_score": 32,
+ "group_recognition_score": 16,
+ "visual_score": 16,
+ "comedy_angle": "群友接力形成回环",
+ "visual_gag": "两人一上一下接住火箭",
+ "score_reason": "连续对话适合漫画",
+ },
+ ]}, ensure_ascii=False)
+ return json.dumps({"events": []}, ensure_ascii=False)
+
+
+def _builder(provider: FakeSummaryProvider | None = None, tmp_path=None):
+ return DeepSeekImagePromptBuilder(
+ provider=provider or FakeSummaryProvider(),
+ templates=ImagePromptTemplateService(
+ templates_dir=(tmp_path / "image_prompt") if tmp_path else None
+ ),
+ )
-class TruncatedOnceDeepSeek(FakeDeepSeek):
- """第一次候选 JSON 截断,第二次返回完整响应。"""
+def test_build_renders_only_fixed_sections_and_real_multi_person_dialogue():
+ output = _builder().build(_input())
- def __init__(self):
- super().__init__()
- self.structured_calls = 0
+ assert output.success
+ assert validate_fixed_prompt_contract(output.prompt, expected_panel_count=2) == 2
+ headings = re.findall(r"(?m)^【([^\n】]+)】$", output.prompt)
+ assert headings == [
+ "任务", "群名称", "统计时间", "数据", "主标题", "副标题",
+ "整体视觉", "漫画分镜", "版面1", "版面2", "文字规则", "底部总结",
+ ]
+ assert output.prompt.count("【漫画分镜】") == 1
+ assert output.prompt.index("【整体视觉】") < output.prompt.index("【漫画分镜】")
+ assert output.prompt.index("【漫画分镜】") < output.prompt.index("【版面1】")
+ assert "每个话题先作为一个独立漫画框" in output.prompt
+ assert "示例交流群 A(实时名)" in output.prompt
+ assert "2026-08-17 00:00:00 ~ 2026-08-17 23:59:59" in output.prompt
+ assert (
+ output.meta["topic_selection"]["message_snapshot_sha256"]
+ == output.meta["message_snapshot_sha256"]
+ )
+ assert (
+ output.meta["topic_selection"]["speaker_fingerprint"]
+ == output.meta["speaker_fingerprint"]
+ )
+ assert output.prompt.count("人物旁清晰标注“张三”") == 2
+ assert output.prompt.count("人物旁清晰标注“李四”") == 2
+ for quote in ("今天群里聊了票房", "《牛来》破500万了", "那就把票房画成火箭", "我在下面接着"):
+ assert quote in output.prompt
+ for hidden in ("topic-", "message_id", "participant_options", "evidence_dialogue"):
+ assert hidden not in output.prompt
+
+
+def test_summary_and_prompt_phases_use_independent_provider_instances():
+ summary = FakeSummaryProvider()
+ prompt = FakeSummaryProvider()
+ summary.name = "summary-fake"
+ prompt.name = "prompt-fake"
+ builder = DeepSeekImagePromptBuilder(
+ summary_provider=summary,
+ prompt_provider=prompt,
+ )
- def _chat(self, messages: list[dict], **kwargs) -> str:
- if kwargs.get("response_format") == "json_object":
- self.structured_calls += 1
- if self.structured_calls == 1:
- self.calls.append((messages[0]["content"], messages[1]["content"]))
- return '{"candidates":[{"topic_id":"topic-01","title":"截断'
- return super()._chat(messages, **kwargs)
+ output = builder.build(_input())
+
+ assert output.success
+ assert summary.calls
+ assert prompt.calls
+ assert output.meta["summary_provider_actual"] == "summary-fake"
+ assert output.meta["prompt_provider_actual"] == "prompt-fake"
+ assert output.meta["summary_api_call_count"] >= 1
+ assert output.meta["prompt_api_call_count"] >= 1
+
+
+def _long_quote_payload(quote: str) -> tuple[str, dict]:
+ source = {
+ "topics": [
+ {
+ "topic_id": "topic-long",
+ "source_title": "部署复盘",
+ "source_summary": "张三分享部署复盘,大家讨论发布流程。",
+ "source_visual_gag": "把发布流程画成接力跑",
+ "participant_options": ["张三"],
+ "evidence_dialogue": [
+ {
+ "message_id": "m-long",
+ "sender_id": "wxid-zhangsan",
+ "speaker": "张三",
+ "text": quote,
+ }
+ ],
+ "speaker_bindings": [
+ {
+ "message_id": "m-long",
+ "sender_id": "wxid-zhangsan",
+ "speaker": "张三",
+ "text": quote,
+ }
+ ],
+ "shot_hints": ["对白"],
+ }
+ ]
+ }
+ payload = {
+ "title": "部署复盘",
+ "subtitle": "张三分享发布流程",
+ "panels": [
+ {
+ "topic_id": "topic-long",
+ "title": "部署复盘",
+ "event_summary": "张三分享部署复盘。",
+ "composition": "张三站在流程图旁讲解",
+ "participants": [
+ {
+ "message_id": "m-long",
+ "action": "指向发布流程图",
+ "quote": quote,
+ }
+ ],
+ "visual_gag": "把发布流程画成接力跑",
+ "fact_line": "张三分享部署复盘。",
+ }
+ ],
+ "footer_summary": "张三分享部署复盘",
+ }
+ return json.dumps(payload, ensure_ascii=False), source
-def _builder(fake: FakeDeepSeek | None = None, tmp_path=None):
- return DeepSeekImagePromptBuilder(
- provider=fake or FakeDeepSeek(),
- templates=ImagePromptTemplateService(templates_dir=(tmp_path / "image_prompt") if tmp_path else None),
+def test_long_grounded_quote_is_reduced_to_complete_contiguous_sentence():
+ long_quote = (
+ "今天把部署流程从头到尾重新走了一遍,所有检查点都已经记录。"
+ "明天会按照这份记录继续核对自动发布和回滚步骤,避免遗漏。"
)
+ raw, source = _long_quote_payload(long_quote)
+ copy = parse_poster_copy(raw, source)
+ quote = copy.panels[0].participants[0].quote
-def test_build_success_returns_prompt():
- b = _builder()
- out = b.build(_input())
- assert out.success is True
- assert "生成成功" in out.prompt
- assert out.model == "deepseek-v4-flash"
- assert out.meta is not None
- assert out.meta["mode"] == "direct"
- assert out.meta["api_model"] == "deepseek-v4-flash"
- assert "template" in out.meta
- assert out.meta["topic_selection"]["selected_count"] == 2
- assert out.meta["layout_id"] == "hero_with_insets"
- assert out.meta["structure_mode"] == "dual_rhythm"
- assert out.meta["featured_topic_ids"] == ["topic-01", "topic-02"]
- assert out.meta["topic_order"] == ["topic-01", "topic-02"]
- assert out.meta["panel_count"] == 3
- assert out.meta["comedy_device"] == "反差"
- assert "统计日期:2026-08-17" in out.prompt
- assert "今天群里聊了票房" in out.prompt
- assert "《牛来》破500万了" in out.prompt
- assert "张三" in out.prompt
- assert "李四" in out.prompt
- assert "参与群友" not in out.prompt
- assert "事实信息" not in out.prompt
- assert "真实原话" not in out.prompt
- assert "信息卡" not in out.prompt
- assert "topic-" not in out.prompt
-
-
-def test_direct_candidate_truncation_retries_once_and_records_real_call_count():
- fake = TruncatedOnceDeepSeek()
- out = _builder(fake=fake).build(_input())
-
- assert out.success is True
- assert fake.structured_calls == 3
- assert out.meta["api_call_count"] == 4
- assert "上一次响应不完整" in fake.calls[1][1]
-
-
-def test_build_input_contains_group_and_data():
- b = _builder()
- out = b.build(_input())
- assert out.success
- system, _ = b._provider.calls[-1]
- _, user = b._provider.calls[0]
- assert "示例交流群 A" in system # 模板变量已渲染进输出结构
- assert "3 条消息" in system # 数据渲染进输出结构
- assert "2 人发言" in system
- assert "完整群聊记录" in user # 候选提取输入含完整聊天内容
- assert "张三" in user
-
-
-def test_build_media_type_prefix():
- messages = [
- _msg("张三", "IMAGE_BINARY_BODY", mtype="image", i=1),
- _msg("李四", "FILE_BINARY_BODY", mtype="file", i=2),
- _msg("王五", "", mtype="voice", i=3),
- ]
- b = _builder()
- out = b.build(_input(messages=messages))
- assert out.success
- _, user = b._provider.calls[0]
- assert "[图片]" in user
- assert "[文件]" in user
- assert "[语音]" in user
- assert "IMAGE_BINARY_BODY" not in user
- assert "FILE_BINARY_BODY" not in user
-
-
-def test_chunk_strategy_for_long_chat():
- # 构造超过字符预算的连续消息,验证自然分段、结构化提取与最终合并。
- messages = [_msg(f"用户{n}", f"消息{n}-" + ("连续内容" * 40), i=n) for n in range(150)]
- b = _builder()
- b.settings.max_context_chars = 1_000
- out = b.build(_input(messages=messages))
- assert out.success
- assert out.meta["mode"] == "natural_chunked"
- assert out.meta["chunk_count"] > 1
- assert len(b._provider.calls) == out.meta["chunk_count"] + 3
- assert "message_ids" in b._provider.calls[0][1]
-
-
-def test_build_failure_returns_error():
- b = _builder(fake=FakeDeepSeek(raise_on=RuntimeError("DeepSeek 挂了")))
- out = b.build(_input())
- assert out.success is False
- assert "DeepSeek 挂了" in out.error
- assert out.model == "deepseek-v4-flash"
-
-
-def test_unknown_template_returns_error(tmp_path):
- b = _builder(tmp_path=tmp_path)
- out = b.build(_input(template="not_exist"))
- assert out.success is False
- assert "不存在" in out.error
-
-
-def test_template_variable_render():
- from app.ai.prompt_templates import render_image_prompt_template
-
- text = render_image_prompt_template(
- "【群名称】{{group_name}}\n【数据】{{message_count}}条/{{speaker_count}}人\n【时间】{{period_start}}~{{period_end}}",
- {"group_name": "示例群", "message_count": "409", "speaker_count": "27",
- "period_start": "2026-08-17 00:00:00", "period_end": "2026-08-17 23:59:59"},
+ assert 2 <= len(quote) <= 48
+ assert quote in long_quote
+ assert quote.endswith("。")
+
+
+def test_long_grounded_quote_without_safe_boundary_is_rejected():
+ long_quote = "这是一条完全没有任何标点因此无法安全判断语义边界的真实长消息" * 2
+ raw, source = _long_quote_payload(long_quote)
+
+ with pytest.raises(PosterCopyError, match="无法安全缩短"):
+ parse_poster_copy(raw, source)
+
+
+def test_exact_full_source_quote_may_end_with_ellipsis():
+ raw, source = _long_quote_payload("我再想想……")
+
+ copy = parse_poster_copy(raw, source)
+
+ assert copy.panels[0].participants[0].quote == "我再想想……"
+
+
+def test_truncated_source_quote_may_not_end_with_ellipsis():
+ raw, source = _long_quote_payload("我再想想……然后再决定。")
+ payload = json.loads(raw)
+ payload["panels"][0]["participants"][0]["quote"] = "我再想想……"
+
+ with pytest.raises(PosterCopyError, match="悬空省略号"):
+ parse_poster_copy(json.dumps(payload, ensure_ascii=False), source)
+
+
+def test_ai_free_does_not_inject_daily_style_library():
+ output = _builder().build(_input(image_theme="ai_free"))
+ assert output.success
+ assert output.meta["style_intervention"] is False
+ assert output.prompt.count("根据当天真实聊天内容自由选择统一视觉风格。") == 1
+ assert "优先采用 1024×1536、2:3 画布" in output.prompt
+ assert "其他完整可读的竖版尺寸也可以采用" in output.prompt
+ assert "不要为了匹配尺寸裁切或拉伸" in output.prompt
+ assert "本次手动视觉风格" not in output.prompt
+ assert "daily-style-v3" not in output.prompt
+
+
+def test_manual_style_is_only_appended_inside_overall_visual():
+ output = _builder().build(
+ _input(image_theme="custom", image_theme_custom="低饱和黏土摄影")
+ )
+ assert output.success
+ assert "本次手动视觉风格:低饱和黏土摄影" in output.prompt
+ assert output.meta["style_intervention"] is True
+ assert "【视觉风格】" not in output.prompt
+
+
+def test_public_presets_remain_compatible():
+ for key in STYLE_FAMILY_KEYS:
+ output = _builder().build(_input(image_theme=key))
+ assert output.success, key
+ assert output.meta["resolved_theme"] == key
+ assert "本次手动视觉风格:" in output.prompt
+
+
+def test_persisted_topics_and_layout_only_call_editor_once():
+ first = _builder().build(_input())
+ assert first.success
+ provider = FakeSummaryProvider()
+ rebuilt_input = _input(persisted_theme_meta=first.meta)
+ rebuilt_input.persisted_topic_selection = first.meta["topic_selection"]
+ rebuilt = _builder(provider).build(rebuilt_input)
+ assert rebuilt.success
+ assert rebuilt.meta["topic_selection_reused"] is True
+ assert rebuilt.meta["layout_reused"] is True
+ assert rebuilt.meta["api_call_count"] == 1
+ assert len(provider.calls) == 1
+
+
+def test_persisted_topics_without_snapshot_contract_are_rejected():
+ first = _builder().build(_input())
+ stale_meta = dict(first.meta)
+ stale_meta.pop("message_snapshot_sha256")
+ stale_meta.pop("speaker_fingerprint")
+ rebuilt_input = _input(
+ persisted_theme_meta=stale_meta,
+ persisted_topic_selection=first.meta["topic_selection"],
)
- assert "【群名称】示例群" in text
- assert "【数据】409条/27人" in text
- assert "2026-08-17 00:00:00~2026-08-17 23:59:59" in text
- preview = render_image_prompt_template("{{layout_name}}\n{{layout_instruction}}", {})
- assert "{{layout_name}}" not in preview
- assert "生成时自动选择漫画分镜骨架" in preview
+ rebuilt = _builder().build(rebuilt_input)
+ assert rebuilt.success is False
+ assert "缺少消息快照指纹" in rebuilt.error
-def test_default_template_reset_contains_theme_and_safe_topic_limit(tmp_path):
- service = ImagePromptTemplateService(templates_dir=tmp_path / "image_prompt")
- service.save("default", "{{group_name}}")
- restored = service.reset()
- assert restored == DEFAULT_IMAGE_PROMPT_TEMPLATE
- assert "{{image_theme}}" in restored
- assert "{{layout_name}}" in restored
- assert "{{layout_instruction}}" in restored
- assert "2~7 个入选主题" in restored
- assert "统计日期:{{report_date}}" in restored
- assert "顶部大标题,中部按事件分区" not in restored
- assert "整齐两列等高矩形" in restored
-
-
-def test_concrete_theme_enters_prompt_and_metadata():
- b = _builder()
- out = b.build(_input(image_theme="pink"))
- assert out.success
- assert out.meta["requested_theme"] == "pink"
- assert out.meta["resolved_theme"] == "pink"
- assert out.meta["theme_display_name"] == "粉红色"
- assert "粉红" in b._provider.calls[-1][0]
-
-
-def test_all_concrete_themes_are_supported():
- from app.ai.image_themes import CONCRETE_THEME_KEYS
-
- for key in CONCRETE_THEME_KEYS:
- b = _builder()
- out = b.build(_input(image_theme=key))
- assert out.success, key
- assert out.meta["resolved_theme"] == key
-
-
-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["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_custom_theme_and_ai_free_theme_are_explicitly_injected():
- b = _builder()
- custom = b.build(_input(image_theme="custom", image_theme_custom="夏日海边漫画"))
- assert custom.success
- assert custom.meta["requested_theme"] == "custom"
- assert custom.meta["resolved_theme"] == "custom"
- assert custom.meta["theme_display_name"] == "夏日海边漫画"
- assert "夏日海边漫画" in b._provider.calls[-1][0]
-
- b2 = _builder()
- ai_free = b2.build(_input(image_theme="ai_free"))
- assert ai_free.success
- assert "统一视觉主题" in b2._provider.calls[-1][0]
-
-
-def test_invalid_custom_theme_fails_before_model_call():
- b = _builder()
- out = b.build(_input(image_theme="custom", image_theme_custom=""))
- assert out.success is False
- assert "1~80" in out.error
- assert b._provider.calls == []
-
-
-def test_legacy_template_still_gets_theme_system_constraint(tmp_path):
- templates = ImagePromptTemplateService(templates_dir=tmp_path / "image_prompt")
- templates.save("legacy", "【群名称】{{group_name}}\n【版面】最多五个话题")
- b = DeepSeekImagePromptBuilder(provider=FakeDeepSeek(), templates=templates)
- out = b.build(_input(template="legacy", image_theme="cyber_neon"))
- assert out.success
- assert "赛博霓虹" in b._provider.calls[-1][0]
- assert "2~7" in b._provider.calls[-1][0]
- assert "漫画分镜约束" in b._provider.calls[-1][0]
- assert "今天群里聊了票房" in out.prompt
- assert "参与群友" not in out.prompt
-
-
-def test_group_template_override_cannot_remove_grounded_story_material(tmp_path):
- b = _builder(tmp_path=tmp_path)
- data = _input()
- data.template_override = "【群名称】{{group_name}}\n只画装饰"
- out = b.build(data)
- assert out.success
- assert "张三" in out.prompt
- assert "李四" in out.prompt
- assert "今天群里聊了票房" in out.prompt
- assert "《牛来》破500万了" in out.prompt
- assert "参与群友" not in out.prompt
- assert "事实信息" not in out.prompt
- final_system = b._provider.calls[-1][0]
- assert "不得只画匿名人物或自由生成人名" in final_system
-
-
-def test_previous_layout_is_avoided_and_theme_remains_independent_hard_constraint():
- b = _builder()
- out = b.build(
- _input(
- image_theme="custom",
- image_theme_custom="低饱和黏土摄影",
- recent_layout_history=({"layout_id": "hero_with_insets", "comedy_device": "字面化"},),
- )
+
+def test_persisted_selection_without_its_own_snapshot_contract_is_rejected():
+ first = _builder().build(_input())
+ stale_selection = dict(first.meta["topic_selection"])
+ stale_selection.pop("message_snapshot_sha256")
+ stale_selection.pop("speaker_fingerprint")
+ rebuilt_input = _input(
+ persisted_theme_meta=first.meta,
+ persisted_topic_selection=stale_selection,
+ )
+
+ rebuilt = _builder().build(rebuilt_input)
+
+ assert rebuilt.success is False
+ assert "选题缺少匹配的消息快照指纹" in rebuilt.error
+
+
+def test_persisted_topics_are_rejected_when_same_message_id_changes_text():
+ first = _builder().build(_input())
+ changed = _input(
+ persisted_theme_meta=first.meta,
+ persisted_topic_selection=first.meta["topic_selection"],
)
- assert out.success
- assert out.meta["layout_id"] == "cinematic_strips"
- assert out.meta["recent_layout_ids"] == ["hero_with_insets"]
- assert "低饱和黏土摄影" in out.prompt
- final_system = b._provider.calls[-1][0]
- assert "大主题约束|全图最高视觉约束" in final_system
- assert "漫画分镜只控制格子几何" in final_system
-
-
-def test_legacy_same_date_layout_is_not_reused_for_new_prompt():
- legacy = {
- "layout_id": "group_court",
- "hero_topic_id": "topic-02",
- "support_topic_ids": ["topic-01"],
- "comedy_device": "一本正经地荒诞",
- "layout_reason": "同日已有选择",
+ changed.messages[0].content = "同一个 ID 但原文已经变化"
+
+ rebuilt = _builder().build(changed)
+
+ assert rebuilt.success is False
+ assert "旧选题证据已过期" in rebuilt.error
+
+
+def test_poster_name_is_derived_from_message_id_when_quotes_are_identical():
+ source = {
+ "topics": [
+ {
+ "topic_id": "topic-same-quote",
+ "source_title": "同句接话",
+ "source_summary": "甲和乙先后说了同一句话。",
+ "source_visual_gag": "两人举起相同文字牌",
+ "participant_options": ["甲", "乙"],
+ "evidence_dialogue": [
+ {"message_id": "m-a", "sender_id": "wxid-a", "speaker": "甲", "text": "收到"},
+ {"message_id": "m-b", "sender_id": "wxid-b", "speaker": "乙", "text": "收到"},
+ ],
+ "speaker_bindings": [
+ {"message_id": "m-a", "sender_id": "wxid-a", "speaker": "甲", "text": "收到"},
+ {"message_id": "m-b", "sender_id": "wxid-b", "speaker": "乙", "text": "收到"},
+ ],
+ "shot_hints": ["对白"],
+ }
+ ]
}
- b = _builder()
- out = b.build(_input(persisted_theme_meta=legacy))
- assert out.success
- assert out.meta["layout_catalog_version"] == "comic-panels-v3"
- assert out.meta["layout_reused"] is False
- assert out.meta["api_call_count"] == 3 # 候选 + 新版式 + 最终 Prompt
- assert len(b._provider.calls) == 3
-
-
-def test_same_date_reuses_v3_storyboard_without_director_call():
- persisted = {
- "layout_catalog_version": "comic-panels-v3",
- "layout_id": "split_focus",
- "structure_mode": "dual_rhythm",
- "featured_topic_ids": ["topic-01", "topic-02"],
- "topic_order": ["topic-02", "topic-01"],
- "panel_beats": [
- {"topic_id": "topic-02", "shots": ["establishing", "reaction"]},
- {"topic_id": "topic-01", "shots": ["dialogue"]},
+ payload = {
+ "title": "同句接话",
+ "subtitle": "两位群友先后回应",
+ "panels": [
+ {
+ "topic_id": "topic-same-quote",
+ "title": "同句接话",
+ "event_summary": "甲和乙先后说了同一句话。",
+ "composition": "两人分别站在画面左右两侧",
+ "participants": [
+ {"message_id": "m-b", "action": "站在右侧举手", "quote": "收到"},
+ {"message_id": "m-a", "action": "站在左侧点头", "quote": "收到"},
+ ],
+ "visual_gag": "两人举起相同文字牌",
+ "fact_line": "甲和乙先后说了同一句话。",
+ }
],
- "comedy_device": "一本正经地荒诞",
- "layout_reason": "同日已有双核心选择",
+ "footer_summary": "同一句回应由两位群友先后说出",
}
- b = _builder()
- out = b.build(_input(persisted_theme_meta=persisted))
- assert out.success
- assert out.meta["layout_id"] == "split_focus"
- assert out.meta["structure_mode"] == "dual_rhythm"
- assert out.meta["layout_reused"] is True
- assert out.meta["panel_count"] == 3
- assert out.meta["api_call_count"] == 2
- assert len(b._provider.calls) == 2
-
-
-def test_explicit_layout_inside_custom_style_wins_without_changing_style_text():
- custom_style = "复古报纸三栏头版"
- b = _builder()
- out = b.build(_input(image_theme="custom", image_theme_custom=custom_style))
- assert out.success
- assert out.meta["layout_id"] == "staggered_mosaic"
- assert out.meta["style_layout_locked"] is True
- assert out.meta["structure_mode"] == "dual_rhythm"
- assert custom_style in out.prompt
- assert out.meta["api_call_count"] == 2
-
- from app.ai.image_themes import resolve_image_theme
- from app.ai.prompt_editing import replace_theme_section
-
- switched = replace_theme_section(out.prompt, resolve_image_theme("pink"))
- assert custom_style not in switched
- assert "粉红色" in switched
- assert len(re.findall(r"(?m)^【大主题】$", switched)) == 1
+
+ copy = parse_poster_copy(json.dumps(payload, ensure_ascii=False), source)
+
+ assert [(item.message_id, item.name) for item in copy.panels[0].participants] == [
+ ("m-b", "乙"),
+ ("m-a", "甲"),
+ ]
+
+
+def test_poster_source_keeps_each_message_scoped_name_for_same_sender():
+ selection = {
+ "candidates": [
+ {
+ "topic_id": "topic-rename",
+ "selected": True,
+ "title": "当天改名",
+ "summary": "同一位群友当天使用了两个显示名。",
+ "visual_gag": "两张连续姓名牌",
+ "visible_participants": ["早些时候的名字"],
+ "participants": ["早些时候的名字"],
+ "evidence_dialogue": [
+ {
+ "message_id": "m-old",
+ "sender_id": "WXID-A",
+ "speaker": "早些时候的名字",
+ "text": "第一条原话",
+ "original_text": "第一条原话的完整内容",
+ },
+ {
+ "message_id": "m-new",
+ "sender_id": "wxid-a",
+ "speaker": "后来改的名字",
+ "text": "第二条原话",
+ "original_text": "第二条原话的完整内容",
+ },
+ ],
+ }
+ ]
+ }
+ layout = LayoutPlan(
+ layout_id="split_focus",
+ layout_name="双焦点",
+ structure_mode="dual_rhythm",
+ featured_topic_ids=("topic-rename",),
+ topic_order=("topic-rename",),
+ panel_beats=(PanelBeat("topic-rename", ("dialogue",)),),
+ comedy_device="回环",
+ layout_reason="测试消息级署名",
+ )
+
+ source = build_poster_editor_source(selection, layout)
+ bindings = source["topics"][0]["speaker_bindings"]
+
+ assert [(item["message_id"], item["speaker"]) for item in bindings] == [
+ ("m-old", "早些时候的名字"),
+ ("m-new", "后来改的名字"),
+ ]
+ assert bindings[0]["original_text"] == "第一条原话的完整内容"
+ assert '"original_text"' not in build_poster_editor_prompt(source)
+
+
+def test_fixed_validator_rejects_conflicting_header_footer_rule():
+ output = _builder().build(_input())
+ assert output.success
+ conflicting = output.prompt.replace(
+ "空间不足时先减少装饰、道具和次要反应",
+ "不得绘制群名称;空间不足时先减少装饰、道具和次要反应",
+ )
+ try:
+ validate_fixed_prompt_contract(conflicting, expected_panel_count=2)
+ except PosterCopyError as exc:
+ assert "冲突规则" in str(exc)
+ else:
+ raise AssertionError("冲突头尾规则必须被拒绝")
+
+
+def test_default_template_file_and_builtin_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()
+ assert "{{main_title}}" in file_body
+ assert "{{subtitle}}" in file_body
+ assert "{{panels}}" in file_body
+ assert "{{footer_summary}}" in file_body
+ assert file_body.count("【漫画分镜】") == 1
+
+
+def test_fixed_storyboard_contract_supports_two_five_and_seven_panels():
+ output = _builder().build(_input())
+ assert output.success
+ for panel_count in (2, 5, 7):
+ panels = "\n\n".join(
+ f"【版面{index}】\n真实话题{index}"
+ for index in range(1, panel_count + 1)
+ )
+ prompt = re.sub(
+ r"(?ms)^【版面1】.*?(?=^【文字规则】)",
+ panels + "\n\n",
+ output.prompt,
+ )
+ assert validate_fixed_prompt_contract(
+ prompt,
+ expected_panel_count=panel_count,
+ ) == panel_count
+ assert prompt.count("【漫画分镜】") == 1
+ assert prompt.index("【漫画分镜】") < prompt.index("【版面1】")
+
+
+def test_fixed_storyboard_heading_is_required_in_exact_position():
+ output = _builder().build(_input())
+ assert output.success
+ invalid = output.prompt.replace("【漫画分镜】", "【分镜说明】", 1)
+ try:
+ validate_fixed_prompt_contract(invalid, expected_panel_count=2)
+ except PosterCopyError as exc:
+ assert "区块名称、顺序" in str(exc)
+ else:
+ raise AssertionError("缺少固定漫画分镜区块必须被拒绝")
+
+
+def test_template_rejects_missing_or_misordered_storyboard_section(tmp_path):
+ service = ImagePromptTemplateService(templates_dir=tmp_path / "image_prompt")
+ missing = DEFAULT_IMAGE_PROMPT_TEMPLATE.replace("【漫画分镜】", "【分镜说明】", 1)
+ storyboard_start = DEFAULT_IMAGE_PROMPT_TEMPLATE.index("【漫画分镜】")
+ panels_start = DEFAULT_IMAGE_PROMPT_TEMPLATE.index("{{panels}}")
+ storyboard = DEFAULT_IMAGE_PROMPT_TEMPLATE[storyboard_start:panels_start]
+ without_storyboard = (
+ DEFAULT_IMAGE_PROMPT_TEMPLATE[:storyboard_start]
+ + DEFAULT_IMAGE_PROMPT_TEMPLATE[panels_start:]
+ )
+ misordered = without_storyboard.replace("【整体视觉】", storyboard + "【整体视觉】", 1)
+
+ for name, content in (("missing", missing), ("misordered", misordered)):
+ try:
+ service.save(name, content)
+ except Exception as exc:
+ assert "模板区块必须严格为" in str(exc)
+ else:
+ raise AssertionError(f"{name} 漫画分镜模板必须被拒绝")
+
+
+def test_default_reset_restores_fixed_storyboard_section(tmp_path):
+ service = ImagePromptTemplateService(templates_dir=tmp_path / "image_prompt")
+ changed = DEFAULT_IMAGE_PROMPT_TEMPLATE.replace(
+ "漫画分镜负责强化真实聊天中的动作、误会、吐槽、反差和群友反应",
+ "临时修改的漫画分镜规则",
+ )
+ service.save("default", changed)
+
+ restored = service.reset()
+
+ assert restored == DEFAULT_IMAGE_PROMPT_TEMPLATE
+ assert restored.count("【漫画分镜】") == 1
+ assert service.read("default") == DEFAULT_IMAGE_PROMPT_TEMPLATE
+
+
+def test_invalid_legacy_template_is_rejected_instead_of_exposing_extra_sections(tmp_path):
+ service = ImagePromptTemplateService(templates_dir=tmp_path / "image_prompt")
+ try:
+ service.save("legacy", "【群名称】{{group_name}}\n只画装饰")
+ except Exception as exc:
+ assert "模板区块必须严格为" in str(exc)
+ else:
+ raise AssertionError("旧自由结构模板必须被拒绝")
+
+
+def test_provider_failure_returns_error_without_partial_prompt():
+ output = _builder(FakeSummaryProvider(RuntimeError("总结模型不可用"))).build(_input())
+ assert output.success is False
+ assert "总结模型不可用" in output.error
+ assert output.prompt == ""
diff --git a/tests/test_v2_prompt_editing.py b/tests/test_v2_prompt_editing.py
index fd3495e..d82a6ed 100644
--- a/tests/test_v2_prompt_editing.py
+++ b/tests/test_v2_prompt_editing.py
@@ -11,23 +11,49 @@
from app.v2.run_store import RunStore
-def test_theme_replacement_preserves_facts_and_other_manual_content():
- original = """【大主题】
-旧主题
+def _fixed_prompt(fact: str) -> str:
+ return f"""【任务】
+生成一张竖版微信群日报漫画信息图。
+【群名称】
+Prompt 测试群
+【统计时间】
+2026-08-20 00:00:00 ~ 2026-08-20 23:59:59
+【数据】
+10 条消息
+2 人发言
+【主标题】
+真实主标题
+【副标题】
+真实副标题
+【整体视觉】
+固定群聊漫画要求。
+根据当天真实聊天内容自由选择统一视觉风格。
+【漫画分镜】
+每个话题先作为一个独立漫画框,框内可拆成1~3个连续分镜。
+【版面1】
+{fact}
+【版面2】
+第二个真实话题
+【文字规则】
+保留顶部、底部、姓名和主要对白。
+【底部总结】
+真实底部总结
+"""
-【事件】
-小王发布 3 个版本,金额 128 元。
-【手工补充】
-这句话必须保留。
-"""
- updated = replace_theme_section(original, resolve_image_theme("pink"))
+def test_theme_replacement_preserves_facts_and_other_manual_content():
+ original = _fixed_prompt("小王发布 3 个版本,金额 128 元。这句话必须保留。")
+ 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 updated.count("【整体视觉】") == 1
+ assert updated.count("【漫画分镜】") == 1
+ assert "每个话题先作为一个独立漫画框" in updated
+ assert "水墨留白漫画" in updated
assert "小王发布 3 个版本,金额 128 元。" in updated
assert "这句话必须保留。" in updated
- assert "旧主题" not in updated
def test_theme_is_inserted_when_prompt_has_no_canonical_section():
@@ -36,6 +62,17 @@ def test_theme_is_inserted_when_prompt_has_no_canonical_section():
assert "【事件】\n真实事件" in updated
+def test_ai_free_replaces_concrete_theme_with_one_neutral_hint():
+ original = "【大主题】\n赛博霓虹:深蓝黑底、青紫粉霓虹。\n\n【事件】\n真实事件\n"
+ updated = replace_theme_section(original, resolve_image_theme("ai_free"))
+
+ assert updated.count("【视觉风格】") == 1
+ assert "根据当天真实聊天内容自由选择统一视觉风格。" in updated
+ assert "赛博霓虹" not in updated
+ assert "深蓝黑底" not in updated
+ assert "【事件】\n真实事件" in updated
+
+
def test_random_theme_resolves_to_one_fixed_concrete_theme():
class Picker:
@staticmethod
@@ -55,7 +92,7 @@ def test_run_prompt_backup_revision_conflict_and_restore(tmp_path):
store = RunStore(tmp_path)
group = "Prompt 测试群"
run_date = "2026-08-21"
- original = "【大主题】\n旧主题\n\n【事件】\n原始事实\n"
+ original = _fixed_prompt("原始事实")
store.save_run(group, run_date, {"status": "SENT", "image_theme": "blue_white"})
store.prompt_path(group, run_date).write_text(original, encoding="utf-8")
@@ -63,7 +100,7 @@ def test_run_prompt_backup_revision_conflict_and_restore(tmp_path):
group,
run_date,
RunPromptUpdateBody(
- content="【大主题】\n旧主题\n\n【事件】\n修正后的真实事实\n",
+ content=_fixed_prompt("修正后的真实事实"),
expected_revision=prompt_revision(original),
image_theme="pink",
),
diff --git a/tests/test_v2_ranking.py b/tests/test_v2_ranking.py
index b7999b9..ef906bd 100644
--- a/tests/test_v2_ranking.py
+++ b/tests/test_v2_ranking.py
@@ -10,12 +10,18 @@
from app.data_sources.base import V2Message
from app.ranking.engine import RankingEngine
+from app.ranking.policies import uses_strict_image_fact_contract
engine = RankingEngine()
PERIOD_START = "2026-08-17 00:00:00"
PERIOD_END = "2026-08-17 23:59:59"
+def test_strict_image_fact_contract_is_policy_driven_for_every_group():
+ assert uses_strict_image_fact_contract("text_primary_with_interactions") is True
+ assert uses_strict_image_fact_contract("all_messages") is False
+
+
def _msg(
sender: str,
mtype: str = "text",
@@ -95,6 +101,62 @@ def test_system_message_filtered():
assert r.speaker_count == 2
+def test_text_primary_counts_text_and_interactions_separately():
+ messages = [
+ _msg("白白", "text", i=1),
+ _msg("白白", "text", i=2),
+ _msg("白白", "image", i=3),
+ _msg("白白", "quote", i=4),
+ _msg("只发图", "image", i=5),
+ _msg("系统", "system", i=6),
+ ]
+
+ result = engine.compute(
+ messages,
+ "测试群",
+ PERIOD_START,
+ PERIOD_END,
+ count_policy="text_primary_with_interactions",
+ name_source="wechat_data_analysis",
+ )
+
+ assert result.message_count == 5
+ assert result.text_message_count == 2
+ assert result.interaction_message_count == 3
+ assert result.speaker_count == 2
+ assert result.text_speaker_count == 1
+ assert [item.name for item in result.top_speakers] == ["白白"]
+ assert result.top_speakers[0].count == 2
+ assert result.top_speakers[0].text_count == 2
+ assert result.top_speakers[0].interaction_count == 2
+ assert result.top_speakers[0].name_source == "wechat_data_analysis"
+
+
+def test_text_primary_tie_does_not_use_interactions_to_change_rank():
+ messages = [
+ _msg("B", "text", i=1),
+ _msg("B", "image", i=2),
+ _msg("B", "emoji", i=3),
+ _msg("A", "text", i=4),
+ ]
+
+ result = engine.compute(
+ messages,
+ "测试群",
+ PERIOD_START,
+ PERIOD_END,
+ count_policy="text_primary_with_interactions",
+ )
+
+ assert [
+ (item.name, item.text_count, item.interaction_count)
+ for item in result.top_speakers
+ ] == [
+ ("A", 1, 0),
+ ("B", 1, 2),
+ ]
+
+
def test_identity_uses_sender_id_and_counts_supported_forward_types():
messages = [
_msg("同名", "red_packet", i=1, sender_id="wxid-a"),
@@ -113,6 +175,16 @@ def test_identity_uses_sender_id_and_counts_supported_forward_types():
assert renamed.count == 2
+def test_trusted_contact_name_matching_sender_id_casefold_remains_visible():
+ message = _msg("EXALEX", i=1, sender_id="exalex")
+ message.sender_name_source = "contact"
+
+ result = engine.compute([message], "测试群", PERIOD_START, PERIOD_END)
+
+ assert result.speaker_count == 1
+ assert result.top_speakers[0].name == "EXALEX"
+
+
def test_deterministic():
messages = [_msg(f"用户{n}", i=n) for n in range(12)]
r1 = engine.compute(messages, "测试群", PERIOD_START, PERIOD_END)
@@ -188,8 +260,18 @@ def test_ranking_json_structure():
"period_end",
"speaker_count",
"message_count",
+ "count_policy",
+ "text_message_count",
+ "interaction_message_count",
+ "text_speaker_count",
"top_limit",
"top_speakers",
}
assert d["top_limit"] == 10
- assert d["top_speakers"][0] == {"rank": 1, "name": "张三", "count": 1}
+ assert d["top_speakers"][0]["rank"] == 1
+ assert d["top_speakers"][0]["name"] == "张三"
+ assert d["top_speakers"][0]["count"] == 1
+ assert d["top_speakers"][0]["text_count"] == 1
+ assert d["top_speakers"][0]["interaction_count"] == 0
+ assert d["top_speakers"][0]["name_source"] == "resolved"
+ assert len(d["top_speakers"][0]["identity_key"]) == 16
diff --git a/tests/test_v2_ranking_template.py b/tests/test_v2_ranking_template.py
index 5ba6af6..bed7e49 100644
--- a/tests/test_v2_ranking_template.py
+++ b/tests/test_v2_ranking_template.py
@@ -103,6 +103,66 @@ def test_render_top10_lines_format():
assert lines[-1] == "10.神奇小郭【7】"
+def test_render_text_primary_shows_text_and_interactions():
+ result = RankingResult(
+ group_name="Eason张UED-4群🤘",
+ period_start="2026-08-28 00:00:00",
+ period_end="2026-08-28 08:14:59",
+ speaker_count=2,
+ message_count=4,
+ count_policy="text_primary_with_interactions",
+ text_message_count=2,
+ interaction_message_count=2,
+ text_speaker_count=2,
+ top_speakers=[
+ TopSpeaker(
+ rank=1,
+ name="深圳-UI-白白",
+ count=1,
+ text_count=1,
+ interaction_count=2,
+ name_source="wechat_data_analysis",
+ )
+ ],
+ )
+
+ text = render_ranking(result, "{{top_lines}}")
+
+ assert text == "1.深圳-UI-白白【文字 1|互动 2】"
+
+
+def test_text_interactions_template_appends_approved_explanation_once():
+ result = RankingResult(
+ group_name="测试群",
+ period_start="2026-08-28 00:00:00",
+ period_end="2026-08-28 23:59:59",
+ speaker_count=1,
+ message_count=3,
+ count_policy="text_primary_with_interactions",
+ text_message_count=1,
+ interaction_message_count=2,
+ text_speaker_count=1,
+ top_speakers=[
+ TopSpeaker(
+ rank=1,
+ name="群友",
+ count=1,
+ text_count=1,
+ interaction_count=2,
+ name_source="wechat_data_analysis",
+ )
+ ],
+ )
+
+ text = RankingRenderer().render(result, template_name="text_interactions")
+ explanation = "说明:互动指图片、表情、引用等非文字消息,仅展示活跃度,不影响排名。"
+
+ assert text.count(explanation) == 1
+ assert text.rstrip().endswith(explanation)
+ assert text.index("1.群友【文字 1|互动 2】") < text.index(explanation)
+ assert explanation not in render_ranking(result, DEFAULT_RANKING_TEMPLATE)
+
+
def test_render_top15_heading_and_lines():
result = RankingResult(
group_name="周末群",
@@ -211,19 +271,21 @@ def test_renderer_uses_template_file(tmp_path):
def test_group_v2_defaults():
g = Group()
- assert g.schedule_rule == "weekday_default"
+ assert g.schedule_rule == "daily_previous_day"
assert g.send_time == "08:30"
assert g.summary_model == "gpt-5.6-sol"
assert g.prompt_model == "gpt-5.6-sol"
assert g.image_enabled is True
assert g.send_target == ""
assert g.ranking_template == "default"
+ assert g.ranking_count_policy == "all_messages"
+ assert g.sender_name_policy == "resolved"
assert g.image_prompt_template == "default"
- assert g.image_theme == "random_preset"
+ assert g.image_theme == "ai_free"
assert g.image_theme_custom == ""
-def test_existing_groups_table_gets_image_theme_columns(tmp_path, monkeypatch):
+def test_existing_groups_table_gets_v2_policy_columns(tmp_path, monkeypatch):
"""旧数据库启动时应补列,并为已有群写入安全默认值。"""
engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
with engine.begin() as conn:
@@ -238,7 +300,13 @@ def test_existing_groups_table_gets_image_theme_columns(tmp_path, monkeypatch):
with engine.connect() as conn:
columns = {row[1] for row in conn.exec_driver_sql("PRAGMA table_info(groups)")}
row = conn.exec_driver_sql(
- "SELECT image_theme, image_theme_custom FROM groups WHERE id = 1"
+ "SELECT image_theme, image_theme_custom, ranking_count_policy, "
+ "sender_name_policy FROM groups WHERE id = 1"
).one()
- assert {"image_theme", "image_theme_custom"}.issubset(columns)
- assert tuple(row) == ("random_preset", "")
+ assert {
+ "image_theme",
+ "image_theme_custom",
+ "ranking_count_policy",
+ "sender_name_policy",
+ }.issubset(columns)
+ assert tuple(row) == ("ai_free", "", "all_messages", "resolved")
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")
diff --git a/tests/test_v2_run_batch.py b/tests/test_v2_run_batch.py
new file mode 100644
index 0000000..1c4f6d7
--- /dev/null
+++ b/tests/test_v2_run_batch.py
@@ -0,0 +1,47 @@
+from types import SimpleNamespace
+
+from app.api.v2_ui_read import list_runs
+from app.v2.run_store import RunStore
+
+
+def test_list_runs_can_include_allowed_files_without_reloading_each_detail(tmp_path):
+ store = RunStore(tmp_path)
+ store.save_run(
+ "测试群",
+ "2026-08-25",
+ {
+ "group_name": "测试群",
+ "run_date": "2026-08-25",
+ "status": "PROMPT_READY",
+ },
+ )
+ store.messages_path("测试群", "2026-08-25").write_text("[]", encoding="utf-8")
+ (store.group_dir("测试群", "2026-08-25") / "private.tmp").write_text(
+ "not public",
+ encoding="utf-8",
+ )
+
+ response = list_runs(
+ settings=SimpleNamespace(output_dir=tmp_path),
+ include_files=True,
+ )
+
+ assert response["total"] == 1
+ assert response["runs"][0]["files"] == ["messages.json", "run.json"]
+
+
+def test_list_runs_default_response_does_not_add_file_scan_field(tmp_path):
+ store = RunStore(tmp_path)
+ store.save_run(
+ "测试群",
+ "2026-08-25",
+ {
+ "group_name": "测试群",
+ "run_date": "2026-08-25",
+ "status": "PENDING",
+ },
+ )
+
+ response = list_runs(settings=SimpleNamespace(output_dir=tmp_path))
+
+ assert "files" not in response["runs"][0]
diff --git a/tests/test_v2_ui_router_contract.py b/tests/test_v2_ui_router_contract.py
new file mode 100644
index 0000000..cedf392
--- /dev/null
+++ b/tests/test_v2_ui_router_contract.py
@@ -0,0 +1,249 @@
+from pathlib import Path
+import json
+from types import SimpleNamespace
+
+from fastapi import FastAPI
+
+from app.api import v2_ui, v2_ui_read
+from app.config.settings import Settings
+
+
+EXPECTED_V2_UI_OPERATIONS = {
+ ("GET", "/api/v2/dashboard", "dashboard_api_v2_dashboard_get"),
+ ("GET", "/api/v2/runtime/logs", "runtime_logs_api_v2_runtime_logs_get"),
+ ("GET", "/api/v2/runs", "list_runs_api_v2_runs_get"),
+ ("GET", "/api/v2/archive/groups", "archive_groups_api_v2_archive_groups_get"),
+ ("GET", "/api/v2/runs/{group}/{run_date}", "run_detail_api_v2_runs__group___run_date__get"),
+ ("GET", "/api/v2/files/{group}/{run_date}/{file_name}", "read_output_file_api_v2_files__group___run_date___file_name__get"),
+ ("GET", "/api/v2/image-themes", "image_themes_api_v2_image_themes_get"),
+ ("POST", "/api/v2/image-themes/resolve", "resolve_theme_preview_api_v2_image_themes_resolve_post"),
+ ("GET", "/api/v2/runs/{group}/{run_date}/prompt", "get_run_prompt_api_v2_runs__group___run_date__prompt_get"),
+ ("PUT", "/api/v2/runs/{group}/{run_date}/prompt", "update_run_prompt_api_v2_runs__group___run_date__prompt_put"),
+ ("POST", "/api/v2/runs/{group}/{run_date}/prompt/restore", "restore_run_prompt_api_v2_runs__group___run_date__prompt_restore_post"),
+ ("POST", "/api/v2/runs/{group}/{run_date}/regenerate-image", "regenerate_run_image_api_v2_runs__group___run_date__regenerate_image_post"),
+ ("GET", "/api/v2/runs/{group}/{run_date}/image-candidates", "get_run_image_candidates_api_v2_runs__group___run_date__image_candidates_get"),
+ ("GET", "/api/v2/runs/{group}/{run_date}/image-candidates/{candidate_id}", "preview_run_image_candidate_api_v2_runs__group___run_date__image_candidates__candidate_id__get"),
+ ("POST", "/api/v2/runs/{group}/{run_date}/image-candidates/claim", "claim_run_image_candidate_api_v2_runs__group___run_date__image_candidates_claim_post"),
+ ("POST", "/api/v2/runs/{group}/{run_date}/refresh-messages", "refresh_run_messages_api_v2_runs__group___run_date__refresh_messages_post"),
+ ("POST", "/api/v2/runs/{group}/{run_date}/rebuild-prompt", "rebuild_run_prompt_api_v2_runs__group___run_date__rebuild_prompt_post"),
+ ("POST", "/api/v2/runs/batch/rebuild-prompts", "rebuild_run_prompts_batch_api_v2_runs_batch_rebuild_prompts_post"),
+ ("POST", "/api/v2/runs/batch/regenerate-images", "regenerate_run_images_batch_api_v2_runs_batch_regenerate_images_post"),
+ ("GET", "/api/v2/system/health", "system_health_api_v2_system_health_get"),
+ ("GET", "/api/v2/system/startup", "startup_checks_api_v2_system_startup_get"),
+ ("GET", "/api/v2/system/recovery", "recovery_info_api_v2_system_recovery_get"),
+ ("POST", "/api/v2/pipeline/retry-failed", "retry_failed_api_v2_pipeline_retry_failed_post"),
+ ("POST", "/api/v2/pipeline/generate", "pipeline_generate_api_v2_pipeline_generate_post"),
+ ("POST", "/api/v2/pipeline/send-due", "pipeline_send_due_api_v2_pipeline_send_due_post"),
+ ("POST", "/api/v2/pipeline/send", "pipeline_send_api_v2_pipeline_send_post"),
+ ("POST", "/api/v2/pipeline/resolve-send-unknown", "pipeline_resolve_send_unknown_api_v2_pipeline_resolve_send_unknown_post"),
+ ("POST", "/api/v2/pipeline/reset-send-failure", "pipeline_reset_send_failure_api_v2_pipeline_reset_send_failure_post"),
+ ("POST", "/api/v2/pipeline/resolve-prompt-unknown", "pipeline_resolve_prompt_unknown_api_v2_pipeline_resolve_prompt_unknown_post"),
+ ("POST", "/api/v2/pipeline/resolve-manual-send", "pipeline_resolve_manual_send_api_v2_pipeline_resolve_manual_send_post"),
+ ("GET", "/api/v2/recovery/backlog", "recovery_backlog_api_v2_recovery_backlog_get"),
+ ("POST", "/api/v2/recovery/confirm", "confirm_recovery_api_v2_recovery_confirm_post"),
+ ("POST", "/api/v2/recovery/repair-empty-manifest", "repair_empty_manifest_api_v2_recovery_repair_empty_manifest_post"),
+ ("GET", "/api/v2/weekly", "list_weekly_insights_api_v2_weekly_get"),
+ ("GET", "/api/v2/weekly/{week_start}/{group_id}", "weekly_insight_detail_api_v2_weekly__week_start___group_id__get"),
+ ("GET", "/api/v2/weekly/{week_start}/{group_id}/card", "weekly_insight_card_api_v2_weekly__week_start___group_id__card_get"),
+}
+
+
+def test_v2_ui_router_preserves_paths_methods_and_operation_ids() -> None:
+ app = FastAPI()
+ app.include_router(v2_ui.router)
+ schema = app.openapi()
+ actual = {
+ (method.upper(), path, operation["operationId"])
+ for path, path_item in schema["paths"].items()
+ for method, operation in path_item.items()
+ if method != "parameters"
+ }
+
+ assert actual == EXPECTED_V2_UI_OPERATIONS
+
+
+def test_v2_ui_compatibility_exports_remain_available() -> None:
+ assert v2_ui.router is not None
+ assert v2_ui.RunPromptUpdateBody is not None
+ assert callable(v2_ui.update_run_prompt)
+ assert callable(v2_ui.restore_run_prompt)
+ assert v2_ui.RetryBody is not None
+ assert callable(v2_ui.retry_failed)
+ assert callable(v2_ui._store)
+
+
+def test_dashboard_counts_send_unknown_as_held_and_surfaces_send_error(
+ tmp_path, monkeypatch
+) -> None:
+ group = SimpleNamespace(
+ id=7,
+ display_name="测试群",
+ wechat_group_name="测试群",
+ send_time="08:30",
+ schedule_rule="daily",
+ image_enabled=True,
+ wechat_send_enabled=True,
+ ranking_template="",
+ image_prompt_template="",
+ )
+
+ class FakeStore:
+ def load_run(self, _group_name, _run_date):
+ return {
+ "status": "READY_TO_SEND",
+ "send_state": "unknown",
+ "send_hold": True,
+ "send_hold_reason": "SEND_RESULT_UNKNOWN",
+ "send_error": "文字已提交但 UI 验证结果未知",
+ "send_error_type": "SEND_RESULT_UNKNOWN",
+ "send_unknown_at": "2026-08-26T08:30:59+08:00",
+ }
+
+ def image_path(self, _group_name, _run_date):
+ return Path(tmp_path) / "missing.png"
+
+ def ranking_json_path(self, _group_name, _run_date):
+ return Path(tmp_path) / "missing-ranking.json"
+
+ monkeypatch.setattr(v2_ui_read, "_store", lambda _settings: FakeStore())
+ monkeypatch.setattr(
+ v2_ui_read.repo,
+ "list_groups",
+ lambda _session, only_enabled=True: [group],
+ )
+
+ result = v2_ui_read.dashboard(
+ session=object(),
+ settings=Settings(_env_file=None, output_dir=tmp_path),
+ )
+
+ assert result["counts"] == {
+ "pending": 0,
+ "generated": 0,
+ "sent": 0,
+ "failed": 0,
+ "held": 1,
+ }
+ assert result["cards"][0]["error"] == "文字已提交但 UI 验证结果未知"
+ assert result["cards"][0]["send_error_type"] == "SEND_RESULT_UNKNOWN"
+
+
+def test_dashboard_counts_prompt_unknown_as_held(tmp_path, monkeypatch) -> None:
+ group = SimpleNamespace(
+ id=9,
+ display_name="Prompt 暂停群",
+ wechat_group_name="Prompt 暂停群",
+ send_time="08:30",
+ schedule_rule="daily",
+ image_enabled=True,
+ wechat_send_enabled=True,
+ ranking_template="",
+ image_prompt_template="",
+ )
+
+ class FakeStore:
+ def load_run(self, _group_name, _run_date):
+ return {
+ "status": "RANKING_READY",
+ "prompt_hold": True,
+ "prompt_hold_reason": "PROMPT_RESULT_UNKNOWN",
+ "prompt_operation_id": "operation-123",
+ "prompt_operation_status": "unknown",
+ "error": "Codex GPT 超时且结果未知",
+ }
+
+ def image_path(self, _group_name, _run_date):
+ return Path(tmp_path) / "missing.png"
+
+ def ranking_json_path(self, _group_name, _run_date):
+ return Path(tmp_path) / "missing-ranking.json"
+
+ monkeypatch.setattr(v2_ui_read, "_store", lambda _settings: FakeStore())
+ monkeypatch.setattr(v2_ui_read.repo, "list_groups", lambda *_args, **_kwargs: [group])
+
+ result = v2_ui_read.dashboard(
+ session=object(),
+ settings=Settings(_env_file=None, output_dir=tmp_path),
+ run_date="2026-08-27",
+ )
+
+ assert result["counts"]["held"] == 1
+ assert result["counts"]["pending"] == 0
+ assert result["cards"][0]["prompt_operation_id"] == "operation-123"
+
+
+def test_dashboard_accepts_run_date_and_returns_top_five_ranking_preview(
+ tmp_path, monkeypatch
+) -> None:
+ group = SimpleNamespace(
+ id=8,
+ display_name="排行测试群",
+ wechat_group_name="排行测试群",
+ send_time="08:30",
+ schedule_rule="daily",
+ image_enabled=False,
+ wechat_send_enabled=False,
+ ranking_template="",
+ image_prompt_template="",
+ )
+ ranking_path = Path(tmp_path) / "ranking.json"
+ ranking_path.write_text(
+ json.dumps(
+ {
+ "top_speakers": [
+ {"rank": index, "name": f"成员{index}", "count": 20 - index}
+ for index in range(1, 8)
+ ]
+ },
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+
+ class FakeStore:
+ def load_run(self, _group_name, run_date):
+ assert run_date == "2026-08-25"
+ return {
+ "status": "RANKING_READY",
+ "period_start": "2026-08-25 00:00:00",
+ "period_end": "2026-08-25 23:59:59",
+ "message_count": 99,
+ "speaker_count": 7,
+ }
+
+ def image_path(self, _group_name, _run_date):
+ return Path(tmp_path) / "missing.png"
+
+ def ranking_json_path(self, _group_name, _run_date):
+ return ranking_path
+
+ monkeypatch.setattr(v2_ui_read, "_store", lambda _settings: FakeStore())
+ monkeypatch.setattr(v2_ui_read.repo, "list_groups", lambda *_args, **_kwargs: [group])
+
+ result = v2_ui_read.dashboard(
+ session=object(),
+ settings=Settings(_env_file=None, output_dir=tmp_path),
+ run_date="2026-08-25",
+ )
+
+ assert result["today"] == result["run_date"] == "2026-08-25"
+ assert result["runtime"]["overall_status"] == "needs_attention"
+ assert [node["id"] for node in result["runtime"]["nodes"]] == [
+ "scheduler",
+ "data",
+ "ranking",
+ "prompt",
+ "image",
+ "send",
+ ]
+ assert result["runtime"]["groups"][0]["current_node"] == "prompt"
+ assert result["daily_status"]["overall_status"] == result["runtime"]["overall_status"]
+ assert len(result["cards"][0]["ranking_preview"]) == 5
+ assert result["cards"][0]["ranking_preview"][0] == {
+ "rank": 1,
+ "name": "成员1",
+ "count": 19,
+ "text_count": 0,
+ "interaction_count": 0,
+ "name_source": "resolved",
+ }
diff --git a/tests/test_v2_wechat_native.py b/tests/test_v2_wechat_native.py
index 442ff14..01f0bb0 100644
--- a/tests/test_v2_wechat_native.py
+++ b/tests/test_v2_wechat_native.py
@@ -8,14 +8,23 @@
from app.sender.wechat_native import (
NativeActionResult,
OcrLine,
+ UiaSearchItem,
WechatNativeSender,
WindowsWechatDriver,
+ create_wechat_sender,
_main_chat_horizontal_bounds,
_selected_header_matches,
_select_group_search_match,
+ _select_uia_group_search_match,
_title_matches,
)
+import pytest
+
+
+def _write_valid_png(path: Path) -> None:
+ Image.new("RGBA", (8, 8), (0, 0, 0, 0)).save(path, format="PNG")
+
class FakeNativeDriver:
def __init__(self, *, verify=True, text=True, image=True):
@@ -48,6 +57,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)", "测试群")
@@ -69,6 +85,7 @@ def test_selected_header_allows_one_ocr_substitution_only_for_long_title():
assert not _selected_header_matches("米游涩泛二次元同好摸鱼群32(422)", target)
assert not _selected_header_matches("Grok张UED-4ä#", "Eason张UED-4群🤘")
assert not _selected_header_matches("测试一(10)", "测试二")
+ assert not _selected_header_matches("Grok Web 交流群", "Grok App 交流群")
def test_search_selects_only_group_section_match():
@@ -104,12 +121,13 @@ def test_search_rejects_similar_group_with_different_version_suffix():
def test_search_allows_missing_second_version_digit_but_not_wrong_first_digit():
target = "米游涩泛二次元同好摸鱼群1.1"
+ section = OcrLine("群聊", 137, 70, 50, 18)
partial = OcrLine("米游涩泛二次元同好摸鱼群1。", 203, 125, 277, 21)
wrong = OcrLine("米游涩泛二次元同好摸鱼群2。", 203, 125, 277, 21)
boundary = OcrLine("聊天记录", 137, 200, 72, 17)
- matched, detail = _select_group_search_match([partial, boundary], target)
- rejected, _ = _select_group_search_match([wrong, boundary], target)
+ matched, detail = _select_group_search_match([section, partial, boundary], target)
+ rejected, _ = _select_group_search_match([section, wrong, boundary], target)
assert detail == ""
assert matched is partial
@@ -118,10 +136,11 @@ def test_search_allows_missing_second_version_digit_but_not_wrong_first_digit():
def test_search_accepts_collapsed_version_digits_with_leading_ocr_noise():
target = "米游涩泛二次元同好摸鱼群2.3"
+ section = OcrLine("群聊", 10, 50, 60, 24)
noisy_group_result = OcrLine("孙睿 米游涩泛二次元同好摸鱼群23", 10, 95, 320, 24)
boundary = OcrLine("聊天记录", 10, 150, 90, 24)
- matched, detail = _select_group_search_match([noisy_group_result, boundary], target)
+ matched, detail = _select_group_search_match([section, noisy_group_result, boundary], target)
assert matched == noisy_group_result
assert detail == ""
@@ -129,10 +148,11 @@ def test_search_accepts_collapsed_version_digits_with_leading_ocr_noise():
def test_search_rejects_different_collapsed_version_digits():
target = "米游涩泛二次元同好摸鱼群2.3"
+ section = OcrLine("群聊", 10, 50, 60, 24)
wrong_group_result = OcrLine("孙睿 米游涩泛二次元同好摸鱼群32", 10, 95, 320, 24)
boundary = OcrLine("聊天记录", 10, 150, 90, 24)
- matched, detail = _select_group_search_match([wrong_group_result, boundary], target)
+ matched, detail = _select_group_search_match([section, wrong_group_result, boundary], target)
assert matched is None
assert "匹配数 0" in detail
@@ -152,6 +172,7 @@ def test_search_does_not_fallback_to_chat_record_when_group_section_exists():
def test_search_allows_bounded_ocr_errors_with_stable_ascii_anchor():
lines = [
+ OcrLine("最常使用", 137, 56, 71, 17),
OcrLine("Grok App 交 氵 充 君 丰", 204, 125, 164, 23),
OcrLine("聊天记录", 137, 200, 72, 17),
]
@@ -159,7 +180,132 @@ def test_search_allows_bounded_ocr_errors_with_stable_ascii_anchor():
matched, detail = _select_group_search_match(lines, "Grok App 交流群")
assert detail == ""
- assert matched is lines[0]
+ assert matched is lines[1]
+
+
+def test_search_selects_grok_only_from_real_trusted_section_layout():
+ target = "Grok App 交流群"
+ trusted_result = OcrLine("Grok App 交 氵 充 君 丰", 202, 124, 190, 24)
+ lines = [
+ OcrLine(target, 130, 8, 180, 20),
+ OcrLine("最常使用", 137, 56, 71, 17),
+ trusted_result,
+ OcrLine("聊天记录", 137, 190, 72, 17),
+ OcrLine(target, 202, 230, 190, 24),
+ OcrLine("搜索网络结果", 137, 310, 145, 18),
+ OcrLine(target, 202, 350, 190, 24),
+ ]
+
+ matched, detail = _select_group_search_match(lines, target)
+
+ assert detail == ""
+ assert matched is trusted_result
+
+
+def test_search_tolerates_one_ocr_substitution_in_most_used_section():
+ target = "Eason张UED-4群🤘"
+ trusted_result = OcrLine("Eason 张 UED-4 君 羊", 239, 158, 192, 24)
+ matched, detail = _select_group_search_match(
+ [
+ OcrLine("最 常 使 岸", 160, 78, 82, 20),
+ trusted_result,
+ OcrLine("聊 天 记 录", 160, 246, 84, 20),
+ OcrLine("Eason 张 UED-4ä*", 162, 308, 269, 24),
+ OcrLine("搜 索 网 络 结 果", 162, 574, 169, 21),
+ ],
+ target,
+ )
+
+ assert detail == ""
+ assert matched is trusted_result
+
+
+def test_search_accepts_live_grok_ocr_only_inside_trusted_section():
+ target = "Grok App 交流群"
+ trusted_result = OcrLine("Gr01< App 交 氵 充 君 羊", 238, 158, 192, 27)
+ matched, detail = _select_group_search_match(
+ [
+ OcrLine("最 常 使 岸", 160, 78, 82, 20),
+ trusted_result,
+ OcrLine("聊 天 记 录", 160, 246, 84, 20),
+ OcrLine("Gr01< App 交 氵 充 君 羊", 238, 308, 192, 27),
+ ],
+ target,
+ )
+
+ assert detail == ""
+ assert matched is trusted_result
+
+
+def test_search_rejects_wrong_english_token_even_in_trusted_section():
+ target = "Grok App 交流群"
+ matched, detail = _select_group_search_match(
+ [
+ OcrLine("最常使用", 160, 78, 82, 20),
+ OcrLine("Grok Web 交流群", 238, 158, 192, 27),
+ OcrLine("聊天记录", 160, 246, 84, 20),
+ ],
+ target,
+ )
+
+ assert matched is None
+ assert "匹配数 0" in detail
+
+
+def test_search_rejects_target_when_trusted_group_section_is_missing():
+ target = "Grok App 交流群"
+ matched, detail = _select_group_search_match(
+ [
+ OcrLine(target, 202, 124, 190, 24),
+ OcrLine("聊天记录", 137, 190, 72, 17),
+ OcrLine(target, 202, 230, 190, 24),
+ ],
+ target,
+ )
+
+ assert matched is None
+ assert "可信分区 0" in detail
+
+
+def test_uia_search_selects_only_unique_exact_group_item_inside_search_box():
+ target = "Grok App 交流群"
+ search_box = (10, 20, 500, 600)
+ exact = UiaSearchItem(target, f"search_item_{target}", 100, 120, 330, 170)
+ chat_record = UiaSearchItem(target, "", 100, 220, 330, 270)
+ network = UiaSearchItem(target, "network_result", 100, 320, 330, 370)
+
+ matched, detail = _select_uia_group_search_match(
+ [chat_record, network, exact],
+ target,
+ search_box,
+ )
+
+ assert detail == ""
+ assert matched == OcrLine(target, 90, 100, 230, 50)
+
+
+def test_uia_search_rejects_duplicate_or_out_of_bounds_exact_items():
+ target = "Grok App 交流群"
+ expected_id = f"search_item_{target}"
+ inside = UiaSearchItem(target, expected_id, 100, 120, 330, 170)
+ duplicate = UiaSearchItem(target, expected_id, 100, 180, 330, 230)
+ outside = UiaSearchItem(target, expected_id, 600, 120, 830, 170)
+
+ ambiguous, ambiguous_detail = _select_uia_group_search_match(
+ [inside, duplicate],
+ target,
+ (10, 20, 500, 600),
+ )
+ bounded, bounded_detail = _select_uia_group_search_match(
+ [outside],
+ target,
+ (10, 20, 500, 600),
+ )
+
+ assert ambiguous is None
+ assert "当前 2" in ambiguous_detail
+ assert bounded is None
+ assert "当前 0" in bounded_detail
def test_search_selects_exact_recent_group_before_network_section():
@@ -203,7 +349,7 @@ def test_verify_target_fails_closed_on_ambiguous_result(tmp_path):
def test_bundle_verifies_once_then_sends_text_and_image(tmp_path):
image = tmp_path / "daily_image.png"
- image.write_bytes(b"\x89PNG\r\n\x1a\nmock")
+ _write_valid_png(image)
driver = FakeNativeDriver()
sender = WechatNativeSender(_settings(tmp_path), driver=driver)
@@ -216,7 +362,7 @@ def test_bundle_verifies_once_then_sends_text_and_image(tmp_path):
def test_bundle_stops_when_target_verification_fails(tmp_path):
image = tmp_path / "daily_image.png"
- image.write_bytes(b"\x89PNG\r\n\x1a\nmock")
+ _write_valid_png(image)
driver = FakeNativeDriver(verify=False)
sender = WechatNativeSender(_settings(tmp_path), driver=driver)
@@ -229,7 +375,7 @@ def test_bundle_stops_when_target_verification_fails(tmp_path):
def test_bundle_reports_image_failure_after_text_success(tmp_path):
image = tmp_path / "daily_image.png"
- image.write_bytes(b"\x89PNG\r\n\x1a\nmock")
+ _write_valid_png(image)
driver = FakeNativeDriver(image=False)
sender = WechatNativeSender(_settings(tmp_path), driver=driver)
@@ -244,7 +390,14 @@ def test_native_result_preserves_submitted_but_unknown_state(tmp_path):
class UnknownDriver(FakeNativeDriver):
def paste_text(self, text: str):
self.calls.append(("text", text))
- return NativeActionResult(False, "UI 未确认", True, "unknown", True)
+ return NativeActionResult(
+ False,
+ "UI 未确认",
+ True,
+ "unknown",
+ True,
+ {"phase": "submit_unknown", "submit_attempts": 3},
+ )
sender = WechatNativeSender(_settings(tmp_path), driver=UnknownDriver())
@@ -254,6 +407,69 @@ def paste_text(self, text: str):
assert result.submitted is True
assert result.outcome_unknown is True
assert result.verification_level == "unknown"
+ assert result.diagnostics["phase"] == "submit_unknown"
+
+
+def test_text_waits_for_staged_change_before_enter(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ before = Image.new("RGB", (20, 20), "white")
+ staged = Image.new("RGB", (20, 20), "black")
+ pressed: list[str] = []
+ monkeypatch.setattr(driver, "_focus_composer", lambda: None)
+ monkeypatch.setattr(driver, "_composer_is_empty", lambda: (True, "empty"))
+ monkeypatch.setattr(driver, "_capture_stable_baseline", lambda: (True, before, before, 2))
+ monkeypatch.setattr(driver, "_set_clipboard_text", lambda _text: None)
+ monkeypatch.setattr(driver, "_hotkey", lambda *_args: None)
+ monkeypatch.setattr(driver, "_wait_for_staged_change", lambda _before: (staged, 0.1, 4))
+ monkeypatch.setattr(driver, "_key", lambda key, key_up=False: pressed.append(key) if not key_up else None)
+ monkeypatch.setattr(
+ driver,
+ "_wait_for_submission",
+ lambda *_args: (True, "ok", {"phase": "submit_verified", "submit_attempts": 3}),
+ )
+
+ result = driver.paste_text("文字")
+
+ assert result.success is True
+ assert pressed == ["enter"]
+ assert result.diagnostics["stage_attempts"] == 4
+ assert result.diagnostics["submit_attempts"] == 3
+
+
+def test_text_never_enters_when_staged_change_is_not_observed(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ before = Image.new("RGB", (20, 20), "white")
+ pressed: list[str] = []
+ monkeypatch.setattr(driver, "_focus_composer", lambda: None)
+ monkeypatch.setattr(driver, "_composer_is_empty", lambda: (True, "empty"))
+ monkeypatch.setattr(driver, "_capture_stable_baseline", lambda: (True, before, before, 1))
+ monkeypatch.setattr(driver, "_set_clipboard_text", lambda _text: None)
+ monkeypatch.setattr(driver, "_hotkey", lambda *_args: None)
+ monkeypatch.setattr(driver, "_wait_for_staged_change", lambda _before: (None, 0.0, 25))
+ monkeypatch.setattr(driver, "_key", lambda key, key_up=False: pressed.append(key))
+
+ result = driver.paste_text("文字")
+
+ assert result.success is False
+ assert result.submitted is False
+ assert result.outcome_unknown is False
+ assert pressed == []
+ assert "未按 Enter" in result.detail
+
+
+def test_text_holds_without_touching_draft_when_composer_is_not_empty(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ clipboard_writes: list[str] = []
+ monkeypatch.setattr(driver, "_focus_composer", lambda: None)
+ monkeypatch.setattr(driver, "_composer_is_empty", lambda: (False, "检测到草稿"))
+ monkeypatch.setattr(driver, "_set_clipboard_text", lambda text: clipboard_writes.append(text))
+
+ result = driver.paste_text("文字")
+
+ assert result.success is False
+ assert result.submitted is False
+ assert clipboard_writes == []
+ assert "草稿" in result.detail
def test_health_report_rejects_missing_chinese_ocr(tmp_path, monkeypatch):
@@ -276,6 +492,58 @@ async def english_ocr():
assert "中文" in report["ocr"]["detail"]
+def test_prepare_window_restores_only_one_existing_hidden_main_window(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ visible = iter([[], [321]])
+ activated: list[int] = []
+ monkeypatch.setattr(driver, "_wechat_windows", lambda: next(visible))
+ monkeypatch.setattr(driver, "_hidden_wechat_windows", lambda: [321])
+ monkeypatch.setattr(driver, "_activate", lambda hwnd: activated.append(hwnd) or True)
+
+ ok, detail = driver._prepare_wechat_window()
+
+ assert ok is True
+ assert "已恢复" in detail
+ assert activated == [321]
+
+
+def test_prepare_window_rejects_multiple_hidden_candidates_without_activation(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ activated: list[int] = []
+ monkeypatch.setattr(driver, "_wechat_windows", lambda: [])
+ monkeypatch.setattr(driver, "_hidden_wechat_windows", lambda: [321, 654])
+ monkeypatch.setattr(driver, "_activate", lambda hwnd: activated.append(hwnd) or True)
+
+ ok, detail = driver._prepare_wechat_window()
+
+ assert ok is False
+ assert "隐藏候选 2" in detail
+ assert activated == []
+
+
+def test_open_and_verify_never_restores_window_while_desktop_is_locked(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ monkeypatch.setattr("app.sender.wechat_native.os.name", "nt")
+ monkeypatch.setattr(driver, "_desktop_unlocked", lambda: False)
+ monkeypatch.setattr(
+ driver,
+ "_prepare_wechat_window",
+ lambda: pytest.fail("桌面锁定时不得尝试恢复微信窗口"),
+ )
+
+ ok, detail = driver.open_and_verify("Grok App 交流群")
+
+ assert ok is False
+ assert "桌面已锁定" in detail
+
+
+def test_wechat_main_window_class_rejects_auxiliary_windows():
+ assert WindowsWechatDriver._wechat_main_window_class("Qt51514QWindowIcon")
+ assert WindowsWechatDriver._wechat_main_window_class("WeChatMainWndForPC")
+ assert not WindowsWechatDriver._wechat_main_window_class("Qt51514QWindowToolSaveBits")
+ assert not WindowsWechatDriver._wechat_main_window_class("Chrome_WidgetWin_1")
+
+
def test_target_search_overwrites_stale_query_before_paste(tmp_path, monkeypatch):
driver = WindowsWechatDriver(_settings(tmp_path))
hotkeys: list[tuple[str, str]] = []
@@ -346,6 +614,41 @@ def test_target_search_retries_transient_ocr_miss(tmp_path, monkeypatch):
assert ok is True
+def test_target_search_falls_back_to_unique_uia_group_item(tmp_path, monkeypatch):
+ driver = WindowsWechatDriver(_settings(tmp_path))
+ target = "Grok App 交流群"
+ screenshots = iter(
+ [
+ [OcrLine("其他会话", 10, 10, 100, 20)],
+ [OcrLine("聊天记录", 10, 210, 80, 20)],
+ [OcrLine("聊天记录", 10, 210, 80, 20)],
+ [OcrLine("聊天记录", 10, 210, 80, 20)],
+ [OcrLine(target, 10, 10, 160, 20)],
+ ]
+ )
+ clicks: list[tuple[float, float]] = []
+ monkeypatch.setattr(driver, "health_check", lambda: (True, "ok"))
+ monkeypatch.setattr(driver, "_wechat_windows", lambda: [123])
+ monkeypatch.setattr(driver, "_activate", lambda hwnd: True)
+ monkeypatch.setattr(driver, "_hotkey", lambda modifier, key: None)
+ monkeypatch.setattr(driver, "_set_clipboard_text", lambda text: None)
+ monkeypatch.setattr(driver, "_window_rect", lambda hwnd: (0, 0, 1000, 800))
+ monkeypatch.setattr(driver, "_ocr_screen", lambda box: next(screenshots))
+ monkeypatch.setattr(driver, "_click", lambda x, y: clicks.append((x, y)))
+ monkeypatch.setattr(
+ driver,
+ "_find_uia_group_search_match",
+ lambda value, box: (OcrLine(value, 80, 150, 160, 20), ""),
+ )
+ monkeypatch.setattr("app.sender.wechat_native.time.sleep", lambda seconds: None)
+
+ ok, detail = driver.open_and_verify(target)
+
+ assert ok is True
+ assert "精确查找并验证" in detail
+ assert clicks[1] == (160.0, 200.0)
+
+
def test_submission_verification_requires_composer_to_return_near_empty():
before = Image.new("RGB", (120, 80), "white")
staged = before.copy()
diff --git a/tests/test_wechat_mcp.py b/tests/test_wechat_mcp.py
index 059ff7c..4745b8f 100644
--- a/tests/test_wechat_mcp.py
+++ b/tests/test_wechat_mcp.py
@@ -15,7 +15,7 @@
from fastapi import FastAPI
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
-from starlette.testclient import TestClient
+from fastapi.testclient import TestClient
from app.api import settings as settings_api
from app.config.settings import Settings, get_settings
@@ -41,6 +41,20 @@
WINDOW_END = datetime(2026, 8, 17, 23, 59, 59)
+@pytest.fixture(autouse=True)
+def _isolate_range_capability_cache():
+ """每个用例独立探测范围读取能力,避免模块缓存污染测试顺序。"""
+
+ cache = wechat_data_analysis._RANGE_CAPABILITY_CACHE
+ original = dict(cache)
+ cache.clear()
+ try:
+ yield
+ finally:
+ cache.clear()
+ cache.update(original)
+
+
# ---------- 假 MCP 客户端 ----------
@@ -490,6 +504,85 @@ def test_shared_wrong_upstream_name_uses_contact_mapping(monkeypatch):
assert result.meta["sender_name_contact_count"] == 2
+def test_group_card_wins_for_owner_and_member_without_card_uses_contact(monkeypatch):
+ fake = FakeMCPClient().on(
+ "wechat.chat.get_messages_range",
+ lambda params: {
+ "messages": [
+ _msg("m1", 1786420000, "jiangzhema123", "鲁布斯"),
+ _msg("m2", 1786420100, "to1900", "鲁布斯"),
+ ],
+ "hasMore": False,
+ },
+ )
+ provider = _provider(fake)
+ monkeypatch.setattr(
+ provider._contacts,
+ "group_nicknames",
+ lambda group_id, sender_ids: {"jiangzhema123": "鲁布斯"},
+ )
+ monkeypatch.setattr(
+ provider._contacts,
+ "resolve_name",
+ lambda sender_id: {"jiangzhema123": "春夏秋冬", "to1900": "罗斯"}.get(
+ sender_id, ""
+ ),
+ )
+
+ result = provider.fetch_messages("tea@chatroom", WINDOW_START, WINDOW_END)
+
+ assert result.status == ProviderStatus.OK
+ assert [message.sender_name for message in result.messages] == ["鲁布斯", "罗斯"]
+ assert [message.sender_name_source for message in result.messages] == [
+ "wechat_data_analysis",
+ "contact",
+ ]
+
+
+def test_contact_name_equal_to_sender_id_casefold_is_trusted(monkeypatch):
+ fake = FakeMCPClient().on(
+ "wechat.chat.get_messages_range",
+ lambda params: {
+ "messages": [_msg("m1", 1786420000, "exalex", "EXALEX")],
+ "hasMore": False,
+ },
+ )
+ provider = _provider(fake)
+ monkeypatch.setattr(provider._contacts, "group_nicknames", lambda *_: {})
+ monkeypatch.setattr(provider._contacts, "resolve_name", lambda _: "EXALEX")
+
+ result = provider.fetch_messages("grok@chatroom", WINDOW_START, WINDOW_END)
+
+ assert result.status == ProviderStatus.OK
+ assert result.messages[0].sender_name == "EXALEX"
+ assert result.messages[0].sender_name_source == "contact"
+
+
+def test_system_event_text_is_not_accepted_as_sender_name(monkeypatch):
+ fake = FakeMCPClient().on(
+ "wechat.chat.get_messages_range",
+ lambda params: {
+ "messages": [
+ _msg("m1", 1786420000, "liang763621", "群主邀请了“景甜”进入群聊")
+ ],
+ "hasMore": False,
+ },
+ )
+ provider = _provider(fake)
+ monkeypatch.setattr(
+ provider._contacts,
+ "group_nicknames",
+ lambda *_: {"liang763621": "群主邀请了“景甜”进入群聊"},
+ )
+ monkeypatch.setattr(provider._contacts, "resolve_name", lambda _: "意念合一")
+
+ result = provider.fetch_messages("grok@chatroom", WINDOW_START, WINDOW_END)
+
+ assert result.status == ProviderStatus.OK
+ assert result.messages[0].sender_name == "意念合一"
+ assert result.messages[0].sender_name_source == "contact"
+
+
def test_mcp_camel_case_message_types_are_normalized_but_unknown_types_remain_unknown():
assert _mcp_message_type("redPacket") == "red_packet"
assert _mcp_message_type("chatHistory") == "chat_history"
diff --git a/tests/test_weekly_insights.py b/tests/test_weekly_insights.py
new file mode 100644
index 0000000..0099233
--- /dev/null
+++ b/tests/test_weekly_insights.py
@@ -0,0 +1,234 @@
+import json
+from datetime import date, datetime
+from zoneinfo import ZoneInfo
+
+from sqlmodel import Session
+
+from app.config.settings import Settings
+from app.db import repository as repo
+from app.db.models import Group
+from app.sender.base import SendResult
+from app.v2.run_store import RunStore
+from app.weekly.service import WeeklyInsightsService, previous_natural_week
+from app.weekly.store import WeeklyStore
+
+
+class FakeProvider:
+ name = "fake_ai"
+ model = "gpt-5.6-sol"
+
+ def __init__(self, calls, *, fail=False):
+ self.calls = calls
+ self.fail = fail
+
+ def _chat(self, messages, **_kwargs):
+ self.calls.append(messages)
+ if self.fail:
+ raise RuntimeError("simulated AI failure")
+ return "这是只根据聚合统计生成的一次周度叙述。"
+
+
+class FakeSender:
+ def __init__(self):
+ self.calls = []
+
+ def send_bundle(self, target, text, image_path):
+ self.calls.append((target, text, image_path))
+ return (
+ SendResult(True, "文字已验证", "2026-08-31T08:30:00+08:00", True, "ui_observed"),
+ SendResult(True, "图片已验证", "2026-08-31T08:30:01+08:00", True, "ui_observed"),
+ )
+
+
+def _settings(tmp_path, **updates):
+ return Settings(
+ _env_file=None,
+ database_url=f"sqlite:///{(tmp_path / 'weekly.db').as_posix()}",
+ **updates,
+ )
+
+
+def _group(settings, *, send=False):
+ repo.init_db(settings)
+ with Session(repo.engine) as session:
+ return repo.save_group(
+ session,
+ Group(
+ display_name="周报群",
+ wechat_group_id="weekly@chatroom",
+ wechat_group_name="周报当前群名",
+ summary_provider="codex",
+ summary_model="gpt-5.6-sol",
+ wechat_send_enabled=send,
+ ),
+ )
+
+
+def _daily(store, group, run_date, *, count, identity, name, topic=""):
+ store.update(
+ group.display_name,
+ run_date,
+ group_id=str(group.id),
+ status="READY_TO_SEND",
+ message_count=count,
+ speaker_count=1,
+ prompt_meta={
+ "topic_selection": {
+ "candidates": [
+ {"selected": True, "title": topic, "summary": "已保存摘要"}
+ ]
+ if topic
+ else []
+ }
+ },
+ )
+ path = store.ranking_json_path(group.display_name, run_date)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(
+ {
+ "message_count": count,
+ "speaker_count": 1,
+ "top_speakers": [
+ {"rank": 1, "name": name, "count": count, "identity_key": identity}
+ ],
+ },
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+
+
+def test_previous_natural_week_uses_monday_to_sunday_boundary():
+ assert previous_natural_week(date(2026, 8, 31)) == (
+ date(2026, 8, 24),
+ date(2026, 8, 30),
+ )
+ assert previous_natural_week(date(2026, 9, 2)) == (
+ date(2026, 8, 24),
+ date(2026, 8, 30),
+ )
+
+
+def test_weekly_aggregates_saved_daily_artifacts_once_and_preserves_identity(tmp_path):
+ settings = _settings(tmp_path)
+ group = _group(settings)
+ daily_store = RunStore(tmp_path / "output")
+ weekly_store = WeeklyStore(daily_store.root)
+ _daily(daily_store, group, "2026-08-24", count=3, identity="stable-a", name="旧昵称", topic="项目进展")
+ _daily(daily_store, group, "2026-08-25", count=2, identity="stable-a", name="新昵称", topic="项目进展")
+ calls = []
+ service = WeeklyInsightsService(
+ settings,
+ daily_store=daily_store,
+ weekly_store=weekly_store,
+ provider_factory=lambda _settings: FakeProvider(calls),
+ sender=FakeSender(),
+ )
+ now = datetime(2026, 8, 31, 7, 45, tzinfo=ZoneInfo("Asia/Shanghai"))
+
+ first = service.generate_previous_week(now=now)
+ second = service.generate_previous_week(now=now)
+ state = weekly_store.load("2026-08-24", "2026-08-30", group.id)
+
+ assert first["status"] == "complete"
+ assert second["status"] == "complete"
+ assert len(calls) == 1
+ assert state["aggregation"]["raw_messages_uploaded"] is False
+ assert state["aggregation"]["contributors"][0]["identity_key"] == "stable-a"
+ assert state["aggregation"]["contributors"][0]["count"] == 5
+ assert state["aggregation"]["topics"] == [{"title": "项目进展", "days": 2}]
+ assert len(state["aggregation"]["missing_days"]) == 5
+ assert weekly_store.card_path("2026-08-24", "2026-08-30", group.id).is_file()
+
+
+def test_weekly_ai_failure_still_creates_deterministic_text_and_card(tmp_path):
+ settings = _settings(tmp_path)
+ group = _group(settings)
+ daily_store = RunStore(tmp_path / "output")
+ weekly_store = WeeklyStore(daily_store.root)
+ _daily(daily_store, group, "2026-08-24", count=1, identity="a", name="成员A")
+ service = WeeklyInsightsService(
+ settings,
+ daily_store=daily_store,
+ weekly_store=weekly_store,
+ provider_factory=lambda _settings: FakeProvider([], fail=True),
+ sender=FakeSender(),
+ )
+
+ service.generate_previous_week(
+ now=datetime(2026, 8, 31, 7, 45, tzinfo=ZoneInfo("Asia/Shanghai"))
+ )
+ state = weekly_store.load("2026-08-24", "2026-08-30", group.id)
+ assert state["status"] == "ready_to_send"
+ assert state["ai_status"] == "failed"
+ assert state["narrative_source"] == "local_deterministic"
+ assert "周度洞察" in state["narrative"]
+
+
+def test_weekly_send_has_independent_state_and_delivery_evidence(tmp_path):
+ settings = _settings(tmp_path, weekly_send_enabled=True)
+ group = _group(settings, send=True)
+ daily_store = RunStore(tmp_path / "output")
+ weekly_store = WeeklyStore(daily_store.root)
+ _daily(daily_store, group, "2026-08-24", count=2, identity="a", name="成员A")
+ sender = FakeSender()
+ service = WeeklyInsightsService(
+ settings,
+ daily_store=daily_store,
+ weekly_store=weekly_store,
+ provider_factory=lambda _settings: FakeProvider([]),
+ sender=sender,
+ )
+ service.generate_previous_week(
+ now=datetime(2026, 8, 31, 7, 45, tzinfo=ZoneInfo("Asia/Shanghai"))
+ )
+
+ result = service.send_due(
+ now=datetime(2026, 8, 31, 8, 30, tzinfo=ZoneInfo("Asia/Shanghai"))
+ )
+ state = weekly_store.load("2026-08-24", "2026-08-30", group.id)
+
+ assert result == [{"group_name": "周报群", "status": "sent"}]
+ assert len(sender.calls) == 1
+ assert state["status"] == "sent"
+ assert state["send_target"] == "周报当前群名"
+ assert len(state["text_sha256"]) == len(state["card_sha256"]) == 64
+ assert state["send_result"]["verification_level"] == "ui_observed"
+
+
+def test_stale_weekly_send_claim_becomes_manual_hold_without_resubmit(tmp_path):
+ settings = _settings(tmp_path, weekly_send_enabled=True)
+ group = _group(settings, send=True)
+ daily_store = RunStore(tmp_path / "output")
+ weekly_store = WeeklyStore(daily_store.root)
+ sender = FakeSender()
+ weekly_store.save(
+ "2026-08-24",
+ "2026-08-30",
+ group.id,
+ {
+ "status": "sending",
+ "group_name": group.display_name,
+ "send_claim_id": "crashed-claim",
+ "send_claim_expires_at": "2026-08-31T08:20:00+08:00",
+ },
+ )
+ service = WeeklyInsightsService(
+ settings,
+ daily_store=daily_store,
+ weekly_store=weekly_store,
+ provider_factory=lambda _settings: FakeProvider([]),
+ sender=sender,
+ )
+
+ result = service.send_due(
+ now=datetime(2026, 8, 31, 8, 31, tzinfo=ZoneInfo("Asia/Shanghai"))
+ )
+ state = weekly_store.load("2026-08-24", "2026-08-30", group.id)
+
+ assert result[0]["status"] == "held"
+ assert result[0]["error_type"] == "WEEKLY_SEND_RESULT_UNKNOWN"
+ assert sender.calls == []
+ assert state["status"] == "needs_attention"
+ assert state["send_claim_id"] == ""
|