diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 5898408..1f28db8 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -2387,7 +2387,7 @@ components: DtoClientCreateOrderRequest: properties: app_type: - description: 应用类型(强充必传)(official_account:公众号, miniapp:小程序) + description: 应用类型(微信强充必传)(official_account:公众号, miniapp:小程序) type: string identifier: description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) @@ -2401,6 +2401,9 @@ components: type: integer nullable: true type: array + payment_method: + description: 支付方式(强充必传)(wechat:微信, alipay:支付宝) + type: string required: - identifier - package_ids @@ -2419,6 +2422,8 @@ components: type: string pay_config: $ref: '#/components/schemas/DtoClientPayConfig' + payment_link: + $ref: '#/components/schemas/DtoClientPaymentLink' recharge: $ref: '#/components/schemas/DtoClientRechargeInfo' type: object @@ -2427,10 +2432,10 @@ components: amount: description: 充值金额(分) maximum: 1e+07 - minimum: 100 + minimum: 1 type: integer app_type: - description: 应用类型 (official_account:公众号, miniapp:小程序) + description: 应用类型(微信支付必传)(official_account:公众号, miniapp:小程序) type: string identifier: description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) @@ -2438,13 +2443,12 @@ components: minLength: 1 type: string payment_method: - description: 支付方式 (wechat:微信支付) + description: 支付方式 (wechat:微信支付, alipay:支付宝) type: string required: - identifier - amount - payment_method - - app_type type: object DtoClientExchangePendingResponse: properties: @@ -2656,7 +2660,7 @@ components: description: 应用类型(微信支付必传)(official_account:公众号, miniapp:小程序) type: string payment_method: - description: 支付方式 (wallet:钱包, wechat:微信) + description: 支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) type: string required: - payment_method @@ -2665,10 +2669,27 @@ components: properties: pay_config: $ref: '#/components/schemas/DtoClientPayConfig' + payment_link: + $ref: '#/components/schemas/DtoClientPaymentLink' payment_method: description: 支付方式 type: string type: object + DtoClientPaymentLink: + properties: + copy_link: + description: 复制到普通浏览器打开的支付链接(与 qr_link 相同) + type: string + pay_expire_at: + description: 支付链接过期时间(RFC3339 格式) + type: string + payment_no: + description: 支付单号 + type: string + qr_link: + description: 二维码内容链接,前端用该链接生成二维码 + type: string + type: object DtoClientRechargeCheckResponse: properties: force_recharge_amount: @@ -2780,6 +2801,8 @@ components: properties: pay_config: $ref: '#/components/schemas/DtoClientRechargePayConfig' + payment_link: + $ref: '#/components/schemas/DtoClientPaymentLink' recharge: $ref: '#/components/schemas/DtoClientRechargeResult' type: object @@ -3602,6 +3625,32 @@ components: type: object DtoCreateWechatConfigRequest: properties: + ali_app_id: + description: 支付宝应用ID + maxLength: 100 + type: string + ali_notify_url: + description: 支付宝异步通知地址 + maxLength: 500 + type: string + ali_pay_expire_minutes: + description: 支付过期分钟数 (默认30分钟) + maximum: 1440 + minimum: 1 + type: integer + ali_private_key: + description: 支付宝应用私钥(PEM格式) + type: string + ali_production: + description: 是否生产环境 (true:生产, false:沙箱) + type: boolean + ali_public_key: + description: 支付宝公钥(PEM格式,用于验签) + type: string + ali_return_url: + description: 支付宝同步跳转地址 + maxLength: 500 + type: string description: description: 配置描述 maxLength: 500 @@ -3666,7 +3715,7 @@ components: maxLength: 200 type: string provider_type: - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + description: 支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友) type: string wx_api_v2_key: description: 微信APIv2密钥 @@ -8626,6 +8675,39 @@ components: type: object DtoUpdateWechatConfigParams: properties: + ali_app_id: + description: 支付宝应用ID + maxLength: 100 + nullable: true + type: string + ali_notify_url: + description: 支付宝异步通知地址 + maxLength: 500 + nullable: true + type: string + ali_pay_expire_minutes: + description: 支付过期分钟数 + maximum: 1440 + minimum: 1 + nullable: true + type: integer + ali_private_key: + description: 支付宝应用私钥(PEM格式) + nullable: true + type: string + ali_production: + description: 是否生产环境 (true:生产, false:沙箱) + nullable: true + type: boolean + ali_public_key: + description: 支付宝公钥(PEM格式,用于验签) + nullable: true + type: string + ali_return_url: + description: 支付宝同步跳转地址 + maxLength: 500 + nullable: true + type: string description: description: 配置描述 maxLength: 500 @@ -8706,7 +8788,7 @@ components: nullable: true type: string provider_type: - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + description: 支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友) nullable: true type: string wx_api_v2_key: @@ -8890,6 +8972,27 @@ components: type: object DtoWechatConfigResponse: properties: + ali_app_id: + description: 支付宝应用ID + type: string + ali_notify_url: + description: 支付宝异步通知地址 + type: string + ali_pay_expire_minutes: + description: 支付过期分钟数 + type: integer + ali_private_key: + description: 支付宝应用私钥(配置状态) + type: string + ali_production: + description: 是否生产环境 + type: boolean + ali_public_key: + description: 支付宝公钥(配置状态) + type: string + ali_return_url: + description: 支付宝同步跳转地址 + type: string created_at: description: 创建时间 type: string @@ -8949,7 +9052,7 @@ components: description: 公众号Token(已脱敏) type: string provider_type: - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + description: 支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友) type: string updated_at: description: 更新时间 @@ -23253,11 +23356,11 @@ paths: maximum: 100 minimum: 1 type: integer - - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + - description: 支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友) in: query name: provider_type schema: - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + description: 支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友) nullable: true type: string - description: 是否激活 (true:已激活, false:未激活) diff --git a/docs/alipay-integration/前端对接说明.md b/docs/alipay-integration/前端对接说明.md new file mode 100644 index 0000000..531414e --- /dev/null +++ b/docs/alipay-integration/前端对接说明.md @@ -0,0 +1,155 @@ +# 支付宝支付链接前端对接说明 + +## 1. 对接范围 + +本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK,后端会返回已签名的支付宝 WAP 支付链接。 + +覆盖场景: + +1. 套餐订单支付 +2. 钱包充值 +3. 强制充值购包场景 + +微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`。 + +具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。 + +## 2. 支付方式取值 + +前端统一使用 `payment_method` 区分支付方式: + +| 值 | 含义 | 前端处理 | +| --- | --- | --- | +| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 | +| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 | +| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 | + +注意事项: + +- `app_type` 只在微信支付时需要。 +- 支付宝支付时不需要传 `app_type`。 +- 支付宝支付返回的是支付链接,不是 JSAPI 参数。 + +## 3. 前端需要关注的响应字段 + +当选择支付宝支付时,接口响应中会返回 `payment_link`: + +```json +{ + "payment_link": { + "payment_no": "支付单号", + "qr_link": "支付宝支付链接,可用于生成二维码", + "copy_link": "支付宝支付链接,可复制或直接打开", + "pay_expire_at": "支付链接过期时间,RFC3339 格式" + } +} +``` + +字段说明: + +| 字段 | 用途 | +| --- | --- | +| `payment_no` | 支付单号,问题排查 / 客服查询使用 | +| `qr_link` | 前端可用该链接生成二维码 | +| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 | +| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 | + +## 4. 推荐交互流程 + +### 4.1 支付宝支付流程 + +前端拿到 `payment_link` 后: + +1. H5 / 浏览器场景:直接跳转 `copy_link`。 +2. PC 场景:使用 `qr_link` 生成二维码。 +3. App / WebView 场景:在外部浏览器或支付宝可识别环境中打开 `copy_link`。 + +支付成功后: + +- 不要只依赖支付宝返回页判断支付成功。 +- 最终支付状态以服务端订单 / 充值记录状态为准。 +- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。 + +## 5. 各业务场景说明 + +### 5.1 套餐订单支付 + +普通套餐下单后,如果需要支付,前端调用订单支付接口,并传: + +```json +{ + "payment_method": "alipay" +} +``` + +支付宝支付响应示例: + +```json +{ + "payment_method": "alipay", + "payment_link": { + "payment_no": "...", + "qr_link": "...", + "copy_link": "...", + "pay_expire_at": "..." + } +} +``` + +前端根据 `payment_link` 发起支付宝支付。 + +### 5.2 钱包充值 + +创建钱包充值单时传: + +```json +{ + "payment_method": "alipay" +} +``` + +支付宝场景返回 `payment_link`;微信场景返回 `pay_config`。 + +### 5.3 强制充值购包 + +创建订单时,如果后端判断需要强制充值,前端可指定: + +```json +{ + "payment_method": "alipay" +} +``` + +如果是支付宝强充,响应中会返回: + +```json +{ + "order_type": "recharge", + "recharge": {}, + "payment_link": {} +} +``` + +前端按支付宝支付链接流程处理。 + +## 6. 前端展示建议 + +支付宝支付页面建议展示: + +- 支付金额 +- 支付宝二维码或“去支付宝支付”按钮 +- 支付链接过期倒计时 +- “我已完成支付”按钮 +- “重新获取支付链接”按钮 + +点击“我已完成支付”时,不直接判定成功,应查询服务端状态。 + +## 7. 注意事项 + +1. 金额单位仍然是 **分**。 +2. 支付宝支付不需要 `app_type`。 +3. `pay_config` 只用于微信支付。 +4. `payment_link` 只用于支付宝支付。 +5. 支付状态以服务端为准,不以前端跳转结果为准。 +6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。 +7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。 diff --git a/docs/polling-system/operations.md b/docs/polling-system/operations.md index 40b6b6e..00ef8eb 100644 --- a/docs/polling-system/operations.md +++ b/docs/polling-system/operations.md @@ -56,6 +56,47 @@ redis-cli HGETALL polling:stats:package redis-cli HGETALL polling:init:progress ``` +### 4. 跨 Worker 日志查询 + +线上多实例 Worker 日志分散时,使用仓库内 Rust 工具统一扫描普通日志和 `.gz` 轮转日志。工具默认只输出汇总、命中文件和少量样例,不会把窗口刷满。 + +```bash +rustc scripts/ops/search-worker-logs.rs -O -o /tmp/jh-logq + +/tmp/jh-logq \ + --roots "/data/worker-1/logs:/data/worker-2/logs:/data/worker-3/logs:/data/worker-4/logs" \ + --iccid 8986000000000000000 \ + --date 2026-05-24 \ + --interface realname +``` + +常用接口别名: + +| 别名 | 匹配内容 | +|------|----------| +| `realname` | 实名接口、`realStatus`、实名相关业务日志 | +| `flow` / `carddata` | 流量接口、`used`、流量相关业务日志 | +| `card_status` | 卡状态接口、`cardStatus`、卡状态业务日志 | +| `stop` | 停机接口或停机业务日志 | +| `start` / `resume` | 复机接口或复机业务日志 | + +排查“23 号有实名同步,24 号没有”的建议顺序: + +```bash +# 对比两天命中数,先确认是不是所有 Worker 都没有 +/tmp/jh-logq --roots "/data/worker-1/logs:/data/worker-2/logs:/data/worker-3/logs:/data/worker-4/logs" \ + --iccid 8986000000000000000 --date 2026-05-23 --interface realname --count-only + +/tmp/jh-logq --roots "/data/worker-1/logs:/data/worker-2/logs:/data/worker-3/logs:/data/worker-4/logs" \ + --iccid 8986000000000000000 --date 2026-05-24 --interface realname --count-only + +# 如果 24 号实名没有命中,再看这张卡当天是否仍在跑其他轮询 +/tmp/jh-logq --roots "/data/worker-1/logs:/data/worker-2/logs:/data/worker-3/logs:/data/worker-4/logs" \ + --iccid 8986000000000000000 --date 2026-05-24 +``` + +注意:旧版本 Gateway 成功请求日志没有稳定输出接口 `path`,脚本对历史日志的 `realname` 匹配会同时使用 `realStatus` 和“实名”业务日志推断。若需要严格按 Gateway 路径查询,生产版本需要在 Gateway 请求/响应日志中输出 `path`。 + ## 告警配置 ### 1. 默认告警规则 diff --git a/docs/脚本/清理代理预充值.sql b/docs/脚本/清理代理预充值.sql new file mode 100644 index 0000000..ea6bb35 --- /dev/null +++ b/docs/脚本/清理代理预充值.sql @@ -0,0 +1,66 @@ +BEGIN; + +-- 锁定代理主钱包,确认余额和冻结余额 +SELECT * +FROM tb_agent_wallet +WHERE shop_id = :shop_id + AND wallet_type = 'main' + AND deleted_at IS NULL +FOR UPDATE; + +-- 确认 frozen_balance = 0 且 balance > 0 后执行 +INSERT INTO tb_agent_wallet_transaction ( + agent_wallet_id, + shop_id, + user_id, + transaction_type, + amount, + balance_before, + balance_after, + status, + reference_type, + reference_id, + remark, + metadata, + creator, + shop_id_tag, + enterprise_id_tag, + created_at, + updated_at +) +SELECT + id, + shop_id, + :operator_user_id, + 'deduct', + -balance, + balance, + 0, + 1, + NULL, + NULL, + '平台清理代理预充值剩余余额,历史消费流水保留', + jsonb_build_object('reason', 'clear_agent_main_wallet_balance'), + :operator_user_id, + shop_id_tag, + enterprise_id_tag, + NOW(), + NOW() +FROM tb_agent_wallet +WHERE shop_id = :shop_id + AND wallet_type = 'main' + AND deleted_at IS NULL + AND balance > 0 + AND frozen_balance = 0; + +UPDATE tb_agent_wallet +SET balance = 0, + version = version + 1, + updated_at = NOW() +WHERE shop_id = :shop_id + AND wallet_type = 'main' + AND deleted_at IS NULL + AND balance > 0 + AND frozen_balance = 0; + +COMMIT; diff --git a/scripts/ops/README.md b/scripts/ops/README.md new file mode 100644 index 0000000..8d26eed --- /dev/null +++ b/scripts/ops/README.md @@ -0,0 +1,95 @@ +# 线上日志查询工具 + +## 编译和打包 + +Rust 工具是单文件实现,不依赖 Cargo 包: + +```bash +rustc scripts/ops/search-worker-logs.rs -O -o /tmp/jh-logq +``` + +上面命令只编译当前机器可运行的版本。本机是 macOS 时,不能把这个默认产物直接放到 Linux 服务器。 + +打 Linux x86_64 服务器包: + +```bash +mkdir -p dist +rustup target add x86_64-unknown-linux-musl +HOST_TRIPLE="$(rustc -vV | awk '/^host:/ {print $2}')" +RUST_LLD="$(rustc --print sysroot)/lib/rustlib/${HOST_TRIPLE}/bin/rust-lld" +rustc --target x86_64-unknown-linux-musl \ + -C linker="$RUST_LLD" \ + scripts/ops/search-worker-logs.rs \ + -O \ + -o dist/jh-logq-linux-amd64 +tar -czf dist/jh-logq-linux-amd64.tar.gz -C dist jh-logq-linux-amd64 +``` + +如果服务器是 ARM64: + +```bash +mkdir -p dist +rustup target add aarch64-unknown-linux-musl +HOST_TRIPLE="$(rustc -vV | awk '/^host:/ {print $2}')" +RUST_LLD="$(rustc --print sysroot)/lib/rustlib/${HOST_TRIPLE}/bin/rust-lld" +rustc --target aarch64-unknown-linux-musl \ + -C linker="$RUST_LLD" \ + scripts/ops/search-worker-logs.rs \ + -O \ + -o dist/jh-logq-linux-arm64 +tar -czf dist/jh-logq-linux-arm64.tar.gz -C dist jh-logq-linux-arm64 +``` + +上传并安装到服务器: + +```bash +scp dist/jh-logq-linux-amd64.tar.gz user@server:/tmp/ +ssh user@server +cd /tmp +tar -xzf jh-logq-linux-amd64.tar.gz +sudo install -m 0755 jh-logq-linux-amd64 /usr/local/bin/jh-logq +jh-logq --help +``` + +如果交叉编译仍报 linker 相关错误,用 Docker 编译 Linux 产物兜底: + +```bash +mkdir -p dist +docker run --rm -v "$PWD":/work -w /work rust:1-bookworm \ + sh -lc 'rustc scripts/ops/search-worker-logs.rs -O -o dist/jh-logq-linux-amd64' +``` + +## 目录选择 + +```bash +# 推荐:直接打开交互向导,自动发现日志目录并按编号选择 +/tmp/jh-logq + +# 自动发现候选日志目录 +/tmp/jh-logq --list-roots + +# 已经知道 ICCID 时,也会先自动发现目录并让你按编号选择 +/tmp/jh-logq --choose --iccid 8986000000000000000 --date 2026-05-24 --interface realname +``` + +目录选择支持: + +- 回车或 `all`:扫描全部候选目录 +- `1,3-4`:只扫描第 1、3、4 个目录 +- `all,!2,!5-7`:扫描全部,但排除第 2、5、6、7 个目录 + +显式传目录仍然保留给自动化脚本使用: + +```bash +/tmp/jh-logq \ + --roots "/data/worker-1/logs:/data/worker-2/logs:/data/worker-3/logs:/data/worker-4/logs" \ + --iccid 8986000000000000000 \ + --date 2026-05-24 \ + --interface realname +``` + +默认只输出汇总、命中文件和少量样例,不会把窗口刷满。需要原始行时再加: + +```bash +/tmp/jh-logq ... --lines --limit 100 +``` diff --git a/scripts/ops/search-worker-logs.rs b/scripts/ops/search-worker-logs.rs new file mode 100644 index 0000000..1c82fca --- /dev/null +++ b/scripts/ops/search-worker-logs.rs @@ -0,0 +1,1308 @@ +use std::collections::{HashSet, VecDeque}; +use std::env; +use std::fs::{self, File}; +use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Instant; + +const DEFAULT_SAMPLES: usize = 5; +const DEFAULT_LIMIT: usize = 200; +const DEFAULT_SCAN_DEPTH: usize = 3; +const DEFAULT_DISCOVER_DEPTH: usize = 4; +const MAX_DISCOVER_DIRS: usize = 20_000; + +#[derive(Clone, Debug)] +struct Config { + roots: Vec, + iccid: String, + date: Option, + interface: Option, + contains: Vec, + lines_mode: bool, + count_only: bool, + list_roots: bool, + choose_roots: bool, + interactive: bool, + include_gz: bool, + samples: usize, + limit: usize, + threads: usize, + max_depth: usize, + max_files: Option, +} + +impl Default for Config { + fn default() -> Self { + let threads = thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .clamp(1, 16); + Self { + roots: Vec::new(), + iccid: String::new(), + date: None, + interface: None, + contains: Vec::new(), + lines_mode: false, + count_only: false, + list_roots: false, + choose_roots: false, + interactive: false, + include_gz: true, + samples: DEFAULT_SAMPLES, + limit: DEFAULT_LIMIT, + threads, + max_depth: DEFAULT_SCAN_DEPTH, + max_files: None, + } + } +} + +#[derive(Clone, Debug)] +struct RootCandidate { + path: PathBuf, + log_files: usize, + gz_files: usize, +} + +#[derive(Clone, Debug)] +struct SampleLine { + file: PathBuf, + line_no: usize, + line: String, +} + +#[derive(Debug)] +struct FileResult { + path: PathBuf, + matched: usize, + scanned_lines: usize, + samples: Vec, + error: Option, +} + +#[derive(Debug)] +struct MatchFilter { + iccid: String, + date: Option, + interface_terms: Vec, + contains: Vec, +} + +fn main() { + let start = Instant::now(); + let mut cfg = match parse_args() { + Ok(cfg) => cfg, + Err(msg) => { + eprintln!("{msg}"); + print_usage(io::stderr()); + std::process::exit(2); + } + }; + + if cfg.list_roots { + let roots = if cfg.roots.is_empty() { + discover_roots(DEFAULT_DISCOVER_DEPTH) + } else { + discover_log_dirs(&cfg.roots, cfg.max_depth, cfg.include_gz) + }; + print_root_candidates(&roots); + return; + } + + if cfg.interactive { + cfg = run_wizard(cfg).unwrap_or_else(|err| { + eprintln!("交互配置失败: {err}"); + std::process::exit(2); + }); + } + + if cfg.iccid.is_empty() { + eprintln!("必须传入 --iccid,避免误扫大日志"); + print_usage(io::stderr()); + std::process::exit(2); + } + + let roots_were_provided = !cfg.roots.is_empty() || !roots_from_args().is_empty(); + if cfg.roots.is_empty() { + cfg.roots = resolve_default_roots(); + } + + if cfg.choose_roots || (!roots_were_provided && !cfg.interactive && io::stdin().is_terminal()) { + cfg.roots = choose_roots(&cfg.roots, cfg.max_depth, cfg.include_gz).unwrap_or_else(|err| { + eprintln!("选择目录失败: {err}"); + std::process::exit(2); + }); + } + + if cfg.roots.is_empty() { + eprintln!("没有可用日志目录。先运行 --list-roots 查看自动发现结果,或确认日志目录已挂载。"); + std::process::exit(2); + } + + let filter = MatchFilter { + iccid: cfg.iccid.clone(), + date: cfg.date.clone(), + interface_terms: interface_terms(cfg.interface.as_deref()).unwrap_or_else(|err| { + eprintln!("{err}"); + std::process::exit(2); + }), + contains: cfg.contains.clone(), + }; + + let mut files = collect_log_files( + &cfg.roots, + cfg.max_depth, + cfg.include_gz, + cfg.date.as_deref(), + ); + files.sort_by(|a, b| b.modified_score.cmp(&a.modified_score)); + if let Some(max_files) = cfg.max_files { + files.truncate(max_files); + } + + if files.is_empty() { + eprintln!("没有找到可扫描日志文件。"); + std::process::exit(1); + } + + let scan_total = files.len(); + let queue = Arc::new(Mutex::new(VecDeque::from(files))); + let results = Arc::new(Mutex::new(Vec::::new())); + let filter = Arc::new(filter); + let cfg_arc = Arc::new(cfg.clone()); + + let mut handles = Vec::new(); + for _ in 0..cfg.threads { + let queue = Arc::clone(&queue); + let results = Arc::clone(&results); + let filter = Arc::clone(&filter); + let cfg_arc = Arc::clone(&cfg_arc); + handles.push(thread::spawn(move || loop { + let file = { + let mut guard = queue.lock().expect("文件队列锁异常"); + guard.pop_front() + }; + let Some(file) = file else { + break; + }; + let result = scan_file(&file.path, &filter, &cfg_arc); + results.lock().expect("结果锁异常").push(result); + })); + } + + for handle in handles { + if handle.join().is_err() { + eprintln!("有扫描线程异常退出"); + } + } + + let mut results = Arc::try_unwrap(results) + .expect("结果仍被引用") + .into_inner() + .expect("结果锁异常"); + results.sort_by(|a, b| b.matched.cmp(&a.matched).then_with(|| a.path.cmp(&b.path))); + + print_results(&cfg, &results, scan_total, start.elapsed().as_millis()); +} + +#[derive(Clone, Debug)] +struct LogFile { + path: PathBuf, + modified_score: u64, +} + +fn parse_args() -> Result { + let mut cfg = Config::default(); + let raw_args: Vec = env::args().skip(1).collect(); + if raw_args.is_empty() && io::stdin().is_terminal() { + cfg.interactive = true; + } + let mut args = raw_args.into_iter().peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--root" => cfg + .roots + .push(PathBuf::from(next_value(&mut args, "--root")?)), + "--roots" => add_roots(&mut cfg.roots, &next_value(&mut args, "--roots")?), + "--iccid" => cfg.iccid = next_value(&mut args, "--iccid")?, + "--date" => cfg.date = Some(next_value(&mut args, "--date")?), + "--interface" | "--api" => cfg.interface = Some(next_value(&mut args, "--interface")?), + "--contains" => cfg.contains.push(next_value(&mut args, "--contains")?), + "--lines" => cfg.lines_mode = true, + "--count-only" => cfg.count_only = true, + "--list-roots" => cfg.list_roots = true, + "--choose" => cfg.choose_roots = true, + "--interactive" | "-i" => cfg.interactive = true, + "--no-gz" => cfg.include_gz = false, + "--samples" => { + cfg.samples = parse_usize(&next_value(&mut args, "--samples")?, "--samples")? + } + "--limit" => cfg.limit = parse_usize(&next_value(&mut args, "--limit")?, "--limit")?, + "--threads" => { + cfg.threads = + parse_usize(&next_value(&mut args, "--threads")?, "--threads")?.clamp(1, 64) + } + "--max-depth" => { + cfg.max_depth = parse_usize(&next_value(&mut args, "--max-depth")?, "--max-depth")? + } + "--max-files" => { + cfg.max_files = Some(parse_usize( + &next_value(&mut args, "--max-files")?, + "--max-files", + )?) + } + "-h" | "--help" => { + print_usage(io::stdout()); + std::process::exit(0); + } + _ => return Err(format!("未知参数: {arg}")), + } + } + if cfg.roots.is_empty() { + for root in roots_from_args() { + cfg.roots.push(root); + } + } + Ok(cfg) +} + +fn next_value( + args: &mut std::iter::Peekable>, + name: &str, +) -> Result { + args.next().ok_or_else(|| format!("{name} 缺少参数")) +} + +fn parse_usize(raw: &str, name: &str) -> Result { + raw.parse::() + .map_err(|_| format!("{name} 需要整数,当前值: {raw}")) +} + +fn roots_from_args() -> Vec { + let mut roots = Vec::new(); + if let Ok(raw) = env::var("JUNHONG_WORKER_LOG_ROOTS") { + add_roots(&mut roots, &raw); + } + roots +} + +fn add_roots(roots: &mut Vec, raw: &str) { + for part in raw.split(':') { + let trimmed = part.trim(); + if !trimmed.is_empty() { + roots.push(PathBuf::from(trimmed)); + } + } +} + +fn run_wizard(mut cfg: Config) -> io::Result { + clear_screen(); + print_banner(); + + let root_candidates = if cfg.roots.is_empty() { + discover_roots(DEFAULT_DISCOVER_DEPTH) + } else { + cfg.roots + .iter() + .filter(|p| p.is_dir()) + .flat_map(|p| discover_log_dirs(&[p.clone()], cfg.max_depth, cfg.include_gz)) + .collect() + }; + + if root_candidates.is_empty() { + println!("{}", color("没有自动发现日志目录。", "31;1")); + println!("可先确认日志挂载目录,或设置 JUNHONG_LOG_DISCOVERY_BASES 后重新运行。"); + return Ok(cfg); + } + + cfg.roots = choose_root_candidates_pretty(&root_candidates)?; + + println!(); + cfg.iccid = prompt_required("ICCID", "输入要查询的卡 ICCID")?; + + println!(); + cfg.date = choose_date_pretty()?; + + println!(); + cfg.interface = choose_interface_pretty()?; + + println!(); + let extra = prompt_optional("追加关键词过滤(可直接回车跳过)", None)?; + if !extra.is_empty() { + cfg.contains.push(extra); + } + + println!(); + cfg.lines_mode = prompt_yes_no("是否输出原始命中行?默认只看摘要,避免刷屏", false)?; + if cfg.lines_mode { + let raw_limit = prompt_optional("最多输出多少行", Some(&cfg.limit.to_string()))?; + if !raw_limit.is_empty() { + if let Ok(limit) = raw_limit.parse::() { + cfg.limit = limit; + } + } + } + + cfg.include_gz = prompt_yes_no("是否扫描 .gz 压缩轮转日志", true)?; + + println!(); + println!("{}", color("即将扫描", "36;1")); + println!(" 目录数: {}", cfg.roots.len()); + for root in &cfg.roots { + println!(" - {}", root.display()); + } + println!(" ICCID: {}", cfg.iccid); + println!(" 日期: {}", cfg.date.as_deref().unwrap_or("不限")); + println!(" 接口: {}", cfg.interface.as_deref().unwrap_or("不限")); + println!( + " 输出: {}", + if cfg.lines_mode { + "原始行" + } else { + "摘要" + } + ); + pause("按回车开始扫描")?; + clear_screen(); + Ok(cfg) +} + +fn clear_screen() { + if io::stdout().is_terminal() { + print!("\x1b[2J\x1b[H"); + let _ = io::stdout().flush(); + } +} + +fn supports_color() -> bool { + io::stdout().is_terminal() && env::var_os("NO_COLOR").is_none() +} + +fn color(text: &str, code: &str) -> String { + if supports_color() { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_string() + } +} + +fn print_banner() { + println!( + "{}", + color("╭────────────────────────────────────────────╮", "36;1") + ); + println!( + "{}", + color("│ 君鸿 Worker 日志查询向导 │", "36;1") + ); + println!( + "{}", + color("╰────────────────────────────────────────────╯", "36;1") + ); + println!("{}", color("默认只输出摘要;需要原始行时再开启。", "2")); + println!(); +} + +fn choose_root_candidates_pretty(candidates: &[RootCandidate]) -> io::Result> { + println!("{}", color("1. 日志目录", "36;1")); + println!("已自动发现以下候选目录,默认选择全部:"); + println!(); + for (index, item) in candidates.iter().enumerate() { + let marker = if item.gz_files > 0 { + "含压缩" + } else { + "普通" + }; + println!( + " {} {:<48} {:>3} 个日志 {:>3} 个 gz {}", + color(&format!("{:>2}.", index + 1), "32;1"), + truncate_path(&item.path, 48), + item.log_files, + item.gz_files, + color(marker, "2") + ); + } + println!(); + println!( + "{}", + color( + "回车=全部;输入 1,3-4=只选部分;all,!2,!5-7=排除部分;q=退出", + "2" + ) + ); + let input = prompt_optional("选择目录", Some("all"))?; + if input.eq_ignore_ascii_case("q") { + std::process::exit(0); + } + if input.is_empty() || input.eq_ignore_ascii_case("all") { + return Ok(candidates.iter().map(|c| c.path.clone()).collect()); + } + + let selected = select_candidate_paths(candidates, &input); + if selected.is_empty() { + println!("{}", color("没有选中有效目录,使用全部候选目录。", "33")); + return Ok(candidates.iter().map(|c| c.path.clone()).collect()); + } + Ok(selected) +} + +fn truncate_path(path: &Path, width: usize) -> String { + let text = path.display().to_string(); + let count = text.chars().count(); + if count <= width { + return text; + } + let keep = width.saturating_sub(1); + let suffix: String = text + .chars() + .rev() + .take(keep) + .collect::() + .chars() + .rev() + .collect(); + format!("…{suffix}") +} + +fn prompt_required(label: &str, help: &str) -> io::Result { + loop { + println!("{}", color(help, "2")); + let value = prompt_optional(label, None)?; + if !value.trim().is_empty() { + return Ok(value.trim().to_string()); + } + println!("{}", color("不能为空。", "31")); + } +} + +fn prompt_optional(label: &str, default: Option<&str>) -> io::Result { + match default { + Some(default) => print!("{} [{}]: ", color(label, "33;1"), default), + None => print!("{}: ", color(label, "33;1")), + } + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let value = input.trim().to_string(); + if value.is_empty() { + Ok(default.unwrap_or("").to_string()) + } else { + Ok(value) + } +} + +fn prompt_yes_no(label: &str, default: bool) -> io::Result { + let default_text = if default { "Y/n" } else { "y/N" }; + loop { + let value = prompt_optional(label, Some(default_text))?; + if value == default_text || value.is_empty() { + return Ok(default); + } + match value.to_ascii_lowercase().as_str() { + "y" | "yes" | "是" => return Ok(true), + "n" | "no" | "否" => return Ok(false), + _ => println!("{}", color("请输入 y 或 n。", "31")), + } + } +} + +fn pause(label: &str) -> io::Result<()> { + print!("{}...", color(label, "33;1")); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(()) +} + +fn choose_date_pretty() -> io::Result> { + println!("{}", color("2. 查询日期", "36;1")); + let today = command_output("date", &["+%F"]).unwrap_or_default(); + let yesterday = command_output("date", &["-d", "yesterday", "+%F"]) + .or_else(|| command_output("date", &["-v-1d", "+%F"])) + .unwrap_or_default(); + let today_label = if today.is_empty() { + "今天".to_string() + } else { + format!("今天 {today}") + }; + let yesterday_label = if yesterday.is_empty() { + "昨天".to_string() + } else { + format!("昨天 {yesterday}") + }; + println!(" {} {}", color("1.", "32;1"), today_label); + println!(" {} {}", color("2.", "32;1"), yesterday_label); + println!(" {} 自定义日期", color("3.", "32;1")); + println!(" {} 不限制日期", color("4.", "32;1")); + let choice = prompt_optional("选择", Some("2"))?; + match choice.as_str() { + "" | "2" => Ok(if yesterday.is_empty() { + None + } else { + Some(yesterday) + }), + "1" => Ok(if today.is_empty() { None } else { Some(today) }), + "3" => { + let date = prompt_required("日期", "格式:YYYY-MM-DD,例如 2026-05-24")?; + Ok(Some(date)) + } + "4" => Ok(None), + other if other.len() == 10 && other.chars().nth(4) == Some('-') => { + Ok(Some(other.to_string())) + } + _ => Ok(if yesterday.is_empty() { + None + } else { + Some(yesterday) + }), + } +} + +fn choose_interface_pretty() -> io::Result> { + println!("{}", color("3. 接口类型", "36;1")); + println!(" {} 实名 realname", color("1.", "32;1")); + println!(" {} 流量 flow/carddata", color("2.", "32;1")); + println!(" {} 卡状态 card_status", color("3.", "32;1")); + println!(" {} 停机 stop", color("4.", "32;1")); + println!(" {} 复机 start", color("5.", "32;1")); + println!(" {} 不限制接口", color("6.", "32;1")); + println!(" {} 自定义 Gateway 路径", color("7.", "32;1")); + let choice = prompt_optional("选择", Some("1"))?; + let value = match choice.as_str() { + "" | "1" => Some("realname".to_string()), + "2" => Some("flow".to_string()), + "3" => Some("card_status".to_string()), + "4" => Some("stop".to_string()), + "5" => Some("start".to_string()), + "6" => None, + "7" => Some(prompt_required("Gateway 路径", "例如 /flow-card/realName")?), + other => Some(other.to_string()), + }; + Ok(value) +} + +fn command_output(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn print_usage(mut writer: W) { + let _ = writeln!( + writer, + r#"用法: + rustc scripts/ops/search-worker-logs.rs -O -o /tmp/jh-logq + /tmp/jh-logq + /tmp/jh-logq --iccid [选项] + +核心选项: + --root 增加一个日志目录,可重复 + --roots 一次传多个日志目录 + --list-roots 自动发现候选日志目录并退出;配合 --root/--roots 时只列指定范围 + --choose 从扫描范围内的日志目录交互选择;无需手动输入目录路径 + --iccid 必填,按 ICCID 过滤 + --date 按日志行日期过滤,并按文件名日期剪枝 + --interface realname, flow, carddata, card_status, stop, start,或直接传 /flow-card/realName + --contains 追加文本过滤,可重复 + +输出控制: + 默认只输出汇总、命中文件和少量样例,不刷屏。 + --lines 输出原始命中行 + --limit --lines 最大输出行数,默认 200 + --samples 汇总模式样例行数,默认 5 + --count-only 只输出命中文件和行数 + +性能选项: + --threads 并发扫描线程数,默认自动 + --max-depth 每个 root 下递归深度,默认 3 + --max-files 最多扫描 N 个文件 + --no-gz 跳过 .gz 文件 + +环境变量: + JUNHONG_WORKER_LOG_ROOTS 默认日志目录,冒号分隔 +"# + ); +} + +fn resolve_default_roots() -> Vec { + let mut roots = roots_from_args(); + if !roots.is_empty() { + return dedup_paths(roots); + } + + for path in ["/opt/junhong_cmp/logs", "/app/logs", "./logs"] { + let p = PathBuf::from(path); + if p.is_dir() { + roots.push(p); + } + } + + if roots.is_empty() { + roots = discover_roots(DEFAULT_DISCOVER_DEPTH) + .into_iter() + .map(|c| c.path) + .collect(); + } + + dedup_paths(roots) +} + +fn dedup_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for path in paths { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + result.push(path); + } + } + result +} + +fn discover_roots(max_depth: usize) -> Vec { + let mut bases = Vec::new(); + if let Ok(raw) = env::var("JUNHONG_LOG_DISCOVERY_BASES") { + add_roots(&mut bases, &raw); + } else { + for path in [".", "/opt/junhong_cmp", "/app", "/data", "/srv"] { + let p = PathBuf::from(path); + if p.is_dir() { + bases.push(p); + } + } + } + + let mut candidates = Vec::new(); + let mut visited = HashSet::new(); + let mut visited_dirs = 0usize; + for base in bases { + let mut queue = VecDeque::new(); + queue.push_back((base, 0usize)); + while let Some((dir, depth)) = queue.pop_front() { + if visited_dirs >= MAX_DISCOVER_DIRS { + break; + } + visited_dirs += 1; + + let key = dir.to_string_lossy().to_string(); + if !visited.insert(key) { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + let mut log_files = 0usize; + let mut gz_files = 0usize; + let mut subdirs = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if depth < max_depth && !should_skip_dir(&path) { + subdirs.push(path); + } + continue; + } + if is_log_file(&path, true) { + log_files += 1; + if is_gz(&path) { + gz_files += 1; + } + } + } + + if log_files > 0 { + candidates.push(RootCandidate { + path: dir.clone(), + log_files, + gz_files, + }); + } + for subdir in subdirs { + queue.push_back((subdir, depth + 1)); + } + } + } + + candidates.sort_by(|a, b| a.path.cmp(&b.path)); + candidates +} + +fn discover_log_dirs(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> Vec { + let mut candidates = Vec::new(); + let mut visited = HashSet::new(); + let mut visited_dirs = 0usize; + + for root in roots { + if !root.is_dir() { + continue; + } + + let mut queue = VecDeque::new(); + queue.push_back((root.clone(), 0usize)); + while let Some((dir, depth)) = queue.pop_front() { + if visited_dirs >= MAX_DISCOVER_DIRS { + break; + } + visited_dirs += 1; + + let key = dir.to_string_lossy().to_string(); + if !visited.insert(key) { + continue; + } + + let (log_files, gz_files) = count_immediate_logs(&dir, include_gz); + if log_files > 0 { + candidates.push(RootCandidate { + path: dir.clone(), + log_files, + gz_files, + }); + } + + if depth >= max_depth { + continue; + } + + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && !should_skip_dir(&path) { + queue.push_back((path, depth + 1)); + } + } + } + } + + candidates.sort_by(|a, b| a.path.cmp(&b.path)); + candidates +} + +fn should_skip_dir(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + matches!( + name, + ".git" | ".omx" | "node_modules" | "vendor" | "tmp" | "cache" + ) +} + +fn print_root_candidates(candidates: &[RootCandidate]) { + if candidates.is_empty() { + println!("没有发现候选日志目录。可设置 JUNHONG_LOG_DISCOVERY_BASES 后重试。"); + return; + } + println!("发现 {} 个候选日志目录:", candidates.len()); + for (index, item) in candidates.iter().enumerate() { + println!( + "{:>2}. {} (日志文件: {}, gz: {})", + index + 1, + item.path.display(), + item.log_files, + item.gz_files + ); + } +} + +fn choose_roots(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> io::Result> { + let candidates = discover_log_dirs(roots, max_depth, include_gz); + + print_root_candidates(&candidates); + if candidates.is_empty() { + return Ok(Vec::new()); + } + + print!("选择目录编号,例如 1,3-4,或 all,!2,!5-7: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim(); + if input.eq_ignore_ascii_case("all") || input.is_empty() { + return Ok(candidates.into_iter().map(|c| c.path).collect()); + } + + let selected = select_candidate_paths(&candidates, input); + Ok(selected) +} + +fn select_candidate_paths(candidates: &[RootCandidate], input: &str) -> Vec { + let indexes = parse_selection_indexes(input, candidates.len()); + indexes + .into_iter() + .filter_map(|idx| candidates.get(idx - 1).map(|c| c.path.clone())) + .collect() +} + +fn parse_selection_indexes(input: &str, len: usize) -> Vec { + if len == 0 { + return Vec::new(); + } + + let mut selected = vec![false; len]; + let mut touched = false; + for raw_part in input.split(',') { + let part = raw_part.trim(); + if part.is_empty() { + continue; + } + if part.eq_ignore_ascii_case("all") { + selected.fill(true); + touched = true; + continue; + } + + let (exclude, range_part) = match part.strip_prefix('!') { + Some(rest) => (true, rest.trim()), + None => (false, part), + }; + for idx in parse_index_range(range_part, len) { + selected[idx - 1] = !exclude; + touched = true; + } + } + + if !touched { + return Vec::new(); + } + + selected + .into_iter() + .enumerate() + .filter_map(|(idx, is_selected)| if is_selected { Some(idx + 1) } else { None }) + .collect() +} + +fn parse_index_range(raw: &str, len: usize) -> Vec { + if let Some((start, end)) = raw.split_once('-') { + let start = start.trim().parse::().unwrap_or(0); + let end = end.trim().parse::().unwrap_or(0); + if start == 0 || end == 0 { + return Vec::new(); + } + let start = start.min(len); + let end = end.min(len); + let (start, end) = if start <= end { + (start, end) + } else { + (end, start) + }; + return (start..=end).collect(); + } + + let idx = raw.trim().parse::().unwrap_or(0); + if idx == 0 || idx > len { + Vec::new() + } else { + vec![idx] + } +} + +fn count_immediate_logs(dir: &Path, include_gz: bool) -> (usize, usize) { + let Ok(entries) = fs::read_dir(dir) else { + return (0, 0); + }; + let mut log_files = 0usize; + let mut gz_files = 0usize; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && is_log_file(&path, include_gz) { + log_files += 1; + if is_gz(&path) { + gz_files += 1; + } + } + } + (log_files, gz_files) +} + +fn collect_log_files( + roots: &[PathBuf], + max_depth: usize, + include_gz: bool, + date: Option<&str>, +) -> Vec { + let mut result = Vec::new(); + let mut seen = HashSet::new(); + + for root in roots { + if root.is_file() { + maybe_add_file(root, include_gz, date, &mut result, &mut seen); + continue; + } + if !root.is_dir() { + eprintln!("跳过不存在的目录: {}", root.display()); + continue; + } + + let mut queue = VecDeque::new(); + queue.push_back((root.clone(), 0usize)); + while let Some((dir, depth)) = queue.pop_front() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if depth < max_depth && !should_skip_dir(&path) { + queue.push_back((path, depth + 1)); + } + continue; + } + maybe_add_file(&path, include_gz, date, &mut result, &mut seen); + } + } + } + + result +} + +fn maybe_add_file( + path: &Path, + include_gz: bool, + date: Option<&str>, + result: &mut Vec, + seen: &mut HashSet, +) { + if !is_log_file(path, include_gz) { + return; + } + if let Some(date) = date { + if !filename_can_contain_date(path, date) { + return; + } + } + let key = path.to_string_lossy().to_string(); + if !seen.insert(key) { + return; + } + let modified_score = path + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + result.push(LogFile { + path: path.to_path_buf(), + modified_score, + }); +} + +fn is_log_file(path: &Path, include_gz: bool) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".gz") { + return include_gz && lower.contains("log"); + } + lower.ends_with(".log") + || lower.contains(".log.") + || lower.ends_with("-json.log") + || lower == "stdout" + || lower == "stderr" +} + +fn is_gz(path: &Path) -> bool { + path.extension() + .and_then(|s| s.to_str()) + .map(|s| s.eq_ignore_ascii_case("gz")) + .unwrap_or(false) +} + +fn filename_can_contain_date(path: &Path, date: &str) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return true; + }; + let compact = date.replace('-', ""); + let dates = extract_filename_dates(name); + dates.is_empty() || dates.iter().any(|d| d == date || d == &compact) +} + +fn extract_filename_dates(name: &str) -> Vec { + let bytes = name.as_bytes(); + let mut dates = Vec::new(); + for i in 0..bytes.len() { + if i + 10 <= bytes.len() + && bytes[i..].get(4) == Some(&b'-') + && bytes[i..].get(7) == Some(&b'-') + && bytes[i..i + 4].iter().all(u8::is_ascii_digit) + && bytes[i + 5..i + 7].iter().all(u8::is_ascii_digit) + && bytes[i + 8..i + 10].iter().all(u8::is_ascii_digit) + { + dates.push(name[i..i + 10].to_string()); + } + if i + 8 <= bytes.len() && bytes[i..i + 8].iter().all(u8::is_ascii_digit) { + let year = &name[i..i + 4]; + if year.starts_with("20") { + dates.push(name[i..i + 8].to_string()); + } + } + } + dates +} + +fn interface_terms(interface: Option<&str>) -> Result, String> { + let Some(interface) = interface else { + return Ok(Vec::new()); + }; + let terms = match interface { + "realname" => vec!["/flow-card/realName", "realStatus", "实名"], + "flow" | "carddata" => vec!["/flow-card/flow", "gateway_flow", "流量", "\"used\""], + "card_status" | "status" => vec!["/flow-card/status", "cardStatus", "卡状态"], + "stop" => vec!["/flow-card/cardStop", "cardStop", "停机"], + "start" | "resume" => vec!["/flow-card/cardStart", "cardStart", "复机"], + other if other.starts_with('/') => vec![other], + other => return Err(format!("未知接口别名: {other}")), + }; + Ok(terms.into_iter().map(String::from).collect()) +} + +fn scan_file(path: &Path, filter: &MatchFilter, cfg: &Config) -> FileResult { + let mut result = FileResult { + path: path.to_path_buf(), + matched: 0, + scanned_lines: 0, + samples: Vec::new(), + error: None, + }; + + let scan_result = if is_gz(path) { + scan_gzip_file(path, filter, cfg, &mut result) + } else { + scan_plain_file(path, filter, cfg, &mut result) + }; + + if let Err(err) = scan_result { + result.error = Some(err); + } + result +} + +fn scan_plain_file( + path: &Path, + filter: &MatchFilter, + cfg: &Config, + result: &mut FileResult, +) -> Result<(), String> { + let file = File::open(path).map_err(|err| format!("打开失败: {err}"))?; + let reader = BufReader::new(file); + scan_reader(path, reader, filter, cfg, result) +} + +fn scan_gzip_file( + path: &Path, + filter: &MatchFilter, + cfg: &Config, + result: &mut FileResult, +) -> Result<(), String> { + let mut child = Command::new("gzip") + .arg("-cd") + .arg("--") + .arg(path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("启动 gzip 失败: {err}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| String::from("读取 gzip stdout 失败"))?; + let reader = BufReader::new(stdout); + let scan_result = scan_reader(path, reader, filter, cfg, result); + let output = child + .wait_with_output() + .map_err(|err| format!("等待 gzip 失败: {err}"))?; + if !output.status.success() { + let mut msg = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if msg.is_empty() { + msg = format!("gzip 退出码 {:?}", output.status.code()); + } + return Err(msg); + } + scan_result +} + +fn scan_reader( + path: &Path, + reader: BufReader, + filter: &MatchFilter, + cfg: &Config, + result: &mut FileResult, +) -> Result<(), String> { + for (idx, line) in reader.lines().enumerate() { + let line = line.map_err(|err| format!("读取失败: {err}"))?; + result.scanned_lines += 1; + if matches_line(&line, filter) { + result.matched += 1; + let sample_cap = if cfg.lines_mode { + cfg.limit + } else { + cfg.samples + }; + if sample_cap == 0 || result.samples.len() < sample_cap.min(1_000) { + result.samples.push(SampleLine { + file: path.to_path_buf(), + line_no: idx + 1, + line, + }); + } + } + } + Ok(()) +} + +fn matches_line(line: &str, filter: &MatchFilter) -> bool { + if !line.contains(&filter.iccid) { + return false; + } + if let Some(date) = &filter.date { + if !line.contains(date) { + return false; + } + } + if !filter.interface_terms.is_empty() + && !filter + .interface_terms + .iter() + .any(|term| line.contains(term)) + { + return false; + } + for term in &filter.contains { + if !line.contains(term) { + return false; + } + } + true +} + +fn print_results(cfg: &Config, results: &[FileResult], scan_total: usize, elapsed_ms: u128) { + let total_matches: usize = results.iter().map(|r| r.matched).sum(); + let total_lines: usize = results.iter().map(|r| r.scanned_lines).sum(); + let matched_files: Vec<&FileResult> = results.iter().filter(|r| r.matched > 0).collect(); + let errors: Vec<&FileResult> = results.iter().filter(|r| r.error.is_some()).collect(); + + if cfg.count_only { + for item in matched_files { + println!("{}\t{}", item.matched, item.path.display()); + } + if !errors.is_empty() { + eprintln!( + "有 {} 个文件读取失败,去掉 --count-only 可看详情", + errors.len() + ); + } + return; + } + + println!("扫描完成: {} ms", elapsed_ms); + println!( + "文件: 扫描 {} 个,命中 {} 个;行: 扫描 {} 行,命中 {} 行", + scan_total, + matched_files.len(), + total_lines, + total_matches + ); + println!("ICCID: {}", cfg.iccid); + if let Some(date) = &cfg.date { + println!("日期: {}", date); + } + if let Some(interface) = &cfg.interface { + println!("接口过滤: {}", interface); + } + + if !errors.is_empty() { + println!(); + println!("读取失败文件(最多 10 个):"); + for item in errors.iter().take(10) { + println!( + "- {}: {}", + item.path.display(), + item.error.as_deref().unwrap_or("") + ); + } + } + + if total_matches == 0 { + println!(); + println!("没有命中。建议先去掉 --interface 只按 ICCID+日期查,确认这张卡当天是否有任何轮询日志。"); + return; + } + + println!(); + println!("命中文件(最多 30 个):"); + for item in matched_files.iter().take(30) { + println!("{:>8} {}", item.matched, item.path.display()); + } + if matched_files.len() > 30 { + println!( + "... 还有 {} 个命中文件,使用 --count-only 查看完整列表", + matched_files.len() - 30 + ); + } + + let mut samples = Vec::new(); + for item in results { + for sample in &item.samples { + samples.push(sample.clone()); + } + } + samples.sort_by(|a, b| a.file.cmp(&b.file).then(a.line_no.cmp(&b.line_no))); + + if cfg.lines_mode { + println!(); + println!("原始命中行(最多 {} 行):", cfg.limit); + for sample in samples.into_iter().take(cfg.limit) { + println!( + "{}:{}:{}", + sample.file.display(), + sample.line_no, + sample.line + ); + } + if total_matches > cfg.limit { + println!( + "... 已按 --limit={} 截断,实际命中 {} 行", + cfg.limit, total_matches + ); + } + } else if cfg.samples > 0 { + println!(); + println!("样例(最多 {} 条;完整原始行用 --lines):", cfg.samples); + for sample in samples.into_iter().take(cfg.samples) { + println!( + "{}:{}:{}", + sample.file.display(), + sample.line_no, + truncate_line(&sample.line, 260) + ); + } + } +} + +fn truncate_line(line: &str, max_chars: usize) -> String { + let mut out = String::new(); + for (idx, ch) in line.chars().enumerate() { + if idx >= max_chars { + out.push_str("..."); + return out; + } + out.push(ch); + } + out +}