Skip to content

Commit 17e757d

Browse files
committed
feat(goal): v0.18.1 M2 后端——毕业报告快照/回顾流/毕业与放弃命令(REQ-255~257)
- goal_graduation_reports 快照表(FK SET NULL——目标删除后报告仍可读;goal_name 冗余) - goal_retro 纯函数:毕业报告组装(里程碑/子组结算/复习统计/成果物清单)+ 回顾流时间线(四源现算:skipped 无发生时刻不造假事件)+ 纯文本摘要兜底 - goal_settle 守卫链:状态机(仅 active 可毕业)+ 判据(未全达标拒绝,明细回显) → 快照先行/状态归档/埋点 goal_graduated - goal_abandon:active/paused→abandoned(终态拒绝)+ 可选原因(200 字,埋点审计) - goal_retro / list_goal_graduations:时间线现算 + 毕业档案(已删目标仍可读) - 修复:goal_settlements_snapshot 锁嵌套死锁(with_conn 内再调 self.*——禁重入先例) - 测试:纯函数 golden 6 + 守卫矩阵/快照保留/埋点 7(34+19 全绿)
1 parent 09b765e commit 17e757d

7 files changed

Lines changed: 755 additions & 7 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -602,14 +602,14 @@ fn goal_card_metrics(db: &Db, goal: &Goal) -> Result<(String, f64, usize, usize,
602602
))
603603
}
604604

