feat(sns): sns-notifications / sns-feed / sns-search (#14)

新增 3 个朋友圈相关命令:sns-notifications / sns-feed / sns-search。
PR review 修复(已 push 进同一分支):
- 修 --user 过滤与 XML <username> fallback 打架的 bug(@wx-cli-codex 发现)
- 加 SNS_MAX_LIMIT / SNS_MAX_SCAN 防御性上限
- 抽 escape_like_pattern() helper
- 补 8 个单测(parse_post_xml / escape_like_pattern)

Cargo check 三 target 全过:aarch64-darwin / x86_64-pc-windows-gnu / x86_64-unknown-linux-gnu。
Co-authored-by: fengliu222 <fengliu222@users.noreply.github.com>
pull/15/head
JL 2026-04-19 01:58:21 +08:00 committed by GitHub
parent f0dcd4ea05
commit e8939f315d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 684 additions and 0 deletions

View File

@ -156,6 +156,27 @@ wx search "会议" --in "工作群" --since 2026-01-01
会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。 会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。
### 朋友圈SNS
三个独立命令,区分"通知"和"帖子"
```bash
wx sns-notifications # 点赞/评论通知(默认仅未读)
wx sns-notifications --include-read -n 100 # 含已读
wx sns-feed # 近 20 条朋友圈(时间线)
wx sns-feed --user "张三" # 限定作者
wx sns-feed --since 2026-04-01 -n 100 # 按时间
wx sns-search "关键词" # 全文搜索朋友圈正文
wx sns-search "婚礼" --user "李四" --since 2023-01-01
```
- **sns-notifications** 返回互动通知:`type``like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖)
- **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media_count`、`location`、`timestamp`
朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。
### 联系人 & 群组 ### 联系人 & 群组
```bash ```bash

View File

@ -148,6 +148,32 @@ wx contacts --query "李"
wx members "AI交流群" wx members "AI交流群"
``` ```
### 朋友圈SNS
三个命令,作用各不同:
```bash
# 1) 互动通知(点赞 / 评论,默认仅未读)
wx sns-notifications
wx sns-notifications --include-read --since 2026-04-01 -n 100
# 2) 时间线:浏览本地缓存的朋友圈帖子
wx sns-feed # 近 20 条
wx sns-feed --user "张三" # 只看某人
wx sns-feed --since 2026-04-01 --until 2026-04-18 -n 100
# 3) 全文搜索:在正文里找关键词
wx sns-search "关键词"
wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
```
**字段区分**
- `sns-notifications` 返回"通知"条目:`type``like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖)
- `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media_count`(图片/视频数)、`location`、`timestamp`
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。
### 收藏与统计 ### 收藏与统计
```bash ```bash

View File

@ -12,6 +12,9 @@ pub mod members;
pub mod new_messages; pub mod new_messages;
pub mod stats; pub mod stats;
pub mod favorites; pub mod favorites;
pub mod sns_notifications;
pub mod sns_feed;
pub mod sns_search;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -181,6 +184,62 @@ enum Commands {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// 朋友圈互动通知:别人对我的朋友圈点赞/评论 + 我评过的帖子下的跟帖
SnsNotifications {
/// 显示数量
#[arg(short = 'n', long, default_value = "50")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 包含已读通知(默认仅未读)
#[arg(long)]
include_read: bool,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 朋友圈时间线:按时间/作者筛选本地缓存的朋友圈
SnsFeed {
/// 显示数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 只看指定作者(昵称 / 备注名 / 微信 ID模糊匹配
#[arg(long)]
user: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 朋友圈全文搜索:匹配正文关键词
SnsSearch {
/// 关键词
keyword: String,
/// 结果数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 限定作者(昵称 / 备注名 / 微信 ID
#[arg(long)]
user: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 管理 wx-daemon /// 管理 wx-daemon
Daemon { Daemon {
#[command(subcommand)] #[command(subcommand)]
@ -236,6 +295,15 @@ fn dispatch(cli: Cli) -> Result<()> {
Commands::Favorites { limit, fav_type, query, json } => { Commands::Favorites { limit, fav_type, query, json } => {
favorites::cmd_favorites(limit, fav_type, query, json) favorites::cmd_favorites(limit, fav_type, query, json)
} }
Commands::SnsNotifications { limit, since, until, include_read, json } => {
sns_notifications::cmd_sns_notifications(limit, since, until, include_read, json)
}
Commands::SnsFeed { limit, since, until, user, json } => {
sns_feed::cmd_sns_feed(limit, since, until, user, json)
}
Commands::SnsSearch { keyword, limit, since, until, user, json } => {
sns_search::cmd_sns_search(keyword, limit, since, until, user, json)
}
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
} }
} }

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_feed(
limit: usize,
since: Option<String>,
until: Option<String>,
user: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsFeed {
limit,
since: since_ts,
until: until_ts,
user,
};
let resp = transport::send(req)?;
let data = resp.data.get("posts")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_notifications(
limit: usize,
since: Option<String>,
until: Option<String>,
include_read: bool,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsNotifications {
limit,
since: since_ts,
until: until_ts,
include_read,
};
let resp = transport::send(req)?;
let data = resp.data.get("notifications")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -0,0 +1,30 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_search(
keyword: String,
limit: usize,
since: Option<String>,
until: Option<String>,
user: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsSearch {
keyword,
limit,
since: since_ts,
until: until_ts,
user,
};
let resp = transport::send(req)?;
let data = resp.data.get("posts")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -73,6 +73,7 @@ async fn async_run() -> Result<()> {
names.msg_db_keys = msg_db_keys; names.msg_db_keys = msg_db_keys;
let _ = db.get("session/session.db").await; let _ = db.get("session/session.db").await;
let _ = db.get("sns/sns.db").await;
eprintln!("[daemon] 预热完成,联系人 {}", names.map.len()); eprintln!("[daemon] 预热完成,联系人 {}", names.map.len());
// 包一层内部 ArcIPC 请求取 guard 后只做 Arc::cloneO(1) // 包一层内部 ArcIPC 请求取 guard 后只做 Arc::cloneO(1)

View File

@ -1502,3 +1502,431 @@ pub async fn q_stats(
})) }))
} }
/// 查询朋友圈互动通知(点赞 + 评论),对应微信 app 右上角的红点入口。
/// 空 `content` 是点赞,非空是评论正文。
pub async fn q_sns_notifications(
db: &DbCache,
names: &Names,
limit: usize,
since: Option<i64>,
until: Option<i64>,
include_read: bool,
) -> Result<Value> {
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let path2 = path.clone();
type Row = (i64, i64, i64, i64, String, String, String);
let rows: Vec<Row> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let mut clauses: Vec<&str> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if !include_read {
clauses.push("is_unread = 1");
}
if let Some(s) = since {
clauses.push("create_time >= ?");
params.push(Box::new(s));
}
if let Some(u) = until {
clauses.push("create_time <= ?");
params.push(Box::new(u));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", clauses.join(" AND "))
};
let sql = format!(
"SELECT local_id, create_time, type, feed_id, from_username, from_nickname, content
FROM SnsMessage_tmp3 {} ORDER BY create_time DESC LIMIT ?",
where_clause
);
params.push(Box::new(limit as i64));
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2).unwrap_or(0),
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, String>(4).unwrap_or_default(),
row.get::<_, String>(5).unwrap_or_default(),
row.get::<_, String>(6).unwrap_or_default(),
)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await??;
// 一次性取出涉及的 feed 原帖,避免 N+1 查询
let feed_ids: Vec<i64> = {
let mut v: Vec<i64> = rows.iter().map(|r| r.3).collect();
v.sort_unstable();
v.dedup();
v
};
let path3 = path.clone();
let feed_ids_clone = feed_ids.clone();
let feeds: HashMap<i64, (String, String)> = tokio::task::spawn_blocking(move || {
if feed_ids_clone.is_empty() {
return Ok::<_, anyhow::Error>(HashMap::new());
}
let conn = Connection::open(&path3)?;
let placeholders = std::iter::repeat("?").take(feed_ids_clone.len()).collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE tid IN ({})",
placeholders
);
let params: Vec<&dyn rusqlite::types::ToSql> =
feed_ids_clone.iter().map(|id| id as &dyn rusqlite::types::ToSql).collect();
let mut stmt = conn.prepare(&sql)?;
let mut map = HashMap::new();
let mut rows2 = stmt.query(params.as_slice())?;
while let Some(row) = rows2.next()? {
let tid: i64 = row.get(0)?;
let author: String = row.get::<_, String>(1).unwrap_or_default();
let content: String = row.get::<_, String>(2).unwrap_or_default();
let preview = extract_xml_text(&content, "contentDesc")
.map(|s| s.chars().take(60).collect::<String>())
.unwrap_or_default();
// 原帖 user_name 偶尔为空(转发帖),再从 XML 兜一下
let author = if author.is_empty() {
extract_xml_text(&content, "username").unwrap_or_default()
} else {
author
};
map.insert(tid, (author, preview));
}
Ok(map)
}).await??;
let mut out = Vec::with_capacity(rows.len());
for (_local_id, ct, _typ, fid, from_u, from_nick, content) in rows {
let kind = if content.trim().is_empty() { "like" } else { "comment" };
let display = if !from_nick.is_empty() {
from_nick.clone()
} else {
names.display(&from_u)
};
let (feed_author_u, feed_preview) = feeds.get(&fid)
.cloned()
.unwrap_or_default();
let feed_author_display = if feed_author_u.is_empty() {
String::new()
} else {
names.display(&feed_author_u)
};
out.push(json!({
"type": kind,
"time": fmt_time(ct, "%m-%d %H:%M"),
"timestamp": ct,
"from_username": from_u,
"from_nickname": display,
"content": content,
"feed_id": fid,
"feed_author_username": feed_author_u,
"feed_author": feed_author_display,
"feed_preview": feed_preview,
}));
}
let total = out.len();
Ok(json!({ "notifications": out, "total": total }))
}
fn sns_media_count_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
// 只在 <mediaList> 里数 <media> 开标签,避免匹配到嵌套的其他 <media*> 字段
RE.get_or_init(|| Regex::new(r"<media>").unwrap())
}
fn sns_location_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
// location 是自闭合标签poiName 在属性里
RE.get_or_init(|| Regex::new(r#"<location[^>]*poiName="([^"]*)""#).unwrap())
}
// 朋友圈扫描的硬上限:单次查询最多解析这么多行 SnsTimeLine
// 防止用户传超大 limit 或者底层数据异常时把 daemon 卡住。
// 当前账号 ~10k+ 帖子5w 上限留足缓冲。
const SNS_MAX_LIMIT: usize = 10_000;
const SNS_MAX_SCAN: usize = 50_000;
/// 转义 SQL LIKE 模式中的元字符。配合 `ESCAPE '\\'` 使用。
/// 反斜杠必须最先转义,否则后续替换出的 `\%` / `\_` 会被再次吞掉。
fn escape_like_pattern(s: &str) -> String {
s.replace('\\', r"\\")
.replace('%', r"\%")
.replace('_', r"\_")
}
/// SnsTimeLine 行解析产物。不含 display name依赖 Names需要出 spawn_blocking 再补)。
struct ParsedPost {
tid: i64,
create_time: i64,
author_username: String,
content: String,
media_count: i64,
location: String,
}
/// 纯 XML 解析,无 Names 依赖,可以在 spawn_blocking 里跑。
/// user_name_column 为空时从 TimelineObject/<username> 兜底(转发帖)。
fn parse_post_xml(tid: i64, user_name_column: &str, content: &str) -> ParsedPost {
let create_time = extract_xml_text(content, "createTime")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let text = extract_xml_text(content, "contentDesc").unwrap_or_default();
let author_username = if user_name_column.is_empty() {
extract_xml_text(content, "username").unwrap_or_default()
} else {
user_name_column.to_string()
};
let media_count = sns_media_count_re().find_iter(content).count() as i64;
let location = sns_location_re()
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_default();
ParsedPost { tid, create_time, author_username, content: text, media_count, location }
}
fn post_to_value(p: ParsedPost, names: &Names) -> Value {
let author = if p.author_username.is_empty() {
String::new()
} else {
names.display(&p.author_username)
};
json!({
"tid": p.tid,
"timestamp": p.create_time,
"time": fmt_time(p.create_time, "%Y-%m-%d %H:%M"),
"author_username": p.author_username,
"author": author,
"content": p.content,
"media_count": p.media_count,
"location": p.location,
})
}
/// 查询朋友圈时间线:按时间/作者筛选。用于浏览自己或好友的朋友圈。
pub async fn q_sns_feed(
db: &DbCache,
names: &Names,
limit: usize,
since: Option<i64>,
until: Option<i64>,
user: Option<&str>,
) -> Result<Value> {
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let limit = limit.min(SNS_MAX_LIMIT);
let user_uname = match user {
Some(q) => Some(
resolve_username(q, names)
.with_context(|| format!("找不到联系人: {}", q))?,
),
None => None,
};
// user 过滤不在 SQL 层做SnsTimeLine.user_name 列对部分(转发)帖子是空,
// 真正作者只在 XML <username> 里。SQL 层 `user_name = ?` 会把这部分提前漏掉,
// 让 parse_post_xml 的 fallback 失效。所以扫全表 → parse → 用 ParsedPost.author_username 过滤。
// (createTime 也不是列,本来就要扫全表 parse XML 才能正确按时间排序。)
let path2 = path.clone();
let parsed: Vec<ParsedPost> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let sql = "SELECT tid, user_name, content FROM SnsTimeLine ORDER BY tid DESC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
)))?;
let mut scanned = 0usize;
let mut out: Vec<ParsedPost> = Vec::new();
for row in rows {
scanned += 1;
if scanned > SNS_MAX_SCAN {
eprintln!(
"[sns_feed] scan 超过硬上限 {},结果可能不完整。建议加 --user / --since 缩小范围。",
SNS_MAX_SCAN
);
break;
}
let (tid, uname, content) = row?;
let p = parse_post_xml(tid, &uname, &content);
if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } }
if let Some(s) = since { if p.create_time < s { continue; } }
if let Some(u) = until { if p.create_time > u { continue; } }
out.push(p);
}
// tid DESC 不严格等于 createTime DESC不同账号 tid 生成算法不同),
// 所以要先收齐全部匹配的、按 create_time 排序,再 truncate —— 否则会丢帖。
out.sort_by_key(|p| std::cmp::Reverse(p.create_time));
out.truncate(limit);
Ok::<_, anyhow::Error>(out)
}).await??;
let posts: Vec<Value> = parsed.into_iter().map(|p| post_to_value(p, names)).collect();
let total = posts.len();
Ok(json!({ "posts": posts, "total": total }))
}
/// 搜索朋友圈全文:在 contentDesc正文里匹配 keyword可叠加时间 / 作者过滤。
pub async fn q_sns_search(
db: &DbCache,
names: &Names,
keyword: &str,
limit: usize,
since: Option<i64>,
until: Option<i64>,
user: Option<&str>,
) -> Result<Value> {
if keyword.trim().is_empty() {
anyhow::bail!("搜索关键词不能为空");
}
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let limit = limit.min(SNS_MAX_LIMIT);
let user_uname = match user {
Some(q) => Some(
resolve_username(q, names)
.with_context(|| format!("找不到联系人: {}", q))?,
),
None => None,
};
// SQL LIKE 在 content 上粗筛 keyword这步省掉绝大多数行的 XML parse 开销)。
// user 不在 SQL 层过滤,原因同 q_sns_feedSnsTimeLine.user_name 列对部分(转发)
// 帖子为空,真实作者只在 XML <username> 里。
let like_pattern = format!("%{}%", escape_like_pattern(keyword));
let keyword_owned = keyword.to_string();
let path2 = path.clone();
let parsed: Vec<ParsedPost> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let sql = "SELECT tid, user_name, content FROM SnsTimeLine \
WHERE content LIKE ? ESCAPE '\\' ORDER BY tid DESC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([&like_pattern], |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
)))?;
let needle = keyword_owned.to_lowercase();
let mut scanned = 0usize;
let mut out: Vec<ParsedPost> = Vec::new();
for row in rows {
scanned += 1;
if scanned > SNS_MAX_SCAN {
eprintln!(
"[sns_search] scan 超过硬上限 {},结果可能不完整。建议缩小 keyword 或加 --user / --since。",
SNS_MAX_SCAN
);
break;
}
let (tid, uname, content) = row?;
let desc = extract_xml_text(&content, "contentDesc").unwrap_or_default();
if !desc.to_lowercase().contains(&needle) { continue; }
let p = parse_post_xml(tid, &uname, &content);
if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } }
if let Some(s) = since { if p.create_time < s { continue; } }
if let Some(u) = until { if p.create_time > u { continue; } }
out.push(p);
}
out.sort_by_key(|p| std::cmp::Reverse(p.create_time));
out.truncate(limit);
Ok::<_, anyhow::Error>(out)
}).await??;
let posts: Vec<Value> = parsed.into_iter().map(|p| post_to_value(p, names)).collect();
let total = posts.len();
Ok(json!({ "keyword": keyword, "posts": posts, "total": total }))
}
#[cfg(test)]
mod sns_tests {
use super::*;
fn make_post_xml(create_time: &str, desc: &str, username_tag: Option<&str>, media: usize, location: Option<&str>) -> String {
let username = username_tag.map(|u| format!("<username>{}</username>", u)).unwrap_or_default();
let media_tags = "<media>...</media>".repeat(media);
let media_list = if media > 0 { format!("<mediaList>{}</mediaList>", media_tags) } else { String::new() };
let loc = location
.map(|p| format!(r#"<location poiName="{}" longitude="0" latitude="0" />"#, p))
.unwrap_or_default();
format!(
"<TimelineObject>{}<createTime>{}</createTime><contentDesc>{}</contentDesc>{}{}</TimelineObject>",
username, create_time, desc, media_list, loc
)
}
#[test]
fn parse_uses_user_name_column_when_present() {
let xml = make_post_xml("1700000000", "hello", Some("wxid_xml"), 0, None);
let p = parse_post_xml(1, "wxid_column", &xml);
assert_eq!(p.author_username, "wxid_column");
assert_eq!(p.create_time, 1700000000);
assert_eq!(p.content, "hello");
assert_eq!(p.media_count, 0);
assert_eq!(p.location, "");
}
#[test]
fn parse_falls_back_to_xml_username_when_column_empty() {
let xml = make_post_xml("1700000001", "world", Some("wxid_xml_only"), 0, None);
let p = parse_post_xml(2, "", &xml);
assert_eq!(p.author_username, "wxid_xml_only");
}
#[test]
fn parse_handles_missing_create_time() {
let xml = "<TimelineObject><contentDesc>x</contentDesc></TimelineObject>";
let p = parse_post_xml(3, "wxid", xml);
assert_eq!(p.create_time, 0);
assert_eq!(p.content, "x");
}
#[test]
fn parse_counts_media_and_extracts_location() {
let xml = make_post_xml("1700000002", "post", None, 3, Some("Wuxi"));
let p = parse_post_xml(4, "wxid", &xml);
assert_eq!(p.media_count, 3);
assert_eq!(p.location, "Wuxi");
}
#[test]
fn parse_when_both_column_and_xml_username_empty_returns_empty_author() {
let xml = "<TimelineObject><createTime>1700000003</createTime><contentDesc>orphan</contentDesc></TimelineObject>";
let p = parse_post_xml(5, "", xml);
assert_eq!(p.author_username, "");
}
#[test]
fn escape_like_pattern_escapes_backslash_first() {
// 反斜杠必须在 % / _ 之前转义;否则后面塞进去的 \% / \_ 会被再次双转义吃掉
assert_eq!(escape_like_pattern("a\\b"), "a\\\\b");
assert_eq!(escape_like_pattern("100%"), "100\\%");
assert_eq!(escape_like_pattern("foo_bar"), "foo\\_bar");
}
#[test]
fn escape_like_pattern_combined() {
// \%_ 三个元字符同时出现
let escaped = escape_like_pattern("a\\b%c_d");
assert_eq!(escaped, "a\\\\b\\%c\\_d");
}
#[test]
fn escape_like_pattern_no_special_chars_unchanged() {
assert_eq!(escape_like_pattern("hello world"), "hello world");
assert_eq!(escape_like_pattern("中文关键词"), "中文关键词");
assert_eq!(escape_like_pattern(""), "");
}
}

View File

@ -213,5 +213,23 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
SnsNotifications { limit, since, until, include_read } => {
match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
SnsFeed { limit, since, until, user } => {
match query::q_sns_feed(db, &names_arc, limit, since, until, user.as_deref()).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
SnsSearch { keyword, limit, since, until, user } => {
match query::q_sns_search(db, &names_arc, &keyword, limit, since, until, user.as_deref()).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
} }
} }

View File

@ -78,6 +78,42 @@ pub enum Request {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
query: Option<String>, query: Option<String>,
}, },
/// 朋友圈互动通知(点赞 + 评论)
SnsNotifications {
#[serde(default = "default_limit_50")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
/// 包含已读通知(默认仅未读)
#[serde(default)]
include_read: bool,
},
/// 朋友圈时间线(按时间 / 作者筛选帖子)
SnsFeed {
#[serde(default = "default_limit_20")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
/// 作者昵称 / 备注名 / 微信 username模糊匹配
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
},
/// 朋友圈全文搜索(匹配 contentDesc
SnsSearch {
keyword: String,
#[serde(default = "default_limit_20")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
},
} }