Skip to content

Commit 012f419

Browse files
committed
fix(goal): v0.18.2 审查即修——编辑回弹/重复建目标/规划并发互斥/白名单守卫/坏快照降级
- P1:GoalPlanApprovalDialog 标题编辑被原题覆盖(...editingTitles, ...m 展开顺序) ——编辑优先 + 3 项组件测试回归 - P1:InterviewDialog AI 路径重复建目标——失败回滚新建(delete_goal+置空)/ 确认成功 onCreated 收口结束向导 - P1:ai_goal_plan 无并发互斥(双击/多窗口重复扣费)——AppState Arc<AtomicBool> swap 占位 + BusyGuard Drop 释放(async Send 兼容) - P2:goal_apply_plan 里程碑白名单与周界再校验(防任意请求直写 self_test 占位) - P2:ai_goal_plan 状态守卫(仅 active/paused 可规划——毕业/放弃无实际意义防误操作) - P3:毕业档案坏快照单条跳过+日志(原整命令失败——归档可读性优先) - P3:goal_summary 历法换算已知锚点回归(1700000000=2023-11-14 UTC)
1 parent 8a1b257 commit 012f419

8 files changed

Lines changed: 128 additions & 6 deletions

‎app/src-tauri/src/app_setup.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
236236
live_session: LiveSessionManager::new(),
237237
model_downloader: ModelDownloader::new(),
238238
app: app.handle().clone(),
239+
// v0.18.2(REQ-251):目标规划互斥占位(初值 false——未占用)
240+
goal_plan_busy: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
239241
ocr_device_config_path,
240242
ocr_device_config,
241243
ocr_models,

