卫星平台 2.0 M2 交付实录:从登录防爆破到米级轨道预报

本文是一次真实交付的完整拆解,对应仓库提交 1501d2f:94 个文件变更、+5869 行 / -14 行,覆盖 M2 后半程的 4 条任务线、9 个工作日段(实施计划 D30–D38)。

这一轮不是单点优化,而是四条任务线并行推进:M2-6 认证加固(下)、M2-3 可观测性(后端接入 → 监控栈 → 日志与告警规则)、M2-4 SGP4 轨道引擎、M2-5 地面站过境预报与链路排程。每条线都有独立的接口边界、验证手段和明确取舍。

全文按「问题 → 设计选择 → 关键实现 → 验证证据 → 偏差与遗留」的顺序逐项展开,配置片段与指标名均取自仓库实际内容,数字取自实际运行结果,可按文末索引自行核对。不适用于快速浏览,建议按节选读。

_cover-preview-stage-1280x540.png

0. 交付全景

编号任务项核心交付验证方式与结果状态
D30M2-6 认证加固(下)登录失败锁定、认证接口限流、refresh 校验链相关 6 个测试类50/50 全绿;前端 lint / vue-tsc 0 error已交付
D31M2-3 可观测性后端接入Actuator + Micrometer、独立管理端口 8081、2 个业务指标实测:8081/actuator/health 200(db/redis/diskSpace/ping 全 UP)、/actuator/prometheus 200 含自定义指标已交付
D32M2-3 监控栈入 composePrometheus / Alertmanager / Grafana / Loki / Promtail 五服务 + 配置6 个 YAML + 看板 JSON 静态校验全 PASS;本机原生二进制拉起 Prometheus 9090 + Grafana 3000 后,targets 全 UP、大盘 10 面板拉到真实数据配置交付;Prometheus/Grafana 已验,Alertmanager/Loki 待推进
D33M2-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已交付
D34M2-4 SGP4 引擎(上)OrbitPropagator 抽象 + Orekit 实现 + 最小数据集定向单测5/5 全绿已交付
D35M2-4 SGP4 引擎(下)坐标转换双路径、航迹 API、TLE 上传、仿真灰度开关定向单测12/12 全绿 + D34 回归 5/5已交付
D36M2-4 单测与对比验证独立参考实现比对、边界用例、性能基线12/12 全绿;位置偏差米级;万次传播 405 ms已交付(零生产代码改动)
D37M2-5 过境预报(后端)仰角扫描状态机、AOS/LOS 插值、分页 API、Redis 缓存、EOP 纳入21 全绿 / 0 跳过,日志无 ITRF 回退告警已交付
D38M2-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 中看到:

prometheus-targets-all-up.png

上图为 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-graph-telemetry-rate.png

上图为 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 限额数据卷关键参数
prometheusprom/prometheus:v2.53.49090512M / 0.5Cprometheus_dataretention.time=7d、retention.size=2GB、web.enable-admin-api=false
alertmanagerprom/alertmanager:v0.27.09093128M / 0.2Calertmanager_dataextra_hosts: host-gateway
grafanagrafana/grafana:11.6.03001:3000256M / 0.3Cgrafana_data关匿名访问、关上报、默认 dark 主题
lokigrafana/loki:3.4.23100512M / 0.5Cloki_datafilesystem + tsdb、retention_period=168h
promtailgrafana/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 自动加载,面板与查询一一对应:

面板查询要点
后端 Upup{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 个面板实际渲染如下:

grafana-leo-dashboard-top.png

  • 后端 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 字段层MaskingJsonGeneratorDecoratormdc / 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级别
BackendServiceDownup{job="leo-backend"} == 01mcritical
TelemetryPushStalledsum(increase(leo_telemetry_push_total[1m])) < 11mwarning
ApiHigh5xxRate5xx / 全部rate(...[5m]) > 0.055mcritical
JvmHeapUsageHighsum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"} > 0) > 0.855mwarning

三条写规则时的判断:

  • 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(预期):

prometheus-alerts-4rules.png

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 依赖,离线可跑默认基线 / 回退路径
OrekitItrfGeoConverterDataContext ITRF(IERS_2010) ← TEME 正变换,自带完整 EOP需外部 orekit-data高保真增强
GeoConverterFacadeisAvailable() 判定 → 命中 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ΔposmaxΔvel容差结论
MV100005 / SGP4+0/+360/+1080 min0.002229 km(≈2.2 m)0.000002 km/s≤1 kmPASS
DELTA-1 DEB06251 / SGP4+0/+120/+1560 min0.000637 km(≈0.64 m)0.000001 km/s≤1 kmPASS
MOLNIYA 2-1408195 / SDP4+0/+120/+1440 min0.001723 km(≈1.7 m)0.000001 km/s≤2 kmPASS

sgp4-compare-terminal.png 参考矢量的工程处理原则:联网实测一次,随后固化为 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),零生产代码改动,纯测试与文档。

screenshot-simulation-sgp4.png

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 依赖、可离线单测。

扫描状态机的四个边界处理,是这类算法真正容易错的地方:

  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 全量展示。

地面站过境预报-MadridDSN-全列.png

7.2 GSL 窗口排程:为什么自绘而不是用日历

views/link/PassScheduleView.vue(路由 /pass-schedule,菜单项 + Timer 图标)是链路排程的实际工作面:行 = 地面站,横轴 = 时间,每段过境是一条着色区间,颜色按 satelliteId % PALETTE 取。

计划里写的是 Element Plus 日历,实现改为自绘 CSS 时间线,理由有三:一站多窗口在日历格内会严重重叠、无法横向比较各站负载;跨站冲突总览需要统一时间轴,日历的「按天分格」结构天然不适合;项目内 OrbitView 已有自绘 SVG 的先例,风格一致且零新依赖。

冲突的定义是业务约束,不是技术现象:同一地面站同一时刻只能跟踪一颗卫星,因此判定式为区间交叠 aosA < losB && aosB < losA,命中的双方标注红描边斜纹,站行头以 el-badge 计数。不同卫星在同一站重叠是排程问题,不是数据错误,所以标注但不隐藏。

pass-schedule-current.png

其余细节:顶部时间刻度按跨度自适应(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 } }) 聚焦预选站。

pass-schedule-tooltip.png

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

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP