helix-im 0.1.21

基于 Helix Core 的确定性 MessageV3 IM 业务模块
Documentation
//! vote/score 第二网关 outbound 命令族(spec06 方案 A → 生命周期扩到 14 命令)。
//!
//! ## 为什么走第二网关(`Gateway::Default`)
//!
//! 现网 vote/score 走**默认业务网关** `this.api.post$`(base = `env.restHost`,**无** `/api/cses`
//! 前缀),endpoint `/vote/*` `/average/*`;而既有 88 命令走 **IM 网关** `imHttp`(base 含
//! `/api/cses`)。两者 base host 不同 → 本族 override `gateway() -> Gateway::Default`,由 host 经
//! `ImConfig.default_api_base_url` 注入第二 base(HX-C001:core/im 零 host 硬编码,base 仍 host 注入)。
//!
//! ## endpoint / body 真源(cses-client message.service.ts 951-1043,param type 34-100)
//!
//! - createVote:字段多(fromUserId/fromUserName/title/content/votes/isReal/finishTime/options[]/
//!   orgIds[]/joinCount/source?)→ **整 args 透传**;core 会把 Host 的 snake_case 递归转换为
//!   Java camelCase,并剥离 transport 字段。校验「非空 object」即放行(边界零信任,不 panic)。
//! - readVote/deleteVote → `{ id }`;average/read·average/delete → `{ id }`(id 必填)。
//!
//! ## 生命周期扩充(caller = message-vote / average-content.component)
//!
//! 第二批 5 个写命令(spec:VoteSubmitParams/VoteCloseParams/AverageAttendParams/
//! AverageCloseParams/AveragePublishParams,真源 message.service.ts 29-110):
//! - vote/vote `{id, postId?, indexes:string[]}`(提交投票)—— id+indexes 必填,postId 可选透传。
//! - vote/closeVote `{id}`(截止投票)—— 复用 `id_body`。
//! - average/attend `{id, score:number, postId?}`(提交评分)—— id 必填、score 必为 number、postId 可选。
//! - average/close `{id, postId?}`(截止评分)—— id 必填、postId 可选。
//! - average/publish `{title, content, maxScore, minScore, isDelMaxMin, isAnonymous, cutoff,
//!   members, hasDecimal?, decimalPlaces?, source?}`(发布评分)—— 字段多同 createVote → **整 args 透传**。
//!
//! 12 个写命令使用 WS 回声/前端自刷;readVote / average/read 是读族,HTTP 响应体通过
//! `OutboundReadReply` 回灌前端。
//!
//! ## 读写族(spec06 缺陷A)
//!
//! readVote / average/read = **读族**(`is_read=true`,无 WS 回声、HTTP 响应体即数据 → dispatch
//! 注册 `OutboundReadReply` 回灌前端);create/delete = 写族(默认 `is_read=false`)。

use serde_json::{json, Map, Value};

use crate::error::ImError;

use crate::outbound::registry::{require_str, Gateway, OutboundCommand, OutboundRegistration};

/// 注册第二网关命令样板:`name` + `build` 闭包 → struct/static/slice 三件套。
/// `$read`(bool 字面量)= is_read(读族 true / 写族 false);`gateway()` 全族恒为 `Default`。
macro_rules! vote_cmd {
    ($cmd_struct:ident, $reg:ident, $name:literal, $read: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 {
                $read
            }
            // 全族走第二网关(spec06 方案 A):base = ImConfig.default_api_base_url(env.restHost)。
            fn gateway(&self) -> Gateway {
                Gateway::Default
            }
        }
        inventory::submit! {
            OutboundRegistration {
                name: $name,
                command: &$cmd_struct,
            }
        }
    };
}

/// 取必填 `id` 字段并产 `{ "id": <id> }` body(readVote/deleteVote/average 三命令复用)。
fn id_body(args: &Value, cmd: &'static str) -> Result<Value, ImError> {
    let id = require_str(args, "id", cmd)?;
    Ok(json!({ "id": id }))
}

/// 把 snake_case 的 Command 字段还原为 Java REST 要求的 camelCase;数组和对象递归保留。
fn to_java_wire_value(value: &Value) -> Value {
    match value {
        Value::Array(items) => Value::Array(items.iter().map(to_java_wire_value).collect()),
        Value::Object(object) => Value::Object(
            object
                .iter()
                .map(|(key, value)| (snake_to_camel(key), to_java_wire_value(value)))
                .collect(),
        ),
        other => other.clone(),
    }
}