605-
/// 判据 JSON 解析(损坏 → 错误——调用方降级 ready=false;不静默空白)。
606-
fn parse_criteria(goal: &Goal) -> Result<SuccessCriteria, String> {
607-
serde_json::from_str(&goal.success_criteria_json).map_err(|e| e.to_string())
605+
/// 进度信号收集(详情/列表共用——口径单一;lifecycle 命令组复用)。
606+
pub(crate) fn collect_signals(db: &Db, goal_id: i64) -> Result<GoalSignals, String> {
607+
db.goal_progress_signals(goal_id, unix_seconds()).map_err(|e| e.to_string())
608608
}
609609

610-
/// 进度信号收集(详情/列表共用——口径单一)。
611-
fn collect_signals(db: &Db, goal_id: i64) -> Result<GoalSignals, String> {
612-
db.goal_progress_signals(goal_id, unix_seconds()).map_err(|e| e.to_string())
610+
/// 判据 JSON 解析(损坏 → 错误——调用方降级 ready=false;不静默空白)。
611+
pub(crate) fn parse_criteria(goal: &Goal) -> Result<SuccessCriteria, String> {
612+
serde_json::from_str(&goal.success_criteria_json).map_err(|e| e.to_string())
613613
}
614614

615615
/// ReadinessCheck → 客户端视图。
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//! 学习目标生命周期 commands(v0.18.1 REQ-255~257;毕业仪式/回顾流/放弃/档案)。
2+
//!
3+
//! @ai-context: 拆自 commands_goals.rs(该文件逼近 600 硬限——M2 四命令组独立成域:
4+
//! 毕业仪式/回顾流/放弃/毕业档案)。毕业=用户确认仪式:本层只做
5+
//! 守卫(判据未达标/重复毕业/终态拒绝)+ 组装报告快照 + 状态归档 +
6+
//! 埋点——纯函数组装在 goal_retro.rs,取数在 db_goals.rs。
7+
//! @ai-context: 报告快照独立于 goals 行(目标删除后仍可读——毕业档案区;
8+
//! FK SET NULL 保历史,同 notes_versions「回滚不破坏历史」哲学)。
9+
10+
use serde::Serialize;
11+
use tauri::State;
12+
13+
use crate::commands::AppState;
14+
use crate::db::{unix_seconds, Db};
15+
use crate::goal_progress::{build_report, GoalProgressReport};
16+
use crate::goal_retro::{
17+
assemble_report, assemble_retro, GraduationReport, MilestoneSnapshot, ReportSignals, RetroEntry,
18+
};
19+
use crate::goal_rules::{can_transition, graduation_readiness};
20+
use crate::goal_schema::{Goal, GOAL_ABANDONED, GOAL_GRADUATED};
21+
22+
/// 放弃原因文本上限(可选原因——防超大 payload)。
23+
const ABANDON_REASON_MAX: usize = 200;
24+
25+
/// 回顾流视图(时间线现算 + 毕业报告(如已毕业——快照永久保留))。
26+
#[derive(Debug, Clone, Serialize)]
27+
#[serde(rename_all = "camelCase")]
28+
pub struct GoalRetroView {
29+
pub status: String,
30+
pub entries: Vec<RetroEntry>,
31+
pub graduation: Option<GraduationReport>,
32+
}
33+
34+
/// 毕业仪式(goal_settle——信号达标 + 用户确认后执行)。
35+
#[tauri::command]
36+
pub fn goal_settle(state: State<'_, AppState>, id: i64) -> Result<GraduationReport, String> {
37+
goal_settle_inner(&state.db, id)
38+
}
39+
40+
/// 回顾流(创建→里程碑→组结算→毕业;全现算零双写)。
41+
#[tauri::command]
42+
pub fn goal_retro(state: State<'_, AppState>, id: i64) -> Result<GoalRetroView, String> {
43+
goal_retro_inner(&state.db, id)
44+
}
45+
46+
/// 放弃(显式动作 + 可选原因——无惩罚文案;终态拒绝)。
47+
#[tauri::command]
48+
pub fn goal_abandon(state: State<'_, AppState>, id: i64, reason: Option<String>) -> Result<bool, String> {
49+
goal_abandon_inner(&state.db, id, reason.as_deref())
50+
}
51+
52+
/// 毕业档案(全部报告——已删目标的报告仍在此处可读)。
53+
#[tauri::command]
54+
pub fn list_goal_graduations(state: State<'_, AppState>) -> Result<Vec<GraduationReport>, String> {
55+
list_goal_graduations_inner(&state.db)
56+
}
57+
58+
pub(crate) fn goal_settle_inner(db: &Db, id: i64) -> Result<GraduationReport, String> {
59+
crate::commands_goals::require_goal(db, id)?;
60+
let goal = db
61+
.get_goal(id)
62+
.map_err(|e| e.to_string())?
63+
.ok_or_else(|| format!("目标不存在: {}", id))?;
64+
// 守卫①:状态机——毕业仅从 active 发起(已毕业/放弃/暂停均拒绝)
65+
if !can_transition(&goal.status, GOAL_GRADUATED) {
66+
return Err(format!("非法毕业状态转移: {}(毕业仅从进行中发起)", goal.status));
67+
}
68+
// 守卫②:判据——未达标不可毕业(毕业确认仪式不豁免信号)
69+
let progress: GoalProgressReport =
70+
build_report(&crate::commands_goals::collect_signals(db, id)?);
71+
let criteria = crate::commands_goals::parse_criteria(&goal)?;
72+
let ready = graduation_readiness(&goal.status, &progress, &criteria);
73+
if !ready.ready {
74+
let missing: Vec<String> = ready
75+
.checks
76+
.iter()
77+
.filter(|c| !c.met)
78+
.map(|c| format!("{}({})", c.label, c.detail))
79+
.collect();
80+
return Err(format!("毕业判据未全部满足:{}", missing.join(";")));
81+
}
82+
// 组装报告快照(毕业后冻结)
83+
let milestones: Vec<MilestoneSnapshot> = db
84+
.list_milestones(id)
85+
.map_err(|e| e.to_string())?
86+
.into_iter()
87+
.map(|m| MilestoneSnapshot { title: m.title, status: m.status, completed_at: m.completed_at })
88+
.collect();
89+
let report = assemble_report(ReportSignals {
90+
goal_id: goal.id,
91+
goal_name: goal.name.clone(),
92+
graduated_at: unix_seconds(),
93+
milestones,
94+
group_settlements: db.goal_settlements_snapshot(id).map_err(|e| e.to_string())?,
95+
review_stats: db.goal_review_stats(id, unix_seconds()).map_err(|e| e.to_string())?,
96+
artifacts: db.goal_artifacts(id).map_err(|e| e.to_string())?,
97+
criteria_statement: criteria.statement,
98+
});
99+
// 落库:快照(独立保留)→ 状态归档 → 埋点
100+
let json = serde_json::to_string(&report).map_err(|e| e.to_string())?;
101+
db.create_graduation_report(goal.id, &goal.name, &json).map_err(|e| e.to_string())?;
102+
db.set_goal_status(goal.id, GOAL_GRADUATED).map_err(|e| e.to_string())?;
103+
let payload = serde_json::json!({ "goalId": goal.id, "name": goal.name }).to_string();
104+
let _ = db.add_metric_event("goal_graduated", &payload);
105+
Ok(report)
106+
}
107+
108+
pub(crate) fn goal_retro_inner(db: &Db, id: i64) -> Result<GoalRetroView, String> {
109+
crate::commands_goals::require_goal(db, id)?;
110+
let goal: Goal = db
111+
.get_goal(id)
112+
.map_err(|e| e.to_string())?
113+
.ok_or_else(|| format!("目标不存在: {}", id))?;
114+
let milestones: Vec<MilestoneSnapshot> = db
115+
.list_milestones(id)
116+
.map_err(|e| e.to_string())?
117+
.into_iter()
118+
.map(|m| MilestoneSnapshot { title: m.title, status: m.status, completed_at: m.completed_at })
119+
.collect();
120+
let settlements = db.goal_settlements_snapshot(id).map_err(|e| e.to_string())?;
121+
let graduation = db
122+
.get_graduation_report_json(id)
123+
.map_err(|e| e.to_string())?
124+
.map(|json| serde_json::from_str::<GraduationReport>(&json).map_err(|e| e.to_string()))
125+
.transpose()?;
126+
let entries = assemble_retro(goal.created_at, &milestones, &settlements, graduation.as_ref());
127+
Ok(GoalRetroView { status: goal.status, entries, graduation })
128+
}
129+
130+
pub(crate) fn goal_abandon_inner(db: &Db, id: i64, reason: Option<&str>) -> Result<bool, String> {
131+
crate::commands_goals::require_goal(db, id)?;
132+
let goal: Goal = db
133+
.get_goal(id)
134+
.map_err(|e| e.to_string())?
135+
.ok_or_else(|| format!("目标不存在: {}", id))?;
136+
// 守卫:active/paused → abandoned(终态拒绝——毕业/已放弃不可再放弃)
137+
if !can_transition(&goal.status, GOAL_ABANDONED) {
138+
return Err(format!("非法放弃状态转移: {}(进行中/已暂停可放弃;毕业/已放弃为终态)", goal.status));
139+
}
140+
let reason = reason.map(str::trim).filter(|r| !r.is_empty()).map(|r| {
141+
r.chars().take(ABANDON_REASON_MAX).collect::<String>()
142+
});
143+
db.set_goal_status(goal.id, GOAL_ABANDONED).map_err(|e| e.to_string())?;
144+
// 埋点:原因入 payload(可选;审计可查——无惩罚性状态,仅留痕)
145+
let payload = serde_json::json!({ "goalId": goal.id, "reason": reason }).to_string();
146+
let _ = db.add_metric_event("goal_abandoned", &payload);
147+
Ok(true)
148+
}
149+
150+
pub(crate) fn list_goal_graduations_inner(db: &Db) -> Result<Vec<GraduationReport>, String> {
151+
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()
156+
}
157+
158+
#[cfg(test)]
159+
#[path = "commands_goals_lifecycle_tests.rs"]
160+
mod tests;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! commands_goals_lifecycle 单测(毕业仪式守卫矩阵/回顾流/放弃/档案——内存库)。
2+
3+
use crate::commands_goals::{create_goal_inner, set_goal_milestone_status_inner, GoalCreateInput, GoalMilestoneInput};
4+
use crate::commands_goals_lifecycle::{
5+
goal_abandon_inner, goal_retro_inner, goal_settle_inner, list_goal_graduations_inner,
6+
};
7+
use crate::db::Db;
8+
use crate::goal_schema::TIER_HANDS_ON;
9+
use crate::types::NewNoteGroup;
10+
11+
fn mem_db() -> Db {
12+
Db::open(":memory:").expect("内存库打开")
13+
}
14+
15+
fn group(db: &Db, name: &str) -> i64 {
16+
db.create_group(&NewNoteGroup {
17+
name: name.to_string(),
18+
terrain: "container".to_string(),
19+
kind: "standalone".to_string(),
20+
domain_tag: None,
21+
source: "route".to_string(),
22+
series_key: None,
23+
route_reason: None,
24+
})
25+
.expect("建组")
26+
.id
27+
}
28+
29+
fn input(tier: Option<&str>, scenario: Option<&str>, group_ids: Vec<i64>) -> GoalCreateInput {
30+
GoalCreateInput {
31+
name: "学会 Python".to_string(),
32+
domain_tag: Some("programming".to_string()),
33+
horizon: Some("3m".to_string()),
34+
tier: tier.map(str::to_string),
35+
scenario: scenario.map(str::to_string),
36+
level: Some("zero".to_string()),
37+
driver: Some("work".to_string()),
38+
criteria_statement: Some("能独立完成实例".to_string()),
39+
non_scope: Some("不做 Web 框架".to_string()),
40+
weekly_commitment: Some("5h+".to_string()),
41+
obstacles: None,
42+
group_ids,
43+
milestones: vec![
44+
GoalMilestoneInput { title: "基础".to_string(), due_weeks: 4 },
45+
GoalMilestoneInput { title: "应用".to_string(), due_weeks: 8 },
46+
],
47+
}
48+
}
49+
50+
/// 达成毕业判据所需状态(hands_on:里程碑全 done + 组结算 1 次)。
51+
fn make_ready(db: &Db, goal_id: i64, gid: i64) {
52+
db.create_settlement(gid, r#"{}"#).expect("结算");
53+
for m in db.list_milestones(goal_id).expect("里程碑") {
54+
set_goal_milestone_status_inner(db, m.id, "done").expect("完成");
55+
}
56+
}
57+
58+
#[test]
59+
fn settle_rejects_when_criteria_not_met() {
60+
let db = mem_db();
61+
let gid = group(&db, "组");
62+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建");
63+
// 未达标(里程碑 0/2)
64+
let err = goal_settle_inner(&db, goal.id).expect_err("判据不足");
65+
assert!(err.contains("毕业判据未全部满足"));
66+
assert!(err.contains("里程碑"));
67+
// 状态未被改动
68+
assert_eq!(db.get_goal(goal.id).expect("读").unwrap().status, "active");
69+
}
70+
71+
#[test]
72+
fn settle_guards_terminated_statuses() {
73+
let db = mem_db();
74+
let gid = group(&db, "组");
75+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建");
76+
make_ready(&db, goal.id, gid);
77+
assert!(goal_settle_inner(&db, goal.id).expect("毕业").graduated_at > 0);
78+
// 重复毕业:已毕业终态拒绝
79+
let err = goal_settle_inner(&db, goal.id).expect_err("重复毕业");
80+
assert!(err.contains("非法毕业状态转移"));
81+
// 已放弃亦然
82+
let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2");
83+
goal_abandon_inner(&db, g2.id, Some("没时间")).expect("放弃");
84+
let err2 = goal_settle_inner(&db, g2.id).expect_err("放弃后毕业");
85+
assert!(err2.contains("非法毕业状态转移"));
86+
}
87+
88+
#[test]
89+
fn settle_writes_snapshot_and_graduated_status() {
90+
let db = mem_db();
91+
let gid = group(&db, "Python 组");
92+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建");
93+
db.create_settlement(gid, r#"{"merged":2}"#).expect("结算");
94+
for m in db.list_milestones(goal.id).expect("里程碑") {
95+
set_goal_milestone_status_inner(&db, m.id, "done").expect("完成");
96+
}
97+
let report = goal_settle_inner(&db, goal.id).expect("毕业");
98+
assert_eq!(report.goal_name, "学会 Python");
99+
assert_eq!(report.milestones.len(), 2);
100+
assert_eq!(report.group_settlements[0].settlement_count, 1);
101+
assert!(report.criteria_statement.contains("结算 1 次"), "配方语句:{}", report.criteria_statement);
102+
let g = db.get_goal(goal.id).expect("读").unwrap();
103+
assert_eq!(g.status, "graduated");
104+
assert!(g.completed_at.is_some());
105+
assert_eq!(db.count_metric_events("goal_graduated").expect("埋点"), 1);
106+
}
107+
108+
#[test]
109+
fn graduation_report_survives_goal_delete() {
110+
// 验收 5:毕业报告在目标删除后仍可读(快照独立于 goals 行——FK SET NULL)
111+
let db = mem_db();
112+
let gid = group(&db, "组");
113+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建");
114+
make_ready(&db, goal.id, gid);
115+
goal_settle_inner(&db, goal.id).expect("毕业");
116+
crate::commands_goals::delete_goal_inner(&db, goal.id).expect("删目标");
117+
let archives = list_goal_graduations_inner(&db).expect("档案");
118+
assert_eq!(archives.len(), 1, "目标删除后报告保留");
119+
assert_eq!(archives[0].goal_id, goal.id, "快照保留原 goal_id(行链接 SET NULL)");
120+
assert_eq!(archives[0].goal_name, "学会 Python");
121+
}
122+
123+
#[test]
124+
fn retro_shows_timeline_with_graduation() {
125+
let db = mem_db();
126+
let gid = group(&db, "组");
127+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建");
128+
make_ready(&db, goal.id, gid);
129+
goal_settle_inner(&db, goal.id).expect("毕业");
130+
let view = goal_retro_inner(&db, goal.id).expect("回顾流");
131+
assert!(view.graduation.is_some());
132+
let kinds: Vec<&str> = view.entries.iter().map(|e| e.kind.as_str()).collect();
133+
assert!(kinds.contains(&"created"));
134+
assert!(kinds.contains(&"milestone"));
135+
assert!(kinds.contains(&"settlement"));
136+
assert!(kinds.contains(&"graduated"));
137+
assert_eq!(view.status, "graduated");
138+
// 未毕业目标:无 graduation 时间线节点
139+
let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2");
140+
let v2 = goal_retro_inner(&db, g2.id).expect("回顾流2");
141+
assert!(v2.graduation.is_none());
142+
assert!(v2.entries.iter().all(|e| e.kind != "graduated"));
143+
}
144+
145+
#[test]
146+
fn abandon_records_reason_and_guards() {
147+
let db = mem_db();
148+
let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建");
149+
assert!(goal_abandon_inner(&db, goal.id, Some(" 转行,不学了 ")).expect("放弃"));
150+
let g = db.get_goal(goal.id).expect("读").unwrap();
151+
assert_eq!(g.status, "abandoned");
152+
assert!(g.completed_at.is_some());
153+
// 原因入埋点(审计可查)+ 超长截断 200
154+
assert_eq!(db.count_metric_events("goal_abandoned").expect("埋点"), 1);
155+
// 终态拒绝重复放弃
156+
let err = goal_abandon_inner(&db, goal.id, None).expect_err("重复放弃");
157+
assert!(err.contains("非法放弃状态转移"));
158+
// 超长原因截断
159+
let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2");
160+
let long = "长".repeat(300);
161+
goal_abandon_inner(&db, g2.id, Some(&long)).expect("放弃2");
162+
assert_eq!(db.count_metric_events("goal_abandoned").expect("埋点2"), 2);
163+
}

0 commit comments

Comments
 (0)