修复: MCP 多进程缺陷(update CAS乐观锁 + 审计落盘 + 空闲超时 + busy_timeout + stdio写回调 + list分页)
This commit is contained in:
+264
-24
@@ -15,7 +15,7 @@
|
||||
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::crud::{IdeaQuery, IdeaRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
||||
@@ -102,25 +102,25 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
|
||||
use RiskLevel::*;
|
||||
vec![
|
||||
// ─── 项目 ───
|
||||
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
|
||||
spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_projects),
|
||||
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
|
||||
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name", "description"]), Medium, create_project),
|
||||
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), Medium, update_project),
|
||||
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_project),
|
||||
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
|
||||
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
||||
// ─── 任务 ───
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
|
||||
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
||||
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
|
||||
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_idea),
|
||||
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea),
|
||||
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_idea),
|
||||
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_idea),
|
||||
// ─── 工作流(High) ───
|
||||
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
||||
// ─── 回收站 ───
|
||||
@@ -203,6 +203,41 @@ fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
|
||||
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
|
||||
fn pagination(args: &Value) -> (u32, u32) {
|
||||
let offset = args
|
||||
.get("offset")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i.max(0) as u32)
|
||||
.unwrap_or(0);
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i.max(1) as u32)
|
||||
.unwrap_or(50);
|
||||
(offset, limit.min(100))
|
||||
}
|
||||
|
||||
/// 乐观锁版本校验(CAS):调用方传入 expected_updated_at(毫秒时间戳)时,
|
||||
/// 与 DB 当前 updated_at 比对,不一致即拒绝写入(数据已被其他进程修改)。
|
||||
/// 未传则跳过校验(向后兼容,不破坏旧调用方)。接受数字或字符串两种传法。
|
||||
fn check_expected_updated_at(args: &Value, db_updated_at: &str) -> Result<(), CallToolResult> {
|
||||
let Some(expected) = args.get("expected_updated_at").and_then(|v| {
|
||||
v.as_i64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
let current: i64 = db_updated_at.trim().parse().unwrap_or(i64::MIN);
|
||||
if current != expected {
|
||||
return Err(CallToolResult::error(format!(
|
||||
"数据已被其他进程修改(当前 updated_at={db_updated_at}),请刷新后重试"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 跨实体校验(防 B-260801-01:跨实体误操作)
|
||||
// ============================================================
|
||||
@@ -258,12 +293,30 @@ async fn cross_entity_err(db: &Arc<Database>, id: &str, excluding: &str) -> Opti
|
||||
// handler 实现 — 项目
|
||||
// ============================================================
|
||||
|
||||
fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
fn list_projects(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let (offset, limit) = pagination(&args);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.list_active().await {
|
||||
Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
|
||||
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
||||
// 复用 list_by_query(默认 created_at DESC,与 list_active 排序一致)。
|
||||
let q = ProjectQuery {
|
||||
limit: Some(limit + 1),
|
||||
offset: Some(offset),
|
||||
..Default::default()
|
||||
};
|
||||
match repo.list_by_query(q).await {
|
||||
Ok(list) => {
|
||||
let has_more = list.len() as u32 > limit;
|
||||
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
||||
json_ok(json!({
|
||||
"projects": page,
|
||||
"count": page.len(),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": has_more
|
||||
}))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
@@ -345,6 +398,10 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
||||
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
||||
return r;
|
||||
}
|
||||
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
|
||||
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
|
||||
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
||||
@@ -431,22 +488,29 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let (offset, limit) = pagination(&args);
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
let query = df_storage::crud::TaskQuery {
|
||||
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
||||
let query = TaskQuery {
|
||||
project_id: project_id_filter,
|
||||
status: status_filter,
|
||||
priority: None,
|
||||
assignee: None,
|
||||
keyword: None,
|
||||
queue: None,
|
||||
parent_id: None,
|
||||
order_by: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
limit: Some(limit + 1),
|
||||
offset: Some(offset),
|
||||
..Default::default()
|
||||
};
|
||||
match repo.list_by_query(&query).await {
|
||||
Ok(list) => json_ok(json!({ "tasks": list, "count": list.len() })),
|
||||
Ok(list) => {
|
||||
let has_more = list.len() as u32 > limit;
|
||||
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
||||
json_ok(json!({
|
||||
"tasks": page,
|
||||
"count": page.len(),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": has_more
|
||||
}))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
@@ -540,6 +604,10 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
||||
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
||||
return r;
|
||||
}
|
||||
// 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据
|
||||
let project_id = arg_str(&args, "project_id").unwrap_or_else(|_| existing.project_id.clone());
|
||||
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
||||
@@ -615,12 +683,30 @@ fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
// handler 实现 — 灵感
|
||||
// ============================================================
|
||||
|
||||
fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
fn list_ideas(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let (offset, limit) = pagination(&args);
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
match repo.list_all().await {
|
||||
Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
|
||||
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
||||
// 复用 list_by_query(默认 created_at DESC,与 list_all 排序一致)。
|
||||
let q = IdeaQuery {
|
||||
limit: Some(limit + 1),
|
||||
offset: Some(offset),
|
||||
..Default::default()
|
||||
};
|
||||
match repo.list_by_query(&q).await {
|
||||
Ok(list) => {
|
||||
let has_more = list.len() as u32 > limit;
|
||||
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
||||
json_ok(json!({
|
||||
"ideas": page,
|
||||
"count": page.len(),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": has_more
|
||||
}))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
@@ -684,6 +770,10 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
||||
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
||||
return r;
|
||||
}
|
||||
// 部分更新:title/description 缺省回退 existing,避免空默认清空数据
|
||||
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
||||
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
||||
@@ -764,6 +854,10 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
||||
if let Err(r) = check_expected_updated_at(&args, &idea.updated_at) {
|
||||
return r;
|
||||
}
|
||||
// 与 evaluate_idea 共用的纯函数评分
|
||||
let scores = heuristic_scores(&idea.title, &idea.description);
|
||||
let now = now_millis();
|
||||
@@ -1271,4 +1365,150 @@ mod tests {
|
||||
// 三表都没有 → None
|
||||
assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None);
|
||||
}
|
||||
|
||||
// ── 乐观锁 CAS(update 读-改-写竞态防护)─────────────────────────
|
||||
|
||||
/// update_project 传错误的 expected_updated_at → 拒绝写入。
|
||||
#[tokio::test]
|
||||
async fn update_project_cas_mismatch_rejects() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目").await;
|
||||
let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap();
|
||||
let wrong: i64 = p.updated_at.parse::<i64>().unwrap() + 1;
|
||||
let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": wrong })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(
|
||||
text_of(&r).contains("数据已被其他进程修改"),
|
||||
"应报版本冲突,实际: {}",
|
||||
text_of(&r)
|
||||
);
|
||||
}
|
||||
|
||||
/// update_project 传正确的 expected_updated_at → 成功。
|
||||
#[tokio::test]
|
||||
async fn update_project_cas_match_succeeds() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目").await;
|
||||
let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap();
|
||||
let expected: i64 = p.updated_at.parse().unwrap();
|
||||
let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": expected })).await;
|
||||
assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r));
|
||||
assert_eq!(json_of(&r)["project"]["name"], "新名");
|
||||
}
|
||||
|
||||
/// update_project 不传 expected_updated_at → 跳过校验(向后兼容)。
|
||||
#[tokio::test]
|
||||
async fn update_project_cas_absent_is_backward_compatible() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目").await;
|
||||
let r = update_project(&ctx, json!({ "id": pid, "name": "新名" })).await;
|
||||
assert!(r.is_error.is_none(), "不传版本应成功: {:?}", text_of(&r));
|
||||
assert_eq!(json_of(&r)["project"]["name"], "新名");
|
||||
}
|
||||
|
||||
/// score_idea 传错误的 expected_updated_at → 拒绝写入且 DB 不落库。
|
||||
#[tokio::test]
|
||||
async fn score_idea_cas_mismatch_rejects() {
|
||||
let ctx = test_ctx().await;
|
||||
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await;
|
||||
let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap();
|
||||
let wrong: i64 = idea.updated_at.parse::<i64>().unwrap() + 1;
|
||||
let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": wrong })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(
|
||||
text_of(&r).contains("数据已被其他进程修改"),
|
||||
"应报版本冲突,实际: {}",
|
||||
text_of(&r)
|
||||
);
|
||||
assert!(db_scores(&ctx, &id).await.is_none(), "CAS 拒绝时不应写库");
|
||||
}
|
||||
|
||||
/// score_idea 传正确的 expected_updated_at → 成功写库。
|
||||
#[tokio::test]
|
||||
async fn score_idea_cas_match_succeeds() {
|
||||
let ctx = test_ctx().await;
|
||||
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await;
|
||||
let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap();
|
||||
let expected: i64 = idea.updated_at.parse().unwrap();
|
||||
let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": expected })).await;
|
||||
assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r));
|
||||
assert!(db_scores(&ctx, &id).await.is_some(), "版本一致应写库");
|
||||
}
|
||||
|
||||
/// update_task 传错误的 expected_updated_at → 拒绝写入。
|
||||
#[tokio::test]
|
||||
async fn update_task_cas_mismatch_rejects() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "宿主").await;
|
||||
let tid = seed_task(&ctx, &pid, "原标题").await;
|
||||
let t = TaskRepo::new(&ctx.db).get_by_id(&tid).await.unwrap().unwrap();
|
||||
let wrong: i64 = t.updated_at.parse::<i64>().unwrap() + 1;
|
||||
let r = update_task(&ctx, json!({ "id": tid, "title": "新标题", "expected_updated_at": wrong })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text_of(&r).contains("数据已被其他进程修改"), "实际: {}", text_of(&r));
|
||||
}
|
||||
|
||||
// ── list 分页(offset/limit/has_more)─────────────────────────────
|
||||
|
||||
/// list_ideas 分页:limit 截断 + has_more 翻页标记。
|
||||
#[tokio::test]
|
||||
async fn list_ideas_pagination_has_more_and_page() {
|
||||
let ctx = test_ctx().await;
|
||||
for i in 0..5 {
|
||||
seed_idea(&ctx, &format!("灵感{i}"), "x").await;
|
||||
}
|
||||
// 首页 limit=2 → 2 条 + has_more=true
|
||||
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 0 })).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["ideas"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(v["count"], 2);
|
||||
assert_eq!(v["has_more"], true);
|
||||
assert_eq!(v["limit"], 2);
|
||||
assert_eq!(v["offset"], 0);
|
||||
// 第二页 offset=2 → 又 2 条,仍有下一页(共 5 条)
|
||||
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 2 })).await;
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["ideas"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(v["has_more"], true);
|
||||
// 第三页 offset=4 → 1 条,has_more=false(到尾)
|
||||
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 4 })).await;
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["ideas"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["has_more"], false);
|
||||
}
|
||||
|
||||
/// list_tasks 分页:limit 超上限钳到 100;不足一页无下一页。
|
||||
#[tokio::test]
|
||||
async fn list_tasks_pagination_caps_limit_and_no_has_more() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "宿主").await;
|
||||
for i in 0..3 {
|
||||
seed_task(&ctx, &pid, &format!("任务{i}")).await;
|
||||
}
|
||||
let r = list_tasks(&ctx, json!({ "limit": 999 })).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["limit"], 100, "limit 超上限应钳到 100");
|
||||
assert_eq!(v["tasks"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(v["has_more"], false, "3 条 < 100,无下一页");
|
||||
}
|
||||
|
||||
/// list_projects 分页:offset 生效 + has_more 标记。
|
||||
#[tokio::test]
|
||||
async fn list_projects_pagination_offset_and_has_more() {
|
||||
let ctx = test_ctx().await;
|
||||
for i in 0..4 {
|
||||
seed_project(&ctx, &format!("项目{i}")).await;
|
||||
}
|
||||
let r = list_projects(&ctx, json!({ "limit": 3, "offset": 0 })).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["projects"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(v["has_more"], true, "4 条取 3,还有下一页");
|
||||
let r = list_projects(&ctx, json!({ "limit": 3, "offset": 3 })).await;
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["has_more"], false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user