Skip to content
Merged
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 113;
const CURRENT_SCHEMA_VERSION: u32 = 114;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
47 changes: 43 additions & 4 deletions crates/utopia-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,14 +492,18 @@ fn scan_quantity(s: &str, strict: bool) -> Option<Quantity> {
}
}
}
// 2. 数字:前导的 [-+0-9.,_]
// 2. 数字:前导的 [-+0-9.,_],负号也可以是排版用的 U+2212
let num_end = rest
.char_indices()
.find(|(_, c)| !matches!(c, '0'..='9' | '.' | ',' | '_' | '-' | '+'))
.find(|(_, c)| !matches!(c, '0'..='9' | '.' | ',' | '_' | '-' | '+' | '−'))
.map(|(i, _)| i)
.unwrap_or(rest.len());
let (num, after) = rest.split_at(num_end);
let cleaned: String = num.chars().filter(|c| !matches!(c, ',' | '_')).collect();
let cleaned: String = num
.chars()
.filter(|c| !matches!(c, ',' | '_'))
.map(|c| if c == '−' { '-' } else { c })
.collect();
let mut n: f64 = cleaned.parse().ok()?;
// 3. 数字后面:紧贴或空格隔开的量级词、货币词,逐个吃;吃不动的就是「其余」
let mut tail = after.trim_start();
Expand Down Expand Up @@ -758,10 +762,14 @@ pub fn attribute_value(
return normalize_attr_value(datatype, raw);
};
// 声明的单位拆成量级和其余:「万元」→ (4, 元),「million dollars」→ (6, dollars)
// 币种写在前面的「USD million」「€ million」,量级在末尾
let (tok, rest) = next_token(declared);
let trailing = declared
.rsplit_once(char::is_whitespace)
.and_then(|(head, last)| Some((magnitude(&last.to_ascii_lowercase())?, head.trim())));
let (exponent, base) = match magnitude(&tok.to_ascii_lowercase()) {
Some(m) => (m, rest.trim()),
None => (0, declared),
None => trailing.unwrap_or((0, declared)),
};
let written = q.unit.as_deref().and_then(currency_unit);
let wanted = currency_unit(base);
Expand Down Expand Up @@ -1216,6 +1224,19 @@ mod tests {
normalize_attr_value("number", &json!("52%")),
Some(json!(52.0))
);
// 排版过的负号是 U+2212(维基百科、排版出来的 PDF 都这么写),读法同 `-`
assert_eq!(
normalize_attr_value("number", &json!("−3.2%")),
Some(json!(-3.2))
);
assert_eq!(
normalize_attr_value("number", &json!("−1,250")),
Some(json!(-1250.0))
);
assert_eq!(
attribute_value("number", Some("万元"), &json!("−2.3亿元")),
Some(json!(-23000.0))
);
// 原来就认的两种写法不受影响
assert_eq!(
normalize_attr_value("number", &json!("35,000")),
Expand Down Expand Up @@ -1514,6 +1535,24 @@ mod an_attribute_value_is_read_in_its_declared_unit {
assert_eq!(value("date", None, "1982年"), Some(json!("1982")));
}

/// 财报的单位常把币种写在量级前面:「USD million」「€ million」,同「million dollars」
#[test]
fn a_unit_with_its_currency_first_is_still_read_in_millions() {
assert_eq!(
value("number", Some("USD million"), "$5 billion"),
Some(json!(5000.0))
);
assert_eq!(
value("number", Some("€ million"), "€30 million"),
Some(json!(30.0))
);
assert_eq!(value("number", Some("USD million"), "€30 million"), None);
assert_eq!(
value("number", Some("million dollars"), "$5 billion"),
Some(json!(5000.0))
);
}

/// 换不了的不换:货币对不上、读不出数的,宁缺勿脏
#[test]
fn what_cannot_be_read_is_not_written() {
Expand Down
34 changes: 32 additions & 2 deletions crates/utopia-ingest/src/ontology_rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -840,13 +840,20 @@ fn is_opaque_local_name(local: &str) -> bool {
}

/// 派生 key:局部名是纯编号且词汇表给了 label 时用 label,否则用局部名。
/// 局部名派生不出 key 的(`ex:公司` 一个 ASCII 字母都没有)也改用 label。
///
/// **IRI 始终是身份**,这里只换那个给模型读的标签,所以重导入、对齐、
/// `e_by_iri` 的认领都不受影响。
pub fn key_from_iri_or_label(iri: &str, label: Option<&str>) -> String {
let from_iri = key_from_iri(iri);
match label {
Some(l) if is_opaque_local_name(local_name(iri)) && !l.trim().is_empty() => to_key(l),
_ => key_from_iri(iri),
Some(l)
if (from_iri.is_empty() || is_opaque_local_name(local_name(iri)))
&& !l.trim().is_empty() =>
{
to_key(l)
}
_ => from_iri,
}
}

Expand Down Expand Up @@ -1292,6 +1299,29 @@ mod tests {
assert_eq!(k("http://x#part_12", "part twelve"), "part_12");
}

/// 中文写的 IRI 派生不出 key:有英文 label 就用它,不然每个类都是空 key,导入整个被拒
#[test]
fn a_chinese_iri_takes_its_key_from_the_label() {
let ttl = r#"
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix ex: <http://example.cn/onto#> .

ex:公司 a owl:Class ; rdfs:label "Company"@en, "公司"@zh .
ex:人员 a owl:Class ; rdfs:label "Person"@en, "人员"@zh .
ex:注册资本 a owl:DatatypeProperty ; rdfs:label "registered capital"@en ;
rdfs:domain ex:公司 ; rdfs:range xsd:decimal .
"#;
let p = project(ttl.as_bytes(), RdfFormat::Turtle).unwrap();
let mut keys: Vec<&str> = p.classes.iter().map(|c| c.key.as_str()).collect();
keys.sort();
assert_eq!(keys, ["company", "person"]);
assert_eq!(p.properties[0].key, "registered_capital");
// 没有 label 的仍退回局部名
assert_eq!(key_from_iri_or_label("http://x#Company", None), "company");
}

/// schema.org 那一套的最小复刻:数据类型自报家门,属性用
/// domainIncludes / rangeIncludes,全部只声明成 rdf:Property。
const SCHEMA_ISH: &str = r#"
Expand Down
94 changes: 66 additions & 28 deletions crates/utopia-server/src/api/google_drive_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CONNECTION_KEYS: &[&str] = &[
"account_email",
"connected_at",
"connected_by",
"connected_by_name",
"folders",
];
/// 一个来源最多选多少个文件夹。选得更多的,选它们共同的上级
Expand Down Expand Up @@ -98,14 +99,54 @@ pub(super) async fn grant_still_used(
sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM sources
WHERE kind = 'google_drive' AND id <> $1
AND config->>'account_email' = $2 AND config ? 'refresh_token')",
AND lower(config->>'account_email') = lower($2) AND config ? 'refresh_token')",
)
.bind(source_id)
.bind(account_email)
.fetch_one(pool)
.await
}