‎app/src-tauri/src/commands.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ pub struct AppState {
8888
pub model_downloader: ModelDownloader,
8989
/// 应用句柄(事件推送 live:* / model:*)
9090
pub app: tauri::AppHandle,
91+
/// v0.18.2(REQ-251):目标规划并发互斥——防多窗口/双击重复扣费。
92+
/// 同步规划调用无任务去重表(ai_refine_start 的按会话去重先例);
93+
/// Arc<AtomicBool>(AppState Clone 传播)+ swap 占位(async command
94+
/// 跨 await 需 Send,不用 MutexGuard)。
95+
pub goal_plan_busy: std::sync::Arc<std::sync::atomic::AtomicBool>,
9196
/// OCR 设备配置文件路径(ADR-009:模式/校准持久化,应用数据目录)
9297
pub ocr_device_config_path: std::path::PathBuf,
9398
/// OCR 模型标识(ADR-009 校准"重新检测"用;与引擎池启动同源)

‎app/src-tauri/src/commands_goals_lifecycle.rs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,15 @@ pub(crate) fn goal_abandon_inner(db: &Db, id: i64, reason: Option<&str>) -> Resu
149149

150150
pub(crate) fn list_goal_graduations_inner(db: &Db) -> Result<Vec<GraduationReport>, String> {
151151
let jsons = db.list_graduation_reports_json().map_err(|e| e.to_string())?;
152-
jsons
153-
.into_iter()
154-
.map(|json| serde_json::from_str::<GraduationReport>(&json).map_err(|e| e.to_string()))
155-
.collect()
152+
let mut out = Vec::new();
153+
for json in jsons {
154+
match serde_json::from_str::<GraduationReport>(&json) {
155+
Ok(report) => out.push(report),
156+
// 审查修复:单条快照损坏不阻塞档案区(跳过+日志——归档可读性优先)
157+
Err(e) => eprintln!("[Goal] 毕业报告快照损坏跳过(id 不可定位,保留原文供排查): {}", e),
158+
}
159+
}
160+
Ok(out)
156161
}
157162

158163
#[cfg(test)]

‎app/src-tauri/src/commands_goals_plan.rs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ pub async fn ai_goal_plan(
7979
authorized: bool,
8080
) -> Result<GoalPlanView, String> {
8181
require_goal(&state.db, goal_id)?;
82+
// 审查修复:状态守卫——仅进行中/已暂停可规划(已毕业/放弃无规划意义,
83+
// 防误操作与无谓成本)
84+
let goal_status = state
85+
.db
86+
.get_goal(goal_id)
87+
.map_err(|e| e.to_string())?
88+
.ok_or_else(|| format!("目标不存在: {}", goal_id))?
89+
.status;
90+
if goal_status != "active" && goal_status != "paused" {
91+
return Err(format!("仅进行中/已暂停目标可 AI 规划(当前: {})", goal_status));
92+
}
93+
// 并发互斥:同步规划无任务去重表——多窗口/双击防重复扣费
94+
if state
95+
.goal_plan_busy
96+
.swap(true, std::sync::atomic::Ordering::SeqCst)
97+
{
98+
return Err("已有 AI 规划进行中——请等待完成(防重复扣费)".to_string());
99+
}
100+
struct BusyGuard<'a>(&'a std::sync::atomic::AtomicBool);
101+
impl Drop for BusyGuard<'_> {
102+
fn drop(&mut self) {
103+
self.0.store(false, std::sync::atomic::Ordering::SeqCst);
104+
}
105+
}
106+
let _busy = BusyGuard(&state.goal_plan_busy);
82107
let budget = plan_budget(tier.as_deref().unwrap_or("standard"));
83108
let settings: AiSettings = state
84109
.ai_settings
@@ -327,7 +352,15 @@ pub fn goal_apply_plan(
327352
}
328353
}
329354
let mut new_milestones = Vec::new();
355+
// 审查修复:apply 为确认流落点——白名单再校验(协议校验在草案侧,此处
356+
// 防任意请求直写:self_test 占位契约 M3 前不可写;周界与 add_goal_milestone 同口径)
330357
for m in &request.milestones {
358+
if !matches!(m.criteria_type.as_str(), "manual" | "group_settled") {
359+
return Err(format!("不支持的里程碑判据类型: {}", m.criteria_type));
360+
}
361+
if m.due_weeks > 520 {
362+
return Err("里程碑期限超出合理范围(>10 年)".to_string());
363+
}
331364
if let Some(ref_id) = m.ref_group_id {
332365
if db.get_group(ref_id).map_err(|e| e.to_string())?.is_none() {
333366
return Err(format!("里程碑绑定组不存在: {}", ref_id));

‎app/src-tauri/src/goal_summary_tests.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ fn civil_days_roundtrip_known_epochs() {
5353
created_at: 0, ..Default::default()
5454
});
5555
assert!(zero.contains("—"), "epoch 前回退");
56+
// 已知锚点:1700000000 = 2023-11-14 UTC(civil_from_days 换算回归护栏)
57+
let known = crate::goal_summary::build_summary(&GoalSummaryInput {
58+
name: "x".to_string(),
59+
created_at: 1_700_000_000,
60+
..Default::default()
61+
});
62+
assert!(known.contains("2023-11-14"), "历法换算: {}", known);
5663
}
5764

5865
#[test]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* GoalPlanApprovalDialog.test.tsx — 草案确认流关键交互(v0.18.2 审查修复回归:
4+
* 标题编辑不回弹(展开顺序)/勾选排除/确认提交契约)。
5+
*/
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
8+
import GoalPlanApprovalDialog from "./GoalPlanApprovalDialog";
9+
import type { GoalPlanView } from "../types/goals";
10+
11+
const view: GoalPlanView = {
12+
proposal: {
13+
milestones: [
14+
{ title: "基础入门", dueWeeks: 4, criteriaType: "manual", refGroupId: null, note: "" },
15+
{ title: "项目实战", dueWeeks: 12, criteriaType: "group_settled", refGroupId: 5, note: "" },
16+
],
17+
groups: [{ groupId: 5, reason: "素材命中" }],
18+
systems: [{ action: "create", systemId: null, name: "Python 基础", coreQuestion: "怎么把语法变成工具?", domainEntries: ["语言基础"], concepts: [], reason: "" }],
19+
weeklyContract: { targetDays: 3, targetCards: 20 },
20+
summary: "8 周从基础到项目",
21+
},
22+
dropped: { droppedMilestones: [], droppedGroups: [], droppedSystems: [] },
23+
honestNote: "",
24+
costYuan: 0.1,
25+
model: "m",
26+
};
27+
28+
afterEach(() => cleanup());
29+
30+
describe("GoalPlanApprovalDialog 确认流", () => {
31+
it("标题编辑不回弹(仅修改 draft 对应项)", () => {
32+
render(<GoalPlanApprovalDialog view={view} onConfirm={vi.fn()} onClose={vi.fn()} onUseRules={vi.fn()} />);
33+
const input = screen.getByTestId("plan-milestone-0") as HTMLInputElement;
34+
fireEvent.change(input, { target: { value: "修改后的标题" } });
35+
expect((screen.getByTestId("plan-milestone-0") as HTMLInputElement).value).toBe("修改后的标题");
36+
expect((screen.getByTestId("plan-milestone-1") as HTMLInputElement).value).toBe("项目实战");
37+
});
38+
39+
it("取消勾选后确认提交排除该项", async () => {
40+
const onConfirm = vi.fn().mockResolvedValue(undefined);
41+
render(<GoalPlanApprovalDialog view={view} onConfirm={onConfirm} onClose={vi.fn()} onUseRules={vi.fn()} />);
42+
// 取消第 2 条里程碑
43+
fireEvent.click(screen.getAllByRole("checkbox")[1]);
44+
fireEvent.click(screen.getByTestId("plan-confirm"));
45+
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
46+
const req = onConfirm.mock.calls[0][0];
47+
expect(req.milestones).toHaveLength(1);
48+
expect(req.milestones[0].title).toBe("基础入门");
49+
expect(req.weeklyContract.targetDays).toBe(3);
50+
});
51+
52+
it("「改用规则草案」回调可用(不关闭——调用方决定)", () => {
53+
const onUseRules = vi.fn();
54+
render(<GoalPlanApprovalDialog view={view} onConfirm={vi.fn()} onClose={vi.fn()} onUseRules={onUseRules} />);
55+
fireEvent.click(screen.getByText("改用规则草案"));
56+
expect(onUseRules).toHaveBeenCalledTimes(1);
57+
});
58+
});

