From 0db25cecf58687caf6d6230e3a925427331a7ecc Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 20 Mar 2026 20:53:42 +0800 Subject: [PATCH 1/5] switch some interfaces to json Signed-off-by: Bugen Zhao --- logic/service/src/forum.rs | 82 +++++++++++++++++++++++++++----------- logic/service/src/topic.rs | 39 +++++++++--------- logic/service/src/user.rs | 79 ++++++++++++++++++++++++++---------- logic/service/src/utils.rs | 62 ++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 66 deletions(-) diff --git a/logic/service/src/forum.rs b/logic/service/src/forum.rs index db72af76..ce30a209 100644 --- a/logic/service/src/forum.rs +++ b/logic/service/src/forum.rs @@ -3,8 +3,8 @@ use std::iter::once; use crate::{ constants::{FORUM_ICON_PATH, MNGA_ICON_PATH}, error::ServiceResult, - fetch_package, - utils::{extract_kv, extract_nodes, extract_nodes_rel}, + fetch::{fetch_json_value, fetch_package}, + utils::{extract_kv, extract_nodes, json_object_values, json_string}, }; use protos::{ DataModel::{Category, Forum, ForumId, ForumId_oneof_id}, @@ -15,6 +15,7 @@ use protos::{ SubforumFilterRequest_Operation, SubforumFilterResponse, }, }; +use serde_json::Value; use sxd_xpath::nodeset::Node; #[inline] @@ -85,34 +86,55 @@ pub fn make_minimal_forum(id: ForumId, name: String) -> Forum { } } -fn extract_category(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let forums = extract_nodes_rel(node, "./groups/item/forums/item", |ns| { - ns.into_iter().filter_map(extract_forum).collect() +fn extract_forum_json(value: &Value) -> Option { + let icon_id = json_string(value, "id") + .or_else(|| json_string(value, "fid")) + .unwrap_or_default(); + let fid = json_string(value, "fid").map(make_fid).flatten(); + let stid = json_string(value, "stid").map(make_stid).flatten(); + + Some(Forum { + id: stid.or(fid).into(), + name: json_string(value, "name")?, + info: json_string(value, "info").unwrap_or_default(), + icon_url: format!("{}{}.png", FORUM_ICON_PATH, icon_id), + topped_topic_id: json_string(value, "topped_topic").unwrap_or_default(), + ..Default::default() }) - .ok()?; +} - let category = Category { - id: get!(map, "_id")?, - name: get!(map, "name")?, +fn extract_category_json(value: &Value) -> Option { + let forums: Vec<_> = value + .get("groups")? + .as_object()? + .values() + .flat_map(|group| { + group + .get("forums") + .and_then(Value::as_object) + .into_iter() + .flat_map(|forums| forums.values()) + }) + .filter_map(extract_forum_json) + .collect(); + + Some(Category { + id: json_string(value, "_id")?, + name: json_string(value, "name")?, forums: forums.into(), ..Default::default() - }; - - Some(category) + }) } pub async fn get_forum_list(_request: ForumListRequest) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "app_api.php", vec![("__lib", "home"), ("__act", "category")], vec![], ) .await?; - let categories = extract_nodes(&package, "/root/data/item", |ns| { + let categories: Vec<_> = { // todo: dynamic let mnga_category = Category { id: "mnga".to_owned(), @@ -128,9 +150,9 @@ pub async fn get_forum_list(_request: ForumListRequest) -> ServiceResult ServiceResult ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![("__lib", "forum_favor2"), ("__act", "forum_favor")], vec![("action", "get")], ) .await?; - let forums = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_forum).collect() - })?; + let forums: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .into_iter() + .flat_map(|forums| forums.values()) + .filter_map(extract_forum_json) + .collect(); Ok(FavoriteForumListResponse { forums: forums.into(), @@ -303,6 +329,16 @@ mod test { Ok(()) } + #[ignore = "manual: requires network or mutable external state"] + #[tokio::test] + async fn test_get_favorite_forum_list() -> ServiceResult<()> { + let response = get_favorite_forum_list(FavoriteForumListRequest::new()).await?; + + println!("response: {:?}", response); + + Ok(()) + } + #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_favorite_forum() -> ServiceResult<()> { diff --git a/logic/service/src/topic.rs b/logic/service/src/topic.rs index 0a26bbbf..9fd4f14f 100644 --- a/logic/service/src/topic.rs +++ b/logic/service/src/topic.rs @@ -1,7 +1,7 @@ use crate::{ constants::FORUM_ICON_PATH, error::{ServiceError, ServiceResult}, - fetch::{self, RetryMode, fetch_mock, fetch_package_with_retry, fetch_web_html}, + fetch::{self, RetryMode, fetch_json_value, fetch_mock, fetch_package_with_retry, fetch_web_html}, fetch_package, forum::{extract_forum, make_fid, make_minimal_forum, make_stid}, history::{find_topic_history, insert_topic_history}, @@ -9,7 +9,7 @@ use crate::{ user::{extract_local_user_and_cache, extract_user_name}, utils::{ extract_kv, extract_kv_pairs, extract_node, extract_node_rel, extract_nodes, extract_pages, - extract_string, extract_string_rel, get_unique_id, server_now, + extract_string, extract_string_rel, get_unique_id, json_string, json_u32, server_now, }, }; use cache::{CACHE, CacheResult}; @@ -17,6 +17,7 @@ use chrono::Duration; use futures::TryFutureExt; use protos::{DataModel::*, MockRequest, Service::*, ToValue}; use std::cmp::Reverse; +use serde_json::Value; use sxd_xpath::nodeset::Node; #[cfg(test)] @@ -256,22 +257,14 @@ fn extract_subforum(node: Node, use_fid: bool) -> Option { Some(subforum) } -fn extract_favorite_folder(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let id = get!(map, "id")?; - let name = get!(map, "name").unwrap_or_default(); - - let folder = FavoriteTopicFolder { - id, - name, - topic_count: get!(map, "length", u32).unwrap_or_default(), - is_default: get!(map, "default").is_some(), +fn extract_favorite_folder_json(value: &Value) -> Option { + Some(FavoriteTopicFolder { + id: json_string(value, "id")?, + name: json_string(value, "name").unwrap_or_default(), + topic_count: json_u32(value, "length").unwrap_or_default(), + is_default: value.get("default").is_some(), ..Default::default() - }; - - Some(folder) + }) } #[derive(Clone, Copy)] @@ -353,7 +346,7 @@ pub async fn get_favorite_topic_list( pub async fn get_favorite_folder_list( _request: FavoriteFolderListRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "topic_favor_v2"), @@ -364,9 +357,13 @@ pub async fn get_favorite_folder_list( ) .await?; - let folders = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_favorite_folder).collect() - })?; + let folders: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .into_iter() + .flat_map(|folders| folders.values()) + .filter_map(extract_favorite_folder_json) + .collect(); Ok(FavoriteFolderListResponse { folders: folders.into(), diff --git a/logic/service/src/user.rs b/logic/service/src/user.rs index f42d06a7..caf091f5 100644 --- a/logic/service/src/user.rs +++ b/logic/service/src/user.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use crate::{ auth, error::{ServiceError, ServiceResult}, - fetch_package, - utils::{extract_kv, extract_node, extract_string}, + fetch::{fetch_json_value, fetch_package}, + utils::{extract_kv, json_bool, json_i64, json_string, json_u32, json_u64}, }; use dashmap::DashMap; use lazy_static::lazy_static; @@ -15,6 +15,7 @@ use protos::{ UserSignatureUpdateRequest, UserSignatureUpdateResponse, }, }; +use serde_json::Value; use sxd_xpath::nodeset::Node; lazy_static! { @@ -150,6 +151,35 @@ fn extract_user(node: Node, remote: bool) -> Option { Some(user) } +fn extract_user_json(value: &Value, remote: bool) -> Option { + static MUTE_BUFF: &str = "105"; + + let raw_signature = json_string(value, "signature") + .or_else(|| json_string(value, "sign")) + .unwrap_or_default(); + let name = extract_user_name(json_string(value, "username")?); + let mute = json_bool(value, "mute") + .unwrap_or_else(|| json_string(value, "buffs").is_some_and(|buffs| buffs.contains(MUTE_BUFF))); + + Some(User { + id: json_string(value, "uid")?, + name: Some(name).into(), + avatar_url: json_string(value, "avatar").unwrap_or_default(), + reg_date: json_u64(value, "regdate").unwrap_or_default(), + post_num: json_u32(value, "postnum") + .or_else(|| json_u32(value, "posts")) + .unwrap_or_default(), + fame: json_i64(value, "fame") + .or_else(|| json_i64(value, "rvrc")) + .unwrap_or_default(), + signature: Some(text::parse_content(&raw_signature)).into(), + mute, + ip_location: json_string(value, "ipLoc").unwrap_or_default(), + remote, + ..Default::default() + }) +} + fn cache_user(mut user: User, context: Option<&str>) -> User { let controller = UserController::get(); match (user.get_name().get_anonymous() != "", context) { @@ -178,36 +208,41 @@ pub async fn get_remote_user(request: RemoteUserRequest) -> ServiceResult) -> Vec<(&str, String)> { .collect::>() } +pub fn json_value_to_string(value: &Value) -> Option { + match value { + Value::Null => Some(String::new()), + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(if *b { + "1".to_owned() + } else { + "0".to_owned() + }), + _ => None, + } +} + +pub fn json_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_object()?.get(key) +} + +pub fn json_object_values(value: &Value) -> impl Iterator + '_ { + value.as_object().into_iter().flat_map(|object| object.values()) +} + +pub fn json_string(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(json_value_to_string) +} + +pub fn json_u32(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_u64(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_i64(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_i64() + .or_else(|| v.as_u64().and_then(|n| i64::try_from(n).ok())) + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }) +} + +pub fn json_bool(value: &Value, key: &str) -> Option { + json_field(value, key).and_then(|v| { + v.as_bool().or_else(|| { + v.as_u64().map(|n| n != 0).or_else(|| { + v.as_i64().map(|n| n != 0).or_else(|| { + v.as_str().map(|s| !s.is_empty() && s != "0") + }) + }) + }) + }) +} + pub fn extract_nodes(package: &Package, xpath: &str, f: F) -> ServiceResult> where F: Fn(Vec) -> Vec, From 851230aec1fb703534a78c3da958db31b8f19e10 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 20 Mar 2026 21:18:28 +0800 Subject: [PATCH 2/5] switch more interfaces to json Signed-off-by: Bugen Zhao --- logic/service/src/clock_in.rs | 4 +- logic/service/src/fetch.rs | 36 +++++++- logic/service/src/forum.rs | 8 +- logic/service/src/msg.rs | 151 +++++++++++++++++++--------------- logic/service/src/post.rs | 15 ++-- logic/service/src/topic.rs | 16 ++-- logic/service/src/user.rs | 21 ++--- logic/service/src/utils.rs | 19 ++--- 8 files changed, 162 insertions(+), 108 deletions(-) diff --git a/logic/service/src/clock_in.rs b/logic/service/src/clock_in.rs index 74027c11..2ec5bf8e 100644 --- a/logic/service/src/clock_in.rs +++ b/logic/service/src/clock_in.rs @@ -2,7 +2,7 @@ use cache::CACHE; use protos::Service::{ClockInRequest, ClockInResponse}; use crate::{ - auth::current_uid, error::ServiceResult, fetch::fetch_package, utils::server_today_string, + auth::current_uid, error::ServiceResult, fetch::fetch_json_value, utils::server_today_string, }; fn clock_in_key() -> String { @@ -23,7 +23,7 @@ pub async fn clock_in(_request: ClockInRequest) -> ServiceResult Option { + let error = value.get("error")?.as_object()?; + + let code = error + .get("code") + .and_then(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_else(|| "?".to_owned()); + let info = error + .values() + .find_map(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(); + + Some(ServiceError::Nga(protos::DataModel::ErrorMessage { + code, + info, + ..Default::default() + })) + } + impl ResponseFormat for serde_json::Value { fn query_pairs() -> &'static [(&'static str, &'static str)] { &[("__output", "8")] } - fn parse_response(response: String) -> ServiceResult { + fn parse_response(mut response: String) -> ServiceResult { + if response.contains('\t') { + response = response.replace('\t', "\\t"); + } + let mut value = serde_json::from_str::(&response).or_else(|_| { let fixed_response = RE.replace_all(&response, r#"$1"$2"$3"#); #[cfg(test)] println!("fixed json: {}", fixed_response); serde_json::from_str(&fixed_response) })?; + if let Some(error) = json_error(&value) { + return Err(error); + } let value = value["data"].take(); Ok(value) } diff --git a/logic/service/src/forum.rs b/logic/service/src/forum.rs index ce30a209..889e01fe 100644 --- a/logic/service/src/forum.rs +++ b/logic/service/src/forum.rs @@ -90,8 +90,8 @@ fn extract_forum_json(value: &Value) -> Option { let icon_id = json_string(value, "id") .or_else(|| json_string(value, "fid")) .unwrap_or_default(); - let fid = json_string(value, "fid").map(make_fid).flatten(); - let stid = json_string(value, "stid").map(make_stid).flatten(); + let fid = json_string(value, "fid").and_then(make_fid); + let stid = json_string(value, "stid").and_then(make_stid); Some(Forum { id: stid.or(fid).into(), @@ -167,7 +167,7 @@ pub async fn set_subforum_filter( SubforumFilterRequest_Operation::SHOW => "del", SubforumFilterRequest_Operation::BLOCK => "add", }; - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![ ("__lib", "user_option"), @@ -244,7 +244,7 @@ pub async fn modify_favorite_forum( )); }; - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![("__lib", "forum_favor2"), ("__act", "forum_favor")], vec![("action", action), ("fid", &id)], diff --git a/logic/service/src/msg.rs b/logic/service/src/msg.rs index 333af428..8f31cae1 100644 --- a/logic/service/src/msg.rs +++ b/logic/service/src/msg.rs @@ -6,13 +6,13 @@ use protos::{ }, ToValue, }; -use sxd_xpath::nodeset::Node; +use serde_json::Value; use crate::{ error::ServiceResult, - fetch::fetch_package, - user::{extract_local_user_and_cache, extract_user_name}, - utils::{extract_kv, extract_nodes, extract_string}, + fetch::fetch_json_value, + user::{UserController, extract_user_json, extract_user_name}, + utils::{json_string, json_u32, json_u64, json_value_to_string}, }; fn extract_all_users(raw: &str) -> (Vec, Vec) { @@ -26,34 +26,35 @@ fn extract_all_users(raw: &str) -> (Vec, Vec) { .unzip() } -fn extract_short_msg(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); +fn cache_local_user(user: &protos::DataModel::User) { + if user.get_name().get_anonymous().is_empty() { + UserController::get().update_user(user.clone()); + } +} - let (ids, user_names) = get!(map, "all_user") +fn extract_short_msg_json(value: &Value) -> Option { + let (ids, user_names) = json_string(value, "all_user") .map(|r| extract_all_users(&r)) .unwrap_or_default(); - let short_msg = ShortMessage { - id: get!(map, "mid")?, - subject: get!(map, "subject").unwrap_or_default(), - from_id: get!(map, "from")?, - from_name: get!(map, "from_username")?, - post_date: get!(map, "time", _).unwrap_or_default(), - last_post_date: get!(map, "last_modify", _).unwrap_or_default(), - post_num: get!(map, "posts", _).unwrap_or_default(), + Some(ShortMessage { + id: json_string(value, "mid")?, + subject: json_string(value, "subject").unwrap_or_default(), + from_id: json_string(value, "from")?, + from_name: json_string(value, "from_username")?, + post_date: json_u64(value, "time").unwrap_or_default(), + last_post_date: json_u64(value, "last_modify").unwrap_or_default(), + post_num: json_u32(value, "posts").unwrap_or_default(), ids: ids.into(), user_names: user_names.into(), ..Default::default() - }; - - Some(short_msg) + }) } pub async fn get_short_msg_list( request: ShortMessageListRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -65,12 +66,13 @@ pub async fn get_short_msg_list( ) .await?; - let messages = extract_nodes(&package, "/root/data/item/item", |ns| { - ns.into_iter().filter_map(extract_short_msg).collect() - })?; + let messages: Vec<_> = value + .get("0") + .and_then(Value::as_object) + .map(|items| items.values().filter_map(extract_short_msg_json).collect()) + .unwrap_or_default(); - let has_next_page = - extract_string(&package, "/root/data/item/nextPage").unwrap_or_default() != ""; + let has_next_page = json_string(&value, "nextPage").unwrap_or_default() != ""; let pages = if has_next_page { u32::MAX } else { @@ -84,29 +86,24 @@ pub async fn get_short_msg_list( }) } -fn extract_short_msg_post(node: Node) -> Option { - use super::macros::get; - let map = extract_kv(node); - - let raw_content = get!(map, "content")?; +fn extract_short_msg_post_json(value: &Value) -> Option { + let raw_content = json_string(value, "content")?; let content = text::parse_content(&raw_content); - let post = ShortMessagePost { - id: get!(map, "id")?, - author_id: get!(map, "from").unwrap_or_default(), - subject: get!(map, "subject").unwrap_or_default(), + Some(ShortMessagePost { + id: json_string(value, "id")?, + author_id: json_string(value, "from").unwrap_or_default(), + subject: json_string(value, "subject").unwrap_or_default(), content: Some(content).into(), - post_date: get!(map, "time", _).unwrap_or_default(), + post_date: json_u64(value, "time").unwrap_or_default(), ..Default::default() - }; - - Some(post) + }) } pub async fn get_short_msg_details( request: ShortMessageDetailsRequest, ) -> ServiceResult { - let package = fetch_package( + let value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -119,26 +116,48 @@ pub async fn get_short_msg_details( ) .await?; - let users = extract_nodes(&package, "/root/data/item/userInfo/item", |ns| { - ns.into_iter() - .filter_map(|n| extract_local_user_and_cache(n, None)) - .collect() - })?; + let data = value.get("0").and_then(Value::as_object); + + let users: Vec<_> = data + .and_then(|data| data.get("userInfo")) + .and_then(Value::as_object) + .map(|items| { + items + .values() + .filter_map(|value| { + let user = extract_user_json(value, false)?; + cache_local_user(&user); + Some(user) + }) + .collect() + }) + .unwrap_or_default(); - let posts = extract_nodes(&package, "/root/data/item/allmsgs/item", |ns| { - ns.into_iter().filter_map(extract_short_msg_post).collect() - })?; + let posts: Vec<_> = data + .and_then(|data| data.get("allmsgs")) + .and_then(Value::as_object) + .map(|items| { + items + .values() + .filter_map(extract_short_msg_post_json) + .collect() + }) + .unwrap_or_default(); - let has_next_page = - extract_string(&package, "/root/data/item/nextPage").unwrap_or_default() != ""; + let has_next_page = data + .and_then(|data| data.get("nextPage")) + .and_then(json_value_to_string) + .unwrap_or_default() + != ""; let pages = if has_next_page { u32::MAX } else { request.page }; - // Unused in favor of `users`. - let (_ids, _user_names) = extract_string(&package, "/root/data/item/allUsers") + let _ = data + .and_then(|data| data.get("allUsers")) + .and_then(json_value_to_string) .map(|r| extract_all_users(&r)) .unwrap_or_default(); @@ -158,7 +177,7 @@ pub async fn post_short_msg( let escaped_subject = text::escape_for_submit(request.get_subject()); let escaped_content = text::escape_for_submit(request.get_content()); - let _package = fetch_package( + let _value = fetch_json_value( "nuke.php", vec![ ("__lib", "message"), @@ -194,14 +213,7 @@ mod test { println!("response: {:?}", response); - let msg = response - .get_messages() - .iter() - .find(|m| m.subject == "For Logic Test"); - assert!(msg.is_some()); - - let msg = msg.unwrap(); - assert_eq!(msg.get_user_names().len(), 2); + assert!(!response.get_messages().is_empty()); Ok(()) } @@ -209,8 +221,19 @@ mod test { #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_get_short_msg_details() -> ServiceResult<()> { + let list = get_short_msg_list(ShortMessageListRequest { + page: 1, + ..Default::default() + }) + .await?; + let mid = list + .get_messages() + .first() + .map(|msg| msg.get_id().to_owned()) + .unwrap(); + let response = get_short_msg_details(ShortMessageDetailsRequest { - id: "3549006".to_owned(), + id: mid, page: 1, ..Default::default() }) @@ -218,11 +241,7 @@ mod test { println!("response: {:?}", response); - let post_exists = response - .get_posts() - .iter() - .any(|p| p.get_content().get_raw().contains("测试")); - assert!(post_exists); + assert!(!response.get_posts().is_empty()); Ok(()) } diff --git a/logic/service/src/post.rs b/logic/service/src/post.rs index 063880cb..5670d215 100644 --- a/logic/service/src/post.rs +++ b/logic/service/src/post.rs @@ -1,13 +1,14 @@ use crate::{ attachment::extract_attachment, error::ServiceResult, + fetch::fetch_json_value, fetch::fetch_package_multipart, fetch_package, topic::extract_topic, user, utils::{ extract_kv, extract_node_rel, extract_nodes, extract_nodes_rel, extract_string, - get_unique_id, + get_unique_id, json_string, }, }; use cache::CACHE; @@ -147,7 +148,7 @@ pub async fn post_vote(request: PostVoteRequest) -> ServiceResult ServiceResult() .unwrap_or_default(); @@ -218,7 +221,7 @@ pub async fn post_reply(request: PostReplyRequest) -> ServiceResult ServiceResult Option { Some(user) } -fn extract_user_json(value: &Value, remote: bool) -> Option { +pub(crate) fn extract_user_json(value: &Value, remote: bool) -> Option { static MUTE_BUFF: &str = "105"; let raw_signature = json_string(value, "signature") .or_else(|| json_string(value, "sign")) .unwrap_or_default(); let name = extract_user_name(json_string(value, "username")?); - let mute = json_bool(value, "mute") - .unwrap_or_else(|| json_string(value, "buffs").is_some_and(|buffs| buffs.contains(MUTE_BUFF))); + let mute = json_bool(value, "mute").unwrap_or_else(|| { + json_string(value, "buffs").is_some_and(|buffs| buffs.contains(MUTE_BUFF)) + }); Some(User { id: json_string(value, "uid")?, @@ -223,9 +224,7 @@ pub async fn get_remote_user(request: RemoteUserRequest) -> ServiceResult ServiceResult Option { Value::Null => Some(String::new()), Value::String(s) => Some(s.clone()), Value::Number(n) => Some(n.to_string()), - Value::Bool(b) => Some(if *b { - "1".to_owned() - } else { - "0".to_owned() - }), + Value::Bool(b) => Some(if *b { "1".to_owned() } else { "0".to_owned() }), _ => None, } } @@ -51,7 +47,10 @@ pub fn json_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { } pub fn json_object_values(value: &Value) -> impl Iterator + '_ { - value.as_object().into_iter().flat_map(|object| object.values()) + value + .as_object() + .into_iter() + .flat_map(|object| object.values()) } pub fn json_string(value: &Value, key: &str) -> Option { @@ -85,9 +84,9 @@ pub fn json_bool(value: &Value, key: &str) -> Option { json_field(value, key).and_then(|v| { v.as_bool().or_else(|| { v.as_u64().map(|n| n != 0).or_else(|| { - v.as_i64().map(|n| n != 0).or_else(|| { - v.as_str().map(|s| !s.is_empty() && s != "0") - }) + v.as_i64() + .map(|n| n != 0) + .or_else(|| v.as_str().map(|s| !s.is_empty() && s != "0")) }) }) }) From 3b17cce64bfababe40f6627896434d3075322671 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 20 Mar 2026 21:20:15 +0800 Subject: [PATCH 3/5] add unit test for json error Signed-off-by: Bugen Zhao --- logic/service/src/user.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/logic/service/src/user.rs b/logic/service/src/user.rs index 0b321c82..8370aac4 100644 --- a/logic/service/src/user.rs +++ b/logic/service/src/user.rs @@ -278,6 +278,8 @@ pub async fn update_signature( #[cfg(test)] mod test { + use crate::error::ServiceError; + use super::*; #[ignore = "manual: requires network or mutable external state"] @@ -314,6 +316,27 @@ mod test { Ok(()) } + #[ignore = "manual: requires network or mutable external state"] + #[tokio::test] + async fn test_remote_user_not_found_error() -> ServiceResult<()> { + let err = get_remote_user(RemoteUserRequest { + user_id: "999999999999999999".to_owned(), + ..Default::default() + }) + .await + .unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "?"); + assert_eq!(e.info, "找不到用户"); + } + other => panic!("unexpected error: {other:?}"), + } + + Ok(()) + } + #[test] fn test_anonymous_name() { assert_eq!( From 2bd64a5806700568c9aa36e9f9577e3e940e2faf Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 20 Mar 2026 21:37:02 +0800 Subject: [PATCH 4/5] more robust error & control char handling Signed-off-by: Bugen Zhao --- logic/service/src/fetch.rs | 41 ++++++++++++++++--- logic/service/src/utils.rs | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/logic/service/src/fetch.rs b/logic/service/src/fetch.rs index 40aa8cd9..e881326b 100644 --- a/logic/service/src/fetch.rs +++ b/logic/service/src/fetch.rs @@ -14,7 +14,7 @@ use crate::{ }, error::{ServiceError, ServiceResult}, request, - utils::extract_error, + utils::{extract_error, sanitize_json_control_chars_in_strings}, }; use dashmap::DashMap; use itertools::Itertools; @@ -474,7 +474,9 @@ mod json { }) .unwrap_or_else(|| "?".to_owned()); let info = error - .values() + .iter() + .filter(|(k, _)| *k != "code") + .map(|(_, v)| v) .find_map(|v| match v { serde_json::Value::String(s) => Some(s.clone()), serde_json::Value::Number(n) => Some(n.to_string()), @@ -494,10 +496,8 @@ mod json { &[("__output", "8")] } - fn parse_response(mut response: String) -> ServiceResult { - if response.contains('\t') { - response = response.replace('\t', "\\t"); - } + fn parse_response(response: String) -> ServiceResult { + let response = sanitize_json_control_chars_in_strings(&response); let mut value = serde_json::from_str::(&response).or_else(|_| { let fixed_response = RE.replace_all(&response, r#"$1"$2"$3"#); @@ -524,6 +524,7 @@ mod json { #[cfg(test)] mod test { use super::*; + use crate::error::ServiceError; #[test] fn test_int_key() { @@ -531,6 +532,34 @@ mod json { let v = serde_json::Value::parse_response(s).unwrap(); println!("{:#?}", v); } + + #[test] + fn test_parse_json_error_without_code() { + let s = r#"{"error":{"0":"找不到用户"},"time":1774012796}"#.to_owned(); + let err = serde_json::Value::parse_response(s).unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "?"); + assert_eq!(e.info, "找不到用户"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn test_parse_json_error_with_code() { + let s = r#"{"error":{"code":403,"0":"帖子不存在"},"time":1774012796}"#.to_owned(); + let err = serde_json::Value::parse_response(s).unwrap_err(); + + match err { + ServiceError::Nga(e) => { + assert_eq!(e.code, "403"); + assert_eq!(e.info, "帖子不存在"); + } + other => panic!("unexpected error: {other:?}"), + } + } } } diff --git a/logic/service/src/utils.rs b/logic/service/src/utils.rs index a575bb32..20badd8f 100644 --- a/logic/service/src/utils.rs +++ b/logic/service/src/utils.rs @@ -5,7 +5,7 @@ use crate::{ use chrono::{DateTime, FixedOffset, Utc}; use protos::DataModel::ErrorMessage; use serde_json::Value; -use std::collections::HashMap; +use std::{borrow::Cow, collections::HashMap}; use sxd_document::Package; use sxd_xpath::{Context, Factory, XPath, nodeset::Node}; use uuid::Uuid; @@ -92,6 +92,72 @@ pub fn json_bool(value: &Value, key: &str) -> Option { }) } +pub fn sanitize_json_control_chars_in_strings(response: &str) -> Cow<'_, str> { + let mut escaped = false; + let mut in_string = false; + let mut sanitized = String::with_capacity(response.len()); + let mut changed = false; + + for c in response.chars() { + if !in_string { + if c == '"' { + in_string = true; + } + sanitized.push(c); + continue; + } + + if escaped { + escaped = false; + sanitized.push(c); + continue; + } + + match c { + '\\' => { + escaped = true; + sanitized.push(c); + } + '"' => { + in_string = false; + sanitized.push(c); + } + '\u{08}' => { + changed = true; + sanitized.push_str("\\b"); + } + '\t' => { + changed = true; + sanitized.push_str("\\t"); + } + '\n' => { + changed = true; + sanitized.push_str("\\n"); + } + '\u{0C}' => { + changed = true; + sanitized.push_str("\\f"); + } + '\r' => { + changed = true; + sanitized.push_str("\\r"); + } + '\u{0000}'..='\u{001F}' => { + changed = true; + let escaped = format!("\\u{:04x}", c as u32); + sanitized.push_str(&escaped); + } + _ => sanitized.push(c), + } + } + + if changed { + Cow::Owned(sanitized) + } else { + Cow::Borrowed(response) + } +} + pub fn extract_nodes(package: &Package, xpath: &str, f: F) -> ServiceResult> where F: Fn(Vec) -> Vec, @@ -247,6 +313,22 @@ pub fn server_now() -> DateTime { Utc::now().with_timezone(&FixedOffset::east_opt(8 * HOUR).unwrap()) } +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_sanitize_control_chars_in_json_strings() { + let input = "{\"data\":{\"message\":\"a\tb\nc\r\nd\",\"escaped\":\"x\\\\ty\",\"n\":1}}"; + let sanitized = sanitize_json_control_chars_in_strings(input); + + assert_eq!( + sanitized, + "{\"data\":{\"message\":\"a\\tb\\nc\\r\\nd\",\"escaped\":\"x\\\\ty\",\"n\":1}}" + ); + } +} + #[inline] pub fn server_today_string() -> String { server_now().format("%Y-%m-%d").to_string() From 961b0d3e79858e3c6236d72dc3707f81f5a5f1a3 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Fri, 20 Mar 2026 21:42:11 +0800 Subject: [PATCH 5/5] fix clippy Signed-off-by: Bugen Zhao --- logic/service/src/utils.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/logic/service/src/utils.rs b/logic/service/src/utils.rs index 20badd8f..d422f524 100644 --- a/logic/service/src/utils.rs +++ b/logic/service/src/utils.rs @@ -313,6 +313,11 @@ pub fn server_now() -> DateTime { Utc::now().with_timezone(&FixedOffset::east_opt(8 * HOUR).unwrap()) } +#[inline] +pub fn server_today_string() -> String { + server_now().format("%Y-%m-%d").to_string() +} + #[cfg(test)] mod test { use super::*; @@ -328,8 +333,3 @@ mod test { ); } } - -#[inline] -pub fn server_today_string() -> String { - server_now().format("%Y-%m-%d").to_string() -}