use serde_json::{json, Value};
use crate::error::ImError;
use crate::outbound::registry::{require_str, OutboundCommand, OutboundRegistration};
use super::read::require_str_array;
fn require_post_read_list_args(args: &Value, cmd: &str) -> Result<Value, ImError> {
let object = args
.as_object()
.ok_or_else(|| ImError::Parse(format!("{cmd}: payload 必须是 JSON 对象")))?;
for key in object.keys() {
if !matches!(key.as_str(), "post_ids" | "req_id") {
return Err(ImError::Parse(format!("{cmd}: 未知或内部字段 '{key}'")));
}
}
if let Some(req_id) = object.get("req_id") {
if !req_id.is_string() {
return Err(ImError::Parse(format!("{cmd}: req_id 必须是字符串")));
}
}
require_str_array(args, "post_ids", cmd)
}
fn require_bookmark_keys(args: &Value, allowed: &[&str], cmd: &str) -> Result<(), ImError> {
let object = args
.as_object()
.ok_or_else(|| ImError::Parse(format!("{cmd}: payload 必须是 JSON 对象")))?;
if let Some(unknown) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(ImError::Parse(format!("{cmd}: 未知或内部字段 '{unknown}'")));
}
if let Some(req_id) = object.get("req_id") {
if req_id.as_str().is_none_or(str::is_empty) {
return Err(ImError::Parse(format!("{cmd}: req_id 必须是非空字符串")));
}
}
Ok(())
}
fn require_positive_page(args: &Value, key: &str, cmd: &str) -> Result<i64, ImError> {
args.get(key)
.and_then(Value::as_i64)
.filter(|value| *value > 0)
.ok_or_else(|| ImError::Parse(format!("{cmd}: {key} 必须是正整数")))
}
fn require_announcement_list_keys(args: &Value, cmd: &str) -> Result<(), ImError> {
let object = args
.as_object()
.ok_or_else(|| ImError::Parse(format!("{cmd}: payload 必须是 JSON 对象")))?;
if let Some(unknown) = object
.keys()
.find(|key| !matches!(key.as_str(), "channel_id" | "post_id" | "req_id"))
{
return Err(ImError::Parse(format!("{cmd}: 未知或内部字段 '{unknown}'")));
}
if let Some(req_id) = object.get("req_id") {
if req_id.as_str().is_none_or(str::is_empty) {
return Err(ImError::Parse(format!("{cmd}: req_id 必须是非空字符串")));
}
}
Ok(())
}
pub fn announcement_list_projection(channel_id: &str, body: &Value) -> Option<Value> {
let payload = body
.get("data")
.filter(|value| value.is_object())
.unwrap_or(body);
let version = payload.get("version").and_then(Value::as_u64)?;
let source_channel_id = payload
.get("channelId")
.or_else(|| payload.get("channel_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or(channel_id);
if source_channel_id != channel_id {
return None;
}
let items = payload.get("announcements").and_then(Value::as_array)?;
let announcements = items
.iter()
.map(announcement_projection)
.collect::<Option<Vec<_>>>()?;
Some(json!({
"channelId": channel_id,
"version": version,
"announcements": announcements,
}))
}
pub fn announcement_delete_projection(channel_id: &str, body: &Value) -> Option<Value> {
let payload = body
.get("data")
.filter(|value| value.is_object())
.unwrap_or(body);
let result_channel_id = payload
.get("channelId")
.or_else(|| payload.get("channel_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())?;
if result_channel_id != channel_id {
return None;
}
let deleted_id = payload
.get("deletedAnnouncementId")
.or_else(|| payload.get("deleted_announcement_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())?;
let version = payload.get("version").and_then(Value::as_u64)?;
let explicit_no_op = payload
.get("noOp")
.or_else(|| payload.get("no_op"))
.or_else(|| payload.get("noop"))
.map(Value::as_bool);
if explicit_no_op.is_some_and(|value| value.is_none()) {
return None;
}
let no_op = version == 0 || explicit_no_op.flatten().unwrap_or(false);
let mut result = json!({
"channelId": channel_id,
"deletedAnnouncementId": deleted_id,
"version": version,
});
if no_op {
result["noOp"] = Value::Bool(true);
}
Some(result)
}
fn announcement_projection(item: &Value) -> Option<Value> {
let announcement_id = item
.get("announcementId")
.or_else(|| item.get("announcement_id"))
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())?;
let post_id = item
.get("postId")
.or_else(|| item.get("post_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())?;
let channel_id = item
.get("channelId")
.or_else(|| item.get("channel_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())?;
let content = item
.get("content")
.or_else(|| item.get("message"))
.or_else(|| item.get("simpleMessage"))
.and_then(Value::as_str)?;
let create_at = item
.get("createAt")
.or_else(|| item.get("create_at"))
.and_then(Value::as_i64)
.or_else(|| {
item.get("createAt")
.or_else(|| item.get("create_at"))
.and_then(Value::as_u64)
.and_then(|value| i64::try_from(value).ok())
})?;
let create_by = item
.get("createBy")
.or_else(|| item.get("create_by"))
.or_else(|| item.get("userId"))
.and_then(Value::as_str)?;
Some(json!({
"announcementId": announcement_id,
"postId": post_id,
"channelId": channel_id,
"content": content,
"createAt": create_at,
"createBy": create_by,
}))
}
macro_rules! read_cmd {
($cmd_struct:ident, $reg:ident, $name:literal, $build:expr) => {
struct $cmd_struct;
impl OutboundCommand for $cmd_struct {
fn name(&self) -> &'static str {
$name
}
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError> {
let f: fn(&Value, &'static str) -> Result<(&'static str, Value), ImError> = $build;
f(args, $name)
}
fn is_read(&self) -> bool {
true
}
}
inventory::submit! {
OutboundRegistration {
name: $name,
command: &$cmd_struct,
}
}
};
}
read_cmd!(
BookmarkCreateCommand,
BOOKMARK_CREATE_REG,
"im_bookmark_create",
|args, cmd| {
require_bookmark_keys(args, &["channel_id", "post_ids", "req_id"], cmd)?;
let channel_id = require_str(args, "channel_id", cmd)?;
let post_ids = require_str_array(args, "post_ids", cmd)?;
Ok((
"post/bookmark/create",
json!({ "channelId": channel_id, "postIds": post_ids }),
))
}
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn announcement_list_projection_requires_version_and_canonical_fields() {
let body = json!({
"status": "SUCCESS",
"data": {
"channelId": "c1",
"version": 3,
"announcements": [{
"announcementId": "a1",
"postId": "p1",
"channelId": "c1",
"content": "公告",
"createAt": 42,
"createBy": "u1",
"extra": "discarded"
}]
}
});
assert_eq!(
announcement_list_projection("c1", &body),
Some(json!({
"channelId": "c1",
"version": 3,
"announcements": [{
"announcementId": "a1",
"postId": "p1",
"channelId": "c1",
"content": "公告",
"createAt": 42,
"createBy": "u1"
}]
}))
);
assert!(announcement_list_projection(
"c1",
&json!({
"data": { "channelId": "c1", "announcements": [] }
})
)
.is_none());
assert!(announcement_list_projection(
"c1",
&json!({
"data": {
"channelId": "c1",
"version": 3,
"announcements": [{ "postId": "p1" }]
}
})
)
.is_none());
assert_eq!(
announcement_list_projection(
"c1",
&json!({
"data": { "channelId": "c1", "version": 0, "announcements": [] }
})
),
Some(json!({ "channelId": "c1", "version": 0, "announcements": [] }))
);
}
#[test]
fn announcement_delete_projection_accepts_version_zero_and_explicit_noop() {
assert_eq!(
announcement_delete_projection(
"c1",
&json!({
"data": {
"channelId": "c1",
"deletedAnnouncementId": "missing",
"version": 0
}
})
),
Some(json!({
"channelId": "c1",
"deletedAnnouncementId": "missing",
"version": 0,
"noOp": true
}))
);
assert_eq!(
announcement_delete_projection(
"c1",
&json!({
"channelId": "c1",
"deletedAnnouncementId": "already-gone",
"version": 4,
"no_op": true
})
)
.and_then(|result| result.get("noOp").cloned()),
Some(Value::Bool(true))
);
assert_eq!(
announcement_delete_projection(
"c1",
&json!({
"channelId": "c1",
"deletedAnnouncementId": "go-noop",
"version": 0,
"noop": true
})
)
.and_then(|result| result.get("noOp").cloned()),
Some(Value::Bool(true))
);
assert_eq!(
announcement_delete_projection(
"c1",
&json!({
"channelId": "c1",
"deletedAnnouncementId": "version-zero",
"version": 0,
"noOp": false
})
)
.and_then(|result| result.get("noOp").cloned()),
Some(Value::Bool(true))
);
assert!(announcement_delete_projection(
"c1",
&json!({
"channelId": "c1",
"deletedAnnouncementId": "bad-marker",
"version": 0,
"noOp": "true"
})
)
.is_none());
}
#[test]
fn announcement_delete_projection_rejects_wrong_channel() {
let body = json!({
"data": {
"channelId": "c2",
"deletedAnnouncementId": "a1",
"version": 4
}
});
assert!(announcement_delete_projection("c1", &body).is_none());
}
}
read_cmd!(
BookmarkDeleteCommand,
BOOKMARK_DELETE_REG,
"im_bookmark_delete",
|args, cmd| {
require_bookmark_keys(args, &["channel_id", "post_id", "req_id"], cmd)?;
let channel_id = require_str(args, "channel_id", cmd)?;
let post_id = require_str(args, "post_id", cmd)?;
Ok((
"post/bookmark/delete",
json!({ "channelId": channel_id, "postId": post_id }),
))
}
);
read_cmd!(
BookmarkLoadCommand,
BOOKMARK_LOAD_REG,
"im_bookmark_load",
|args, cmd| {
require_bookmark_keys(
args,
&["channel_id", "page_number", "page_size", "req_id"],
cmd,
)?;
let channel_id = require_str(args, "channel_id", cmd)?;
let page_number = require_positive_page(args, "page_number", cmd)?;
let page_size = require_positive_page(args, "page_size", cmd)?;
Ok((
"post/bookmark/load",
json!({
"channelId": channel_id,
"pageNumber": page_number,
"pageSize": page_size,
}),
))
}
);
read_cmd!(
AnnounceAcceptListCommand,
ANNOUNCE_ACCEPT_LIST_REG,
"im_announcement_accept_list",
|args, cmd| {
let post_id = require_str(args, "post_id", cmd)?;
Ok(("post/announcement/acceptList", json!({ "postId": post_id })))
}
);
read_cmd!(
AnnounceListCommand,
ANNOUNCE_LIST_REG,
"im_announcement_list",
|args, cmd| {
require_announcement_list_keys(args, cmd)?;
let channel_id = require_str(args, "channel_id", cmd)?;
let mut b = json!({ "channelId": channel_id });
if let Some(post_id) = args.get("post_id").and_then(Value::as_str) {
b["postId"] = json!(post_id);
}
Ok(("post/announcement/list", b))
}
);
read_cmd!(
AnnounceDetailCommand,
ANNOUNCE_DETAIL_REG,
"im_announcement_detail",
|args, cmd| {
let post_ids = require_str_array(args, "post_ids", cmd)?;
Ok(("post/announcement/detail", json!({ "postIds": post_ids })))
}
);
read_cmd!(
PostReadListCommand,
POST_READ_LIST_REG,
"im_post_read_list",
|args, cmd| {
let post_ids = require_post_read_list_args(args, cmd)?;
Ok(("post/read/list", json!({ "postIds": post_ids })))
}
);