352 lines
11 KiB
Go
352 lines
11 KiB
Go
package common
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
)
|
|
|
|
// Audit levels
|
|
const (
|
|
AuditLevelFull = "full"
|
|
AuditLevelFullText = "full_text"
|
|
AuditLevelMasked = "masked"
|
|
AuditLevelQuestionOnly = "question_only"
|
|
AuditLevelNone = "none"
|
|
)
|
|
|
|
// GetAuditLevel 从 sys_options 获取审计级别,默认 full
|
|
func GetAuditLevel(ctx context.Context) string {
|
|
val := Config().GetString(ctx, "audit_level")
|
|
if val == "" {
|
|
return AuditLevelFull
|
|
}
|
|
return val
|
|
}
|
|
|
|
// GetAuditConfig 获取全局审计级别(兼容别名)
|
|
func GetAuditConfig(ctx context.Context) (string, error) {
|
|
return GetAuditLevel(ctx), nil
|
|
}
|
|
|
|
// LogSensitiveAccess 记录敏感数据访问(查看 Key 明文、审计详情等"读取"行为)。
|
|
// OperationLog 只覆盖写操作,敏感读取靠本函数留痕;IP/UA 从请求上下文自动提取。
|
|
// 写入失败只记应用日志不阻断业务 —— 留痕是辅助防线,不影响主流程。
|
|
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason string) {
|
|
ip, ua := "", ""
|
|
if r := g.RequestFromCtx(ctx); r != nil {
|
|
ip = r.GetClientIp()
|
|
ua = r.GetHeader("User-Agent")
|
|
}
|
|
if _, err := AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
|
UserId: userID,
|
|
UserType: userType,
|
|
ResourceType: resourceType,
|
|
ResourceId: resourceID,
|
|
Action: action,
|
|
Reason: reason,
|
|
IpAddress: ip,
|
|
UserAgent: ua,
|
|
}).Insert(); err != nil {
|
|
g.Log().Warningf(ctx, "记录敏感数据访问日志失败: %v", err)
|
|
}
|
|
}
|
|
|
|
// GetAuditLevels 返回全局审计级别和租户审计级别,两者完全独立。
|
|
// 全局级别从 sys_options 读取,默认 full。
|
|
// 租户级别从 tnt_tenants.settings JSONB 读取,未设置时默认 masked。
|
|
func GetAuditLevels(ctx context.Context, tenantID int64) (globalLevel, tenantLevel string) {
|
|
globalLevel = GetAuditLevel(ctx)
|
|
tenantLevel = AuditLevelMasked // 租户默认脱敏记录
|
|
if tenantID > 0 {
|
|
if tl := GetTenantAuditLevel(ctx, tenantID); tl != "" {
|
|
tenantLevel = tl
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetTenantAuditLevel 从 tnt_tenants.settings JSONB 中读取租户自身设置的审计级别。
|
|
// 未设置时返回空字符串,不回退到全局级别。
|
|
func GetTenantAuditLevel(ctx context.Context, tenantID int64) string {
|
|
// 读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列,
|
|
// 传入 *string 直接报错,会导致租户审计级别永远读不到
|
|
v, err := dao.TntTenants.Ctx(ctx).
|
|
Where("id", tenantID).
|
|
Fields("settings").
|
|
Value()
|
|
settingsJSON := ""
|
|
if err == nil {
|
|
settingsJSON = v.String()
|
|
}
|
|
if settingsJSON == "" {
|
|
return ""
|
|
}
|
|
var settings struct {
|
|
AuditLevel string `json:"audit_level"`
|
|
}
|
|
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
|
return ""
|
|
}
|
|
return settings.AuditLevel
|
|
}
|
|
|
|
// ApplyAuditLevel 根据审计级别处理请求体和响应体,返回处理后的内容。
|
|
func ApplyAuditLevel(level string, requestBody, responseBody string, isStream bool, path string) (req, resp string) {
|
|
// 多模态请求(对话内联图片/音频、视频参考图)的 base64 载荷先剥离成长度占位:
|
|
// multipart 体已在 relay handler 侧被挡成 "[binary body omitted]",但内联在 JSON
|
|
// 里的 data URL 会原样落库,单条就能把 aud_request_logs 撑到几 MB。
|
|
// 此处是对话与异步任务两条审计链路的唯一收口点(DataProviderImpl.RecordAudit),
|
|
// 在这里剥离即可同时覆盖。none 级别直接丢弃,无需白做一次剥离。
|
|
if level == AuditLevelNone {
|
|
return "", ""
|
|
}
|
|
req = StripInlineMedia(requestBody)
|
|
resp = StripInlineMedia(responseBody)
|
|
|
|
switch level {
|
|
case AuditLevelQuestionOnly:
|
|
resp = ""
|
|
case AuditLevelMasked:
|
|
req = MaskSensitiveData(req)
|
|
// 先提取纯文本(thinking + content),再对文本整体脱敏
|
|
if isStream {
|
|
resp = ExtractStreamText(resp, path)
|
|
} else {
|
|
resp = ExtractStreamTextNonStream(resp, path)
|
|
}
|
|
resp = MaskSensitiveData(resp)
|
|
case AuditLevelFullText:
|
|
if isStream {
|
|
resp = ExtractStreamText(resp, path)
|
|
} else {
|
|
resp = ExtractStreamTextNonStream(resp, path)
|
|
}
|
|
case AuditLevelFull:
|
|
// 完整记录,不做处理
|
|
}
|
|
return
|
|
}
|
|
|
|
// MaskSensitiveData 对字符串中的敏感信息进行脱敏处理:
|
|
// - 身份证号(18位):显示前3后4,中间脱敏
|
|
// - 手机号(11位):显示前3后4,中间脱敏
|
|
// - 邮箱地址:脱敏本地部分
|
|
// - 银行卡号(16-19位):显示前4后4,中间脱敏
|
|
// - Bearer Token:完全脱敏
|
|
// - Cookie 值:完全脱敏
|
|
// - IPv4 地址:脱敏最后一段
|
|
func MaskSensitiveData(data string) string {
|
|
if data == "" {
|
|
return data
|
|
}
|
|
|
|
result := data
|
|
|
|
// 身份证号
|
|
idCardRegex := regexp.MustCompile(`\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b`)
|
|
result = idCardRegex.ReplaceAllStringFunc(result, func(s string) string {
|
|
if len(s) == 18 {
|
|
return s[:3] + strings.Repeat("*", 11) + s[14:]
|
|
}
|
|
return s
|
|
})
|
|
|
|
// 手机号
|
|
phoneRegex := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
|
result = phoneRegex.ReplaceAllStringFunc(result, func(s string) string {
|
|
if len(s) == 11 {
|
|
return s[:3] + "****" + s[7:]
|
|
}
|
|
return s
|
|
})
|
|
|
|
// 邮箱
|
|
emailRegex := regexp.MustCompile(`\b([a-zA-Z0-9._%+\-])([a-zA-Z0-9._%+\-]*)@([a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b`)
|
|
result = emailRegex.ReplaceAllString(result, "$1****@$3")
|
|
|
|
// 银行卡号
|
|
bankRegex := regexp.MustCompile(`\b[3-6]\d{15,18}\b`)
|
|
result = bankRegex.ReplaceAllStringFunc(result, func(s string) string {
|
|
if len(s) >= 16 {
|
|
return s[:4] + strings.Repeat("*", len(s)-8) + s[len(s)-4:]
|
|
}
|
|
return s
|
|
})
|
|
|
|
// Bearer Token
|
|
tokenRegex := regexp.MustCompile(`(?i)(bearer\s+|token[=:"\s]+|access_token[=:"\s]+|refresh_token[=:"\s]+)([a-zA-Z0-9_\-.]+)`)
|
|
result = tokenRegex.ReplaceAllString(result, "$1****")
|
|
|
|
// Cookie
|
|
cookieRegex := regexp.MustCompile(`(?i)(cookie[=:"\s]+)([a-zA-Z0-9_\-=]+)`)
|
|
result = cookieRegex.ReplaceAllString(result, "$1****")
|
|
|
|
// IPv4
|
|
ipRegex := regexp.MustCompile(`\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.(\d{1,3})\b`)
|
|
result = ipRegex.ReplaceAllString(result, "$1.*")
|
|
|
|
return result
|
|
}
|
|
|
|
// ExtractStreamText 从 SSE 原始数据中提取纯文本(思考过程 + 回答内容)。
|
|
// 根据 path 判断协议格式:OpenAI 格式(/chat/completions 等)和 Claude 格式(/messages)。
|
|
// 返回 JSON 字符串:{"thinking":"...","content":"..."},无对应内容时字段为空字符串。
|
|
func ExtractStreamText(sseData string, path string) string {
|
|
var thinking, content strings.Builder
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(sseData))
|
|
scanner.Buffer(make([]byte, 64*1024), 64*1024)
|
|
|
|
var currentEvent string
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
// Claude SSE 使用 event: 行标记事件类型
|
|
if strings.HasPrefix(line, "event: ") {
|
|
currentEvent = strings.TrimPrefix(line, "event: ")
|
|
continue
|
|
}
|
|
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
|
|
payload := strings.TrimPrefix(line, "data: ")
|
|
if payload == "[DONE]" {
|
|
break
|
|
}
|
|
|
|
if strings.HasSuffix(path, "/messages") {
|
|
extractClaudeChunk(payload, currentEvent, &thinking, &content)
|
|
} else {
|
|
extractOpenAIChunk(payload, &thinking, &content)
|
|
}
|
|
}
|
|
|
|
result := map[string]string{
|
|
"thinking": thinking.String(),
|
|
"content": content.String(),
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return string(b)
|
|
}
|
|
|
|
// extractOpenAIChunk 从 OpenAI 格式 chunk 中提取文本
|
|
func extractOpenAIChunk(payload string, thinking, content *strings.Builder) {
|
|
var chunk struct {
|
|
Choices []struct {
|
|
Delta struct {
|
|
Content *string `json:"content"`
|
|
ReasoningContent *string `json:"reasoning_content"`
|
|
} `json:"delta"`
|
|
} `json:"choices"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
|
return
|
|
}
|
|
for _, choice := range chunk.Choices {
|
|
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
|
|
thinking.WriteString(*choice.Delta.ReasoningContent)
|
|
}
|
|
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
|
|
content.WriteString(*choice.Delta.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractClaudeChunk 从 Claude 格式 SSE 事件中提取文本
|
|
func extractClaudeChunk(payload string, _ string, thinking, content *strings.Builder) {
|
|
var event struct {
|
|
Type string `json:"type"`
|
|
Delta struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Thinking string `json:"thinking"`
|
|
} `json:"delta"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
|
return
|
|
}
|
|
// thinking 块:字段名是 "thinking" 而非 "text"
|
|
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
|
|
thinking.WriteString(event.Delta.Thinking)
|
|
}
|
|
// 文本块
|
|
if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
|
|
content.WriteString(event.Delta.Text)
|
|
}
|
|
}
|
|
|
|
// ExtractStreamTextNonStream 对非流式响应提取文本(full_text 级别复用)
|
|
func ExtractStreamTextNonStream(respBody string, path string) string {
|
|
if strings.HasSuffix(path, "/messages") {
|
|
return extractClaudeNonStreamText(respBody)
|
|
}
|
|
return extractOpenAINonStreamText(respBody)
|
|
}
|
|
|
|
func extractOpenAINonStreamText(respBody string) string {
|
|
var resp struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
if err := json.Unmarshal([]byte(respBody), &resp); err != nil {
|
|
return respBody
|
|
}
|
|
result := map[string]string{}
|
|
for _, c := range resp.Choices {
|
|
if c.Message.ReasoningContent != "" {
|
|
result["thinking"] = result["thinking"] + c.Message.ReasoningContent
|
|
}
|
|
if c.Message.Content != "" {
|
|
result["content"] = result["content"] + c.Message.Content
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return respBody
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return string(b)
|
|
}
|
|
|
|
func extractClaudeNonStreamText(respBody string) string {
|
|
var resp struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Thinking string `json:"thinking"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal([]byte(respBody), &resp); err != nil {
|
|
return respBody
|
|
}
|
|
result := map[string]string{}
|
|
for _, block := range resp.Content {
|
|
// thinking 块:字段名是 "thinking" 而非 "text"
|
|
if block.Type == "thinking" && block.Thinking != "" {
|
|
result["thinking"] = result["thinking"] + block.Thinking
|
|
}
|
|
if block.Type == "text" && block.Text != "" {
|
|
result["content"] = result["content"] + block.Text
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return respBody
|
|
}
|
|
b, _ := json.Marshal(result)
|
|
return string(b)
|
|
}
|