Skip to main content

pku_campuscard/
lib.rs

1mod api;
2mod client;
3pub mod commands;
4mod display;
5pub mod login;
6
7use anyhow::Result;
8use clap::{Parser, Subcommand};
9
10#[derive(Parser)]
11#[command(name = "campuscard", about = "北大校园卡 CLI 客户端", version)]
12pub struct Cli {
13    #[command(subcommand)]
14    pub command: Commands,
15}
16
17#[derive(Subcommand)]
18pub enum Commands {
19    /// 登录校园卡(通过 IAAA 统一身份认证)
20    Login {
21        /// 使用用户名密码登录(默认为扫码登录)
22        #[arg(short, long)]
23        password: bool,
24        /// 学号/职工号(仅密码登录时需要)
25        #[arg(short, long)]
26        username: Option<String>,
27        /// 用系统图片查看器打开二维码(默认终端渲染)
28        #[arg(long)]
29        open: bool,
30    },
31    /// 查看当前登录状态
32    Status,
33    /// 退出登录
34    Logout,
35
36    /// 查看校园卡信息和余额
37    Info,
38
39    /// 显示付款码(二维码)
40    Pay {
41        /// 将付款码导出为 PNG 图片(指定输出路径)
42        #[arg(short, long)]
43        output: Option<String>,
44    },
45
46    /// 充值校园卡
47    Recharge {
48        /// 充值金额(元),不提供则交互选择
49        #[arg(short, long)]
50        amount: Option<f64>,
51    },
52
53    /// 查看交易记录
54    #[command(alias = "ls")]
55    Bills {
56        /// 页码(默认 1)
57        #[arg(short, long, default_value = "1")]
58        page: usize,
59        /// 每页条数(默认 10)
60        #[arg(short = 'n', long, default_value = "10")]
61        size: usize,
62        /// 按月筛选,格式 YYYY-MM
63        #[arg(short, long)]
64        month: Option<String>,
65    },
66
67    /// 查看消费统计
68    Stats {
69        /// 月份,格式 YYYY-MM(默认当月)
70        #[arg(short, long)]
71        month: Option<String>,
72    },
73
74    /// 手机令牌 (OTP) 管理
75    Otp {
76        #[command(subcommand)]
77        action: OtpAction,
78    },
79}
80
81#[derive(Subcommand)]
82pub enum OtpAction {
83    /// 绑定手机令牌(默认交互式;支持 --send / --verify 两阶段绑定)
84    Bind {
85        /// 学号/职工号
86        #[arg(short, long)]
87        username: Option<String>,
88        /// 只发送短信验证码并保存会话,不等待输入(供 AI Agent 使用)
89        #[arg(long, conflicts_with = "verify")]
90        send: bool,
91        /// 用已保存的会话和指定短信验证码完成绑定
92        #[arg(long, value_name = "CODE", conflicts_with = "send")]
93        verify: Option<String>,
94    },
95    /// 手动设置 TOTP secret
96    Set {
97        /// Base32 编码的 TOTP secret
98        secret: String,
99        /// 学号/职工号
100        #[arg(short, long)]
101        username: Option<String>,
102    },
103    /// 查看当前 OTP 码
104    Show,
105    /// 清除已保存的 OTP 配置
106    Clear,
107}
108
109fn init_tracing() {
110    tracing_subscriber::fmt()
111        .with_env_filter(
112            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
113        )
114        .init();
115}
116
117pub async fn run() -> Result<()> {
118    init_tracing();
119    let cli = Cli::parse();
120    dispatch(cli.command).await
121}
122
123pub async fn run_from<I, T>(args: I) -> Result<()>
124where
125    I: IntoIterator<Item = T>,
126    T: Into<std::ffi::OsString> + Clone,
127{
128    let cli = Cli::try_parse_from(args)?;
129    dispatch(cli.command).await
130}
131
132pub async fn dispatch(command: Commands) -> Result<()> {
133    match command {
134        // ── Auth ──
135        Commands::Login {
136            password,
137            username,
138            open,
139        } => {
140            if password {
141                login::login_with_password(username.as_deref()).await?;
142            } else {
143                let qr_mode = if open {
144                    pkuinfo_common::qr::QrDisplayMode::Open
145                } else {
146                    pkuinfo_common::qr::QrDisplayMode::Terminal
147                };
148                login::login_with_qrcode(qr_mode).await?;
149            }
150        }
151        Commands::Status => login::status()?,
152        Commands::Logout => login::logout()?,
153
154        // ── Card ──
155        Commands::Info => commands::cmd_info().await?,
156        Commands::Pay { output } => commands::cmd_pay(output.as_deref()).await?,
157        Commands::Recharge { amount } => commands::cmd_recharge(amount).await?,
158        Commands::Bills { page, size, month } => {
159            commands::cmd_bills(Some(page), Some(size), month.as_deref()).await?;
160        }
161        Commands::Stats { month } => commands::cmd_stats(month.as_deref()).await?,
162
163        // ── OTP ──
164        Commands::Otp { action } => {
165            let store = pkuinfo_common::session::Store::new("campuscard")?;
166            handle_otp(action, store.config_dir()).await?;
167        }
168    }
169    Ok(())
170}
171
172async fn handle_otp(action: OtpAction, config_dir: &std::path::Path) -> anyhow::Result<()> {
173    use colored::Colorize;
174    match action {
175        OtpAction::Bind {
176            username,
177            send,
178            verify,
179        } => {
180            if send {
181                pkuinfo_common::otp::bind_otp_send_sms(config_dir, username.as_deref()).await?;
182            } else if let Some(code) = verify {
183                pkuinfo_common::otp::bind_otp_verify(config_dir, &code).await?;
184            } else {
185                pkuinfo_common::otp::bind_otp_interactive(config_dir, username.as_deref()).await?;
186            }
187        }
188        OtpAction::Set { secret, username } => {
189            let uid = username.unwrap_or_default();
190            pkuinfo_common::otp::set_otp_secret(config_dir, &secret, &uid)?;
191        }
192        OtpAction::Show => match pkuinfo_common::otp::get_current_otp(config_dir)? {
193            Some(code) => {
194                let config = pkuinfo_common::otp::load_otp_config(config_dir)?
195                    .ok_or_else(|| anyhow::anyhow!("OTP 配置文件缺失,请先运行 `otp bind`"))?;
196                println!(
197                    "{} {} ({})",
198                    "OTP:".green().bold(),
199                    code.bold(),
200                    config.user_id
201                );
202            }
203            None => {
204                println!(
205                    "{} 未配置 OTP。使用 `otp bind` 绑定或 `otp set <SECRET>` 手动设置",
206                    "○".red()
207                );
208            }
209        },
210        OtpAction::Clear => {
211            pkuinfo_common::otp::clear_otp_config(config_dir)?;
212            println!("{} OTP 配置已清除", "✓".green());
213        }
214    }
215    Ok(())
216}
217
218pub const VERSION: &str = env!("CARGO_PKG_VERSION");