Skip to main content

ek_ui/
appconfig.rs

1//! 无界面一次性写档共用面(0.9.0;BD-APP-2/4):`ekapp add-account` CLI 与
2//! **等效判据**(tests/ui_app_contract.rs)共用同一构造函数 —— 判据钉的
3//! 就是「CLI 写出的段,喂给**真受理方法** `apply_config` 必被全采纳」。
4//!
5//! 段形对齐真实受理面(`ek-cta::dump_config`):明文键 + `secret://` 引用行,
6//! 密码**值**先进密库(Windows 凭据管理器)再出引用;值永不落文件。
7//! 形态常量(`gw_td_password`/`gw_md_password`)与 ek-cta 单源同拼写 —— 改名
8//! 必须双动(判据钉 willcatch 漂移)。
9
10use ek_core::{Field, SectionMap};
11
12/// CLI 一次提交的账户档要素(密码不入结构面:与本进程 stdin 通道分开)。
13#[derive(Debug, Clone)]
14pub struct AcctSpec {
15    pub broker_id: String,
16    pub user: String,
17    pub td_addr: String,
18    pub md_addr: String,
19    pub app_id: String,
20    pub auth_code: String,
21    /// 收录产品类(单字符 CTP 码逗号分隔;空 = 不写档,交给缺省)。
22    pub classes: String,
23    pub md_user: String,
24    /// 行情侧独立密码(缺省 = None → 段里不出 md_password,行情会话回落 td 凭据)。
25    pub md_password: Option<String>,
26}
27
28/// 派生注册名(ek-cta 单源;形 `ctp_<broker>_<账号>`)。
29pub fn derived_name(spec: &AcctSpec) -> Result<String, String> {
30    ek_ctp::derived_gateway_name(&spec.broker_id, &spec.user).ok_or_else(|| {
31        format!(
32            "派生注册名失败(broker={:?} 账号={:?}):字符不合名纪律",
33            spec.broker_id, spec.user
34        )
35    })
36}
37
38/// 0.9.10 用户裁定「同一账户可多套前置(不同 IP/端口),别名备注切换」:
39/// 唯一键 = **(broker, 账号, td 前置整串)**;`td 前置整串` = host:port 全串,
40/// 天然区分「同 IP 不同端口」「同端口不同 IP」。
41///
42/// 命名规则(用户拍板 = 后缀式,老档零迁移):
43/// - 基名 `ctp_<broker>_<账号>` 空 → 直用;
44/// - 基名被占且来者 td 前置**与占者不同** → `ctp_…_2`、`_3`… 递增找空位(总长≤32);
45/// - (broker,账号,td 前置)三处全同 → `Err`(重复登记,列表不重复)。
46///
47/// `taken`: 在场档案清单 `(gw, broker, user, td_addr)`;UI 面板与 CLI 共用本函数
48/// (算法单源,两路永不长出两种后缀)。
49///
50/// # Errors
51/// 三全同重复;后缀位耗尽(>32 字符上限)。
52pub fn unique_gw_name(
53    taken: &[(String, String, String, String)],
54    broker: &str,
55    user: &str,
56    td_addr: &str,
57) -> Result<String, String> {
58    let broker = broker.trim();
59    let user = user.trim();
60    let td = td_addr.trim();
61    let base = ek_ctp::derived_gateway_name(broker, user)
62        .ok_or_else(|| format!("派生注册名失败(broker={broker:?} 账号={user:?}):字符不合名纪律"))?;
63    for (gw, b, u, t) in taken {
64        if b.trim() == broker && u.trim() == user && t.trim() == td {
65            return Err(format!(
66                "该 broker+账号+td 前置已在册(行 {gw},前置 {td});同前置不重复登记"
67            ));
68        }
69    }
70    if !taken.iter().any(|(gw, _, _, _)| gw == &base) {
71        return Ok(base);
72    }
73    for n in 2..=99u32 {
74        let cand = format!("{base}_{n}");
75        if cand.len() > 32 {
76            return Err(format!(
77                "同 broker+账号 的第 {n} 套前置超派生名 32 字符上限({cand})"
78            ));
79        }
80        if !taken.iter().any(|(gw, _, _, _)| gw == &cand) {
81            return Ok(cand);
82        }
83    }
84    Err("同 broker+账号 的前置套数已超 98 条(找不着后缀空位)".to_string())
85}
86
87/// 档案侧在场清单(扫 ctp.* 段 → (gw, broker, user, td_addr)),UI 与 CLI 共用。
88///
89/// # Errors
90/// 目录列举失败(配置根不可用)。
91pub fn taken_accounts(
92    store: &ek_cfg::Store,
93) -> Result<Vec<(String, String, String, String)>, String> {
94    let mut out = Vec::new();
95    for ns in store
96        .list_namespaces()
97        .map_err(|e| format!("档案目录列举失败: {e:?}"))?
98    {
99        let Some(gw) = ns.strip_prefix("ctp.") else {
100            continue;
101        };
102        if let Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) = store.load(&ns) {
103            out.push((
104                gw.to_string(),
105                map.get_plain("broker_id").unwrap_or("").to_string(),
106                map.get_plain("td_user").unwrap_or("").to_string(),
107                map.get_plain("td_addr").unwrap_or("").to_string(),
108            ));
109        }
110    }
111    Ok(out)
112}
113
114/// 组段(校验 + 秘面登记 + 段构造;**store 由调用方给**,判据用临时根,
115/// CLI 用生态缺省根 —— 同一函数,一字不差)。
116///
117/// 返回:网关派生名;调用方即将其拼 `ns = "ctp.<gw>"` 存档。
118///
119/// # Errors
120/// 派生名/必填项/product_classes 形制不合;密库登记失败(缺后端面)。
121pub fn write_account(
122    store: &ek_cfg::Store,
123    spec: &AcctSpec,
124    password: &str,
125) -> Result<String, String> {
126    if spec.broker_id.is_empty()
127        || spec.user.is_empty()
128        || spec.td_addr.is_empty()
129        || spec.app_id.is_empty()
130        || spec.auth_code.is_empty()
131    {
132        return Err("缺必填要素(broker/user/td 前置/app_id/auth_code)".to_string());
133    }
134    if password.trim().is_empty() {
135        return Err("密码为空:档案不接受无密码账户(受理校验同款)".to_string());
136    }
137    let gw = unique_gw_name(
138        &taken_accounts(store)?,
139        &spec.broker_id,
140        &spec.user,
141        &spec.td_addr,
142    )?;
143    let ns = format!("ctp.{gw}");
144    ek_cfg::secret::register(
145        &ns,
146        "gw_td_password",
147        ek_cfg::SecretValue::from_bytes(password.trim().to_owned()),
148    )
149    .map_err(|e| format!("密码登记密库失败: {e:?}"))?;
150    let mut map = SectionMap::new(1)
151        .set("broker_id", Field::plain(spec.broker_id.trim()))
152        .set("td_addr", Field::plain(spec.td_addr.trim()))
153        .set("td_user", Field::plain(spec.user.trim()))
154        .set("td_app_id", Field::plain(spec.app_id.trim()))
155        .set("td_auth_code", Field::plain(spec.auth_code.trim()))
156        .set("td_password", Field::secret("gw_td_password"));
157    if !spec.md_addr.trim().is_empty() {
158        map = map.set("md_addr", Field::plain(spec.md_addr.trim()));
159        if !spec.md_user.trim().is_empty() {
160            map = map.set("md_user", Field::plain(spec.md_user.trim()));
161        }
162        if let Some(mp) = spec.md_password.as_deref() {
163            let mp = mp.trim();
164            if !mp.is_empty() {
165                ek_cfg::secret::register(
166                    &ns,
167                    "gw_md_password",
168                    ek_cfg::SecretValue::from_bytes(mp),
169                )
170                .map_err(|e| format!("行情密码登记密库失败: {e:?}"))?;
171                map = map.set("md_password", Field::secret("gw_md_password"));
172            }
173        }
174    }
175    let classes = spec.classes.trim();
176    if !classes.is_empty() {
177        for c in classes.split(',').map(str::trim) {
178            if c.chars().count() != 1 {
179                return Err(format!(
180                    "product_classes 项 {c:?} 非单字符 CTP 码(D-PC-FILTER)"
181                ));
182            }
183        }
184        map = map.set("product_classes", Field::plain(classes));
185    }
186    store
187        .save(&ns, &map)
188        .map_err(|e| format!("档案落盘失败: {e:?}"))?;
189    Ok(gw)
190}
191
192/// 抹一个网关的档案段(**与 `write_account` 对称**,0.9.10 用户实锤定罪):
193/// 界面删账户若只摘内存清册,**档案段 `ctp.{gw}` 还在** —— 启动清点
194/// (`restore_saved_accounts_from` 扫 `ctp.*` 段)会把删掉的账户原样复活
195/// (用户报「删了不存在的第三个账户,下次启动又显示」= 这条鬼户口)。
196/// 所以摘行必须连档案一起抹:段文件(current/.bak/.new 三态)+ 该 ns 的
197/// 秘库条(`secret::delete_ns` 整 ns 清,引用串指向的值不留孤儿)。
198/// 在 plugin worker 落盘拍执行(渲染线程零文件 IO 纪律,与别名/自动连接同规)。
199///
200/// # Errors
201/// 档案段存在但读不动(存储根故障)。**文件本就不存在 = 成功**(幂等:
202/// 重复摘、摘从未存过档的行都无所失)。
203pub fn purge_account_archive(store: &ek_cfg::Store, gw: &str) -> Result<(), String> {
204    purge_ns_direct(store, &format!("ctp.{gw}"))
205}
206
207/// 直清负形(步骤 3 铲插件自写后的**软集中托底**:cfg.delete 总线缺席时才走;
208/// 语义 = `cfg.delete` 清段同形 —— 段文件三态 + 秘库整 ns 清,幂等)。
209pub fn purge_ns_direct(store: &ek_cfg::Store, ns: &str) -> Result<(), String> {
210    match store.load(ns) {
211        Ok(ek_cfg::LoadOutcome::Loaded { .. }) => {}
212        Ok(ek_cfg::LoadOutcome::Fresh) => return Ok(()),
213        Err(e) => return Err(format!("档案 {ns} 读不动: {e}")),
214    }
215    // 步骤 4 门面:清段走 `clear_ns` 单口(文件形 = 三件尽删;库形 = 行尽删,
216    // 禁删库文件)。秘面整 ns 清照旧配对(后端层,与 cfg.delete 同序)。
217    store
218        .purge(ns)
219        .map_err(|e| format!("档案 {ns} 清段失败: {e}"))?;
220    ek_cfg::secret::forget_ns(ns).map_err(|e| format!("秘库 {ns} 清理失败: {e}"))?;
221    Ok(())
222}