卫星平台 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 的判定顺序被明确固定为:
▼text复制代码验证码校验 → checkLocked(锁定前置拒绝)→ 查用户 / 比对密码 → 状态校验 → reset 计数 → 签发令牌
三个细节值得单独说明:
- 锁定判定放在查库之前。锁定期内的请求不触碰用户表,既降低持续爆破的数据库成本,也不产生任何可用于枚举账户的差异响应。
- 未知用户与密码错误走同一条
recordFailure分支。区分二者等于告诉攻击者「这个用户名存在」,账户枚举防线在这里必须合并。 - 账户禁用(凭据正确但
status != 1)不计入爆破窗口。禁用不是爆破行为,计入会污染计数语义。
1.2 失败锁定:两把 Redis key,不是一张表
LoginAttemptServiceImpl 用两个 key 表达状态,全部依赖 Redis TTL 自然过期,没有定时任务、没有数据库字段:
▼text复制代码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 解析有严格优先级:
▼text复制代码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 数据流:三条链路
▼text复制代码指标: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:19095webhook 闭环验收同样搁置。 - 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 的三个决定:
@Order(Ordered.HIGHEST_PRECEDENCE),早于 Security 过滤链(order=-100)。否则被鉴权拒绝的请求日志没有 traceId,而那恰恰是最需要排查的一类日志。- 白名单校验客户端传入值:
[A-Za-z0-9\-_]{1,64},不合规即重新生成。允许任意字符串进 MDC 等于开放日志注入(CRLF 伪造日志行、超长垃圾串破坏 Loki 检索)。 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 | 容差 | 结论 |
|---|---|---|---|---|---|
MV100005 / SGP4 | +0/+360/+1080 min | 0.002229 km(≈2.2 m) | 0.000002 km/s | ≤1 km | PASS |
DELTA-1 DEB06251 / SGP4 | +0/+120/+1560 min | 0.000637 km(≈0.64 m) | 0.000001 km/s | ≤1 km | PASS |
MOLNIYA 2-1408195 / 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 的逐星扫描流程:
▼text复制代码propagateTrack(tle, start, end, step) # 采样 TEME 轨迹 → geoConverter.toEcefKm(state) # 逐帧转 ECEF(含 EOP 或回退 GMST) → GroundLookAngles.compute(...) # 站心 ENU 解仰角/方位角/斜距 → 状态机扫描仰角跨越掩星角的段 # 产出 PassWindow
几何基座 GroundLookAngles 是纯静态类(stationEcefKm 做 WGS84 大地→ECEF,compute 解站心 ENU),无 Spring 依赖、可离线单测。
扫描状态机的四个边界处理,是这类算法真正容易错的地方:
- AOS/LOS 不做整步长取整,而是对跨越掩星角的前后两帧做线性插值(
crossingFraction夹到 [0,1],分母近零退化取 0.5),时刻精度因此不受 10 s 步长限制。 - 方位角插值走最短角:
delta = ((b - a + 540) % 360) - 180。直接线性插值在 359°→1° 的过境上会算出 180°,AOS/LOS 方位角完全失真。 - 窗口起点即已在站上方时 AOS 取窗口起点,不因「没有上一帧」而漏掉这次过境。
- 窗口结束仍在站上方时以末帧收 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 明确未达标与未完成的部分
一份可信的交付记录必须包含失败与缺口,本轮的清单如下:
- 监控栈运行时验收部分推进(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 拆分同批推)。
- JaCoCo 全局门禁未达标:覆盖率阈值 70%/80% 不降,D34–D38 均以「目标单测绿 + 编译通过」为验收档,
mvn verify在全量补测完成前失败属预期,转正安排在 M2 补测冲刺。 - prod JSON 日志未做真实启动验证:以
LogbackSpringConfigTest加载真实配置文件替代;容器当前默认localprofile(文本日志),切SPRING_PROFILES_ACTIVE=prod才出 JSON,而 profile 正式拆分属于 D39。 - dev/prod 配置分离未开始(D39),
application-dev.yml/application-prod.yml与 compose 环境变量清单待交付。 - Cesium 渲染坐标系核对未做:
/orbit-predictions/{id}/track、/compare两个接口目前无前端页面消费,3D 视图的坐标系一致性核对留待 SU5 真实 TLE 接入时一并验证。 - 限流为固定窗口,窗口边界 2× 阈值的已知特性未处理(见 1.3)。
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 条约定
- 锁定/限流用 Redis TTL 表达状态,不建表、不写定时任务;剩余时间向上取整回传,成功即清全部相关 key。
- 限流维度取
X-Forwarded-For首跳,其次X-Real-IP,最后remoteAddr;整串入键会让计数维度分裂。 - 防滥用的组件可以 fail-open,防越权的组件不行——并把这个前提写进类注释,前提变了结论就要重估。
- Actuator 走独立管理端口也要显式放行 GET:安全过滤链同样拦管理端口,「独立端口天然绕过」是错误假设。
- 业务指标与告警规则同时设计:先确定要写哪条 PromQL,再决定埋 Counter 还是 Gauge。停摆类告警必须有可微分的累计量。
- 比率型告警要理解零流量时的 NaN 语义;JVM 堆比率的分母必须写
jvm_memory_max_bytes > 0排除无上限池。 - 告警配抑制规则:服务 down 时抑制同目标的资源/错误率/停摆告警,保住第一现场。
- 日志三件套缺一不可:traceId 过滤器 order 早于 Security、客户端传入值走白名单校验、
finally清 MDC。脱敏正则长键必须排在短键前。 - 数值算法的验证必须找同源不同体的独立实现,且比对前先对齐「什么时候」——亚秒级历元错位 × 7.6 km/s 就是公里级假误差。参考数据联网实测一次即固化为常量,测试永不联网。
- 后端返回无偏移的 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