/// 把 Rust Command 键转换成 Java REST 的 camelCase 键;已经是 camelCase 的键保持不变。
fn snake_to_camel(key: &str) -> String {
    let mut result = String::with_capacity(key.len());
    let mut uppercase = false;
    for character in key.chars() {
        if character == '_' {
            uppercase = true;
            continue;
        }
        if uppercase {
            result.extend(character.to_uppercase());
            uppercase = false;
        } else {
            result.push(character);
        }
    }
    result
}

/// 复制业务 object 并移除 Host 注入的 transport-only 字段,避免把 req_id 发给 Java。
fn business_object(args: &Value, cmd: &'static str) -> Result<Value, ImError> {
    let object = args
        .as_object()
        .filter(|object| !object.is_empty())
        .ok_or_else(|| ImError::Parse(format!("{cmd}: args 须为非空 object")))?;
    let mut body = object.clone();
    for key in [
        "req_id",
        "reqId",
        "operation_id",
        "operationId",
        "client_mutation_id",
        "clientMutationId",
    ] {
        body.remove(key);
    }
    if body.is_empty() {
        return Err(ImError::Parse(format!(
            "{cmd}: args 不能只包含 transport 字段"
        )));
    }
    Ok(to_java_wire_value(&Value::Object(body)))
}

/// 把可选字段从 snake/camel 两种 Command 输入键复制成 Java camelCase wire 键。
fn carry_optional(
    args: &Value,
    body: &mut Map<String, Value>,
    input_keys: &[&str],
    wire_key: &str,
) {
    for key in input_keys {
        if let Some(v) = args.get(*key) {
            if !v.is_null() {
                body.insert(wire_key.to_string(), to_java_wire_value(v));
                return;
            }
        }
    }
}

/// 读取可选 post id,兼容旧的 camelCase 直接调用和当前 snake_case Host payload。
fn optional_post_id() -> [&'static str; 2] {
    ["post_id", "postId"]
}

/// 仅保留业务字段;该函数名用于让调用处明确其不是整 payload 透传。
fn copy_optional_post_id(args: &Value, body: &mut Map<String, Value>) {
    carry_optional(args, body, &optional_post_id(), "postId");
}

// #1 createVote:发起投票;core 负责整 payload 的 camelCase 转换和 transport 字段裁剪。
// 校验非空 object(缺 args / 非 object → Err,边界零信任不 panic)。
vote_cmd!(
    VoteCreateCommand,
    VOTE_CREATE_REG,
    "im_vote_create",
    false,
    |args, cmd| { Ok(("vote/createVote", business_object(args, cmd)?)) }
);

// #2 readVote:读投票详情(读族)。真源 { id }。
vote_cmd!(
    VoteReadCommand,
    VOTE_READ_REG,
    "im_vote_read",
    true,
    |args, cmd| { Ok(("vote/readVote", id_body(args, cmd)?)) }
);

// #3 deleteVote:删投票(写族)。真源 { id }。
vote_cmd!(
    VoteDeleteCommand,
    VOTE_DELETE_REG,
    "im_vote_delete",
    false,
    |args, cmd| { Ok(("vote/deleteVote", id_body(args, cmd)?)) }
);

// #4 average/read:读评分(读族)。真源 { id }。
vote_cmd!(
    AverageReadCommand,
    AVERAGE_READ_REG,
    "im_average_read",
    true,
    |args, cmd| { Ok(("average/read", id_body(args, cmd)?)) }
);

// #5 average/delete:删评分(写族)。真源 { id }。
vote_cmd!(
    AverageDeleteCommand,
    AVERAGE_DELETE_REG,
    "im_average_delete",
    false,
    |args, cmd| { Ok(("average/delete", id_body(args, cmd)?)) }
);

// ── vote-score-5 扩充(5 写命令)─────────────────────────────────────────────

// #6 vote/vote:提交投票(写族)。真源 VoteSubmitParams { id, postId?, indexes:string[] }。
// id 必填;indexes 必填且须为 array(边界零信任,缺/类型错 → Err);postId 可选透传。
vote_cmd!(
    VoteDoCommand,
    VOTE_DO_REG,
    "im_vote_do",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let indexes = args
            .get("indexes")
            .filter(|v| v.is_array())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 array 必填字段 'indexes'")))?;
        let mut body = serde_json::Map::new();
        body.insert("id".to_string(), json!(id));
        body.insert("indexes".to_string(), indexes.clone());
        copy_optional_post_id(args, &mut body);
        Ok(("vote/vote", Value::Object(body)))
    }
);

