优化: MCP 收尾(update原子CAS治TOCTOU/advance_task数据变更映射/list_trash分页) + miniapp渲染收尾(mention chip可视化/代码块语言标签) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:02 +08:00
parent 37040616bd
commit e099eff4cb
8 changed files with 479 additions and 36 deletions
+72 -2
View File
@@ -350,6 +350,50 @@ function shouldRenderMsg(m: ChatMessage): boolean {
return true return true
} }
// ────────────────────────────────────────────────────────────────
// MR-P2-2 用户消息 mention chip:解析 `[项目:名]`/`[任务:名]`/`[灵感:名]` 标记分段渲染。
//
// 对齐桌面 MessageItem.segmentUserContent:命中已知实体的标记渲染为 chip(浅底圆角徽标),
// 未命中保持字面(防误伤正常方括号文本)。
// 标记格式与 @ 选中插入一致(onEntitySelect):冒号后是实体展示名 —— project 用 name,
// task/idea 用 title。miniapp 消息内容仅存原始文本(AiUserMessage 只回 message,无 span 元数据),
// 故「已解析成功」以实体名是否命中当前 entities 列表判定(与插入时同字段)。
// ────────────────────────────────────────────────────────────────
type UserSegment =
| { type: 'text'; text: string }
| { type: 'chip'; kind: 'project' | 'task' | 'idea'; label: string }
/** 实体名是否命中已知实体(项目 name / 任务·灵感 title,与 @ 选中插入同字段) */
function isKnownEntity(kind: 'project' | 'task' | 'idea', name: string): boolean {
const e = entities.value
if (kind === 'project') return e.projects.some((p) => p.name === name)
if (kind === 'task') return e.tasks.some((t) => t.title === name)
return e.ideas.some((i) => i.title === name)
}
/** 用户消息内容 → 分段(文本段 + chip 段),纯文本返回单文本段(零回归) */
function segmentUserContent(content: string): UserSegment[] {
const segs: UserSegment[] = []
const re = /\[(项目|任务|灵感):([^\]]+)\]/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(content)) !== null) {
if (m.index > last) segs.push({ type: 'text', text: content.slice(last, m.index) })
const kind = m[1] === '项目' ? 'project' : m[1] === '任务' ? 'task' : 'idea'
const name = m[2].trim()
// 仅命中已知实体渲染 chip;未命中(正常方括号文本/无效引用)保持字面
segs.push(isKnownEntity(kind, name) ? { type: 'chip', kind, label: name } : { type: 'text', text: m[0] })
last = m.index + m[0].length
}
if (last < content.length) segs.push({ type: 'text', text: content.slice(last) })
return segs.length ? segs : [{ type: 'text', text: content }]
}
/** 用户消息内容是否含 mention 标记段(决定是否走分段渲染;纯文本走原单一 text 零回归) */
function hasMentionMarker(content: string): boolean {
return /\[(项目|任务|灵感):[^\]]+\]/.test(content)
}
/** /**
* 输入变化时检测联想触发条件。 * 输入变化时检测联想触发条件。
* *
@@ -771,8 +815,17 @@ function tokenInOf(t: TokenUsage): number {
<view v-if="m.role === 'user'"> <view v-if="m.role === 'user'">
<!-- 图片预览(消息带图时) --> <!-- 图片预览(消息带图时) -->
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" /> <image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) --> <!-- MR-P2-2 文本: mention 标记时分段渲染 chip(命中已知实体的 `[项目:名]`/`[任务:名]`/`[灵感:名]`
<text v-if="m.content" user-select>{{ m.content }}</text> 渲染为 chip,未命中保持字面);纯文本走原单一 `<text>` 渲染零回归 -->
<template v-if="m.content && hasMentionMarker(m.content)">
<text user-select>
<template v-for="(seg, idx) in segmentUserContent(m.content)" :key="m.id + '-seg-' + idx">
<text v-if="seg.type === 'text'">{{ seg.text }}</text>
<text v-else class="user-chip" :class="'user-chip--' + seg.kind">{{ seg.label }}</text>
</template>
</text>
</template>
<text v-else-if="m.content" user-select>{{ m.content }}</text>
</view> </view>
<template v-else> <template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) --> <!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
@@ -1054,6 +1107,23 @@ function tokenInOf(t: TokenUsage): number {
.msg.user text { .msg.user text {
color: #ffffff; color: #ffffff;
} }
/* MR-P2-2 用户消息 mention chip(对齐桌面 ai-msg-chip:浅底圆角徽标)。
用户气泡底 = #4a9eff,chip 用半透明白底 + 深蓝字保证对比度(对齐桌面 rgba(255,255,255,.32));
按 kind 区分颜色变体(project/task/idea),与 @ mention 浮层分组色呼应。
mp-weixin <text> 组件天然 inline,嵌套于外层 <text> 内联排布;背景/圆角/padding 真机可渲染。
选择器用 .msg.user text.user-chip(≥ .msg.user text 的 (0,2,1))盖过白字,保证深蓝字生效。 */
.msg.user text.user-chip {
padding: 1px 6px;
margin: 0 1px;
border-radius: 4px;
font-size: 13px;
line-height: 1.4;
color: #14407a;
background-color: rgba(255, 255, 255, 0.32);
}
.msg.user text.user-chip--project { background-color: rgba(255, 255, 255, 0.38); }
.msg.user text.user-chip--task { background-color: rgba(255, 255, 255, 0.3); }
.msg.user text.user-chip--idea { background-color: rgba(255, 255, 255, 0.24); }
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */ /* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
.msg-user-img { .msg-user-img {
display: block; display: block;
+14 -3
View File
@@ -130,23 +130,34 @@ function inlineHljs(html: string): string {
}) })
} }
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
function codeLangBadge(lang: string): string {
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
}
/** /**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。 * 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。 * marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/ */
function highlightCodeBlocks(html: string): string { function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => { return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs) const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : '' const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw const lang = LANG_ALIASES[langRaw] ?? langRaw
if (!lang || !hljs.getLanguage(lang)) return m const badge = langRaw ? codeLangBadge(langRaw) : ''
if (!lang || !hljs.getLanguage(lang)) {
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
let value: string let value: string
try { try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch { } catch {
return m return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
} }
return `<pre><code${attrs}>${inlineHljs(value)}</code></pre>` return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
}) })
} }
+98 -15
View File
@@ -140,7 +140,7 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
"confidence": opt_str_field("置信度(可空:high/medium/low)") "confidence": opt_str_field("置信度(可空:high/medium/low)")
}), &["kind", "title", "content"]), Medium, insert_knowledge), }), &["kind", "title", "content"]), Medium, insert_knowledge),
// ─── 回收站 ─── // ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash), spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务;分页 offset/limit,默认 limit=50 上限 100,projects/tasks 各自独立分页)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project), spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
] ]
}) })
@@ -440,12 +440,15 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 与 check_expected_updated_at(客户端显式 expected_updated_at 的提前拦截)不同,
// 这里封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
} }
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -694,12 +697,14 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated })) json_ok(json!({ "id": id, "task": updated }))
} }
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -854,12 +859,14 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated })) json_ok(json!({ "id": id, "idea": updated }))
} }
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -928,10 +935,14 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")), Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
}); });
rec.updated_at = now; rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
return err_str(e); // 旧 update_full 忽略 Ok(false) 静默覆盖;改后并发端(GUI)已改则 affected==0,
// 报版本冲突,不再互相覆盖。
match repo.update_full_cas(&rec, &idea.updated_at).await {
Ok(true) => json_ok(json!({ "id": id, "idea": rec, "scores": scores })),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
} }
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
}) })
} }
@@ -973,8 +984,9 @@ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult>
// handler 实现 — 回收站 // handler 实现 — 回收站
// ============================================================ // ============================================================
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { fn list_trash(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone(); let db = ctx.db.clone();
let (offset, limit) = pagination(&args);
Box::pin(async move { Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await { let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v, Ok(v) => v,
@@ -984,11 +996,34 @@ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
// 分页:与 list_projects/tasks/ideas 同契约(offset/limit/has_more)。
// projects 与 tasks 各自独立分页(同页语义:各自第 N 页),has_more 取两者并集。
// 回收站数据量小(仅软删项),内存分页足够,避免给共享 list_deleted 加 SQL 分页侵入。
// 探页:多取一条探测是否有下一页(对齐 list_* 的 limit+1 探页语义)。
let project_probe: Vec<_> = projects
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let project_has_more = project_probe.len() > limit as usize;
let project_page: Vec<_> = project_probe.into_iter().take(limit as usize).collect();
let task_probe: Vec<_> = tasks
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let task_has_more = task_probe.len() > limit as usize;
let task_page: Vec<_> = task_probe.into_iter().take(limit as usize).collect();
json_ok(json!({ json_ok(json!({
"projects": projects, "projects": project_page,
"tasks": tasks, "tasks": task_page,
"project_count": projects.len(), "project_count": project_page.len(),
"task_count": tasks.len() "task_count": task_page.len(),
"offset": offset,
"limit": limit,
"has_more": project_has_more || task_has_more,
"project_has_more": project_has_more,
"task_has_more": task_has_more
})) }))
}) })
} }
@@ -1820,4 +1855,52 @@ mod tests {
assert!(visible_for_test(false, "search_knowledge")); assert!(visible_for_test(false, "search_knowledge"));
assert!(visible_for_test(false, "insert_knowledge")); assert!(visible_for_test(false, "insert_knowledge"));
} }
// ── list_trash 分页(offset/limit/has_more,projects/tasks 各自独立分页)──
#[tokio::test]
async fn list_trash_pagination_and_has_more() {
let ctx = test_ctx().await;
// 3 项目软删进回收站 + 1 任务软删(宿主项目不删,仅任务进回收站)
let mut pids = Vec::new();
for i in 0..3 {
pids.push(seed_project(&ctx, &format!("回收项目{i}")).await);
}
for p in &pids {
ProjectRepo::new(&ctx.db).soft_delete(p).await.unwrap();
}
let tid = seed_task(&ctx, &pids[0], "回收任务").await;
TaskRepo::new(&ctx.db).soft_delete(&tid).await.unwrap();
// 首页 limit=2 → 项目 2 条(仍有下一页),任务 1 条(无下一页)
let r = list_trash(&ctx, json!({ "limit": 2, "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(), 2);
assert_eq!(v["tasks"].as_array().unwrap().len(), 1);
assert_eq!(v["project_count"], 2);
assert_eq!(v["task_count"], 1);
assert_eq!(v["project_has_more"], true, "3 项目取 2 还有下一页");
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], true);
assert_eq!(v["limit"], 2);
assert_eq!(v["offset"], 0);
// 第二页 offset=2 → 项目 1 条,两边都无下一页
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 2 })).await;
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["project_has_more"], false);
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], false);
}
/// limit 超上限钳到 100(对齐其他 list 工具的钳制)。
#[tokio::test]
async fn list_trash_caps_limit_to_100() {
let ctx = test_ctx().await;
let r = list_trash(&ctx, json!({ "limit": 999 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
assert_eq!(json_of(&r)["limit"], 100, "limit 超上限应钳到 100");
}
} }
+83
View File
@@ -538,6 +538,43 @@ impl IdeaRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &IdeaRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
} }
impl KnowledgeRepo { impl KnowledgeRepo {
@@ -1497,4 +1534,50 @@ mod tests {
// i1 活跃不出现;i3 删除最晚在前 // i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC"); assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn idea_update_full_cas_success_when_expected_matches() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
let current = repo.get_by_id("i1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("i1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("i1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
} }
+106
View File
@@ -414,6 +414,41 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &ProjectRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9",
params![
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
/// ///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
@@ -611,3 +646,74 @@ impl_repo!(
) )
} }
); );
// ============================================================
// 单元测试 — update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn prec(id: &str, name: &str) -> ProjectRecord {
ProjectRecord {
id: id.to_string(),
name: name.to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup() -> ProjectRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
}
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
let current = repo.get_by_id("p1").await.unwrap().unwrap();
// 本地构建新版本(name + updated_at 递增)
let mut rec = current.clone();
rec.name = "新项目".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "新项目");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("p1").await.unwrap().unwrap();
other.name = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("p1").await.unwrap().unwrap();
mine.name = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}
+84
View File
@@ -743,6 +743,44 @@ impl TaskRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &TaskRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19",
params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
} }
// ============================================================ // ============================================================
@@ -1160,4 +1198,50 @@ mod tests {
assert_eq!(updated.queue, "todo"); assert_eq!(updated.queue, "todo");
assert_eq!(updated.status.as_str(), "todo"); assert_eq!(updated.status.as_str(), "todo");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
let current = repo.get_by_id("t1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("t1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("t1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
} }
+18 -16
View File
@@ -413,35 +413,37 @@ graph TD
> 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。 > 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。
> 分析类登记,待决策后实施。 > 分析类登记,待决策后实施。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:8 项中 4 已落地(提交 02c8d8e 等)、4 本批补修(8cb666a)。
- [ ] **P0-1** update_* TOCTOU 竞态:仅 advance_task 有 CAS,update_project/task/idea/score_idea 读-改-写无乐观锁,多进程并发写互相覆盖(方案:version 列或 updated_at CAS) - [x] **P0-1** ✅ 已落地(2026-08-09 8cb666a):df-storage 三 Repo 新增 `update_full_cas`(WHERE id AND updated_at 原子条件写,affected==0 拒绝),df-mcp update_project/task/idea/score_idea 四工具改 CAS 调用(顺带修 score_idea 旧忽略 Ok(false) 静默覆盖),+6 单测
- [ ] **P0-2** MCP 写库后 GUI 无感知:df-data-changed 事件仅 GUI AI 工具能 emit,MCP 进程无 AppHandle,双端数据断层致重复创建(方案:GUI 轮询/文件 watcher/定时刷新) - [~] **P0-2** 🟡 部分(2026-08-09):GUI 内嵌 HTTP 形态已通(advance_task 数据变更映射本批补,data_change.rs);**独立 stdio 进程仍无感知**(on_write_call: None 无 AppHandle + 无轮询兜底),需设计 GUI 侧刷新机制
- [ ] **P0-3** 审计形同虚设:run_mcp_server 直接 return 未 init tracing subscriber,medium_audit warn 无订阅者不落盘(方案:MCP 进程 init tracing + 文件 appender) - [x] **P0-3** ✅ 已落地:main.rs:19-37 init_mcp_tracing(tracing-appender non_blocking 写 %TEMP%/devflow-trace.log,medium_audit warn 落盘)
- [ ] **P1-4** 两套工具规则不一致:GUI AI 工具有 queue 白名单/data_change/完整审计,MCP 精简实现无,演进易漂移(方案:校验下沉 Repo 层复用同源函数) - [x] **P1-4** ✅ 已落地(e722823):校验下沉 crates/df-storage/src/crud/task_validation.rs,df-mcp/GUI 复用同源函数
- [ ] **P1-5** 多进程同库:迁移 PRAGMA 幂等但并发 ALTER 可能 database is locked;MCP release/GUI debug 版本可能不一致 - [x] **P1-5** ✅ 已落地(02c8d8e):db.rs busy_timeout(5s) + WAL + foreign_keys
- [ ] **P1-6** 孤儿进程:stdin EOF 才退出,无超时/心跳,Claude Code 强杀致进程累积(方案:空闲超时退出) - [x] **P1-6** ✅ 已落地(02c8d8e):server.rs:100-107 空闲超时 60s 退出(--idle-timeout 可配)
- [ ] **P1-7** AI 全写权限:Medium 默认允许 + 无幂等键,AI 可污染真实业务库(方案:默认 --read-only 或写前确认) - [~] **P1-7** 🟡 部分(设计取舍):高危默认拒 + 中危审计 + --read-only 可选;未做默认只读/写前确认(有缓解可接受)
- [ ] **P2-8** list 无分页; **P2-9** Windows lowercase 跨平台语义; **P2-10** MCP 仅 CRUD 能力弱(有意收敛) - [x] **P2-8** ✅ 已落地(8cb666a):主 list 工具分页 + list_trash 补分页(offset/limit/has_more); **P2-9** 🟡 Windows lowercase 待核; **P2-10** 有意收敛
--- ---
### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记) ### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记)
> 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。 > 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:9 项中 7 已落地(e722823/795e05f/8cb666a)、本批补 2(mention chip + 语言标签)。
**P1(兼容性/体验·2 项)**: **P1(兼容性/体验·2 项)**:
- [ ] **MR-P1-1** — miniapp 流式生成中 raw markdown 字面量显示:生成中 currentText 用纯 `<text>` 渲染,`**bold**`/`#` 等显示语法字符,完成才渲染 markdown。需块级 memo 渲染设计(对齐桌面 useStreamRenderer),性能+体验权衡,暂缓 - [x] **MR-P1-1** ✅ 已落地:chat/index.vue:66-131 splitStreamBlocks 块级 memo 流式渲染(currentText 不再纯 text 显语法字符;未闭合围栏降级 escapeFallback 属流式固有取舍)
- [ ] **MR-P1-2** — miniapp 表格 overflow 破坏列对齐:`mdRenderer.ts``<table>` 注入 `display:block`,破坏表格列对齐;需 `display:inline-table` 或保留原生行为,需真机验证 - [x] **MR-P1-2** ✅ 已落地:mdRenderer.ts:203-207 table 显式 `display:table`(非 block)保留列对齐 + max-width/word-break 兜底
**P2(打磨·6 项)**: **P2(打磨·6 项)**:
- [ ] **MR-P2-1** — miniapp 图片溢出:rich-text `<img>` 自然尺寸无 max-width,宽图溢出气泡;需注入 max-width + 域名白名单 + tap 预览(需 mp-html 决策) - [x] **MR-P2-1** ✅ 已落地:mdRenderer.ts:198 img max-width:100% + border-radius(网络图依赖微信 downloadFile 白名单,非渲染代码)
- [ ] **MR-P2-2** — miniapp mention chip 可视化:用户消息内 `[项目:名]` 显示字面文本,桌面端有 chip 样式;需分段渲染 - [x] **MR-P2-2** ✅ 已落地(2026-08-09 8cb666a):chat/index.vue segmentUserContent 分段渲染 `[项目:名]`/`[任务:名]`/`[灵感:名]` chip(命中已知实体才 chip,未命中保持字面,纯文本零回归)
- [ ] **MR-P2-3** — miniapp 任务列表 checkbox 丢失:rich-text 不认 `<input>`,GFM task-list 勾选框被剥离成纯文本 - [x] **MR-P2-3** ✅ 已落地:mdRenderer.ts:167-172 input checkbox → ☑/☐ 字符符号
- [ ] **MR-P2-4** — miniapp 代码块无高亮/语言标签:对齐桌面 hljs 17 语言高亮,需引入方案 - [x] **MR-P2-4** ✅ 已落地(8cb666a):highlight.js 14 语言高亮 + 代码块语言徽标(codeLangBadge,围栏 lang 显标签)
- [ ] **MR-P2-5** — 桌面端实时 JSON 工具结果无折叠防御:LLM 文本 echo 工具 JSON 时桌面渲染成正常气泡(miniapp 已有 isToolResultJson 折叠);罕见场景,决策是否做 - [x] **MR-P2-5** ✅ 已落地:桌面 MessageList.vue:698-708 isToolResultJson 折叠(对齐 miniapp)
- [ ] **MR-P2-6** — miniapp 设置页编辑功能:只读版已上线(`pages/settings/index.vue`),编辑(relayHost/deviceId/token 手填)接 setConfig 即可,后续做 - [x] **MR-P2-6** ✅ 已落地(795e05f):settings/index.vue 编辑模式(setConfig + 脱敏 + resumeIfDisconnected)
**P3(外部依赖/低优·2 项)**: **P3(外部依赖/低优·2 项)**:
@@ -25,6 +25,10 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"update_project" => ("project", "update"), "update_project" => ("project", "update"),
"update_task" => ("task", "update"), "update_task" => ("task", "update"),
"update_idea" => ("idea", "update"), "update_idea" => ("idea", "update"),
// P0-2:advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"),
"delete_project" | "purge_project" => ("project", "delete"), "delete_project" | "purge_project" => ("project", "delete"),
"delete_task" => ("task", "delete"), "delete_task" => ("task", "delete"),
"delete_idea" => ("idea", "delete"), "delete_idea" => ("idea", "delete"),