futu_auth/
scope.rs

1//! Scope: 能力分组
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8/// API Key 能力分组
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub enum Scope {
12    /// 行情只读(11 个工具)
13    QotRead,
14    /// 账户只读(5 个工具)
15    AccRead,
16    /// 模拟交易写
17    TradeSimulate,
18    /// 真实交易写
19    TradeReal,
20    /// 允许自动 unlock_trade(从 keychain 读密码)
21    TradeUnlock,
22}
23
24impl Scope {
25    pub const ALL: &'static [Scope] = &[
26        Scope::QotRead,
27        Scope::AccRead,
28        Scope::TradeSimulate,
29        Scope::TradeReal,
30        Scope::TradeUnlock,
31    ];
32
33    pub fn as_str(&self) -> &'static str {
34        match self {
35            Scope::QotRead => "qot:read",
36            Scope::AccRead => "acc:read",
37            Scope::TradeSimulate => "trade:simulate",
38            Scope::TradeReal => "trade:real",
39            Scope::TradeUnlock => "trade:unlock",
40        }
41    }
42}
43
44impl fmt::Display for Scope {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.write_str(self.as_str())
47    }
48}
49
50#[derive(Debug, thiserror::Error)]
51#[error(
52    "unknown scope {0:?} (valid: qot:read, acc:read, trade:simulate, trade:real, trade:unlock)"
53)]
54pub struct ScopeParseError(pub String);
55
56impl FromStr for Scope {
57    type Err = ScopeParseError;
58
59    fn from_str(s: &str) -> Result<Self, Self::Err> {
60        match s {
61            "qot:read" => Ok(Scope::QotRead),
62            "acc:read" => Ok(Scope::AccRead),
63            "trade:simulate" => Ok(Scope::TradeSimulate),
64            "trade:real" => Ok(Scope::TradeReal),
65            "trade:unlock" => Ok(Scope::TradeUnlock),
66            other => Err(ScopeParseError(other.to_string())),
67        }
68    }
69}
70
71impl TryFrom<String> for Scope {
72    type Error = ScopeParseError;
73    fn try_from(value: String) -> Result<Self, Self::Error> {
74        value.parse()
75    }
76}
77
78impl From<Scope> for String {
79    fn from(s: Scope) -> String {
80        s.as_str().to_string()
81    }
82}
83
84/// Futu API protocol id → 所需 scope 的**通用映射**
85///
86/// gRPC 和核心 WS 都用这个函数做 scope 检查。proto_id 常量定义在 futu-core
87/// (circular dep 顾虑下这里手动枚举);新增 proto 时必须同步更新这里的 match
88/// 分支,否则落到 catch-all `TradeReal` 被拒(fail-closed)。
89///
90/// | proto_id 范围 | 所需 scope |
91/// |---|---|
92/// | 1xxx 系统(InitConnect / GetGlobalState / KeepAlive / …) | 无(放行) |
93/// | 3xxx 行情(含 push updates) | `qot:read` |
94/// | 2005 UnlockTrade | `trade:real` |
95/// | 2202 PlaceOrder / 2205 ModifyOrder / 2237 ReconfirmOrder | `trade:real` |
96/// | 2xxx 账户只读(AccList / Funds / Positions / Orders / Deals / 费率 / push) | `acc:read` |
97/// | 其他 | catch-all `trade:real`(fail-closed) |
98pub fn scope_for_proto_id(proto_id: u32) -> Option<Scope> {
99    match proto_id {
100        // 1xxx 系统 / 连接管理:InitConnect / GlobalState / KeepAlive / UserInfo ...
101        1000..=1999 => None,
102
103        // 3xxx 全部行情(请求 + push 全挂 qot:read)
104        3000..=3999 => Some(Scope::QotRead),
105
106        // 2005 UnlockTrade —— 未解锁不能下单,视同 trade:real
107        2005 => Some(Scope::TradeReal),
108
109        // 2202 PlaceOrder / 2205 ModifyOrder / 2237 ReconfirmOrder
110        2202 | 2205 | 2237 => Some(Scope::TradeReal),
111
112        // 2xxx 账户只读:list / funds / positions / orders / deals / push / 费率
113        2001 | 2008 | 2101 | 2102 | 2111 | 2201 | 2208 | 2211 | 2218 | 2221 | 2222 | 2223
114        | 2225 | 2226 | 2240 => Some(Scope::AccRead),
115
116        // 未覆盖 → fail-closed,统一拒(返回 TradeReal 让上游 check_scope 比对最严格)
117        _ => Some(Scope::TradeReal),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn roundtrip() {
127        for s in Scope::ALL {
128            let as_s = s.as_str();
129            let back: Scope = as_s.parse().unwrap();
130            assert_eq!(*s, back);
131        }
132    }
133
134    #[test]
135    fn unknown_rejected() {
136        assert!("qot:write".parse::<Scope>().is_err());
137    }
138
139    #[test]
140    fn serde_roundtrip() {
141        let s = Scope::TradeReal;
142        let j = serde_json::to_string(&s).unwrap();
143        assert_eq!(j, "\"trade:real\"");
144        let back: Scope = serde_json::from_str(&j).unwrap();
145        assert_eq!(back, Scope::TradeReal);
146    }
147
148    #[test]
149    fn proto_scope_common_cases() {
150        // 1xxx 系统放行
151        assert_eq!(scope_for_proto_id(1001), None); // InitConnect
152        assert_eq!(scope_for_proto_id(1004), None); // KeepAlive
153                                                    // 3xxx 行情
154        assert_eq!(scope_for_proto_id(3004), Some(Scope::QotRead));
155        assert_eq!(scope_for_proto_id(3223), Some(Scope::QotRead));
156        // 下单类
157        assert_eq!(scope_for_proto_id(2202), Some(Scope::TradeReal));
158        assert_eq!(scope_for_proto_id(2205), Some(Scope::TradeReal));
159        assert_eq!(scope_for_proto_id(2005), Some(Scope::TradeReal));
160        // 账户读
161        assert_eq!(scope_for_proto_id(2001), Some(Scope::AccRead));
162        assert_eq!(scope_for_proto_id(2208), Some(Scope::AccRead));
163        assert_eq!(scope_for_proto_id(2240), Some(Scope::AccRead));
164        // 未覆盖 fail-closed
165        assert_eq!(scope_for_proto_id(9999), Some(Scope::TradeReal));
166    }
167}