feat(secrets-mcp): Web 条目编辑 API 与 Notes 列表展示优化(0.3.6)
- secrets-core: EntryWriteRow;按 id 更新/删除(含并发冲突与唯一键)
- Web: PATCH/DELETE /api/entries/{id};列表编辑/删除与错误映射
- entries 模板:Notes 限高滚动;空 Notes 不显示占位框
- 版本 0.3.5 → 0.3.6,同步 Cargo.lock
Made-with: Cursor
This commit is contained in:
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::models::EntryRow;
|
||||
use crate::models::{EntryRow, EntryWriteRow};
|
||||
use crate::service::add::{
|
||||
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
||||
parse_kv, remove_path,
|
||||
@@ -306,3 +306,118 @@ pub async fn run(
|
||||
remove_secrets: remove_secret_keys,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match).
|
||||
/// Does not read or modify `secrets` rows.
|
||||
pub struct UpdateEntryFieldsByIdParams<'a> {
|
||||
pub folder: &'a str,
|
||||
pub entry_type: &'a str,
|
||||
pub name: &'a str,
|
||||
pub notes: &'a str,
|
||||
pub tags: &'a [String],
|
||||
pub metadata: &'a serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn update_fields_by_id(
|
||||
pool: &PgPool,
|
||||
entry_id: Uuid,
|
||||
user_id: Uuid,
|
||||
params: UpdateEntryFieldsByIdParams<'_>,
|
||||
) -> Result<()> {
|
||||
if params.folder.len() > 128 {
|
||||
anyhow::bail!("folder must be at most 128 characters");
|
||||
}
|
||||
if params.entry_type.len() > 64 {
|
||||
anyhow::bail!("type must be at most 64 characters");
|
||||
}
|
||||
if params.name.len() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!("Entry not found");
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
user_id: Some(user_id),
|
||||
folder: &row.folder,
|
||||
entry_type: &row.entry_type,
|
||||
name: &row.name,
|
||||
version: row.version,
|
||||
action: "update",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
|
||||
}
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||
version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $7 AND version = $8",
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
.bind(params.metadata)
|
||||
.bind(row.id)
|
||||
.bind(row.version)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref d) = e
|
||||
&& d.code().as_deref() == Some("23505")
|
||||
{
|
||||
return anyhow::anyhow!(
|
||||
"An entry with this folder and name already exists for your account."
|
||||
);
|
||||
}
|
||||
e.into()
|
||||
})?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!("Concurrent modification detected. Please refresh and try again.");
|
||||
}
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"update",
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"source": "web",
|
||||
"entry_id": entry_id,
|
||||
"fields": ["folder", "type", "name", "notes", "tags", "metadata"],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user