修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user