aliyun sensor server

This commit is contained in:
tamakyi
2026-09-10 18:04:19 +08:00
parent e3e59e7c69
commit 8e2325eda5
38 changed files with 3147 additions and 344 deletions
+8 -23
View File
@@ -14,7 +14,7 @@ import (
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -100,17 +100,10 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
return
}
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up text")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
@@ -167,17 +160,9 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Answer)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up answer")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Success("question/item")
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Success("question/item")
return
}
+14 -23
View File
@@ -36,7 +36,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
@@ -343,6 +343,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness),
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
// 在此解析一次,供下方私聊分支与普通提问分支复用,保证同一请求的判定口径一致。
strictness := moderation.StrictnessFor(pageUser.ModerationStrictness)
// ---------- 私聊处理 ----------
if f.StartChat == "true" && ctx.IsLogged {
@@ -431,17 +436,10 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
ctx.SetError(errors.New(censorResponse.ErrorMessage()), f)
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (审核源不可用时 moderation.Check 内部仍为 fail-closed)。
if _, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), f.Content); err != nil {
ctx.SetError(err, f)
ctx.Success("question/list")
return
}
@@ -571,18 +569,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
censorResponse, err := censor.Text(ctx.Request().Context(), content)
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行。
// 返回的 censorResponse 供下方落 content_censor_metadata(审计留痕)。
censorResponse, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行,
// 避免审核服务宕机期间提问内容免审直接公开。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
errorMessage := censorResponse.ErrorMessage()
ctx.SetError(errors.New(errorMessage), f)
ctx.SetError(err, f)
ctx.Success("question/list")
return
}
+5 -11
View File
@@ -20,7 +20,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -165,17 +165,11 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
answer := f.Answer
// 🚨 Content security check.
censorResponse, err := censor.Text(ctx.Request().Context(), answer)
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), answer)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/item")
return
}
if !censorResponse.Pass {
errorMessage := censorResponse.ErrorMessage()
ctx.SetError(errors.New(errorMessage), f)
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
+44
View File
@@ -0,0 +1,44 @@
// 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 user
import (
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/censor"
)
// UpdateModerationStrictness 保存当前账号的「内容审核严格度」档位。
//
// 该档位同时作用于提问、投票、私聊三条链路(由 internal/security/moderation.Check
// 统一裁决),因此只需在账号侧存一份。
func UpdateModerationStrictness(ctx context.Context, f form.UpdateModerationStrictness) {
// 反序列化失败(如请求体超限)时零值 Strictness="" 会被当成「跟随站点默认」写入,
// 把账号已选择的档位静默改回默认档 —— 先中止,不触碰现有设置。
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
// 非法取值一律拒绝保存:宁可保留原档位,也不要写入一个解释不了的中间状态。
if !censor.IsValidStrictness(f.Strictness) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessInvalid"))
ctx.Redirect(returnTab(ctx))
return
}
if err := db.Users.UpdateModerationStrictness(ctx.Request().Context(), ctx.User.ID, f.Strictness); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update moderation strictness")
ctx.SetInternalErrorFlash()
ctx.Redirect(returnTab(ctx))
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessUpdated"))
ctx.Redirect(returnTab(ctx))
}
+39 -25
View File
@@ -26,6 +26,7 @@ import (
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
votepkg "github.com/tamakyi/TamaBox/route/vote"
)
@@ -58,17 +59,13 @@ type voteForm struct {
}
// censorCheck 对单段文本做内容审核。
// fail-closed:审核服务异常时拒绝发布(与提问/回答流程一致);
// 未开启 EnableTextCensor 时 censor.Text 直接放行,不影响开发环境。
//
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
// 投票发起人本人的「审核严格度」档位决定。
// fail-closed:审核服务异常时拒绝发布;未开启 EnableTextCensor 时底层直接放行。
func censorCheck(ctx context.Context, text string) error {
resp, err := censor.Text(ctx.Request().Context(), text)
if err != nil {
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
}
if !resp.Pass {
return errors.New(resp.ErrorMessage())
}
return nil
_, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text)
return err
}
// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空字符串返回 nil。
@@ -159,12 +156,9 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
// 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明)
var censorMeta datatypes.JSON
if !draft {
titleCensor, err := censor.Text(ctx.Request().Context(), title)
titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), title)
if err != nil {
return nil, errors.New("内容审核服务暂时不可用,请稍后再试")
}
if !titleCensor.Pass {
return nil, errors.New(titleCensor.ErrorMessage())
return nil, err
}
censorMeta = datatypes.JSON(titleCensor.ToJSON())
if description != "" {
@@ -996,32 +990,52 @@ func validateForPublish(ctx context.Context, vote *db.Vote) error {
return nil
}
// voteOwnerStrictness 读取投票发起人本人的「审核严格度」档位。
//
// 这里不用 ctx.User 的原因:发布动作未必由发起人本人触发(后台管理、补发布等场景),
// ctx.User 有可能是操作者而非发起人,直接沿用会导致「按别人的档位审核别人箱子的内容」。
// 取不到发起人时回退最严档位 —— 宁可沿用严格判定,也不要因为查不到就把判定放宽。
func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness {
owner, err := db.Users.GetByID(ctx.Request().Context(), ownerID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).WithField("owner_id", ownerID).
Error("Failed to get vote owner for moderation strictness")
return censor.StrictnessStrict
}
return moderation.StrictnessFor(owner.ModerationStrictness)
}
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
// 草稿保存阶段跳过了审核,发布(对外公开)时必须补齐,确保公开内容受管控。
func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
// 同一次发布的全部可见文本必须用「同一个」档位裁决:这里只解析一次发起人的档位并复用。
// 否则标题走发起人档位、描述/选项走 ctx.User 档位(censorCheck 内部读 ctx.User),
// 一旦发布动作不是发起人本人触发,同一条投票的两段文本就会落到两个账号的松紧档上。
strictness := voteOwnerStrictness(ctx, vote.UserID)
check := func(text string) error {
_, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), text)
return err
}
if vote.Title != "" {
resp, err := censor.Text(ctx.Request().Context(), vote.Title)
if err != nil {
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
}
if !resp.Pass {
return errors.New(resp.ErrorMessage())
if err := check(vote.Title); err != nil {
return err
}
}
if vote.Description != "" {
if err := censorCheck(ctx, vote.Description); err != nil {
if err := check(vote.Description); err != nil {
return err
}
}
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, o := range options {
if err := censorCheck(ctx, o.Content); err != nil {
if err := check(o.Content); err != nil {
return err
}
}
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, f := range fields {
if err := censorCheck(ctx, f.Label); err != nil {
if err := check(f.Label); err != nil {
return err
}
// 预置选项(select/radio/checkbox/rank)也纳入审核
@@ -1029,7 +1043,7 @@ func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
if fo == "" {
continue
}
if err := censorCheck(ctx, fo); err != nil {
if err := check(fo); err != nil {
return err
}
}
+17 -13
View File
@@ -31,6 +31,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -57,16 +58,19 @@ func InvalidateResultsCache(voteID uint) {
resultsCache.Delete(strconv.FormatUint(uint64(voteID), 10))
}
// censorCheck 对开放文本做内容审核(fail-closed;EnableTextCensor=false 时自动放行)。
func censorCheck(ctx context.Context, lang, text string) error {
resp, err := censor.Text(ctx.Request().Context(), text)
if err != nil {
return errors.New(i18n.T(lang, "censor.unavailable"))
// censorCheck 对开放文本做内容审核。
//
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
// 投票发起人的「审核严格度」档位决定,而不是无条件采信审核服务的判定。
// 审核服务异常仍为 fail-closed 拒绝提交;EnableTextCensor=false 时底层自动放行。
// owner 为 nil 时回退到最严档位,避免发起人为空就把或从宽放行。
func censorCheck(ctx context.Context, owner *db.User, lang, text string) error {
strictness := censor.StrictnessStrict
if owner != nil {
strictness = moderation.StrictnessFor(owner.ModerationStrictness)
}
if !resp.Pass {
return errors.New(resp.ErrorMessage())
}
return nil
_, err := moderation.Check(ctx.Request().Context(), strictness, lang, text)
return err
}
// blockWordCheck 校验开放文本是否命中投票发起人的全局屏蔽词表
@@ -968,7 +972,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, lang, custom); err != nil {
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
formData[f.Label] = custom
@@ -1004,7 +1008,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, lang, custom); err != nil {
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
chosen = append(chosen, custom)
@@ -1088,7 +1092,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, lang, val); err != nil {
if err := censorCheck(ctx, owner, lang, val); err != nil {
return nil, err
}
formData[f.Label] = val
@@ -1110,7 +1114,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, lang, custom); err != nil {
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
sub.CustomText = custom