Skip to content

Commit 60f4d1e

Browse files
committed
fix(goal): 代码审查修复——skipped 排分母/重访谈改名/无期限宣言/防御取数/文本上限(REQ-248~250)
- 逻辑 P1:skipped 里程碑(废弃计划项)不计入进度分母与毕业判据——防跳过拖死 整个目标;db_goals_progress 计数 SQL 排除 + 单测 - 逻辑 P2:update_goal_interview 名称未生效——重访谈名称随对话框落地(空名回退旧名)+ 单测 - 逻辑 P2:horizon=none 宣言病句「用无期限学会」→「长期目标(无期限):学会…」; 前后端同语义对齐(golden 路径 3 + 组件矩阵补测) - 逻辑 P2:update_goal horizon=None 抹掉既有锚点——未传=不变语义 + 单测 - 健壮性 P2:命令层 require 后 unwrap → ok_or_else(防御检查-取数窗口并发删除) - 验证 P2:访谈答案文本无上限——bounded 200 字截断(intent/配方边界同口径)+ 单测
1 parent 6eb28c6 commit 60f4d1e

6 files changed

Lines changed: 133 additions & 42 deletions

File tree

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

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ use crate::video_profile_domain::DomainKind;
2828

2929
/// 一周秒数(草案 due_at 换算:第 N 周 = created_at + N*7d)。
3030
const WEEK_SECS: i64 = 7 * 86_400;
31+
/// 访谈答案文本上限(防御超大 payload;chips/填空兜底的答案均为短文本)。
32+
const INTENT_FIELD_MAX: usize = 200;
3133

