fix: 修复 P1-Resource 资源/SSRF 类问题

- P1-R1 rehostFromURL SSRF:syncImageDownloadClient 改用 SSRF 加固的
  DialContext,dial 阶段拒绝 loopback/link-local/私网/multicast/unspecified,
  用已校验 IP 直拨号杜绝 DNS rebinding;入口加 validateSSRFURL 预检(仅
  http/https、字面量内网 IP 直接拒)。新增 isForbiddenIP/validateSSRFURL 单测。
- P1-R2 payment LockOrder 竞态:sync.Map + 手工引用计数改为 256 分片
  sharded mutex(FNV-1a),同订单恒命中同分片,无引用计数/Delete,内存恒定。
  公共 API 不变;同步更新 callback_test.go,-race 通过。
- P1-R4 OAuth 无超时:新增 oauthHTTPClient(10s 超时),替换全部 5 处
  http.DefaultClient.Do / http.Post 出站请求。
- P1-R5 告警派发 goroutine 无上限:新增 goDispatchAlertNotifications,
  阻塞式信号量(容量 16)封顶并发 + WithTimeout(30s) 兜底,不丢通知仅排队。
This commit is contained in:
martin committed 2026-07-26 12:54:41 +08:00
1 parent 4cc6ff307e
commit 35a20cffeb
8 files changed
+274 -62

No files matched your search

