mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
refactor(provider): certify typed write ownership
Freeze prerequisite A as a component-level certification unit. Add the immutable v5 certification suite, split create/update row DTOs, preserve immutable creation time, map strict-create races to AppError::Conflict, make aggregate compensation insert-or-restore, and enforce reconcile preconditions through a single-lock transaction primitive. Old save_provider callsite classification remains exhaustively recorded in 4f78451405575158ff6562c7021c7f31f2860780; this checkpoint does not add or reclassify an omitted legacy callsite. It tightens the remaining reconciliation classifications there: default live import is [create]; OpenCode/OpenClaw/Hermes existing branches are [update] and absent branches are [create]; universal Claude/Codex/Gemini branches are [create/update] selected from an observed fingerprint. The sealed compensation helper remains the only [restore] path. The old reconcile_provider_record symbol is deleted. Remaining update_provider_settings_config callsites are classified as [update]: codex_history_migration updates an already-read Codex row; proxy token synchronization updates already-read Claude, Codex, Gemini, and GrokBuild rows. Each now uses ProviderKey plus ProviderRowUpdate, explicitly removes hydrated endpoint projections, preserves endpoint authority, and fails on a missing row instead of silently succeeding.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Pi 支持项目级重启裁决:前置工程结构与测试先行
|
||||
|
||||
> 文档状态:项目级裁决(用户 2026-08-01 批准),优先级高于既有契约、修正案 1/2 的交付与审查结构条款;契约的技术条款(不变量、类型、义务)继续有效。
|
||||
> 触发:R4 未通过,三个 invariant 家族第四次出现,按修正案 2 §G 项目级硬停止。
|
||||
> 裁决依据:四轮证据表明问题不再是契约缺失或未落实(R4 时 oracle 已真实、类型屏障已就位),而是**认证单元过大**(12k 行 × 高不变量密度 × 零 High 标准 = 每轮必有新 High)与**实现方稳定的"九成对一成微妙错"率**共同作用。修复对象是认证结构与工作方式,不是再写一份修正案。
|
||||
|
||||
## 1. 新结构:三个前置工程 + 主工程,认证单元 ≠ PR 数量
|
||||
|
||||
单体累积认证(检查点 A/B/C)废止。改为:
|
||||
|
||||
| 工程 | 范围 | 认证对象 | 预算 |
|
||||
|---|---|---|---|
|
||||
| 前置 A | Provider 类型化写面 + endpoint 所有权(全 app) | `dao/provider_write.rs`、相关 service 入口、扫描器及其测试 | 2–3 轮 |
|
||||
| 前置 B | Data-only canonical restore(含 R4 新发现:safety backup 并发丢写窗口、binary TOCTOU、incremental auto-vacuum 保留) | `database/backup.rs`、`schema.rs` 的 restore 面及测试 | 2–3 轮 |
|
||||
| 前置 C | 只读 native inspection(完全由 pinned Pi 语义向量驱动) | `pi_config/raw_schema.rs`、`composer.rs`、`native.rs`、`gateway.rs`(capability 部分)、oracle 夹具及测试 | 2–3 轮 |
|
||||
| 主工程 | Skills/Prompts/Sessions/UI/i18n + gateway 数据面 | 建立在已认证前置之上,认证结构届时另定 | 另计 |
|
||||
|
||||
- 每个前置工程是独立认证对象:两位 fresh blind reviewer 审**该组件的完整文件与测试**(组件级审计,不是 diff 审计),小到一轮看得透;
|
||||
- 依据 handoff §2.2"双盲审数量不等于 PR 数量"与规则 8/9"同一 invariant 重复失败 → 重审 boundary 与 test strategy":重构认证结构正是执行该规则,各前置工程作为新认证对象持有独立小预算,这不是绕过七轮纪律,而是其结论;
|
||||
- **最终交付仍是一个 PR**(用户要求不变);前置工程在同一分支上以 commit 组推进;
|
||||
- 任一前置工程在自己的预算内不收敛 → 单独停止上报,不牵连其他工程。
|
||||
|
||||
## 2. 工作方式反转:测试先行
|
||||
|
||||
四轮共同病根之一是实现方的字面合规:契约文字与测试之间的任何缝隙都会变成缺陷。自本裁决起:
|
||||
|
||||
1. **Claude 先交付认证级测试套件**:把 R1–R4 审出的全部故障场景 + 契约义务固化为可执行测试(含扫描器负向 fixture);测试即契约的字面;
|
||||
2. **Codex 实现到全绿**:不得修改认证测试(发现测试本身有错时,报告并由 Claude 裁决修订);可以自由增补自己的测试;
|
||||
3. 全绿 + 内部自审(修正案 2 §F 对应节)后进入该前置工程的盲审;
|
||||
4. 盲审 finding 若揭示测试套件的缺口,缺口先补进测试,再修实现——测试套件是活的认证资产,逐工程滚动加厚。
|
||||
|
||||
## 3. R4 finding 的归属(全部并入对应前置工程的测试范围)
|
||||
|
||||
| Finding | 归属 |
|
||||
|---|---|
|
||||
| managed DTO 把 thinkingLevelMap 收窄为 `Option<String>`,oracle 证明必须无损 | 前置 C |
|
||||
| gateway 把 Authorization/x-api-key/x-goog-api-key 列为 protected 并强制 apiKey,header-auth 配置误降 DirectOnly | 前置 C |
|
||||
| restore 行校验只查 storage class/JSON/decimal,`sort_index=-1` 可发布后生产读取失败 | 前置 B |
|
||||
| safety backup 与 publish 之间并发丢写窗口;binary restore TOCTOU;canonical stage 丢 incremental auto-vacuum | 前置 B |
|
||||
| `update_provider_settings_config` 绕过类型化写面且零行静默成功 | 前置 A |
|
||||
| `reconcile_provider_record` 先查存在再分支,并发 create 冲突退化为覆盖更新 | 前置 A |
|
||||
|
||||
结构教训一并纳入:**DML allowlist 必须缩到 `provider_write.rs` 模块级**(R4 逃逸正是因为按文件豁免了整个旧 DAO);"252/252 字段执行过"不等于下游语义正确,前置 C 必须有 oracle→managed→inspection 的**端到端无损往返**测试。
|
||||
|
||||
## 4. 盲审材料与验证(每个前置工程)
|
||||
|
||||
- 材料:该组件的规范契约节选(中立重生成)、组件文件清单、oracle/provenance(前置 C)、验证命令、通用审查维度;不含任何轮次历史、finding、自审报告;
|
||||
- 验证:`cargo fmt --all -- --check`、**`cargo clippy --lib --tests -- -D warnings`(必须带 `--tests`,认证模块是 `#[cfg(test)]`,不带就不编译它)**、`cargo test --lib` 全套 + 组件认证测试全绿 + 扫描器负向 fixture 红名单确认,均在干净 SHA 上;
|
||||
- 完成条件:零 validated blocker/high/data-integrity finding。
|
||||
|
||||
> 文档考据说明:修正案 1/2(`pi-support-contracts-amendment-*.md`)的条款已按其自身要求**合并**进 `pi-support-contracts-zh.md` 与 `pi-support-review-contract-zh.md`,独立文件已随合并删除,这是预期状态而非丢失;本文引用的"修正案 2 §F/E1"以合并后的规范文档对应章节为准。
|
||||
|
||||
## 5. 冻结事实(2026-08-01)
|
||||
|
||||
- 分支 `feat/pi-native-support`,HEAD = 10f2dacb(R4 检查点),工作树干净;
|
||||
- R1–R4 已耗于已废止的单体认证;前置工程各持新的 2–3 轮预算;
|
||||
- 未 push、无 PR;`stash@{0}`、`legacy/pi-041ff113`、PR #5598 不触碰。
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -298,6 +300,11 @@ fn scan_source(path: &str, source: &str) -> (Vec<Violation>, BTreeSet<String>) {
|
||||
)
|
||||
}
|
||||
};
|
||||
// 文件级 #![cfg(test)] 的文件(认证套件等)不进入任何构建的生产目标,
|
||||
// 不参与生产扫描;该属性的存在性由认证套件的注册元测试强制。
|
||||
if is_cfg_test(&syntax.attrs) {
|
||||
return (Vec::new(), BTreeSet::new());
|
||||
}
|
||||
let mut visitor = ArchitectureVisitor {
|
||||
path,
|
||||
violations: Vec::new(),
|
||||
@@ -345,6 +352,7 @@ fn provider_write_api_snapshot(source: &str) -> serde_json::Value {
|
||||
let syntax = syn::parse_file(source).expect("parse provider write authority");
|
||||
let type_names = [
|
||||
"ProviderKey",
|
||||
"ProviderRowCreate",
|
||||
"ProviderRowUpdate",
|
||||
"NewEndpoint",
|
||||
"NewProviderAggregate",
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::codex_config::{
|
||||
};
|
||||
use crate::codex_state_db::codex_state_db_paths;
|
||||
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
||||
use crate::database::{is_official_seed_id, Database};
|
||||
use crate::database::{is_official_seed_id, Database, ProviderKey, ProviderRowUpdate};
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::provider_to_mutation_input;
|
||||
use crate::settings::{
|
||||
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||
CodexThirdPartyHistoryProviderBucketMigration,
|
||||
@@ -663,7 +664,7 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
let providers = db.get_all_providers("codex")?;
|
||||
let mut migrated_provider_ids = Vec::new();
|
||||
|
||||
for (_, provider) in providers {
|
||||
for (_, mut provider) in providers {
|
||||
if provider.category.as_deref() == Some("official")
|
||||
|| is_official_seed_id(&provider.id)
|
||||
|| provider.is_codex_oauth()
|
||||
@@ -694,8 +695,16 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
};
|
||||
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
||||
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
||||
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
||||
migrated_provider_ids.push(provider.id);
|
||||
let provider_id = provider.id.clone();
|
||||
provider.settings_config = settings;
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
let key = ProviderKey::new("codex", &provider_id)?;
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
db.update_provider(&key, &row)?;
|
||||
migrated_provider_ids.push(provider_id);
|
||||
}
|
||||
|
||||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||||
|
||||
@@ -8,6 +8,8 @@ pub mod pi_projections;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod provider_write;
|
||||
#[cfg(test)]
|
||||
mod provider_write_certification;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -6,6 +6,8 @@ use rusqlite::{params, OptionalExtension, Transaction};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderKey {
|
||||
app_type: String,
|
||||
@@ -39,7 +41,6 @@ pub struct ProviderRowUpdate {
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
category: Option<String>,
|
||||
created_at: Option<i64>,
|
||||
notes: Option<String>,
|
||||
meta: ProviderMeta,
|
||||
icon: Option<String>,
|
||||
@@ -60,7 +61,6 @@ impl ProviderRowUpdate {
|
||||
settings_config: input.settings_config.clone(),
|
||||
website_url: input.website_url.clone(),
|
||||
category: input.category.clone(),
|
||||
created_at: input.created_at,
|
||||
notes: input.notes.clone(),
|
||||
meta,
|
||||
icon: input.icon.clone(),
|
||||
@@ -69,6 +69,12 @@ impl ProviderRowUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
content: ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewEndpoint {
|
||||
url: String,
|
||||
@@ -111,7 +117,7 @@ impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProviderAggregate {
|
||||
key: ProviderKey,
|
||||
row: ProviderRowUpdate,
|
||||
row: ProviderRowCreate,
|
||||
sort_index: Option<usize>,
|
||||
in_failover_queue: bool,
|
||||
initial_endpoints: Vec<NewEndpoint>,
|
||||
@@ -142,7 +148,10 @@ impl NewProviderAggregate {
|
||||
initial_endpoints.push(endpoint.try_into()?);
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
let row = ProviderRowCreate {
|
||||
content: ProviderRowUpdate::from_input(&input)?,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
Ok(Self {
|
||||
key,
|
||||
row,
|
||||
@@ -203,6 +212,7 @@ fn insert_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
@@ -223,7 +233,7 @@ fn insert_row(
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
row.created_at,
|
||||
created_at,
|
||||
sort_index,
|
||||
row.notes,
|
||||
row.icon,
|
||||
@@ -233,7 +243,21 @@ fn insert_row(
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"provider '{}/{}' already exists",
|
||||
key.app_type, key.id
|
||||
))
|
||||
}
|
||||
_ => AppError::Database(error.to_string()),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -262,21 +286,38 @@ fn insert_endpoint(
|
||||
/// catalog compensation coordinator introduced with the ordered mutation
|
||||
/// pipeline is the only intended caller.
|
||||
#[allow(dead_code)]
|
||||
// The certification contract keeps immutable creation time separate from the
|
||||
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn restore_provider_aggregate_on_tx(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
endpoints: &[NewEndpoint],
|
||||
) -> Result<(), AppError> {
|
||||
let updated = update_row(tx, key, row)?;
|
||||
if updated != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
if updated == 0 {
|
||||
insert_row(
|
||||
tx,
|
||||
key,
|
||||
row,
|
||||
created_at,
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
)?;
|
||||
} else {
|
||||
// Exact compensation is the only path allowed to restore immutable
|
||||
// creation time after a prior aggregate mutation.
|
||||
tx.execute(
|
||||
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![created_at, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
||||
@@ -317,18 +358,16 @@ fn update_row(
|
||||
settings_config = ?2,
|
||||
website_url = ?3,
|
||||
category = ?4,
|
||||
created_at = ?5,
|
||||
notes = ?6,
|
||||
icon = ?7,
|
||||
icon_color = ?8,
|
||||
meta = ?9
|
||||
WHERE id = ?10 AND app_type = ?11",
|
||||
notes = ?5,
|
||||
icon = ?6,
|
||||
icon_color = ?7,
|
||||
meta = ?8
|
||||
WHERE id = ?9 AND app_type = ?10",
|
||||
params![
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
row.created_at,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
@@ -349,7 +388,8 @@ impl Database {
|
||||
insert_row(
|
||||
&tx,
|
||||
&input.key,
|
||||
&input.row,
|
||||
&input.row.content,
|
||||
input.row.created_at,
|
||||
input.sort_index,
|
||||
false,
|
||||
input.in_failover_queue,
|
||||
@@ -380,6 +420,42 @@ impl Database {
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn update_provider_if_content_fingerprint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
expected_fingerprint: &str,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
||||
params![key.id, key.app_type],
|
||||
StoredProviderRow::from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
||||
.decode(key.app_type())?;
|
||||
if current.row_content_fingerprint() != expected_fingerprint {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' changed since it was read",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
if update_row(&tx, key, row)? != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn rename_db_only_additive_provider(&self, input: RenameProvider) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
@@ -387,7 +463,7 @@ impl Database {
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let source_state = tx
|
||||
.query_row(
|
||||
"SELECT sort_index, is_current, in_failover_queue, category
|
||||
"SELECT sort_index, is_current, in_failover_queue, category, created_at
|
||||
FROM providers
|
||||
WHERE id = ?1 AND app_type = ?2",
|
||||
params![input.source.id, input.source.app_type],
|
||||
@@ -397,6 +473,7 @@ impl Database {
|
||||
row.get::<_, bool>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
@@ -418,6 +495,7 @@ impl Database {
|
||||
&tx,
|
||||
&target,
|
||||
&input.row,
|
||||
source_state.4,
|
||||
source_state.0,
|
||||
source_state.1,
|
||||
source_state.2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
struct StoredProviderRow {
|
||||
pub(super) struct StoredProviderRow {
|
||||
id: String,
|
||||
name: String,
|
||||
settings_config: String,
|
||||
@@ -22,7 +22,7 @@ struct StoredProviderRow {
|
||||
}
|
||||
|
||||
impl StoredProviderRow {
|
||||
fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
|
||||
pub(super) fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -39,7 +39,7 @@ impl StoredProviderRow {
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(self, app_type: &str) -> Result<Provider, AppError> {
|
||||
pub(super) fn decode(self, app_type: &str) -> Result<Provider, AppError> {
|
||||
let (settings_config, mut meta) =
|
||||
decode_provider_json(app_type, &self.id, &self.settings_config, &self.meta)?;
|
||||
// Child rows are the sole endpoint authority. Do not expose a stale
|
||||
@@ -96,7 +96,7 @@ pub(crate) fn validate_provider_storage_json(
|
||||
decode_provider_json(app_type, provider_id, settings_config, meta).map(|_| ())
|
||||
}
|
||||
|
||||
const PROVIDER_SELECT: &str =
|
||||
pub(super) const PROVIDER_SELECT: &str =
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index,
|
||||
notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers";
|
||||
@@ -306,27 +306,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_provider_settings_config(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
settings_config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![
|
||||
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize settings_config: {e}"
|
||||
)))?,
|
||||
provider_id,
|
||||
app_type
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_omo_provider_current(
|
||||
&self,
|
||||
app_type: &str,
|
||||
@@ -1034,6 +1013,7 @@ mod aggregate_tests {
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
snapshot.provider.created_at,
|
||||
snapshot.provider.sort_index,
|
||||
false,
|
||||
snapshot.provider.in_failover_queue,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! 数据库模块测试
|
||||
//!
|
||||
//! 包含 Schema 迁移和基本功能的测试。
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Deep link module tests
|
||||
|
||||
use super::mcp::parse_mcp_apps;
|
||||
|
||||
@@ -11,6 +11,11 @@ pub enum AppError {
|
||||
InvalidInput(String),
|
||||
#[error("未找到: {0}")]
|
||||
NotFound(String),
|
||||
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
|
||||
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
|
||||
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
|
||||
#[error("并发冲突: {0}")]
|
||||
Conflict(String),
|
||||
#[error("IO 错误: {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
|
||||
@@ -145,6 +145,72 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_content_fingerprint(&self) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
|
||||
match value {
|
||||
serde_json::Value::Null => hasher.update(b"n"),
|
||||
serde_json::Value::Bool(value) => {
|
||||
hasher.update(b"b");
|
||||
hasher.update([*value as u8]);
|
||||
}
|
||||
serde_json::Value::Number(value) => {
|
||||
let text = value.to_string();
|
||||
hasher.update(b"#");
|
||||
hasher.update((text.len() as u64).to_le_bytes());
|
||||
hasher.update(text.as_bytes());
|
||||
}
|
||||
serde_json::Value::String(value) => {
|
||||
hasher.update(b"s");
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
hasher.update(b"[");
|
||||
hasher.update((items.len() as u64).to_le_bytes());
|
||||
for item in items {
|
||||
hash_canonical(item, hasher);
|
||||
}
|
||||
hasher.update(b"]");
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
hasher.update(b"{");
|
||||
hasher.update((map.len() as u64).to_le_bytes());
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
hasher.update((key.len() as u64).to_le_bytes());
|
||||
hasher.update(key.as_bytes());
|
||||
hash_canonical(&map[key.as_str()], hasher);
|
||||
}
|
||||
hasher.update(b"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
|
||||
if let serde_json::Value::Object(map) = &mut meta {
|
||||
map.remove("custom_endpoints");
|
||||
map.remove("customEndpoints");
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
for part in [
|
||||
serde_json::Value::String(self.name.clone()),
|
||||
self.settings_config.clone(),
|
||||
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
|
||||
meta,
|
||||
] {
|
||||
hash_canonical(&part, &mut hasher);
|
||||
hasher.update([0u8]);
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn is_codex_oauth(&self) -> bool {
|
||||
self.provider_type() == Some("codex_oauth")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ use super::gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
||||
};
|
||||
use super::{
|
||||
normalize_claude_models_in_value, provider_to_mutation_input, reconcile_provider_record,
|
||||
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
|
||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||
};
|
||||
|
||||
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
||||
@@ -1566,10 +1567,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
reconcile_provider_record(
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
app_type.as_str(),
|
||||
provider_to_mutation_input(provider.clone()),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
)?;
|
||||
state
|
||||
.db
|
||||
@@ -1744,13 +1746,18 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
|
||||
if existing.settings_config != settings_config || existing.name != display_name
|
||||
{
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.name = display_name;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = reconcile_provider_record(
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
||||
@@ -1778,9 +1785,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) =
|
||||
reconcile_provider_record(&state.db, "opencode", provider_to_mutation_input(provider))
|
||||
{
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1834,12 +1844,17 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != settings_config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = reconcile_provider_record(
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
||||
@@ -1873,9 +1888,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) =
|
||||
reconcile_provider_record(&state.db, "openclaw", provider_to_mutation_input(provider))
|
||||
{
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1916,12 +1934,17 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = config;
|
||||
if let Err(e) = reconcile_provider_record(
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update Hermes provider '{name}' from live config: {e}"
|
||||
@@ -1948,9 +1971,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) =
|
||||
reconcile_provider_record(&state.db, "hermes", provider_to_mutation_input(provider))
|
||||
{
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,19 +151,46 @@ fn update_provider_record(
|
||||
state.db.update_provider(&key, &row)
|
||||
}
|
||||
|
||||
/// Reconciliation paths must state their intent explicitly: inspect first,
|
||||
/// then perform either strict create or strict one-row update.
|
||||
pub(crate) fn reconcile_provider_record(
|
||||
/// Reconcile 的显式前置期望(前置工程 A 认证契约 T9)。
|
||||
/// check-then-branch 的 TOCTOU 由调用方在观察时声明期望、由本层强制。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum ReconcilePrecondition {
|
||||
/// 调用方观察到目标不存在;若已被竞争者创建,必须返回
|
||||
/// [`AppError::Conflict`],绝不退化为覆盖更新。
|
||||
ExpectAbsent,
|
||||
/// 调用方观察到目标存在且内容指纹为 `fingerprint`;指纹过期必须返回
|
||||
/// [`AppError::Conflict`],由调用方重读重试。
|
||||
ExpectPresent { fingerprint: String },
|
||||
}
|
||||
|
||||
/// 行内容指纹:并发前置期望的版本标记(纯函数,不含状态列与 endpoint)。
|
||||
///
|
||||
/// 决定性要求:仓库启用了 serde_json `preserve_order`,且 `ProviderMeta`
|
||||
/// 内含 HashMap——直接序列化的键序随机,会产生伪 Conflict。因此必须走
|
||||
/// 递归排序的规范化哈希;`meta.custom_endpoints` 属 endpoint authority,
|
||||
/// 不参与内容指纹(不同读 API 对其填充不一致)。
|
||||
pub(crate) fn provider_row_fingerprint(provider: &crate::provider::Provider) -> String {
|
||||
provider.row_content_fingerprint()
|
||||
}
|
||||
|
||||
/// Reconcile paths must carry the caller's observed state into the write.
|
||||
/// Creation is strict, while updates compare the observed row fingerprint and
|
||||
/// write under one database lock and transaction.
|
||||
pub(crate) fn reconcile_provider_record_with_precondition(
|
||||
db: &crate::database::Database,
|
||||
app_type: &str,
|
||||
input: ProviderMutationInput,
|
||||
precondition: ReconcilePrecondition,
|
||||
) -> Result<(), AppError> {
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
if db.get_provider_aggregate(app_type, key.id())?.is_some() {
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
db.update_provider(&key, &row)
|
||||
} else {
|
||||
db.create_provider(NewProviderAggregate::from_input(app_type, input)?)
|
||||
match precondition {
|
||||
ReconcilePrecondition::ExpectAbsent => {
|
||||
db.create_provider(NewProviderAggregate::from_input(app_type, input)?)
|
||||
}
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint } => {
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
db.update_provider_if_content_fingerprint(&key, &fingerprint, &row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5149,15 +5176,22 @@ impl ProviderService {
|
||||
// 同步到 Claude
|
||||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
|
||||
let precondition = if let Some(existing) =
|
||||
state.db.get_provider_by_id(&claude_provider.id, "claude")?
|
||||
{
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &claude_provider.settings_config);
|
||||
claude_provider.settings_config = merged;
|
||||
}
|
||||
reconcile_provider_record(
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint }
|
||||
} else {
|
||||
ReconcilePrecondition::ExpectAbsent
|
||||
};
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"claude",
|
||||
provider_to_mutation_input(claude_provider),
|
||||
precondition,
|
||||
)?;
|
||||
} else {
|
||||
// 如果禁用了 Claude,删除对应的子供应商
|
||||
@@ -5168,15 +5202,21 @@ impl ProviderService {
|
||||
// 同步到 Codex
|
||||
if let Some(mut codex_provider) = provider.to_codex_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &codex_provider.settings_config);
|
||||
codex_provider.settings_config = merged;
|
||||
}
|
||||
reconcile_provider_record(
|
||||
let precondition =
|
||||
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &codex_provider.settings_config);
|
||||
codex_provider.settings_config = merged;
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint }
|
||||
} else {
|
||||
ReconcilePrecondition::ExpectAbsent
|
||||
};
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"codex",
|
||||
provider_to_mutation_input(codex_provider),
|
||||
precondition,
|
||||
)?;
|
||||
} else {
|
||||
let codex_id = format!("universal-codex-{id}");
|
||||
@@ -5186,15 +5226,22 @@ impl ProviderService {
|
||||
// 同步到 Gemini
|
||||
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
|
||||
let precondition = if let Some(existing) =
|
||||
state.db.get_provider_by_id(&gemini_provider.id, "gemini")?
|
||||
{
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &gemini_provider.settings_config);
|
||||
gemini_provider.settings_config = merged;
|
||||
}
|
||||
reconcile_provider_record(
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint }
|
||||
} else {
|
||||
ReconcilePrecondition::ExpectAbsent
|
||||
};
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"gemini",
|
||||
provider_to_mutation_input(gemini_provider),
|
||||
precondition,
|
||||
)?;
|
||||
} else {
|
||||
let gemini_id = format!("universal-gemini-{id}");
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::database::{Database, ProviderKey, ProviderRowUpdate};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
build_effective_settings_with_common_config, provider_to_mutation_input,
|
||||
write_live_with_common_config,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::str::FromStr;
|
||||
@@ -1055,11 +1056,16 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"claude",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
let result =
|
||||
ProviderKey::new("claude", &provider_id).and_then(|key| {
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
self.db.update_provider(&key, &row)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("同步 Claude Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -1116,11 +1122,15 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"codex",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
let result = ProviderKey::new("codex", &provider_id).and_then(|key| {
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
self.db.update_provider(&key, &row)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("同步 Codex Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})");
|
||||
@@ -1168,11 +1178,15 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"gemini",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
let result = ProviderKey::new("gemini", &provider_id).and_then(|key| {
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
self.db.update_provider(&key, &row)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("同步 Gemini Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -1211,15 +1225,20 @@ impl ProxyService {
|
||||
format!("更新 Grok Build API Key 失败: {e}")
|
||||
})?;
|
||||
provider.settings_config["config"] = json!(updated);
|
||||
self.db
|
||||
.update_provider_settings_config(
|
||||
"grokbuild",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
)
|
||||
.map_err(|e| {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
let key = ProviderKey::new("grokbuild", &provider_id).map_err(
|
||||
|e| format!("同步 Grok Build Token 到数据库失败: {e}"),
|
||||
)?;
|
||||
let row =
|
||||
ProviderRowUpdate::from_input(&input).map_err(|e| {
|
||||
format!("同步 Grok Build Token 到数据库失败: {e}")
|
||||
})?;
|
||||
self.db.update_provider(&key, &row).map_err(|e| {
|
||||
format!("同步 Grok Build Token 到数据库失败: {e}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,12 +3,12 @@
|
||||
"codeAuthority": "src-tauri/src/database/dao/provider_write.rs",
|
||||
"types": {
|
||||
"ProviderKey": ["app_type", "id"],
|
||||
"ProviderRowCreate": ["content", "created_at"],
|
||||
"ProviderRowUpdate": [
|
||||
"name",
|
||||
"settings_config",
|
||||
"website_url",
|
||||
"category",
|
||||
"created_at",
|
||||
"notes",
|
||||
"meta",
|
||||
"icon",
|
||||
|
||||
Reference in New Issue
Block a user