上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点 (/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/ 非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截; 命中后异步完成审计与计费: - 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞 不影响留痕 - ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400) - 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与 WebSocket 计费口径统一,零 token 命中不误扣 - 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截 不再发往上游,仅屏蔽该会话不影响同 Key 其他会话 - 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且 历史行在违规计数中一并排除 WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录; 透传的错误响应不被兜底逻辑追加内容污染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// CyberSessionBlockStore 是 cyber 会话屏蔽表的存取接口。
|
|
// repository 层 gatewayCache 附带实现(类型断言探测接入,不改 GatewayCache
|
|
// 共享接口);测试 stub 不实现时屏蔽能力自动降级关闭。
|
|
type CyberSessionBlockStore interface {
|
|
SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error
|
|
IsCyberSessionBlocked(ctx context.Context, key string) (bool, error)
|
|
}
|
|
|
|
// CyberSessionBlockKey 派生会话屏蔽 key:仅用显式会话标识(header
|
|
// session_id/conversation_id 或 body prompt_cache_key),混入 apiKeyID 隔离后
|
|
// sha256。无显式标识返回空串——调用方必须放行(粒度决策:不退化到
|
|
// user/apikey/内容派生)。
|
|
func CyberSessionBlockKey(apiKeyID int64, c *gin.Context, body []byte) string {
|
|
raw := explicitOpenAISessionID(c, body)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
isolated := isolateOpenAISessionID(apiKeyID, raw)
|
|
sum := sha256.Sum256([]byte(isolated))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// cyberSessionBlockStore 探测 cache 是否具备屏蔽存储能力。
|
|
// 注意:若未来以装饰器包装 GatewayCache(如日志/指标装饰器),该装饰器必须同时实现
|
|
// CyberSessionBlockStore,否则会话屏蔽能力将静默降级关闭
|
|
// (编译断言 var _ service.CyberSessionBlockStore = (*gatewayCache)(nil) 只覆盖
|
|
// *gatewayCache 本体,无法覆盖其外层包装)。
|
|
func (s *OpenAIGatewayService) cyberSessionBlockStore() CyberSessionBlockStore {
|
|
if s == nil || s.cache == nil {
|
|
return nil
|
|
}
|
|
store, ok := s.cache.(CyberSessionBlockStore)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return store
|
|
}
|
|
|
|
// CyberSessionBlockRuntime 返回 (开关, TTL)。开关默认关。
|
|
// 委托给 SettingService.GetCyberSessionBlockRuntime,进程内缓存避免热路径 DB 往返。
|
|
func (s *OpenAIGatewayService) CyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) {
|
|
if s == nil || s.settingService == nil {
|
|
return false, time.Hour
|
|
}
|
|
return s.settingService.GetCyberSessionBlockRuntime(ctx)
|
|
}
|
|
|
|
// MarkCyberSessionBlocked 把会话写入屏蔽表(写入点:cyber 命中后)。
|
|
// 开关关闭、key 为空或存储不可用时静默跳过。
|
|
func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key string) {
|
|
if key == "" {
|
|
return
|
|
}
|
|
enabled, ttl := s.CyberSessionBlockRuntime(ctx)
|
|
if !enabled {
|
|
return
|
|
}
|
|
store := s.cyberSessionBlockStore()
|
|
if store == nil {
|
|
return
|
|
}
|
|
if err := store.SetCyberSessionBlocked(ctx, key, ttl); err != nil {
|
|
logger.LegacyPrintf("service.openai_gateway", "cyber session block write failed: err=%v", err)
|
|
}
|
|
}
|
|
|
|
// IsCyberSessionBlocked 查询会话是否被屏蔽(拦截点)。开关关闭、key 为空、
|
|
// 存储不可用或查询出错时返回 false(fail-open:屏蔽是增强防护,不阻断主链路)。
|
|
func (s *OpenAIGatewayService) IsCyberSessionBlocked(ctx context.Context, key string) bool {
|
|
if key == "" {
|
|
return false
|
|
}
|
|
enabled, _ := s.CyberSessionBlockRuntime(ctx)
|
|
if !enabled {
|
|
return false
|
|
}
|
|
store := s.cyberSessionBlockStore()
|
|
if store == nil {
|
|
return false
|
|
}
|
|
blocked, err := store.IsCyberSessionBlocked(ctx, key)
|
|
if err != nil {
|
|
logger.LegacyPrintf("service.openai_gateway", "cyber session block read failed: err=%v", err)
|
|
return false
|
|
}
|
|
return blocked
|
|
}
|