修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:58 +08:00
parent 8bc5380ecb
commit fbd8fae44b
30 changed files with 829 additions and 138 deletions
+100
View File
@@ -401,6 +401,61 @@ impl IdeaRepo {
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -1516,6 +1571,51 @@ mod tests {
assert!(!repo.restore("i1").await.unwrap());
}
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await;