‎app/src/components/GoalPlanApprovalDialog.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ export default function GoalPlanApprovalDialog({ view, onConfirm, onClose, onUse
4343
const [saving, setSaving] = useState(false);
4444
const [err, setErr] = useState("");
4545

46+
// 里程碑标题:编辑覆盖优先(原实现 ...editingTitles, ...m 后展开 m 覆盖编辑——输入被拉回原文)
4647
const milestoneTitles = useMemo(() => {
4748
const m: Record<number, string> = {};
4849
view.proposal.milestones.forEach((ms, i) => { m[i] = ms.title; });
49-
return { ...editingTitles, ...m };
50+
return { ...m, ...editingTitles };
5051
}, [view, editingTitles]);
5152

5253
const confirm = async () => {

‎app/src/components/InterviewDialog.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,32 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
110110
};
111111

112112
// v0.18.2:✨ AI 规划——先按当前答案建目标(规则基线),再 AI 规划 →
113-
// 确认对话框(替换里程碑草案——建议制语义:人类确认后落库)
113+
// 确认对话框(替换里程碑草案——建议制语义:人类确认后落库)。
114+
// @审查修复:AI 失败回滚本次新建的目标(防"再点确认创建"重复建目标);
115+
// 确认成功由 onConfirm 收口结束向导(同一路径不可能再 create_goal)。
114116
const runAiPlan = async () => {
115117
setAiPlanning(true);
116118
setErr("");
119+
let createdHere = false;
117120
try {
118121
let gid = plannedGoalId;
119122
if (gid == null) {
120123
const input = toCreateInput(name, effectiveHorizon, a, []);
121124
const goal = await invoke<Goal>("create_goal", { input });
122125
gid = goal.id;
126+
createdHere = true;
123127
setPlannedGoalId(gid);
124128
}
125129
const view = await invoke<GoalPlanView>("ai_goal_plan", {
126130
goalId: gid, tier: null, authorized: true,
127131
});
128132
setAiPlan(view);
129133
} catch (e) {
134+
if (createdHere) {
135+
// 回滚基线目标——AI 失败不应留下空目标(规则草案路径由用户主动继续)
136+
await invoke("delete_goal", { id: plannedGoalId }).catch(() => undefined);
137+
setPlannedGoalId(null);
138+
}
130139
setErr(`AI 规划失败: ${e}`);
131140
} finally {
132141
setAiPlanning(false);
@@ -245,6 +254,8 @@ export default function InterviewDialog({ mode, groups, onClose, onCreated, goal
245254
goalId: plannedGoalId,
246255
request: { ...req, replaceMilestones: true },
247256
});
257+
// AI 确认成功:目标已建+规划已落库——结束向导(防再点"确认创建"重复建)
258+
onCreated();
248259
}}
249260
/>
250261
)}

0 commit comments

Comments
 (0)