// #7 vote/closeVote:截止投票(写族)。真源 VoteCloseParams { id }。复用 id_body。
vote_cmd!(
    VoteCloseCommand,
    VOTE_CLOSE_REG,
    "im_vote_close",
    false,
    |args, cmd| { Ok(("vote/closeVote", id_body(args, cmd)?)) }
);

// #8 average/attend:提交评分(写族)。真源 AverageAttendParams { id, score:number, postId? }。
// id 必填;score 必填且须为 number;postId 可选透传。
vote_cmd!(
    AverageAttendCommand,
    AVERAGE_ATTEND_REG,
    "im_average_attend",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let score = args
            .get("score")
            .filter(|v| v.is_number())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 number 必填字段 'score'")))?;
        let mut body = serde_json::Map::new();
        body.insert("id".to_string(), json!(id));
        body.insert("score".to_string(), score.clone());
        copy_optional_post_id(args, &mut body);
        Ok(("average/attend", Value::Object(body)))
    }
);

// #9 average/close:截止评分(写族)。真源 AverageCloseParams { id, postId? }。
// id 必填;postId 可选透传。
vote_cmd!(
    AverageCloseCommand,
    AVERAGE_CLOSE_REG,
    "im_average_close",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let mut body = serde_json::Map::new();
        body.insert("id".to_string(), json!(id));
        copy_optional_post_id(args, &mut body);
        Ok(("average/close", Value::Object(body)))
    }
);

// #10 average/publish:发布评分(写族)。core 负责整 payload 的 camelCase 转换和 transport 字段裁剪。
// (title/content/maxScore/minScore/isDelMaxMin/isAnonymous/cutoff/members/hasDecimal?/
// decimalPlaces?/source?)→ 整 args 透传(逐字段重列易漏;透传更稳)。
// 校验非空 object(缺 args / 非 object → Err,边界零信任不 panic)。
vote_cmd!(
    AveragePublishCommand,
    AVERAGE_PUBLISH_REG,
    "im_average_publish",
    false,
    |args, cmd| { Ok(("average/publish", business_object(args, cmd)?)) }
);

// #11 vote/updateFinishTime:修改截止时间;Java 只接受 id 与 finishTime。
vote_cmd!(
    VoteUpdateFinishTimeCommand,
    VOTE_UPDATE_FINISH_TIME_REG,
    "im_vote_update_finish_time",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let finish_time = args
            .get("finish_time")
            .or_else(|| args.get("finishTime"))
            .filter(|value| value.is_number())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 number 必填字段 'finish_time'")))?;
        Ok((
            "vote/updateFinishTime",
            json!({ "id": id, "finishTime": finish_time }),
        ))
    }
);

// #12 vote/addMembersToVote:增加参与成员;Java 通过 userIds 解析用户姓名。
vote_cmd!(
    VoteAddMembersCommand,
    VOTE_ADD_MEMBERS_REG,
    "im_vote_add_members",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let user_ids = args
            .get("user_ids")
            .or_else(|| args.get("userIds"))
            .filter(|value| value.is_array())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 array 必填字段 'user_ids'")))?;
        Ok((
            "vote/addMembersToVote",
            json!({ "id": id, "userIds": user_ids }),
        ))
    }
);

// #13 average/modifyCutoff:修改评分截止时间;Java 只接受 id 与 cutoff。
vote_cmd!(
    AverageModifyCutoffCommand,
    AVERAGE_MODIFY_CUTOFF_REG,
    "im_average_modify_cutoff",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let cutoff = args
            .get("cutoff")
            .filter(|value| value.is_number())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 number 必填字段 'cutoff'")))?;
        Ok((
            "average/modifyCutoff",
            json!({ "id": id, "cutoff": cutoff }),
        ))
    }
);

// #14 average/addMember:增加评分参与者;成员对象由 Java AddMemberCommand 校验。
vote_cmd!(
    AverageAddMemberCommand,
    AVERAGE_ADD_MEMBER_REG,
    "im_average_add_member",
    false,
    |args, cmd| {
        let id = require_str(args, "id", cmd)?;
        let members = args
            .get("members")
            .filter(|value| value.is_array())
            .ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/非 array 必填字段 'members'")))?;
        Ok((
            "average/addMember",
            json!({ "id": id, "members": to_java_wire_value(members) }),
        ))
    }
);

// 14 命令注册 / body / 读写族单测外提到 sibling(守 ≤300 硬顶;测试外提范本)。
#[cfg(test)]
#[path = "vote_score_tests.rs"]
mod tests;