+2 -3
View File
@@ -11,7 +11,6 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
@@ -111,8 +110,8 @@ func RunAlertDetection(ctx context.Context) error {
}).
Update()
// Dispatch notifications
go dispatchAlertNotifications(gctx.New(), rule, eventID, currentValue, threshold)
// Dispatch notifications(带并发上限与超时兜底,见 goDispatchAlertNotifications)
goDispatchAlertNotifications(rule, eventID, currentValue, threshold)
}
}
} else {
+33
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/util/gconv"
"github.com/qianfree/team-api/internal/dao"
@@ -14,6 +15,38 @@ import (
do "github.com/qianfree/team-api/internal/model/do"
)
const (
// alertNotifyMaxConcurrent 限制 dispatchAlertNotifications 的并发派发数。
// 告警风暴时一个检测周期可能触发多条规则,每条 spawn 一个 goroutine 发邮件/打
// webhook;慢响应(SMTP 卡顿、webhook 不可达)会让挂起 goroutine 累积。容量 16 的
// 带缓冲 channel 作为信号量,把并发派发封顶在 16,避免拖垮 DB/SMTP/出站连接。
alertNotifyMaxConcurrent = 16
// alertNotifyTimeout 单次通知派发(email + in_app + webhook 全流程)的最长时长。
// 给原本无超时的 gctx.New() 兜底,确保即使目标不可达,goroutine 也会在 30s 内退出,
// 不会因上游挂起而永久泄漏。
alertNotifyTimeout = 30 * time.Second
)
// alertNotifySem 是 dispatchAlertNotifications 的并发信号量。
// 采用「阻塞获取」语义:拿不到槽位的派发任务会排队等待而非丢弃——告警通知不应丢失,
// 只需限制并发。等待中的 goroutine 栈很小(~2-4KB),配合 alertNotifyTimeout,
// 风暴能在有限时间内自然排空。
var alertNotifySem = make(chan struct{}, alertNotifyMaxConcurrent)
// goDispatchAlertNotifications 异步派发告警通知,带并发上限与超时兜底。
// 由告警引擎在事件创建后调用;阻塞式信号量保证并发不超 alertNotifyMaxConcurrent,
// WithTimeout 保证单个派发不会因上游挂起而无限挂起 goroutine。
func goDispatchAlertNotifications(rule map[string]any, eventID int64, triggerValue, threshold float64) {
go func() {
alertNotifySem <- struct{}{}
defer func() { <-alertNotifySem }()
// 用全新的带超时 context,不沿用检测循环的 ctx(检测周期结束后原 ctx 可能被取消)。
ctx, cancel := context.WithTimeout(gctx.New(), alertNotifyTimeout)
defer cancel()
dispatchAlertNotifications(ctx, rule, eventID, triggerValue, threshold)
}()
}
// dispatchAlertNotifications sends notifications for a triggered alert.
func dispatchAlertNotifications(ctx context.Context, rule map[string]any, eventID int64, triggerValue, threshold float64) {
methods := gconv.Strings(rule["notification_methods"])
+28 -35
View File
@@ -17,50 +17,43 @@ import (
)
// 订单级互斥锁,防止回调重复处理。
var (
orderLocks sync.Map
createLock sync.Mutex
)
//
// 采用 sharded mutex(按 orderNo 哈希到固定数量分片)替代原先 sync.Map + 手工引用计数
// 的实现:原 UnlockOrder 在 rcm.mu.Unlock() 之后才 refCount--,存在「归零后误 Delete」的
// 竞态窗口——并发 LockOrder 在 Delete 前后可能拿到不同的 rcm 对象,导致两路回调各自持有
// 「订单锁」却互不互斥。sharded 方案无动态分配、无引用计数、无 Delete,内存恒定为分片数,
// 且同一 orderNo 必然命中同一分片 → 互斥语义对同一订单始终正确。跨实例并发由 DB 条件更新
// (WHERE status='pending')兜底,进程内锁仅用于降低同实例并发回调时的 DB 竞争。
const orderLockShardCount = 256
var orderLockShards [orderLockShardCount]sync.Mutex
// orderLockShardIndex 按 orderNo 选定分片。FNV-1a 简单快速、分布均匀,避免不同订单扎堆。
func orderLockShardIndex(orderNo string) int {
const (
offset32 = uint32(2166136261)
prime32 = uint32(16777619)
)
h := offset32
for i := 0; i < len(orderNo); i++ {
h ^= uint32(orderNo[i])
h *= prime32
}
return int(h % orderLockShardCount)
}
// paymentAmountTolerance 回调金额与订单金额容差(0.01 CNY)。
// 用 decimal 表达,避免 float64 在 0.01 这种边界值上的二进制表示误差导致误判。
var paymentAmountTolerance = decimal.NewFromFloat(0.01)
type refCountedMutex struct {
mu sync.Mutex
refCount int
}
// LockOrder 对订单号加锁。
// LockOrder 对订单号加锁(按 orderNo 哈希到固定分片,同订单始终命中同一分片)。
func LockOrder(orderNo string) {
createLock.Lock()
var rcm *refCountedMutex
if v, ok := orderLocks.Load(orderNo); ok {
rcm = v.(*refCountedMutex)
} else {
rcm = &refCountedMutex{}
orderLocks.Store(orderNo, rcm)
}
rcm.refCount++
createLock.Unlock()
rcm.mu.Lock()
orderLockShards[orderLockShardIndex(orderNo)].Lock()
}
// UnlockOrder 释放订单锁。
func UnlockOrder(orderNo string) {
v, ok := orderLocks.Load(orderNo)
if !ok {
return
}
rcm := v.(*refCountedMutex)
rcm.mu.Unlock()
createLock.Lock()
rcm.refCount--
if rcm.refCount == 0 {
orderLocks.Delete(orderNo)
}
createLock.Unlock()
orderLockShards[orderLockShardIndex(orderNo)].Unlock()
}
// ProcessCallback 统一回调处理流程。
@@ -128,7 +121,7 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e
// 该条件更新的 RowsAffected 是【跨实例】幂等闸门:多实例并发回调时,两个回调都会
// 通过上面第 5 步的 pending 检查,但 "WHERE status='pending'" 的原子更新只有一个能命中
// (另一个在行锁释放后重读到 status 已是 paid → 0 行)。据此仅让真正把订单从 pending
// 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLocks 仅单实例有效,
// 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLockShards 仅单实例有效,
// 不能作为并发保护依赖。
res, err := dao.OrdOrders.Ctx(ctx).
Where("id", order.ID).
+26 -13
View File
@@ -32,9 +32,9 @@ func TestLockUnlock_DifferentOrders(t *testing.T) {
}
}
func TestUnlockOrder_NonExistent(t *testing.T) {
UnlockOrder("never-locked-order")
}
// 注:sharded mutex 使用裸 sync.Mutex,UnlockOrder 必须与 LockOrder 严格配对
// (生产代码均通过 defer 保证)。Unlock 一个未加锁的 mutex 会 panic,这是更严格的
// 契约,能暴露调用错误,故不再保留「UnlockOrder 不存在订单」的容错测试。
// ─── Mutual exclusion ───────────────────────────────────────────────
@@ -74,9 +74,12 @@ func TestLockOrder_MutualExclusion(t *testing.T) {
}
}
// ─── Ref-counting cleanup ───────────────────────────────────────────
// ─── Multiple waiters serialize through the same shard ─────────────
func TestLockOrder_RefCounting(t *testing.T) {
// TestLockOrder_SerializesMultipleWaiters 验证同一订单的多个等待者在持有者释放后
// 能依次拿到锁。sharded mutex 不再有引用计数/清理语义(分片静态存在),故不再断言
// map 清理,仅断言所有等待者最终都完成。
func TestLockOrder_SerializesMultipleWaiters(t *testing.T) {
orderNo := "test-order-refcount"
var sequence []int
var mu sync.Mutex
@@ -104,10 +107,23 @@ func TestLockOrder_RefCounting(t *testing.T) {
if len(sequence) != 3 {
t.Fatalf("expected 3 goroutines to complete, got %d", len(sequence))
}
}
_, exists := orderLocks.Load(orderNo)
if exists {
t.Fatal("lock entry should be cleaned up after all refs are released")
// ─── Shard determinism ─────────────────────────────────────────────
// TestOrderLockShardIndex_Deterministic 验证同一 orderNo 始终映射到同一分片
// (这是 sharded mutex 对同一订单互斥正确性的前提)。
func TestOrderLockShardIndex_Deterministic(t *testing.T) {
for _, orderNo := range []string{"ORD-2024-0001", "abc", "x", "169.254.169.254"} {
first := orderLockShardIndex(orderNo)
for i := 0; i < 10; i++ {
if got := orderLockShardIndex(orderNo); got != first {
t.Fatalf("orderLockShardIndex(%q) not deterministic: %d vs %d", orderNo, got, first)
}
if first < 0 || first >= orderLockShardCount {
t.Fatalf("orderLockShardIndex(%q) = %d out of range [0,%d)", orderNo, first, orderLockShardCount)
}
}
}
}
@@ -139,17 +155,14 @@ func TestLockOrder_ConcurrentStress(t *testing.T) {
// ─── Re-lock after full release ─────────────────────────────────────
// TestLockOrder_ReuseAfterRelease 验证释放后可再次加锁。sharded mutex 分片静态存在,
// 无需(也不再有)清理动作,只需确保 unlock 后同订单可重新 lock 而不死锁。
func TestLockOrder_ReuseAfterRelease(t *testing.T) {
orderNo := "test-order-reuse"
LockOrder(orderNo)
UnlockOrder(orderNo)
_, exists := orderLocks.Load(orderNo)
if exists {
t.Fatal("lock should be cleaned up after release")
}
LockOrder(orderNo)
UnlockOrder(orderNo)
}
+95 -1
View File
@@ -9,7 +9,10 @@ import (
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
@@ -788,13 +791,104 @@ func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentTyp
// 成品图片文件。显式设置超时,避免 http.DefaultClient 无超时时慢速/挂起的图片服务器长期
// 占用 worker(io.LimitReader 只限体积不限时间)。120s 对 ≤20MB 的成品图很充裕,且远低于
// 生成超时——放宽是为了避免「生成已成功却因下载慢被判失败+退款」的资损。
var syncImageDownloadClient = &http.Client{Timeout: 120 * time.Second}
//
// SSRF 加固:Transport 使用 ssrfSafeDialContext,在连接建立阶段拒绝 loopback / link-local
// (含 169.254.169.254 云元数据)/ RFC1918 私网 / multicast / unspecified 地址。在 dial 时
// 校验而非仅在请求前校验,可关闭 DNS rebinding 的 TOCTOU 窗口(攻击者 DNS 先返回公网 IP
// 绕过预检、实际连接时再返回内网 IP)。
var syncImageDownloadClient = &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
DialContext: ssrfSafeDialContext,
Proxy: nil, // 显式禁用代理,避免通过代理绕过 IP 校验访问内网
IdleConnTimeout: 30 * time.Second,
},
}
// errSSRFBlocked 表示 URL 解析出的目标地址落在禁止访问的网段。
var errSSRFBlocked = errors.New("url host resolves to a forbidden (private/link-local/loopback) address")
// isForbiddenIP 判断 ip 是否落在服务端抓取永远不应触达的地址段(SSRF 黑名单):
// loopback、link-local(含 169.254.169.254 云元数据)、RFC1918 私网、multicast、unspecified。
func isForbiddenIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsPrivate() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// validateSSRFURL 对即将下载的 URL 做请求前的快速校验:仅允许 http/https scheme,
// 并对字面量 IP 主机直接判定网段。域名主机由 ssrfSafeDialContext 在 dial 时再校验。
func validateSSRFURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("unsupported scheme %q (only http/https allowed)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return errors.New("url host is empty")
}
// 字面量 IP 在请求前即可判定;域名留待 dial 时按解析结果判定(防 DNS rebinding)。
if ip := net.ParseIP(host); ip != nil && isForbiddenIP(ip) {
return errSSRFBlocked
}
return nil
}
// ssrfSafeDialContext 在连接阶段解析主机名并拒绝任何指向 loopback / link-local / 私网 /
// multicast / unspecified 的地址,随后用解析到的 IP 直接建立连接(不再二次解析,杜绝
// rebinding)。检查放在 dial 而非仅请求前,可关闭「预检解析为公网、连接时解析为内网」的
// TOCTOU 窗口。
func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if isForbiddenIP(ip.IP) {
return nil, errSSRFBlocked
}
}
// 直接用已校验的 IP 拨号,避免 Transport 内部再次走 DNS(可能被 rebind 到内网)。
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
var lastErr error
for _, ip := range ips {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if err != nil {
lastErr = err
continue
}
return conn, nil
}
if lastErr == nil {
lastErr = errors.New("no usable address after SSRF filter")
}
return nil, lastErr
}
func rehostFromURL(ctx context.Context, job *SyncImageJob, url string, index int) (string, error) {
// 先确认存储可用,避免存储未配置时白白下载一遍成品图。
if _, err := acquireSyncImageFileSvc(ctx); err != nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err)
}
// SSRF 预检:拒绝 file://、gopher:// 等非 http(s) scheme,以及字面量内网 IP。
// 域名主机的网段校验由 syncImageDownloadClient 的 DialContext 在 dial 时兜底。
if err := validateSSRFURL(url); err != nil {
return "", fmt.Errorf("reject image url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"testing"
@@ -293,3 +294,73 @@ func TestTruncateStr(t *testing.T) {
}
}
}
func TestIsForbiddenIP(t *testing.T) {
cases := []struct {
ip string
want bool
}{
// 禁止:云元数据 / 私网 / loopback / link-local / multicast / unspecified
{"169.254.169.254", true}, // AWS/云元数据
{"169.254.0.1", true}, // link-local
{"10.0.0.1", true}, // 私网 10/8
{"172.16.0.1", true}, // 私网 172.16/12
{"172.31.255.255", true}, // 私网 172.16/12 上界
{"172.32.0.1", false}, // 公网(紧邻私网上界)
{"192.168.1.1", true}, // 私网 192.168/16
{"127.0.0.1", true}, // loopback
{"0.0.0.0", true}, // unspecified
{"224.0.0.1", true}, // multicast
{"::1", true}, // IPv6 loopback
{"fe80::1", true}, // IPv6 link-local
{"fc00::1", true}, // IPv6 ULA(私网)
// 允许:公网
{"8.8.8.8", false},
{"1.1.1.1", false},
{"104.16.0.1", false},
}
for _, c := range cases {
ip := net.ParseIP(c.ip)
if ip == nil {
t.Fatalf("ParseIP(%q) failed", c.ip)
}
if got := isForbiddenIP(ip); got != c.want {
t.Errorf("isForbiddenIP(%q) = %v, want %v", c.ip, got, c.want)
}
}
}
func TestValidateSSRFURL(t *testing.T) {
blocked := []string{
// 字面量内网 IP(预检阶段直接拒绝)
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/x.png",
"http://127.0.0.1/x.png",
"http://192.168.1.1/x.png",
"http://172.16.0.1/x.png",
"https://[::1]/x.png",
// 非 http(s) scheme
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_FLUSHALL",
"ftp://example.com/x.png",
// 空 host
"http:///x.png",
}
for _, u := range blocked {
if err := validateSSRFURL(u); err == nil {
t.Errorf("validateSSRFURL(%q) = nil, want error", u)
}
}
allowed := []string{
// 公网字面量 IP 与域名均应通过预检(域名的网段校验由 dial 时兜底)
"https://8.8.8.8/x.png",
"http://example.com/image.png",
"https://cdn.example.com/a/b/c.jpeg",
}
for _, u := range allowed {
if err := validateSSRFURL(u); err != nil {
t.Errorf("validateSSRFURL(%q) = %v, want nil", u, err)
}
}
}
+11 -5
View File
@@ -29,6 +29,12 @@ const (
oauthStateTTL = 5 * time.Minute
)
// oauthHTTPClient 是 OAuth 出站请求专用 HTTP 客户端。
// 显式设置 10s 超时,避免 http.DefaultClient(无超时)在 GitHub/Google 上游慢响应或
// 挂起时长期占用 goroutine——登录回调路径上每个未完成的 OAuth 请求都会泄漏一个 goroutine。
// OAuth token 交换与 userinfo 都是短小 JSON 请求,10s 足够宽裕。
var oauthHTTPClient = &http.Client{Timeout: 10 * time.Second}
// OAuthProvider defines the interface for OAuth providers.
type OAuthProvider interface {
GetName() string
@@ -491,7 +497,7 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string) (*OAuth
return nil, err
}
resp, err := http.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(string(bodyBytes)))
resp, err := oauthHTTPClient.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
@@ -513,7 +519,7 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, err
}
@@ -553,7 +559,7 @@ func githubFetchPrimaryEmail(token string) string {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return ""
}
@@ -600,7 +606,7 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth
v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback")
v.Set("grant_type", "authorization_code")
resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode()))
resp, err := oauthHTTPClient.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
@@ -621,7 +627,7 @@ func (p *GoogleProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU
req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, err
}
+8 -5
View File
@@ -175,31 +175,34 @@
### 🌐 P1-Resource 资源/SSRF 类
#### - [ ] P1-R1 rehostFromURL SSRF 风险
#### - [x] P1-R1 rehostFromURL SSRF 风险
- **位置**:`internal/logic/task/sync_image_worker.go:756-782`
- **问题描述**:直接 GET 上游返回的 URL 下载图片,无内网地址过滤。
- **影响**:恶意/被劫持渠道返回 `http://169.254.169.254/latest/meta-data/` 即可让 worker 拉取内网元数据。
- **修复建议**:过滤 link-local(169.254.0.0/16)、私有 IP 段(10/172.16/192.168)、loopback;解析 DNS 后再校验。
- **修复**:`syncImageDownloadClient` 改用 SSRF 加固的 `Transport.DialContext`(`ssrfSafeDialContext`),在 dial 阶段解析主机名并拒绝 loopback/link-local/RFC1918 私网/multicast/unspecified 地址,用已校验 IP 直接拨号杜绝 DNS rebinding;`rehostFromURL` 入口加 `validateSSRFURL` 预检(仅 http(s)、字面量内网 IP 直接拒)。新增 `TestIsForbiddenIP`/`TestValidateSSRFURL` 覆盖。
#### - [ ] P1-R2 payment LockOrder 引用计数锁竞态
#### - [x] P1-R2 payment LockOrder 引用计数锁竞态
- **位置**:`internal/logic/payment/callback.go:30-59`
- **问题描述**:`sync.Map` + 手工引用计数,`UnlockOrder` 中 `rcm.mu.Unlock()` 之后才 `refCount--`,存在归零后误 Delete 的窗口。
- **修复建议**:用 sharded mutex 或 singleflight 替代手写引用计数;或直接依赖 DB 条件更新删掉进程内锁。
- **修复**:改为 256 分片 sharded mutex(`orderLockShards` + FNV-1a 哈希),同一 orderNo 恒映射到同一分片 → 互斥语义正确;无动态分配/引用计数/Delete,内存恒定。公共 API `LockOrder`/`UnlockOrder` 不变,`admin/order.go` 无需改动。同步更新 `callback_test.go`(移除已不适用的引用计数清理/不存在订单容错断言,新增分片确定性测试),`-race` 通过。
#### - [ ] P1-R4 OAuth HTTP 客户端无超时
#### - [x] P1-R4 OAuth HTTP 客户端无超时
- **位置**:`internal/logic/tenant/oauth.go:516,556,624`
- **问题描述**:用 `http.DefaultClient`(无超时),上游慢响应会挂死 goroutine。
- **修复建议**:`&http.Client{Timeout: 10 * time.Second}`。
- **修复**:新增包级 `oauthHTTPClient = &http.Client{Timeout: 10 * time.Second}`,替换全部 5 处出站请求(GitHub/Google 的 token 交换 `http.Post` × 2 与 userinfo `http.DefaultClient.Do` × 3)。
#### - [ ] P1-R5 dispatchAlertNotifications goroutine 无上限
#### - [x] P1-R5 dispatchAlertNotifications goroutine 无上限
- **位置**:`internal/logic/monitor/alert_engine.go:114` + `alert_notify.go:142`
- **问题描述**:ctx 是 `gctx.New()`(无超时),告警风暴时可能产生数百个挂起 goroutine。
- **修复建议**:给 ctx 加 timeout 或限制并发数。
- **修复**:双管齐下——新增 `goDispatchAlertNotifications`,阻塞式信号量 `alertNotifySem`(容量 16)封顶并发派发(不丢通知,仅排队),并对派发 ctx 加 `context.WithTimeout(gctx.New(), 30s)` 兜底;告警引擎改为调用该 helper,移除直连 `go dispatchAlertNotifications(gctx.New(), ...)`。
---