/// 重连之后还是不是同一个 Google 账号。邮箱不分大小写;有一边不知道就当换了:
/// 换了账号,上一个账号里选的文件夹不能照着新账号去读
pub(super) fn same_account(previous: Option<&str>, now: Option<&str>) -> bool {
matches!((previous, now), (Some(a), Some(b)) if a.eq_ignore_ascii_case(b))
}

/// 重连之后作废不作废上一份授权:两边的账号都知道、而且不是同一个时才作废。
/// 有一边不知道,就可能是同一个账号——作废会连刚换来的新令牌一起作废(0074 决定 5)
pub(super) fn revokes_previous(previous: Option<&str>, now: Option<&str>) -> bool {
previous.is_some() && now.is_some() && !same_account(previous, now)
}

/// 请 Google 作废一枚刷新令牌,除非同一个账号还连着别的来源([`grant_still_used`])。
/// 不知道是哪个账号就不作废。失败只记一笔:库里已经没有它了
async fn revoke_unless_still_used(
pool: &sqlx::PgPool,
source_id: Uuid,
token: &str,
account: Option<&str>,
) -> Result<(), sqlx::Error> {
let Some(account) = account else {
return Ok(());
};
if grant_still_used(pool, source_id, account).await? {
return Ok(());
}
let revoked = reqwest::Client::new()
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", token)])
.timeout(std::time::Duration::from_secs(10))
.send()
.await;
match revoked {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => tracing::warn!(status = %resp.status(), "Google 拒绝作废刷新令牌,库里已删除"),
Err(e) => tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"),
}
Ok(())
}

