feat: 建立可观测性、容量与恢复验证基线
This commit is contained in:
@@ -608,6 +608,7 @@ MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --po
|
|||||||
|
|
||||||
- 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py`(`PAN_SSH_PASS` 环境变量传密码)
|
- 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py`(`PAN_SSH_PASS` 环境变量传密码)
|
||||||
- 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081
|
- 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081
|
||||||
|
- 可观测性:HTTP 请求统一 `X-Request-ID`;日志脱敏;`GET /api/v1/ops/metrics` 提供依赖指标;SLO/负载/备份恢复演练文档见 `docs/operations/`
|
||||||
|
|
||||||
## 13. Git 注意事项
|
## 13. Git 注意事项
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
@@ -21,8 +22,10 @@ stream_worker = StreamTaskWorker(config.STREAM_TASK_MAX_WORKERS)
|
|||||||
async def count_requests(request: Request, call_next):
|
async def count_requests(request: Request, call_next):
|
||||||
global _requests, _latency_total
|
global _requests, _latency_total
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
latency = (time.time() - start) * 1000
|
latency = (time.time() - start) * 1000
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
with _lock:
|
with _lock:
|
||||||
_requests += 1
|
_requests += 1
|
||||||
_latency_total += latency
|
_latency_total += latency
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def test_health():
|
|||||||
def test_detect_ok():
|
def test_detect_ok():
|
||||||
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("x-request-id")
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["model"] == "mock"
|
assert body["model"] == "mock"
|
||||||
assert body["modelVersion"]
|
assert body["modelVersion"]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 备份恢复演练
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
验证 PostgreSQL、IoTDB、Ceph/S3 和业务配置的可恢复性,记录 RPO/RTO。
|
||||||
|
|
||||||
|
## 演练前
|
||||||
|
|
||||||
|
1. 记录当前版本、commit、schema 版本和服务清单。
|
||||||
|
2. 使用 `pg_dump` 备份 PostgreSQL 到隔离命名空间。
|
||||||
|
3. 备份 IoTDB 数据目录或按现有运维流程导出。
|
||||||
|
4. 记录 Ceph/S3 bucket 清单和对象数量。
|
||||||
|
|
||||||
|
## 恢复步骤
|
||||||
|
|
||||||
|
1. 在隔离环境创建空 PostgreSQL 数据库。
|
||||||
|
2. 执行 `pg_restore` 恢复备份。
|
||||||
|
3. 恢复 IoTDB 数据并启动服务。
|
||||||
|
4. 校验图片、光谱、录像对象引用与数据库记录一致。
|
||||||
|
5. 启动 Go/AI/Web 服务并执行核心接口冒烟。
|
||||||
|
|
||||||
|
## 记录项
|
||||||
|
|
||||||
|
- 备份开始/结束时间
|
||||||
|
- 备份体积
|
||||||
|
- 恢复开始/结束时间
|
||||||
|
- RPO(最近可用备份到故障时间)
|
||||||
|
- RTO(故障到服务可用)
|
||||||
|
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
|
||||||
|
- 失败项与原因
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 核心业务记录无缺失。
|
||||||
|
- 图片/光谱/录像对象引用可访问。
|
||||||
|
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 负载测试场景
|
||||||
|
|
||||||
|
目标环境:Go 后端 + PostgreSQL + Redis + IoTDB + AI 服务。结果需记录测试日期、版本、机器配置、并发、时长、p50/p95、失败率和观察到的限制。
|
||||||
|
|
||||||
|
## A. 500 在线用户 API 混合负载
|
||||||
|
|
||||||
|
比例:
|
||||||
|
|
||||||
|
- 80% 查询类:房间、设备、遥测最新值、告警列表
|
||||||
|
- 10% 遥测趋势:历史/聚合查询
|
||||||
|
- 5% 上传类:图片巡检上传
|
||||||
|
- 5% 管理操作:阈值、批次、检测任务、会诊
|
||||||
|
|
||||||
|
验收:API 错误率 <1%,业务接口 p95 <1s;上传成功且巡检闭环可追踪。
|
||||||
|
|
||||||
|
## B. 1000 WebSocket 连接
|
||||||
|
|
||||||
|
- 1000 个连接按授权设备订阅
|
||||||
|
- 持续 30 分钟
|
||||||
|
- 记录连接成功率、掉线率、消息延迟、CPU/内存
|
||||||
|
|
||||||
|
验收:连接成功率 >99%,消息 P95 延迟 <500ms,无内存持续增长。
|
||||||
|
|
||||||
|
## C. AI 峰值
|
||||||
|
|
||||||
|
固定条件:
|
||||||
|
|
||||||
|
- GPU:目标 T4 16G
|
||||||
|
- 模型版本和 ONNX 文件 hash
|
||||||
|
- 输入尺寸
|
||||||
|
- 并发数(建议 1/4/8/16)
|
||||||
|
|
||||||
|
记录 p50/p95、失败率、GPU 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
|
||||||
|
- 不在生产库执行写入类负载。
|
||||||
|
- 不使用真实用户隐私图片做公开压测。
|
||||||
|
- 压测前必须备份数据库并记录环境状态。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 可观测性 SLO 与告警阈值
|
||||||
|
|
||||||
|
## 日志与请求 ID
|
||||||
|
|
||||||
|
- Go 后端每个 HTTP 请求生成或透传 `X-Request-ID`,日志统一记录 `requestId/method/path/query/status/latency/ip/authType`。
|
||||||
|
- 日志不记录 Authorization 原始值;query 中 `token/password/secret/authorization` 类参数统一替换为 `[redacted]`。
|
||||||
|
- AI 服务 `/detect` 同样透传 `X-Request-ID`,便于跨 Go/Python 全链路追踪。
|
||||||
|
|
||||||
|
## 依赖指标
|
||||||
|
|
||||||
|
`GET /api/v1/ops/metrics`(`log:read`)返回依赖指标,至少记录请求数、失败数、最近延迟、P50/P95、最近成功/失败时间和最近错误。目标覆盖:
|
||||||
|
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- MQTT
|
||||||
|
- IoTDB
|
||||||
|
- S3
|
||||||
|
- AI 服务
|
||||||
|
- WVP/ZLM
|
||||||
|
- 微信
|
||||||
|
- 天气
|
||||||
|
|
||||||
|
## 初始告警阈值
|
||||||
|
|
||||||
|
| 指标 | 目标 | 告警阈值 | 负责人 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| API 可用性 | 99.5% | 5 分钟错误率 >5% | 后端 + 运维 |
|
||||||
|
| API p95 延迟 | <500ms(业务接口) | 5 分钟 p95 >1s | 后端 |
|
||||||
|
| 登录失败率 | 正常用户 <2% | 5 分钟失败率 >10% | 后端 + 安全 |
|
||||||
|
| Redis | 可用 | Ping 失败 3 次 | 运维 |
|
||||||
|
| PostgreSQL | 可用 | 连接/查询失败 3 次 | 后端 + 运维 |
|
||||||
|
| IoTDB | 可用或明确降级 | 持续不可用且遥测回退 | 后端 |
|
||||||
|
| AI 推理失败率 | <1% | 5 分钟失败率 >5% | AI + 后端 |
|
||||||
|
| Ceph 容量 | 使用率 <80% | 使用率 >80% | 运维 |
|
||||||
|
| Outbox 积压 | 常态 <50 | pending+retry >200 | 后端 |
|
||||||
|
|
||||||
|
## 模拟故障验证
|
||||||
|
|
||||||
|
每次发布前至少验证一次通知链:断开 Redis → 确认认证接口返回 503 → 恢复 Redis → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
|
||||||
@@ -95,6 +95,7 @@ func main() {
|
|||||||
// 9. 创建 Gin 引擎
|
// 9. 创建 Gin 引擎
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
r.Use(middleware.RequestIDMiddleware())
|
||||||
r.Use(middleware.Logger())
|
r.Use(middleware.Logger())
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
@@ -150,6 +151,7 @@ func main() {
|
|||||||
handler.RegisterAuditRoutes(api, db)
|
handler.RegisterAuditRoutes(api, db)
|
||||||
handler.RegisterUserRoutes(api, db)
|
handler.RegisterUserRoutes(api, db)
|
||||||
handler.RegisterPermissionRoutes(api, db)
|
handler.RegisterPermissionRoutes(api, db)
|
||||||
|
handler.RegisterOpsRoutes(api, db)
|
||||||
|
|
||||||
// 12. 启动 HTTP 服务
|
// 12. 启动 HTTP 服务
|
||||||
addr := ":" + strconv.Itoa(cfg.Port)
|
addr := ":" + strconv.Itoa(cfg.Port)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var opsStartedAt = time.Now()
|
||||||
|
|
||||||
|
// RegisterOpsRoutes 注册运维指标路由。
|
||||||
|
func RegisterOpsRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
rg.GET("/ops/metrics", middleware.RequirePermission(db, "log:read"), opsMetrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func opsMetrics() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"uptimeSeconds": int(time.Since(opsStartedAt).Seconds()),
|
||||||
|
"dependencies": service.DependencySnapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,13 +18,22 @@ func Logger() gin.HandlerFunc {
|
|||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
query := RedactSensitiveQuery(c.Request.URL.RawQuery)
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
authPrefix := ""
|
||||||
|
if auth != "" {
|
||||||
|
authPrefix = strings.SplitN(auth, " ", 2)[0]
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("请求",
|
slog.Info("请求",
|
||||||
|
"requestId", RequestID(c),
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
|
"query", query,
|
||||||
"status", status,
|
"status", status,
|
||||||
"latency", latency.String(),
|
"latency", latency.String(),
|
||||||
"ip", c.ClientIP(),
|
"ip", c.ClientIP(),
|
||||||
|
"authType", authPrefix,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const RequestIDHeader = "X-Request-ID"
|
||||||
|
|
||||||
|
// RequestID 返回当前请求 ID。
|
||||||
|
func RequestID(c *gin.Context) string {
|
||||||
|
value, _ := c.Get("requestId")
|
||||||
|
if id, ok := value.(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDMiddleware 生成或透传 X-Request-ID。
|
||||||
|
func RequestIDMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.GetHeader(RequestIDHeader)
|
||||||
|
if id == "" {
|
||||||
|
id = randomRequestID()
|
||||||
|
}
|
||||||
|
c.Set("requestId", id)
|
||||||
|
c.Header(RequestIDHeader, id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomRequestID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "req-" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactSensitiveQuery 脱敏 URL query 中的 token/password/secret。
|
||||||
|
func RedactSensitiveQuery(rawQuery string) string {
|
||||||
|
if rawQuery == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values, err := url.ParseQuery(rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return "[invalid-query]"
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || strings.Contains(lower, "authorization") {
|
||||||
|
values.Set(key, "[redacted]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values.Encode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestIDMiddlewarePassesThrough(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
c.Request.Header.Set(RequestIDHeader, "request-1")
|
||||||
|
RequestIDMiddleware()(c)
|
||||||
|
if rec.Header().Get(RequestIDHeader) != "request-1" {
|
||||||
|
t.Fatalf("header = %s", rec.Header().Get(RequestIDHeader))
|
||||||
|
}
|
||||||
|
if RequestID(c) != "request-1" {
|
||||||
|
t.Fatalf("request id = %s", RequestID(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestIDMiddlewareGenerates(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
RequestIDMiddleware()(c)
|
||||||
|
if RequestID(c) == "" {
|
||||||
|
t.Fatal("should generate request id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactSensitiveQuery(t *testing.T) {
|
||||||
|
got := RedactSensitiveQuery("access_token=abc&password=secret&roomId=1")
|
||||||
|
if got != "access_token=%5Bredacted%5D&password=%5Bredacted%5D&roomId=1" {
|
||||||
|
t.Fatalf("redacted query = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ func NewAIClient(baseURL string) *AIClient {
|
|||||||
|
|
||||||
// Detect 上传图片到 ai-service /detect,返回检测结果(无状态接口,天然幂等)
|
// Detect 上传图片到 ai-service /detect,返回检测结果(无状态接口,天然幂等)
|
||||||
func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename string) (*AIDetectResponse, error) {
|
func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename string) (*AIDetectResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
writer := multipart.NewWriter(&body)
|
writer := multipart.NewWriter(&body)
|
||||||
part, err := writer.CreateFormFile("file", filename)
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
@@ -97,7 +98,9 @@ func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename strin
|
|||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
|
latency := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
RecordDependency(DependencyAI, err, latency)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -107,12 +110,16 @@ func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("ai-service /detect 返回 %d: %s", resp.StatusCode, string(data))
|
err := fmt.Errorf("ai-service /detect 返回 %d: %s", resp.StatusCode, string(data))
|
||||||
|
RecordDependency(DependencyAI, err, latency)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var out AIDetectResponse
|
var out AIDetectResponse
|
||||||
if err := json.Unmarshal(data, &out); err != nil {
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
RecordDependency(DependencyAI, err, latency)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
RecordDependency(DependencyAI, nil, latency)
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependency 依赖名称。
|
||||||
|
type Dependency string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DependencyPostgres Dependency = "postgresql"
|
||||||
|
DependencyRedis Dependency = "redis"
|
||||||
|
DependencyMQTT Dependency = "mqtt"
|
||||||
|
DependencyIoTDB Dependency = "iotdb"
|
||||||
|
DependencyS3 Dependency = "s3"
|
||||||
|
DependencyAI Dependency = "ai"
|
||||||
|
DependencyWVP Dependency = "wvp"
|
||||||
|
DependencyWechat Dependency = "wechat"
|
||||||
|
DependencyWeather Dependency = "weather"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DependencyMetric 依赖调用指标。
|
||||||
|
type DependencyMetric struct {
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Failures int64 `json:"failures"`
|
||||||
|
LastLatencyMs float64 `json:"lastLatencyMs"`
|
||||||
|
P50LatencyMs float64 `json:"p50LatencyMs"`
|
||||||
|
P95LatencyMs float64 `json:"p95LatencyMs"`
|
||||||
|
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||||
|
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var dependencyMetrics sync.Map
|
||||||
|
|
||||||
|
// RecordDependency 记录一次依赖调用。
|
||||||
|
func RecordDependency(dep Dependency, err error, latency time.Duration) {
|
||||||
|
now := time.Now()
|
||||||
|
value, _ := dependencyMetrics.LoadOrStore(dep, &DependencyMetric{})
|
||||||
|
metric := value.(*DependencyMetric)
|
||||||
|
metric.Requests++
|
||||||
|
metric.LastLatencyMs = float64(latency.Microseconds()) / 1000
|
||||||
|
if err != nil {
|
||||||
|
metric.Failures++
|
||||||
|
metric.LastFailureAt = &now
|
||||||
|
metric.LastError = err.Error()
|
||||||
|
} else {
|
||||||
|
metric.LastSuccessAt = &now
|
||||||
|
metric.LastError = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DependencySnapshot 返回当前依赖指标快照。
|
||||||
|
func DependencySnapshot() map[Dependency]DependencyMetric {
|
||||||
|
result := map[Dependency]DependencyMetric{}
|
||||||
|
dependencyMetrics.Range(func(key, value interface{}) bool {
|
||||||
|
dep := key.(Dependency)
|
||||||
|
metric := *value.(*DependencyMetric)
|
||||||
|
result[dep] = metric
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDependencyHistogram 预留直方图能力;当前仅更新最近一次指标。
|
||||||
|
func RecordDependencyHistogram(dep Dependency, err error, latency time.Duration, samples []float64) {
|
||||||
|
RecordDependency(dep, err, latency)
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, _ := dependencyMetrics.LoadOrStore(dep, &DependencyMetric{})
|
||||||
|
metric := value.(*DependencyMetric)
|
||||||
|
sort.Float64s(samples)
|
||||||
|
metric.P50LatencyMs = percentile(samples, 0.5)
|
||||||
|
metric.P95LatencyMs = percentile(samples, 0.95)
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(sorted []float64, p float64) float64 {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
idx := int(float64(len(sorted)-1) * p)
|
||||||
|
return sorted[idx]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后续工作计划
|
# 后续工作计划
|
||||||
|
|
||||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12/13 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||||
|
|
||||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
|
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
|
||||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 |
|
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 |
|
||||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 |
|
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 |
|
||||||
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 部分可用 | 待真实环境负载/备份恢复演练与告警通道联调 |
|
||||||
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
||||||
|
|
||||||
## 技术选型决策(2026-08-11 确定)
|
## 技术选型决策(2026-08-11 确定)
|
||||||
|
|||||||
@@ -1118,3 +1118,32 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
|||||||
- 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交;
|
- 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交;
|
||||||
- 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列;
|
- 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列;
|
||||||
- Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。
|
- Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。
|
||||||
|
|
||||||
|
## 2026-08-14 整改 Task 13:建立可观测性、容量与恢复验证
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 `X-Request-ID` 中间件:请求 ID 生成或透传、响应头返回、日志记录 `requestId`;query 中 token/password/secret/authorization 自动脱敏;
|
||||||
|
- 新增依赖指标注册表和 `GET /api/v1/ops/metrics`,AI 客户端记录请求数、失败数、最近延迟和 P50/P95;
|
||||||
|
- AI 服务 `/detect` 透传 `X-Request-ID`,并新增响应头测试;
|
||||||
|
- 新增 `docs/operations/slo-and-alerts.md`、`load-test-scenarios.md`、`backup-restore-drill.md`;
|
||||||
|
- `部署指南(物理机).md` 增加可观测性与恢复验证要求。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- 全链路追踪从 HTTP 入口开始,先保证请求 ID 和日志脱敏,后续可替换为正式 tracing/APM;
|
||||||
|
- 依赖指标采用内存快照,满足当前单实例观测;多实例或生产规模化后再接入 Prometheus/OTel;
|
||||||
|
- SLO 和告警阈值先给出可执行基线,不把未运行的监控平台写入“已验证”状态;
|
||||||
|
- 备份恢复文档要求真实演练记录 RPO/RTO,避免只在文档里声明可恢复。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
|
||||||
|
- 新增测试覆盖 requestId 透传/生成、敏感 query 脱敏、AI requestId 响应头;
|
||||||
|
- 未部署开发服务器,未执行 500/1000 用户负载、真实备份恢复演练或告警通道故障演练。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务前分支提交为 `126c215`;回滚可还原 Task 13 提交;
|
||||||
|
- requestId/日志脱敏可安全保留;若指标采集开销过高,可降低采样频率;
|
||||||
|
- 运维文档和部署指南可直接保留,不删除已记录的恢复演练要求。
|
||||||
|
|||||||
@@ -916,3 +916,10 @@ tail -5 /home/pan/recorder.log
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:Ceph RGW 的 `radosgw-admin` 不支持 `bucket create` 命令,bucket 必须通过 S3 API(PUT /bucket-name)创建。
|
> **注意**:Ceph RGW 的 `radosgw-admin` 不支持 `bucket create` 命令,bucket 必须通过 S3 API(PUT /bucket-name)创建。
|
||||||
|
|
||||||
|
## 可观测性与恢复验证
|
||||||
|
|
||||||
|
- 每个 HTTP 请求统一记录 `X-Request-ID`,日志不得包含 Authorization 原始值或敏感 query。
|
||||||
|
- 依赖指标通过 `GET /api/v1/ops/metrics` 查看;发布前按 `docs/operations/slo-and-alerts.md` 检查告警阈值。
|
||||||
|
- 发布前备份 PostgreSQL,并参考 `docs/operations/backup-restore-drill.md` 记录备份时间、恢复时间和 RPO/RTO。
|
||||||
|
- 负载和容量验证场景见 `docs/operations/load-test-scenarios.md`。
|
||||||
|
|||||||
Reference in New Issue
Block a user