Files
opsx 5e59347c40 feat(settings,analysis): 日志保留时间可配(发送历史保留 7 天)+ 分析页手动刷新;修复我引入的 RWMutex 死锁
=== 0. 【严重】修复上一版引入的死锁(平台写路径全卡死)===
上一版把保留期读取写成 `GetRetentionSettings()`(内部 db.RLock()),
却在 `db.Lock()` 已持有的情况下调用(recordDelivery / RecordAudit 的裁剪路径)。
Go 的 sync.RWMutex **不可重入** → 第一次写投递历史/审计日志就永久死锁。
现象:登录 28 秒返回空(登录也要写审计)、所有写接口挂住。
go build / go vet / 之前的单测(不触发写路径)全都查不出来。
修法:
  - 拆出 retentionFromDBLocked(db) / alertDeliveryRetentionLocked(db) /
    auditLogRetentionLocked(db),调用方已持锁时用无锁版本
  - upsertSettingLocked 里**不能**用 db.NextID()(内部 d.mu.Lock(),同样死锁),
    改为锁内自算 max(id)+1
  - 新增 retention_test.go:持写锁调用裁剪函数,2 秒超时未返回即判死锁
实测修复后:登录 103ms(原 28000ms)、panic/deadlock 命中 0。

=== 1. 平台数据保留期可配(系统设置 → 通用配置 → 日志保留时间)===
新增 GET/PUT /api/v1/settings/retention:
  alertDeliveryDays  告警发送历史保留天数,默认 7(原写死"最近 50 条")
  auditLogDays       审计日志保留天数,默认 90(原写死常量)
保存后立即按新策略裁剪一次,不必等下次触发。
校验:发送历史 1~365 天、审计 1~3650 天,超出返回明确中文原因(不是 500)。
注意与「Loki 日志保留期」区分:那个是部署级配置(组件部署 → 保留策略)。

=== 2. 发送历史:50 条硬上限 → 保留 7 天 ===
后端 Deliveries 接口原来写死 `len(out) < 50`,现在按保留期取(响应上限 500 可调),
标题改为「发送历史(保留 N 天)」,N 从设置里读。

=== 3. 菜单「日志服务」→「组件部署」===

=== 4. 分析页:去掉「全部命名空间」+ 改手动刷新 ===
- 移除「全部命名空间」选项:{namespace=~".+"} 要扫全集群(约 370GB/天),
  一次分析几十秒,得不偿失;历史值 __all__ 也会被忽略并回退到策略/第一个 ns
- 筛选改为「选择值 / 已生效值」分离:切换命名空间或时间范围**不再自动查询**,
  只把刷新按钮点亮并提示「筛选条件已改,点刷新生效」;
  点一次才应用并取数。
  原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮,
  快速连点会把 Loki 打满(并发护栏 429),还拖慢别人。

=== 实测(9739c7c092e7)===
  登录 103ms / token 273 字符
  GET retention → {alertDeliveryDays:7, auditLogDays:90}
  PUT 0 天 → 400「告警发送历史保留天数需在 1~365 之间」
  平台日志 panic/deadlock 命中 0
  go test ./... 全绿(含 2 个新死锁回归用例)
2026-10-29 04:16:52 +00:00

