feat: add tenant content moderation controls

This commit is contained in:
richjohnrambo
2026-09-16 22:21:42 +08:00
parent 32491e9cdd
commit fec2a43b9c
14 changed files with 1156 additions and 431 deletions
@@ -5,16 +5,22 @@ import (
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/immoderation"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"strings"
"time"
)
type sSysImExternalConfig struct{}
@@ -36,7 +42,13 @@ func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, 0, gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
mod := s.model(ctx).Where("tenant_id", tenant.Id)
if tenant.Code != "default" {
mod = mod.WhereNot("service", consts.ImExternalServiceModeration)
}
if in.Service != "" {
mod = mod.Where("service", in.Service)
}
@@ -68,6 +80,9 @@ func (s *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalCon
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return nil, err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return nil, gerror.New("第一阶段仅默认商户可查看内容审核服务")
}
if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!")
}
@@ -91,6 +106,36 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可配置内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以维护内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration {
if in.Provider != "aliyun_green" {
return gerror.New("内容审核服务商必须为aliyun_green")
}
if strings.TrimSpace(in.Endpoint) == "" {
in.Endpoint = "green-cip.cn-shanghai.aliyuncs.com"
}
if in.ConfigJson == nil {
in.ConfigJson = gjson.New(map[string]any{"region_id": "cn-shanghai", "nickname_service": "nickname_detection_pro", "chat_service": "chat_detection_pro", "timeout_ms": 1500, "failure_mode": "allow"})
}
config := in.ConfigJson.Map()
if timeout := gconv.Int(config["timeout_ms"]); timeout < 200 || timeout > 10000 {
return gerror.New("内容审核超时必须为200至10000毫秒")
}
if mode := gconv.String(config["failure_mode"]); mode != "allow" && mode != "block" {
return gerror.New("内容审核异常策略不合法")
}
if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" {
secret := in.SecretJson.Map()
if strings.TrimSpace(gconv.String(secret["access_key_id"])) == "" || strings.TrimSpace(gconv.String(secret["access_key_secret"])) == "" {
return gerror.New("AccessKey ID和AccessKey Secret必须同时填写")
}
}
}
configJson := "{}"
if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" {
configJson = in.ConfigJson.String()
@@ -113,6 +158,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC
_ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{
"service": in.Service, "provider": in.Provider, "enabled": in.Enabled,
})
if in.Service == consts.ImExternalServiceModeration {
_ = publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "third_party.updated", "moderation", 0)
}
return nil
}
@@ -127,6 +175,12 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" {
return gerror.New("第一阶段仅默认商户可检查内容审核服务")
}
if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) {
return gerror.New("只有平台管理员可以检查内容审核服务")
}
var row gdb.Record
if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil {
@@ -137,6 +191,8 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" {
status = consts.ImExternalConfigStatusUnconfigured
result = "未启用或服务商为空"
} else if in.Service == consts.ImExternalServiceModeration {
status, result = s.checkModeration(ctx, row)
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).
@@ -150,6 +206,43 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo
return nil
}
func (s *sSysImExternalConfig) checkModeration(ctx context.Context, row gdb.Record) (string, string) {
if row["provider"].String() != "aliyun_green" {
return consts.ImExternalConfigStatusUnconfigured, "内容审核服务商必须为aliyun_green"
}
config := map[string]any{}
secret := map[string]any{}
_ = json.Unmarshal([]byte(row["config_json"].String()), &config)
_ = json.Unmarshal([]byte(row["secret_json"].String()), &secret)
timeoutMS := gconv.Int(config["timeout_ms"])
if timeoutMS <= 0 {
timeoutMS = 1500
}
check, err := immoderation.CheckAliyunText(ctx, immoderation.AliyunConfig{
AccessKeyID: gconv.String(secret["access_key_id"]), AccessKeySecret: gconv.String(secret["access_key_secret"]),
RegionID: firstExternalValue(gconv.String(config["region_id"]), "cn-shanghai"), Endpoint: row["endpoint"].String(),
Service: firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"), Timeout: time.Duration(timeoutMS) * time.Millisecond,
})
if err != nil {
return consts.ImExternalConfigStatusUnconfigured, "连通性检查失败:" + sanitizeExternalError(err)
}
return consts.ImExternalConfigStatusConfigured, fmt.Sprintf("检查成功,耗时%dms,RequestID=%s", check.Latency.Milliseconds(), check.RequestID)
}
func firstExternalValue(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func sanitizeExternalError(err error) string {
value := strings.ReplaceAll(err.Error(), "\n", " ")
if len([]rune(value)) > 120 {
value = string([]rune(value)[:120])
}
return value
}
func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model {
return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields(
"tenant_id",
@@ -92,7 +92,7 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
if err = in.Filter(ctx); err != nil {
return
}
tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId)
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId)
if err != nil {
return 0, err
}
@@ -123,7 +123,7 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
}
version = uint(maxValue) + 1
}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: policyJSON(in.SensitiveWordPolicy, "{}"), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: normalizedModerationPolicy(in.SensitiveWordPolicy, tenantCode), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator}
if in.Id > 0 {
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update(); updateErr != nil {
return updateErr
@@ -158,6 +158,20 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
return id, nil
}
func normalizedModerationPolicy(value *gjson.Json, _ string) string {
policy := map[string]any{
"scenes": map[string]bool{"user_nickname": true, "user_signature": true, "group_name": true, "group_nickname": true, "private_message": true, "group_message": true, "group_announcement": true},
"risk_actions": map[string]string{"high": "block", "medium": "block", "low": "audit_only", "none": "allow"},
"failure_mode": "allow", "block_message": "内容不符合平台规范,请修改后重试",
}
if value != nil && value.String() != "" && value.String() != "null" {
for key, item := range value.Map() {
policy[key] = item
}
}
return gjson.New(policy).String()
}
func (s *sSysImSecurityPolicy) Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) error {
if err := in.Filter(ctx); err != nil {
return err
+39 -7
View File
@@ -25,6 +25,21 @@ import (
type sSysImTenant struct{}
const (
tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled"
tenantConfigLocalModerationEnabled = "local_moderation_enabled"
)
type imTenantConfigModeration struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
MaxUsers uint64 `json:"max_users"`
MaxGroups uint64 `json:"max_groups"`
MaxGroupMembers uint `json:"max_group_members"`
AliyunModerationEnabled int `json:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled"`
}
func NewSysImTenant() *sSysImTenant {
return &sSysImTenant{}
}
@@ -104,13 +119,13 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan
ids = append(ids, item.Id)
}
var configs []*entity.ImTenantConfig
var configs []*imTenantConfigModeration
if err = s.configModel(ctx).
WhereIn(dao.ImTenantConfig.Columns().TenantId, ids).
Scan(&configs); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
configMap := make(map[int64]*entity.ImTenantConfig, len(configs))
configMap := make(map[int64]*imTenantConfigModeration, len(configs))
for _, item := range configs {
configMap[item.TenantId] = item
}
@@ -132,6 +147,8 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan
item.MaxUsers = conf.MaxUsers
item.MaxGroups = conf.MaxGroups
item.MaxGroupMembers = conf.MaxGroupMembers
item.AliyunModerationEnabled = conf.AliyunModerationEnabled
item.LocalModerationEnabled = conf.LocalModerationEnabled
}
item.DbDsnConfigured = dsnMap[item.Id] != ""
}
@@ -144,7 +161,8 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
return
}
return imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
tenantID := in.Id
err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil {
return
}
@@ -157,7 +175,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
WherePri(in.Id).Data(in).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId)
}
in.CreatedBy = userId
@@ -168,8 +186,14 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
if err != nil {
return gerror.Wrap(err, "新增IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
tenantID = id
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId)
})
if err != nil {
return err
}
_ = publishImPolicyEvent(ctx, tenantID, in.Code, "tenant.features.updated", "moderation", 0)
return nil
}
func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) {
@@ -187,7 +211,7 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin
return
}
func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, userId int64) (err error) {
func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, aliyunModerationEnabled, localModerationEnabled int, userId int64) (err error) {
var conf *entity.ImTenantConfig
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).
@@ -199,6 +223,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers
dao.ImTenantConfig.Columns().MaxUsers: maxUsers,
dao.ImTenantConfig.Columns().MaxGroups: maxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers,
tenantConfigAliyunModerationEnabled: aliyunModerationEnabled,
tenantConfigLocalModerationEnabled: localModerationEnabled,
}
if conf != nil && conf.Id > 0 {
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
@@ -207,6 +233,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
dao.ImTenantConfig.Columns().UpdatedBy,
).
WherePri(conf.Id).Data(data).Update(); err != nil {
@@ -224,6 +252,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
tenantConfigAliyunModerationEnabled,
tenantConfigLocalModerationEnabled,
dao.ImTenantConfig.Columns().CreatedBy,
dao.ImTenantConfig.Columns().UpdatedBy,
).
@@ -246,7 +276,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
return
}
var conf *entity.ImTenantConfig
var conf *imTenantConfigModeration
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
@@ -257,6 +287,8 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res
res.MaxUsers = conf.MaxUsers
res.MaxGroups = conf.MaxGroups
res.MaxGroupMembers = conf.MaxGroupMembers
res.AliyunModerationEnabled = conf.AliyunModerationEnabled
res.LocalModerationEnabled = conf.LocalModerationEnabled
}
res.DbDsnConfigured = res.DbDsn != ""
return