修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账
This commit is contained in:
@@ -121,11 +121,37 @@ pub async fn update_idea(
|
||||
field: String,
|
||||
value: String,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
// BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted
|
||||
// (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。
|
||||
if field == "status" {
|
||||
if IdeaStatus::from_db_str(value.trim()).is_none() {
|
||||
return Err(format!(
|
||||
"非法 status 值 {:?},合法值: draft/pending_review/approved/rejected/promoted/archived",
|
||||
value
|
||||
));
|
||||
}
|
||||
if value.trim() == "promoted" {
|
||||
return Err(
|
||||
"status 不能直接置为 promoted:立项须走 promote_idea(会创建项目并回写 promoted_to)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// BE-CMD-4(含 BE-CMD-23):related_ids/scores 是 JSON 字段,补合法性校验(防脏 JSON 落库)。
|
||||
if field == "related_ids" || field == "scores" {
|
||||
serde_json::from_str::<serde_json::Value>(&value)
|
||||
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
|
||||
}
|
||||
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
|
||||
let updated = state
|
||||
.ideas
|
||||
.update_field(&id, &field, &value)
|
||||
.update_field_active(&id, &field, &value)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
.map_err(err_str)?;
|
||||
if !updated {
|
||||
return Err(format!("灵感 ID {id} 不存在或已删除"));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
|
||||
@@ -204,23 +230,21 @@ pub async fn promote_idea(
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
// 回写灵感:status=promoted + promoted_to(update_full 单事务覆盖可变字段)
|
||||
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而
|
||||
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
|
||||
// 删除而非真事务(改动最小,工程投入产出比最高)。
|
||||
let updated = IdeaRecord {
|
||||
status: IdeaStatus::Promoted,
|
||||
promoted_to: Some(project_id.clone()),
|
||||
updated_at: now,
|
||||
..record
|
||||
};
|
||||
if let Err(e) = state.ideas.update_full(&updated).await {
|
||||
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
|
||||
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
|
||||
// LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
|
||||
// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
|
||||
// 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
|
||||
// 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目。
|
||||
if !state
|
||||
.ideas
|
||||
.claim_promotion(&id, &project_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
{
|
||||
tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}");
|
||||
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
|
||||
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
|
||||
}
|
||||
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
|
||||
return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚"));
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
|
||||
@@ -362,6 +386,15 @@ async fn evaluate_one(
|
||||
engine: &df_ideas::adversarial::AdversarialEngine,
|
||||
) -> Result<IdeaRecord, String> {
|
||||
let id = record.id.clone();
|
||||
// LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。
|
||||
// promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review,
|
||||
// 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。
|
||||
if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) {
|
||||
return Err(format!(
|
||||
"灵感 {id} 已是终态({}),不可再评估",
|
||||
record.status.as_str()
|
||||
));
|
||||
}
|
||||
let idea = record_to_idea(&record);
|
||||
|
||||
// 多维评分(0-10,IPC 层 *10 缩放为 0-100)
|
||||
|
||||
@@ -351,10 +351,55 @@ pub async fn update_project(
|
||||
field: String,
|
||||
value: String,
|
||||
) -> Result<bool, String> {
|
||||
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
||||
// BE-CMD-3:白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒,
|
||||
// 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验:
|
||||
// - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning)
|
||||
// - stack:JSON 合法性(防脏 JSON 落库)
|
||||
// - path:relocate 同款校验(normalize + `..` 段拦截 + is_dir + 防重复绑定)
|
||||
if field == "status" && ProjectStatus::from_db_str(value.trim()).is_none() {
|
||||
return Err(format!(
|
||||
"非法 status 值 {:?},合法值: planning/in_progress/testing/releasing/completed/paused/cancelled",
|
||||
value
|
||||
));
|
||||
}
|
||||
if field == "stack" {
|
||||
serde_json::from_str::<serde_json::Value>(&value)
|
||||
.map_err(|e| format!("stack 不是合法 JSON: {e}"))?;
|
||||
}
|
||||
if field == "path" {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("路径不能为空".to_string());
|
||||
}
|
||||
// `..` 段拦截(防穿越,对齐 create_with_binding / relocate)
|
||||
if trimmed.split(['\\', '/']).any(|seg| seg == "..") {
|
||||
return Err(format!("路径不得包含 '..' 段: {}", trimmed));
|
||||
}
|
||||
// 目录必须存在(对齐 create_project / relocate 的 is_dir 校验,防绑空目录)
|
||||
if !Path::new(trimmed).is_dir() {
|
||||
return Err(format!("目录不存在: {}", trimmed));
|
||||
}
|
||||
// 防重复绑定(排除自身,对齐 relocate)
|
||||
if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? {
|
||||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||
}
|
||||
// 规范化后落库(relocate 同款 normalize_path,防路径写法差异)
|
||||
let normalized = normalize_path(trimmed);
|
||||
let updated = state
|
||||
.projects
|
||||
.update_field_active(&id, &field, &normalized)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !updated {
|
||||
return Err(format!("项目 ID {id} 不存在或已删除"));
|
||||
}
|
||||
state.reload_allowed_dirs().await;
|
||||
return Ok(true);
|
||||
}
|
||||
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。
|
||||
let updated = state
|
||||
.projects
|
||||
.update_field(&id, &field, &value)
|
||||
.update_field_active(&id, &field, &value)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !updated {
|
||||
|
||||
@@ -6,6 +6,8 @@ use tauri::State;
|
||||
use df_types::types::{new_id, TaskStatus};
|
||||
use df_storage::crud::TaskQuery;
|
||||
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
|
||||
// LW-9(BE-CMD-8):父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。
|
||||
use df_nodes::task_advance_node::recompute_parent_status;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -245,6 +247,19 @@ pub async fn create_task(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateTaskInput,
|
||||
) -> Result<TaskRecord, String> {
|
||||
// ── BE-CMD-1:空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与
|
||||
// update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ──
|
||||
let title = input.title.trim().to_string();
|
||||
if title.is_empty() {
|
||||
return Err("任务标题不能为空".to_string());
|
||||
}
|
||||
if !(0..=3).contains(&input.priority) {
|
||||
return Err(format!(
|
||||
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low),收到 {}",
|
||||
input.priority
|
||||
));
|
||||
}
|
||||
|
||||
// ── queue 校验(白名单 + 空串默认 todo)──
|
||||
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
|
||||
let queue = if input.queue.trim().is_empty() {
|
||||
@@ -296,7 +311,7 @@ pub async fn create_task(
|
||||
let record = TaskRecord {
|
||||
id: new_id(),
|
||||
project_id: input.project_id,
|
||||
title: input.title,
|
||||
title,
|
||||
description: input.description,
|
||||
status: TaskStatus::Todo,
|
||||
priority: input.priority,
|
||||
@@ -333,6 +348,18 @@ pub async fn create_task(
|
||||
Some(record.status.as_str()),
|
||||
)
|
||||
.await;
|
||||
// LW-9(BE-CMD-8):建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。
|
||||
// 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。
|
||||
if let Some(pid) = &record.parent_id {
|
||||
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %record.id,
|
||||
parent_id = %pid,
|
||||
error = %e,
|
||||
"[父聚合] create_task 后重算父 status 失败(不阻断)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -412,12 +439,27 @@ pub async fn update_task(
|
||||
}
|
||||
// 空字符串 = None(解除父),合法放行
|
||||
}
|
||||
// LW-4(BE-CMD-1):queue 字段校验(白名单 + queue/status 一致性,读当前 status)。
|
||||
// update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同):
|
||||
// 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。
|
||||
if field == "queue" {
|
||||
let q = value.trim();
|
||||
validate_queue(q)?;
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?;
|
||||
assert_queue_status_consistent(q, current.status.as_str())?;
|
||||
}
|
||||
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
||||
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
|
||||
//
|
||||
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
|
||||
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
|
||||
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
|
||||
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
@@ -425,12 +467,37 @@ pub async fn update_task(
|
||||
.map_err(err_str)?;
|
||||
let updated = state
|
||||
.tasks
|
||||
.update_field(&id, &field, &value)
|
||||
.update_field_active(&id, &field, &value)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !updated {
|
||||
return Err(format!("任务 ID {id} 不存在或已删除"));
|
||||
}
|
||||
// LW-9(BE-CMD-8):改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
|
||||
// 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。
|
||||
if field == "parent_id" {
|
||||
if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) {
|
||||
if let Err(e) = recompute_parent_status(&state.tasks, &old_pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %id,
|
||||
parent_id = %old_pid,
|
||||
error = %e,
|
||||
"[父聚合] 解除父后重算旧父 status 失败(不阻断)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let new_pid = value.trim();
|
||||
if !new_pid.is_empty() {
|
||||
if let Err(e) = recompute_parent_status(&state.tasks, new_pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %id,
|
||||
parent_id = %new_pid,
|
||||
error = %e,
|
||||
"[父聚合] 挂载父后重算新父 status 失败(不阻断)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rec) = current {
|
||||
emit_event(
|
||||
&state,
|
||||
@@ -741,6 +808,20 @@ pub async fn move_task_queue(
|
||||
.await;
|
||||
}
|
||||
|
||||
// LW-9(BE-CMD-8):跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。
|
||||
// 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。
|
||||
// 聚合失败仅 warn 不阻断。
|
||||
if let Some(pid) = &updated.parent_id {
|
||||
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %updated.id,
|
||||
parent_id = %pid,
|
||||
error = %e,
|
||||
"[父聚合] move_task_queue 后重算父 status 失败(不阻断)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user