325 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// StorageTestResult 存储连接测试结果(逐级:可达 → 认证 → 桶)
type StorageTestResult struct {
Reachable bool `json:"reachable"`
Authenticated bool `json:"authenticated"`
BucketExists bool `json:"bucketExists"`
BucketCreated bool `json:"bucketCreated"`
Message string `json:"message"`
}
// StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶)
type StorageTestParams struct {
StorageType string
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
Insecure bool
CreateIfMissing bool
}
// ==================== S3 SigV4(零依赖实现,兼容 MinIO/RustFS/S3/OSS S3 兼容端点) ====================
func hmacSHA256(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// s3Request 发起一次 SigV4 签名的 S3 请求(路径风格),path 为 "/" 或 "/<bucket>"
func s3Request(method, endpoint, region, accessKey, secretKey, path string, insecure bool) (*http.Response, error) {
// endpoint 解析:带 scheme 用之;裸主机名默认 https,insecure 时 http
base := strings.TrimSpace(endpoint)
scheme := ""
if strings.HasPrefix(base, "http://") {
scheme, base = "http", strings.TrimPrefix(base, "http://")
} else if strings.HasPrefix(base, "https://") {
scheme, base = "https", strings.TrimPrefix(base, "https://")
} else {
if insecure {
scheme = "http"
} else {
scheme = "https"
}
}
base = strings.TrimSuffix(base, "/")
if region == "" {
region = "us-east-1"
}
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256Hex("") // 空 body
// 规范请求(SigV4: host/x-amz-content-sha256/x-amz-date 参与签名)
canonicalURI := path
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
"", // query
"host:" + base + "\n" +
"x-amz-content-sha256:" + payloadHash + "\n" +
"x-amz-date:" + amzDate + "\n",
"host;x-amz-content-sha256;x-amz-date",
payloadHash,
}, "\n")
scope := dateStamp + "/" + region + "/s3/aws4_request"
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
sha256Hex(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+secretKey), dateStamp)
kRegion := hmacSHA256(kDate, region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req, err := http.NewRequest(method, scheme+"://"+base+canonicalURI, nil)
if err != nil {
return nil, err
}
req.Host = base
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHash)
req.Header.Set("Authorization", fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=%s",
accessKey, scope, signature))
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
// s3ErrBrief 从 S3 错误响应 XML 中提取 Code/Message,避免把整段 XML 暴露给用户
func s3ErrBrief(body []byte) string {
var e struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
}
if err := xml.Unmarshal(body, &e); err == nil && (e.Code != "" || e.Message != "") {
if e.Code != "" && e.Message != "" {
return e.Code + ": " + e.Message
}
return e.Code + e.Message
}
brief := strings.TrimSpace(string(body))
if len(brief) > 120 {
brief = brief[:120] + "..."
}
return brief
}
// ==================== 测试连接 ====================
// endpointHasBucket 判断 endpoint 是否已包含桶名(虚拟主机式)
func endpointHasBucket(endpoint, bucket string) bool {
host := strings.TrimSpace(endpoint)
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
return bucket != "" && strings.HasPrefix(strings.ToLower(host), strings.ToLower(bucket)+".")
}
// stripBucketPrefix 从 endpoint 里剥掉桶名前缀,得到区域端点。
//
// mybucket-1250000000.cos.ap-hongkong.myqcloud.com → cos.ap-hongkong.myqcloud.com
// cos.ap-hongkong.myqcloud.com → 原样返回
//
// Loki 的 S3 客户端(s3forcepathstyle=false)会自己把 bucketnames 拼到 endpoint 前,
// 所以配置里必须是区域端点,否则会拼出 <bucket>.<bucket>.cos... 触发 TLS 证书错误。
func stripBucketPrefix(endpoint, bucket string) string {
if bucket == "" || endpoint == "" {
return endpoint
}
scheme := ""
host := strings.TrimSpace(endpoint)
if i := strings.Index(host, "://"); i >= 0 {
scheme, host = host[:i+3], host[i+3:]
}
prefix := strings.ToLower(bucket) + "."
if strings.HasPrefix(strings.ToLower(host), prefix) {
return scheme + host[len(prefix):]
}
return endpoint
}
// bucketPathFor 根据 endpoint 风格决定桶路径(用于建桶 PUT)。
//
// 1. 区域端点(推荐): cos.ap-hongkong.myqcloud.com → 路径式 /<bucket>
// 2. 虚拟主机式: mybucket-1250000000.cos.<region>… → 路径为 /
func bucketPathFor(endpoint, bucket string) string {
if endpointHasBucket(endpoint, bucket) {
return "/"
}
return "/" + url.PathEscape(bucket)
}
// TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。
//
// 探测统一用 GET /,再按 endpoint 形态解释响应(不用 HEAD /<bucket>):
// - 区域端点 → GET / 是 ListBuckets,解析 <Buckets><Bucket><Name>
// - 虚拟主机式端点 → GET / 是 ListObjects,返回 <ListBucketResult><Name>桶名</Name>
//
// 之所以不用 HEAD:腾讯云仅有 QcloudCOSDataFullControl(数据读写)的 AK
// 并没有 cos:HeadBucket 权限,HEAD 会 403,会被误判成"桶不存在/无权限"。
func TestStorageConnection(p StorageTestParams) StorageTestResult {
vhost := endpointHasBucket(p.Endpoint, p.Bucket)
resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure)
if err != nil {
return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return StorageTestResult{
Reachable: true,
Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s",
resp.StatusCode, s3ErrBrief(body)),
}
}
if resp.StatusCode != http.StatusOK {
return StorageTestResult{
Reachable: true,
Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s",
resp.StatusCode, s3ErrBrief(body)),
}
}
found := false
if vhost {
// ListObjects:<ListBucketResult><Name>桶名</Name>… 能返回即说明桶存在且可读
if strings.Contains(string(body), "<ListBucketResult") {
if m := regexp.MustCompile(`<Name>([^<]*)</Name>`).FindSubmatch(body); len(m) > 1 {
found = strings.EqualFold(string(m[1]), p.Bucket)
} else {
found = true
}
}
} else {
// ListBuckets:<ListAllMyBucketsResult><Buckets><Bucket><Name>桶名</Name>
var listResp struct {
Buckets struct {
Bucket []struct {
Name string `xml:"Name"`
} `xml:"Bucket"`
} `xml:"Buckets"`
}
_ = xml.Unmarshal(body, &listResp)
for _, b := range listResp.Buckets.Bucket {
if b.Name == p.Bucket {
found = true
break
}
}
}
result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found}
if found {
result.Message = "连接成功,凭证有效,桶已存在"
return result
}
// ② 桶不存在:按需创建
if !p.CreateIfMissing {
result.Message = fmt.Sprintf("连接成功,凭证有效,但桶「%s」不存在;勾选「不存在时自动创建」可一键创建", p.Bucket)
return result
}
putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, bucketPathFor(p.Endpoint, p.Bucket), p.Insecure)
if err != nil {
result.Message = fmt.Sprintf("创建桶失败:%v", err)
return result
}
defer putResp.Body.Close()
// 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在
if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict {
// 二次校验:再用 GET / 确认桶真的存在(不用 HEAD —— 很多 AK 没有 cos:HeadBucket)
verified := false
if vresp, verr := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure); verr == nil {
vbody, _ := io.ReadAll(io.LimitReader(vresp.Body, 1<<20))
vresp.Body.Close()
if vhost {
verified = strings.Contains(string(vbody), "<ListBucketResult")
} else {
var vl struct {
Buckets struct {
Bucket []struct {
Name string `xml:"Name"`
} `xml:"Bucket"`
} `xml:"Buckets"`
}
_ = xml.Unmarshal(vbody, &vl)
for _, b := range vl.Buckets.Bucket {
if b.Name == p.Bucket {
verified = true
break
}
}
}
}
if verified {
result.BucketExists = true
result.BucketCreated = true
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket)
return result
}
result.Message = fmt.Sprintf(
"凭证有效,但桶「%s」创建后校验失败:重新校验仍未找到该桶。"+
"请确认 Endpoint / Region / 桶名是否正确"+
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000)", p.Bucket)
return result
}
putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512))
// 403:AK 通常只有数据读写权限,没有 CreateBucket 权限
if putResp.StatusCode == http.StatusForbidden || putResp.StatusCode == http.StatusUnauthorized {
result.Message = fmt.Sprintf(
"凭证有效,但当前 Access Key 无建桶权限(HTTP %d)。"+
"腾讯云 QcloudCOSDataFullControl 只授予数据读写/列出的权限,不含 CreateBucket。"+
"请到 COS 控制台手动创建桶「%s」(区域需与 Endpoint 一致),创建后重新测试;"+
"或在 CAM 为该 AK 追加 cos:PutBucket 权限",
putResp.StatusCode, p.Bucket)
return result
}
result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody))
return result
}