// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package censor import ( "bytes" "context" "encoding/json" "io" "net/http" "time" "github.com/pkg/errors" "github.com/qiniu/go-sdk/v7/auth" "github.com/samber/lo" ) type QiniuTextCensor struct { accessKey, accessSecret string } func NewQiniuTextCensor(accessKey, accessSecret string) *QiniuTextCensor { return &QiniuTextCensor{ accessKey: accessKey, accessSecret: accessSecret, } } type QiniuTextCensorResponse struct { Code int `json:"code"` Message string `json:"message"` Result struct { Suggestion string `json:"suggestion"` Scenes struct { Antispam struct { Suggestion string `json:"suggestion"` Details []struct { Label string `json:"label"` Score float64 `json:"score"` Contexts []struct { Context string `json:"context"` Positions []struct { StartPos int `json:"startPos"` EndPos int `json:"endPos"` } `json:"positions"` } `json:"contexts"` } `json:"details"` } `json:"antispam"` } `json:"scenes"` } `json:"result"` } func (r *QiniuTextCensorResponse) IsPass() bool { for _, detail := range r.Result.Scenes.Antispam.Details { label := detail.Label // These are absolutely not allowed to display to users. blockedLabels := []string{"politics", "terrorism", "porn"} if lo.Contains(blockedLabels, label) { return false } } // ⚠️ Right now, we allow `review` and `pass` to pass the censor. return r.Result.Scenes.Antispam.Suggestion != "block" } // Censor censors text with Qiniu API. // https://developer.qiniu.com/censor/7260/api-text-censor func (c *QiniuTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) { var bodyBuffer bytes.Buffer if err := json.NewEncoder(&bodyBuffer).Encode(map[string]interface{}{ "data": map[string]interface{}{ "text": text, }, "params": map[string]interface{}{ "scenes": []string{"antispam"}, }, }); err != nil { return nil, errors.Wrap(err, "encode request body") } request, err := http.NewRequest(http.MethodPost, "https://ai.qiniuapi.com/v3/text/censor", &bodyBuffer) if err != nil { return nil, errors.Wrap(err, "new request") } request = request.WithContext(ctx) credentials := auth.New(c.accessKey, c.accessSecret) token, err := credentials.SignRequestV2(request) if err != nil { return nil, errors.Wrap(err, "sign request") } request.Header.Set("Authorization", "Qiniu "+token) // 带超时:避免七牛审核服务无响应时请求永久挂起,拖垮提问/回答流程 client := http.Client{Timeout: 10 * time.Second} resp, err := client.Do(request) if err != nil { return nil, errors.Wrap(err, "do request") } defer func() { _ = resp.Body.Close() }() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.Wrap(err, "read body") } if resp.StatusCode/100 != 2 { return nil, errors.Errorf("unexpected error code: %d, response body: %s", resp.StatusCode, string(bodyBytes)) } return QiniuTextCensorParser(bodyBytes) } func (*QiniuTextCensor) String() string { return "qiniu" } func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) { var responseJSON QiniuTextCensorResponse if err := json.Unmarshal(raw, &responseJSON); err != nil { return nil, errors.Wrap(err, "decode response body") } var hint string var detailKey string var confidence float64 // 收集全部非 normal 维度的判定明细,供严格度策略按维度裁决(见 policy.go)。 // 只取首个维度的做法会丢掉其余维度信息,无法做到「只拦高置信度的辱骂」。 details := make([]LabelScore, 0, len(responseJSON.Result.Scenes.Antispam.Details)) for _, d := range responseJSON.Result.Scenes.Antispam.Details { if d.Label == "normal" { continue } details = append(details, LabelScore{ Label: formatQiniuForbiddenType(d.Label), Score: d.Score, }) // 保留原有语义:首个非 normal 维度决定 ForbiddenType / Hint / Confidence。 if detailKey == "" { for _, context := range d.Contexts { hint = context.Context } detailKey = d.Label confidence = d.Score } } pass := responseJSON.IsPass() // 整体建议优先取外层,缺失时回退到 antispam 场景的建议。 suggestion := responseJSON.Result.Suggestion if suggestion == "" { suggestion = responseJSON.Result.Scenes.Antispam.Suggestion } // 建议字段整体缺失、但服务自身判定为不放行时,把整体建议补成 block: // 否则策略层会因「没有明细维度 + 没有建议」把它当成未命中而放行, // 与改造前的 fail-closed 行为不一致。 if suggestion == "" && !pass { suggestion = SuggestionBlock } return &TextCensorResponse{ SourceName: "qiniu", Pass: pass, ForbiddenType: formatQiniuForbiddenType(detailKey), Hint: hint, Confidence: confidence, RawResponse: raw, Details: details, ProviderSuggestion: suggestion, }, nil } func formatQiniuForbiddenType(typ string) ForbiddenType { switch typ { case "spam": return ForbiddenTypeSpam case "ad": return ForbiddenTypeAd case "politics": return ForbiddenTypePolitics case "terrorism": return ForbiddenTypeTerrorism case "abuse": return ForbiddenTypeAbuse case "porn": return ForbiddenTypePorn case "flood": return ForbiddenTypeFlood case "contraband": return ForbiddenTypeContraband case "meaningless": return ForbiddenTypeMeaningless default: return "" } }