/// 落一次连接流程。总数与这个人的在途数都在同一条插入里判,满了就不插,回 `None`
pub(super) async fn record_flow(
pool: &sqlx::PgPool,
Expand Down Expand Up @@ -280,16 +321,30 @@ pub(super) async fn finish(
)
})?;

let previous_token = source.config["refresh_token"].as_str();
let previous_account = source.config["account_email"].as_str();
// 连上的人换了一个 Google 账号:上一个账号里选的文件夹不留,它的授权在没别人用时作废。
// 同一个账号绝不作废——Google 作废的是整份授权,刚换来的新令牌也会跟着失效
let changed = !same_account(previous_account, grant.account_email.as_deref());
let grant_keys = json!({
"refresh_token": grant.refresh_token,
"account_email": grant.account_email,
// 列表里看不见令牌,界面凭这一项知道「连上了」:没验证过的邮箱不显示,不能拿它当标志
"connected_at": chrono::Utc::now().to_rfc3339(),
"connected_by": user.id,
// 断开前问一句时说清是谁的 Drive;库的成员列表只给管理员,编辑者那边查不到名字
"connected_by_name": user.display_name,
});
utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, &[])
let dropped: &[&str] = if changed { &["folders"] } else { &[] };
utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, dropped)
.await
.map_err(|e| ("unavailable", format!("saving the grant: {e}")))?;
let revoke = revokes_previous(previous_account, grant.account_email.as_deref());
if let (true, Some(token)) = (revoke, previous_token) {
revoke_unless_still_used(&state.pool, source_id, token, previous_account)
.await
.map_err(|e| ("unavailable", format!("checking the previous grant: {e}")))?;
}
state.emit_source(source.kb_id);
// 审计不落令牌,只记连上的是哪个 Google 账号
let _ = utopia_store::audit::record(
Expand All @@ -305,41 +360,24 @@ pub(super) async fn finish(
Ok(())
}

/// 断开:删掉刷新令牌,并请 Google 把它作废——除非同一个账号还连着别的来源
///([`grant_still_used`]),或者不知道是哪个账号。作废失败不拦着:库里已经没有它了
/// 断开:删掉刷新令牌与选的文件夹,再按 [`revoke_unless_still_used`] 请 Google 作废
pub async fn disconnect(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path((kb_id, source_id)): Path<(Uuid, Uuid)>,
) -> ApiResult<Json<Value>> {
require_kb(&state, &user, kb_id, Role::Editor).await?;
let source = drive_source(&state, kb_id, source_id).await?;
let token = source.config["refresh_token"].as_str().map(String::from);
let account = source.config["account_email"].as_str().map(String::from);
utopia_store::sources::patch_config(&state.pool, source_id, &json!({}), CONNECTION_KEYS)
.await?;
let revoke = match (token, account) {
(Some(token), Some(account))
if !grant_still_used(&state.pool, source_id, &account).await? =>
{
Some(token)
}
_ => None,
};
if let Some(token) = revoke {
let revoked = reqwest::Client::new()
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", token.as_str())])
.timeout(std::time::Duration::from_secs(10))
.send()
.await;
match revoked {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
tracing::warn!(status = %resp.status(), "Google 拒绝作废刷新令牌,库里已删除")
}
Err(e) => tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"),
}
if let Some(token) = source.config["refresh_token"].as_str() {
revoke_unless_still_used(
&state.pool,
source_id,
token,
source.config["account_email"].as_str(),
)
.await?;
}
state.emit_source(kb_id);
let _ = utopia_store::audit::record(
Expand Down
34 changes: 32 additions & 2 deletions crates/utopia-server/src/api/google_drive_routes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,12 @@ async fn a_grant_is_kept_while_another_source_uses_the_same_account() -> anyhow:
)
.await?
.id;
for id in [fx.source, other] {
let grant = json!({ "refresh_token": "1//grant", "account_email": account });
// 同一个邮箱,大小写不同:Google 账号不分大小写
for (id, email) in [
(fx.source, account.clone()),
(other, account.to_uppercase()),
] {
let grant = json!({ "refresh_token": "1//grant", "account_email": email });
utopia_store::sources::patch_config(&fx.pool, id, &grant, &[]).await?;
}
let while_other_connected = grant_still_used(&fx.pool, fx.source, &account).await?;
Expand Down Expand Up @@ -381,3 +385,29 @@ async fn a_drive_source_cannot_be_created_without_an_oauth_client() -> anyhow::R
assert_eq!(denied_code(created), Some("google_drive_not_configured"));
Ok(())
}

#[test]
fn a_reconnect_keeps_the_grant_only_for_the_same_account() {
assert_eq!(
[
same_account(Some("Bruno@re-cinq.com"), Some("bruno@re-cinq.com")),
same_account(Some("bruno@re-cinq.com"), Some("ana@re-cinq.com")),
same_account(None, Some("bruno@re-cinq.com")),
same_account(None, None),
],
[true, false, false, false]
);
}

#[test]
fn a_reconnect_revokes_the_previous_grant_only_when_both_accounts_are_known_and_differ() {
assert_eq!(
[
revokes_previous(Some("bruno@re-cinq.com"), Some("ana@re-cinq.com")),
revokes_previous(Some("bruno@re-cinq.com"), Some("Bruno@re-cinq.com")),
revokes_previous(Some("bruno@re-cinq.com"), None),
revokes_previous(None, Some("ana@re-cinq.com")),
],
[true, false, false, false]
);
}
11 changes: 5 additions & 6 deletions crates/utopia-server/src/api/search_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ use crate::error::ApiResult;
use crate::retrieval;
use crate::state::AppState;

/// 翻页最深到第几条:每路召回随页深加长,再深就是在拿检索当导出用(#1271)
const MAX_DEPTH: usize = 100;
/// 前缀最多几条、每条多长:它们进的是一条 SQL 的数组参数,不是让人塞一份清单
const MAX_PREFIXES: usize = 8;
const MAX_PREFIX_BYTES: usize = 256;

/// 不认识的字段答 422:过滤条件还没做,悄悄忽略一个 `filters` 会让调用方以为它生效了
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchReq {
pub q: String,
#[serde(default)]
Expand Down Expand Up @@ -81,14 +81,13 @@ pub async fn search(
if q.is_empty() {
return Err(AppError::invalid("empty_query", "Search query cannot be empty").into());
}
super::documents_routes::require_reader_kb(&state, &reader, kb_id).await?;
let kb = utopia_store::kbs::get(&state.pool, kb_id).await?;
let kb = super::documents_routes::require_reader_kb(&state, &reader, kb_id).await?;

let top_k = req.top_k.unwrap_or(10).min(50);
if req.offset.saturating_add(top_k) > MAX_DEPTH {
if req.offset.saturating_add(top_k) > retrieval::MAX_DEPTH {
return Err(AppError::invalid(
"page_too_deep",
format!("offset + top_k may not exceed {MAX_DEPTH}"),
format!("offset + top_k may not exceed {}", retrieval::MAX_DEPTH),
)
.into());
}
Expand Down
Loading
Loading