Skip to main content

wanning_core/
intent.rs

1//! 消费意图(SpendIntent):agent 发起的一笔待判定消费。
2//!
3//! 这是闸的**输入**。闸只看意图,不看 agent 内部怎么想的——决策循环(GLM/脚本)
4//! 负责产出意图,闸负责判定与扣减。
5//!
6//! 语义对齐 mist-core 的 `SpendIntent`(delegation_hash/recipient/amount/category/
7//! spend_nonce),差异点:
8//! - Wanning 用人类可读的 `delegation_id` / `merchant_id` / `category` 字符串,
9//!   不做哈希(链下审计要人能读懂;Mist 哈希是为了进电路)。
10//! - `nonce` 防重放(同一 nonce_scope 内只许成功消费一次),Mist 侧同名同义。
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::CoreError;
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SpendIntent {
18    /// 目标委托 id(必须已注册,否则 UnknownDelegation 拒)。
19    pub delegation_id: String,
20    /// 防重放 nonce,agent 作用域内单调递增;0 非法(对齐 Mist「spend_nonce > 0」)。
21    pub nonce: u64,
22    /// 本笔金额,单位分。0 非法;溢出在闸内按预算阶段拒绝。
23    pub amount_cents: u64,
24    /// 商户 id(京东 SKU 店铺/商户标识,开放平台侧语义,闸不解析)。
25    pub merchant_id: String,
26    /// 消费类别(自由文本标签,落审计用;Mist 是哈希白名单,P0 不做白名单)。
27    pub category: String,
28    /// 备注(人类可读,落审计;空串 = 无备注)。
29    pub memo: String,
30}
31
32impl SpendIntent {
33    pub fn new(
34        delegation_id: impl Into<String>,
35        nonce: u64,
36        amount_cents: u64,
37        merchant_id: impl Into<String>,
38        category: impl Into<String>,
39        memo: impl Into<String>,
40    ) -> Self {
41        Self {
42            delegation_id: delegation_id.into(),
43            nonce,
44            amount_cents,
45            merchant_id: merchant_id.into(),
46            category: category.into(),
47            memo: memo.into(),
48        }
49    }
50
51    /// 意图自身合法性(闸判定前先过这道,非法意图不必看委托状态)。
52    pub fn validate(&self) -> Result<(), CoreError> {
53        if self.delegation_id.trim().is_empty() {
54            return Err(CoreError::InvalidIntent("delegation_id 不能为空".into()));
55        }
56        if self.merchant_id.trim().is_empty() {
57            return Err(CoreError::InvalidIntent("merchant_id 不能为空".into()));
58        }
59        if self.amount_cents == 0 {
60            return Err(CoreError::InvalidIntent(
61                "amount_cents 不能为 0(单位是分)".into(),
62            ));
63        }
64        if self.nonce == 0 {
65            return Err(CoreError::InvalidIntent(
66                "nonce 不能为 0(防重放 nonce 从 1 起,对齐 mist-core 断言 7)".into(),
67            ));
68        }
69        Ok(())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn sample() -> SpendIntent {
78        SpendIntent::new("d1", 1, 500, "jd:shop-1", "grocery", "早饭")
79    }
80
81    #[test]
82    fn validate_accepts_well_formed_intent() {
83        assert_eq!(sample().validate(), Ok(()));
84    }
85
86    #[test]
87    fn validate_rejects_zero_amount() {
88        let i = SpendIntent {
89            amount_cents: 0,
90            ..sample()
91        };
92        assert!(matches!(i.validate(), Err(CoreError::InvalidIntent(_))));
93    }
94
95    #[test]
96    fn validate_rejects_zero_nonce() {
97        let i = SpendIntent {
98            nonce: 0,
99            ..sample()
100        };
101        assert!(matches!(i.validate(), Err(CoreError::InvalidIntent(_))));
102    }
103
104    #[test]
105    fn validate_rejects_empty_fields() {
106        for bad in [
107            SpendIntent {
108                delegation_id: "".into(),
109                ..sample()
110            },
111            SpendIntent {
112                merchant_id: " ".into(),
113                ..sample()
114            },
115        ] {
116            assert!(
117                matches!(bad.validate(), Err(CoreError::InvalidIntent(_))),
118                "应拒收空字段: {bad:?}"
119            );
120        }
121    }
122
123    #[test]
124    fn serde_roundtrip() {
125        let i = sample();
126        let json = serde_json::to_string(&i).expect("序列化");
127        let back: SpendIntent = serde_json::from_str(&json).expect("反序列化");
128        assert_eq!(back, i);
129        assert!(json.contains("\"amount_cents\":500"));
130        assert!(json.contains("\"nonce\":1"));
131    }
132}