3234
/// 新建目标入参(前端访谈结果全量提交;serde camelCase 契约)。
3335
#[derive(Debug, Clone, Deserialize)]
@@ -273,13 +275,14 @@ pub(crate) fn create_goal_inner(db: &Db, input: &GoalCreateInput) -> Result<Goal
273275
// 访谈校验:tier 与 scenario 同缺=快速模式;缺一=必答拦截(第 1/3 问必答)
274276
let tier_explicit = input.tier.as_deref().map(str::trim).filter(|s| !s.is_empty());
275277
let tier = tier_explicit.unwrap_or(TIER_DEFAULT);
276-
let scenario = trimmed(input.scenario.as_deref());
278+
let scenario = bounded(input.scenario.as_deref());
277279
if tier_explicit.is_some() && scenario.is_none() {
278280
return Err("第 1 问「学会以后想用它做什么?」必答".to_string());
279281
}
280282
let domain = parse_domain(input.domain_tag.as_deref())?;
281283
let now = unix_seconds();
282-
let criteria = derive_criteria(tier, input.non_scope.as_deref());
284+
let non_scope = bounded(input.non_scope.as_deref());
285+
let criteria = derive_criteria(tier, non_scope.as_deref());
283286
// 边界校验:group_ids 逐组存在;草案 due_weeks 有界防溢出
284287
for gid in &input.group_ids {
285288
if db.get_group(*gid).map_err(|e| e.to_string())?.is_none() {
@@ -338,7 +341,10 @@ pub(crate) fn list_goals_inner(db: &Db) -> Result<Vec<GoalCardView>, String> {
338341

339342
pub(crate) fn get_goal_detail_inner(db: &Db, id: i64) -> Result<GoalDetailView, String> {
340343
require_goal(db, id)?;
341-
let goal = db.get_goal(id).map_err(|e| e.to_string())?.unwrap();
344+
let goal = db
345+
.get_goal(id)
346+
.map_err(|e| e.to_string())?
347+
.ok_or_else(|| format!("目标不存在: {}", id))?;
342348
let milestones = db.list_milestones(id).map_err(|e| e.to_string())?;
343349
let group_ids = db.list_goal_group_ids(id).map_err(|e| e.to_string())?;
344350
let mut groups = Vec::new();
@@ -378,7 +384,10 @@ pub(crate) fn get_goal_detail_inner(db: &Db, id: i64) -> Result<GoalDetailView,
378384

379385
pub(crate) fn get_goal_progress_inner(db: &Db, id: i64) -> Result<GoalProgressView, String> {
380386
require_goal(db, id)?;
381-
let goal = db.get_goal(id).map_err(|e| e.to_string())?.unwrap();
387+
let goal = db
388+
.get_goal(id)
389+
.map_err(|e| e.to_string())?
390+
.ok_or_else(|| format!("目标不存在: {}", id))?;
382391
let signals = collect_signals(db, id)?;
383392
let progress = build_report(&signals);
384393
let statement = progress_statement(&progress);
@@ -400,14 +409,22 @@ pub(crate) fn update_goal_inner(
400409
horizon: Option<String>,
401410
) -> Result<bool, String> {
402411
require_goal(db, id)?;
403-
let goal = db.get_goal(id).map_err(|e| e.to_string())?.unwrap();
412+
let goal = db
413+
.get_goal(id)
414+
.map_err(|e| e.to_string())?
415+
.ok_or_else(|| format!("目标不存在: {}", id))?;
404416
let domain = parse_domain(domain_tag.as_deref())?;
405417
let now = unix_seconds();
418+
// horizon 未填(None/空白)= 不改变时限锚点——防「改名顺手抹掉无期限锚点」
419+
let horizon_end = match horizon.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
420+
Some(_) => horizon_end_secs(horizon.as_deref(), now),
421+
None => goal.horizon_end,
422+
};
406423
db.update_goal_core(
407424
id,
408425
&normalize_title(name.to_string(), "未命名目标"),
409426
domain.as_deref(),
410-
horizon_end_secs(horizon.as_deref(), now),
427+
horizon_end,
411428
&goal.success_criteria_json,
412429
&goal.intent_json,
413430
)
@@ -416,22 +433,28 @@ pub(crate) fn update_goal_inner(
416433

417434
pub(crate) fn update_goal_interview_inner(db: &Db, id: i64, input: &GoalCreateInput) -> Result<bool, String> {
418435
require_goal(db, id)?;
419-
let goal = db.get_goal(id).map_err(|e| e.to_string())?.unwrap();
420-
// 配方重推(答案可回溯编辑——判据/意图整体重写;名字与绑组不变)
436+
let goal = db
437+
.get_goal(id)
438+
.map_err(|e| e.to_string())?
439+
.ok_or_else(|| format!("目标不存在: {}", id))?;
440+
// 配方重推(答案可回溯编辑——判据/意图整体重写;绑组不变;名称随对话窗口
441+
// 一并生效——空名回退旧名,防「编辑态改名不落地」契约陷阱)
421442
let tier_explicit = input.tier.as_deref().map(str::trim).filter(|s| !s.is_empty());
422443
let tier = tier_explicit.unwrap_or(TIER_DEFAULT);
423-
let scenario = trimmed(input.scenario.as_deref());
444+
let scenario = bounded(input.scenario.as_deref());
424445
if tier_explicit.is_some() && scenario.is_none() {
425446
return Err("第 1 问「学会以后想用它做什么?」必答".to_string());
426447
}
427-
let criteria = derive_criteria(tier, input.non_scope.as_deref());
448+
let non_scope = bounded(input.non_scope.as_deref());
449+
let criteria = derive_criteria(tier, non_scope.as_deref());
428450
let intent = build_intent(input);
451+
let name = normalize_title(input.name.clone(), &goal.name);
429452
let criteria_json = serde_json::to_string(&criteria).map_err(|e| e.to_string())?;
430453
let intent_json = serde_json::to_string(&intent).map_err(|e| e.to_string())?;
431454
let now = unix_seconds();
432455
db.update_goal_core(
433456
id,
434-
&goal.name,
457+
&name,
435458
goal.domain_tag.as_deref(),
436459
horizon_end_secs(input.horizon.as_deref(), now),
437460
&criteria_json,
@@ -451,7 +474,10 @@ pub(crate) fn delete_goal_inner(db: &Db, id: i64) -> Result<bool, String> {
451474

452475
pub(crate) fn update_goal_status_inner(db: &Db, id: i64, status: &str) -> Result<bool, String> {
453476
require_goal(db, id)?;
454-
let goal = db.get_goal(id).map_err(|e| e.to_string())?.unwrap();
477+
let goal = db
478+
.get_goal(id)
479+
.map_err(|e| e.to_string())?
480+
.ok_or_else(|| format!("目标不存在: {}", id))?;
455481
// M1 只开放 active⇄paused(状态机守卫总入口;放弃/毕业随 M2 流程)
456482
if status != "active" && status != "paused" {
457483
return Err(format!("v0.18.0 M1 仅支持暂停/恢复(收到 {})", status));
@@ -496,11 +522,11 @@ pub(crate) fn set_goal_milestone_status_inner(db: &Db, id: i64, status: &str) ->
496522
return Err(format!("不支持的里程碑状态: {}(支持: {})", status, MILESTONE_STATUSES.join("/")));
497523
}
498524
// 取旧状态(幂等埋点判据:仅「未完成 → 完成」的转变记 goal_milestone_done)
499-
let prev = db.get_milestone(id).map_err(|e| e.to_string())?;
500-
if prev.is_none() {
501-
return Err(format!("里程碑不存在: {}", id));
502-
}
503-
let was_done = prev.unwrap().status == MILESTONE_DONE;
525+
let prev = db
526+
.get_milestone(id)
527+
.map_err(|e| e.to_string())?
528+
.ok_or_else(|| format!("里程碑不存在: {}", id))?;
529+
let was_done = prev.status == MILESTONE_DONE;
504530
let ok = db.set_milestone_status(id, status).map_err(|e| e.to_string())?;
505531
if !ok {
506532
return Err(format!("里程碑不存在: {}", id));
@@ -540,17 +566,22 @@ fn trimmed(s: Option<&str>) -> Option<String> {
540566
s.map(str::trim).filter(|v| !v.is_empty()).map(str::to_string)
541567
}
542568

543-
/// 访谈答案 → GoalIntent(全部可选字段过 trimmed 归一)。
569+
/// 访谈答案:空白归一 + 长度截断(防超大 payload 入库;truncate 保留前 200 字)。
570+
fn bounded(s: Option<&str>) -> Option<String> {
571+
trimmed(s).map(|v| v.chars().take(INTENT_FIELD_MAX).collect())
572+
}
573+
574+
/// 访谈答案 → GoalIntent(全部可选字段过 bounded 归一)。
544575
fn build_intent(input: &GoalCreateInput) -> GoalIntent {
545576
GoalIntent {
546-
scenario: trimmed(input.scenario.as_deref()),
547-
level: trimmed(input.level.as_deref()),
548-
driver: trimmed(input.driver.as_deref()),
549-
criteria_statement: trimmed(input.criteria_statement.as_deref()),
550-
horizon: trimmed(input.horizon.as_deref()),
551-
non_scope: trimmed(input.non_scope.as_deref()),
552-
weekly_commitment: trimmed(input.weekly_commitment.as_deref()),
553-
obstacles: trimmed(input.obstacles.as_deref()),
577+
scenario: bounded(input.scenario.as_deref()),
578+
level: bounded(input.level.as_deref()),
579+
driver: bounded(input.driver.as_deref()),
580+
criteria_statement: bounded(input.criteria_statement.as_deref()),
581+
horizon: bounded(input.horizon.as_deref()),
582+
non_scope: bounded(input.non_scope.as_deref()),
583+
weekly_commitment: bounded(input.weekly_commitment.as_deref()),
584+
obstacles: bounded(input.obstacles.as_deref()),
554585
}
555586
}
556587

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,19 @@ fn update_and_delete_goal() {
186186
assert!(update_goal_inner(&db, goal.id, "改名", None, Some("6m".to_string())).expect("改"));
187187
let g = db.get_goal(goal.id).expect("读").unwrap();
188188
assert_eq!(g.name, "改名");
189+
assert!(g.horizon_end.is_some(), "改名传 6m 重设时限锚点");
189190
assert!(update_goal_status_inner(&db, goal.id, "paused").is_ok());
190-
// 重访谈:信息仍可编辑 → 非法状态转移拦截(paused 状态更新元数据合法,但状态不在这条命令)
191+
// 重访谈:名称随对话窗口生效(空名回退旧名),判据/意图重推
191192
assert!(update_goal_interview_inner(&db, goal.id, &input(Some(TIER_HANDS_ON), Some("新场景"), vec![])).expect("重访谈"));
193+
let g2 = db.get_goal(goal.id).expect("读").unwrap();
194+
assert_eq!(g2.name, "学会 Python", "重访谈名称生效(input 名称)");
195+
assert!(g2.intent_json.contains("新场景"));
196+
// 改名不传 horizon(None)= 不改变时限锚点——防无期限目标被顺带抹掉
197+
let horizon_before = g2.horizon_end;
198+
assert!(update_goal_inner(&db, goal.id, "再改名", None, None).expect("改2"));
199+
let g3 = db.get_goal(goal.id).expect("读").unwrap();
200+
assert_eq!(g3.horizon_end, horizon_before, "horizon=None 保持原锚点");
201+
assert_eq!(g3.name, "再改名");
192202
assert!(delete_goal_inner(&db, goal.id).expect("删除"));
193203
assert!(require_goal(&db, goal.id).is_err());
194204
}
@@ -210,6 +220,21 @@ fn milestone_validation_whitelists() {
210220
assert!(err3.contains("不支持的里程碑状态"));
211221
}
212222

223+
#[test]
224+
fn interview_texts_bounded_in_storage() {
225+
let db = mem_db();
226+
let mut inp = input(Some(TIER_HANDS_ON), Some("场景"), vec![]);
227+
inp.scenario = Some("长".repeat(300));
228+
inp.non_scope = Some("宽".repeat(300));
229+
let goal = create_goal_inner(&db, &inp).expect("建");
230+
let intent: crate::goal_schema::GoalIntent = serde_json::from_str(&goal.intent_json).expect("解析");
231+
assert_eq!(intent.scenario.as_deref().unwrap().chars().count(), 200, "超长截断保留 200 字");
232+
// 判据 statement 的边界文案同样截断(derive_criteria 入参 bounded)
233+
let criteria: crate::goal_schema::SuccessCriteria =
234+
serde_json::from_str(&goal.success_criteria_json).expect("解析");
235+
assert!(!criteria.statement.contains("宽".repeat(250).as_str()), "边界文案截断入配方");
236+
}
237+
213238
#[test]
214239
fn bind_unbind_guards() {
215240
let db = mem_db();

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ impl Db {
3131
// 绑定组先取(独立锁窗口——闭包内不可再进锁)
3232
let group_ids = self.list_goal_group_ids(goal_id)?;
3333
self.with_conn(|conn| {
34-
// 里程碑计数(判据①)
34+
// 里程碑计数(判据①):skipped=废弃计划项——不计入分母与分子,
35+
// 防「跳过」拖死毕业判据(里程碑全部 done 的语义=活跃计划全完成)
3536
let (m_total, m_done): (i64, i64) = conn.query_row(
3637
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
37-
FROM goal_milestones WHERE goal_id = ?1",
38+
FROM goal_milestones WHERE goal_id = ?1 AND status != 'skipped'",
3839
params![goal_id],
3940
|r| Ok((r.get(0)?, r.get(1)?)),
4041
)?;

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,26 @@ fn weak_groups_ratio_and_top_ordering() {
196196
assert_eq!(s.weak_groups[2].group_id, gid_strong, "0% 弱垫底");
197197
}
198198

199+
#[test]
200+
fn skipped_milestones_excluded_from_progress() {
201+
// 审查修复(2026-09-02):skipped=废弃计划项——不计入进度分母与毕业判据,
202+
// 防「跳过」拖死整个目标(里程碑全部 done 的语义=活跃计划全完成)
203+
let db = mem_db();
204+
let goal_id = goal_with(&db, "跳过测试", vec![]);
205+
let m1 = db.add_milestone(goal_id, &crate::goal_schema::NewMilestone {
206+
title: "做".to_string(), due_at: None, order_idx: 0,
207+
criteria_type: "manual".to_string(), ref_group_id: None,
208+
}).expect("m1").id;
209+
let m2 = db.add_milestone(goal_id, &crate::goal_schema::NewMilestone {
210+
title: "跳过".to_string(), due_at: None, order_idx: 1,
211+
criteria_type: "manual".to_string(), ref_group_id: None,
212+
}).expect("m2").id;
213+
db.set_milestone_status(m2, "skipped").expect("跳过");
214+
db.set_milestone_status(m1, "done").expect("完成");
215+
let s = db.goal_progress_signals(goal_id, NOW).expect("信号");
216+
assert_eq!((s.milestone_total, s.milestone_done), (1, 1), "skipped 不计入分母分子");
217+
}
218+
199219
#[test]
200220
fn weak_groups_ignores_invalid_state_json_and_missing_group() {
201221
let db = mem_db();

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ pub fn suggest_milestones(level: Option<&str>, weekly_commitment: Option<&str>)
126126
/// @ai-context: 第 5 步确认前的回显文案——criteria_statement 优先(用户
127127
/// 原始表述),缺失时回退配方的可读 statement(未填第 3 问但
128128
/// 档位已定——配方语义兜底,不是只有一条路径)。
129+
/// @ai-context: 无期限(none)是「长期目标」语义——文案走「长期目标(无期限):
130+
/// 学会…」而非「用无期限学会…」(语法与语义双重校准,审查 2026-09-02)。
129131
pub fn assemble_declaration(
130132
name: &str,
131133
scenario: Option<&str>,
@@ -134,7 +136,6 @@ pub fn assemble_declaration(
134136
non_scope: Option<&str>,
135137
horizon: Option<&str>,
136138
) -> String {
137-
let horizon_label = horizon_label(horizon);
138139
let goal = name.trim();
139140
let scene = scenario
140141
.map(|s| s.trim())
@@ -146,14 +147,24 @@ pub fn assemble_declaration(
146147
.filter(|s| !s.is_empty())
147148
.map(|s| s.to_string())
148149
.unwrap_or_else(|| criteria_fallback.to_string());
149-
format!(
150-
"用{}学会{}{},达成标准:{};边界:{}",
151-
horizon_label,
152-
goal,
153-
scene,
154-
standard,
155-
non_scope_label(non_scope)
156-
)
150+
if horizon == Some("none") {
151+
format!(
152+
"长期目标(无期限):学会{}{},达成标准:{};边界:{}",
153+
goal,
154+
scene,
155+
standard,
156+
non_scope_label(non_scope)
157+
)
158+
} else {
159+
format!(
160+
"用{}学会{}{},达成标准:{};边界:{}",
161+
horizon_label(horizon),
162+
goal,
163+
scene,
164+
standard,
165+
non_scope_label(non_scope)
166+
)
167+
}
157168
}
158169

159170
/// 时限 → 中周期锚点(Unix 秒;None=无期限——合法非 KPI 截止日)。
@@ -170,14 +181,13 @@ pub fn horizon_end_secs(horizon: Option<&str>, now_secs: i64) -> Option<i64> {
170181
}
171182
}
172183

173-
/// 时限 → 展示文案(宣言回显用)。
184+
/// 时限 → 展示文案(宣言回显用;none 走 assemble_declaration 的长期目标分支)。
174185
fn horizon_label(horizon: Option<&str>) -> String {
175186
match horizon {
176187
Some("3m") => "3 个月".to_string(),
177188
Some("6m") => "半年".to_string(),
178189
Some("2w") => "先试两周".to_string(),
179-
// none/None/未知 → 默认节奏(12 周——与里程碑草案的通用节奏呼应,
180-
// 展示层文案不是 KPI,用户可在声明页/详情改期)
190+
// None/未知 → 默认节奏(展示层文案不是 KPI,用户可在声明页/详情改期)
181191
_ => "12 周".to_string(),
182192
}
183193
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const PATHS: [Path; 6] = [
8989
expect_drafts: 3,
9090
expect_due_weeks: &[8, 16, 24],
9191
expect_horizon_end: None,
92-
expect_decl_contains: "用12 周学会",
92+
expect_decl_contains: "长期目标(无期限):学会",
9393
},
9494
Path {
9595
name: "说不清→默认档×系统学过一半",
@@ -264,4 +264,8 @@ fn matrix_declaration_components() {
264264
assert!(blank.contains("学会水彩"));
265265
assert!(!blank.contains("(场景:"));
266266
assert!(blank.contains("达成标准:配方兜底"));
267+
// 无期限(none)= 长期目标语义——不走「用无期限学会」病句
268+
let none = assemble_declaration("乐理", None, None, "配方兜底", None, Some("none"));
269+
assert!(none.starts_with("长期目标(无期限):学会乐理"));
270+
assert!(!none.contains("用无期限"));
267271
}

0 commit comments

Comments
 (0)