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
@@ -2,7 +2,9 @@ package imconfig
import (
"context"
"encoding/json"
api "hotgo/api/admin/imconfiginternal"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
@@ -14,6 +16,18 @@ import (
"github.com/gogf/gf/v2/util/gconv"
)
const defaultModerationTenantCode = "default"
var defaultModerationScenes = map[string]bool{
"user_nickname": true, "user_signature": true, "group_name": true,
"group_nickname": true, "private_message": true, "group_message": true,
"group_announcement": true,
}
var defaultModerationRiskActions = map[string]string{
"high": "block", "medium": "block", "low": "audit_only", "none": "allow",
}
var Config = cConfig{}
type cConfig struct{}
@@ -91,6 +105,177 @@ func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res *
return &api.ThirdPartyRes{Values: values}, nil
}
func (c *cConfig) ModerationProvider(ctx context.Context, req *api.ModerationProviderReq) (res *api.ModerationProviderRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, defaultModerationTenantCode)
if err != nil {
return nil, err
}
var row struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Enabled int `json:"enabled"`
ConfigJSON string `json:"config_json"`
SecretJSON string `json:"secret_json"`
}
if err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Fields("provider", "endpoint", "enabled", "CAST(config_json AS CHAR) config_json", "CAST(secret_json AS CHAR) secret_json").
Where("tenant_id", tenant.Id).Where("service", consts.ImExternalServiceModeration).Scan(&row); err != nil {
return nil, gerror.Wrap(err, "获取内容审核服务配置失败")
}
config := map[string]any{}
secret := map[string]any{}
_ = json.Unmarshal([]byte(row.ConfigJSON), &config)
_ = json.Unmarshal([]byte(row.SecretJSON), &secret)
res = &api.ModerationProviderRes{
Provider: row.Provider, Endpoint: row.Endpoint, Enabled: row.Enabled == 1,
RegionID: stringValue(config, "region_id", "cn-shanghai"),
NicknameService: stringValue(config, "nickname_service", "nickname_detection_pro"),
ChatService: stringValue(config, "chat_service", "chat_detection_pro"),
TimeoutMS: intValue(config, "timeout_ms", 1500),
FailureMode: moderationFailureMode(stringValue(config, "failure_mode", "allow")),
AccessKeyID: stringValue(secret, "access_key_id", ""),
AccessKeySecret: stringValue(secret, "access_key_secret", ""),
}
return res, nil
}
func (c *cConfig) ModerationPolicy(ctx context.Context, req *api.ModerationPolicyReq) (res *api.ModerationPolicyRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code))
if err != nil {
return nil, err
}
res = defaultModerationPolicy(tenant)
var tenantConfig struct {
AliyunModerationEnabled int `json:"aliyun_moderation_enabled"`
LocalModerationEnabled int `json:"local_moderation_enabled"`
}
if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).
Fields("aliyun_moderation_enabled", "local_moderation_enabled").
Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&tenantConfig); err != nil {
return nil, gerror.Wrap(err, "获取商户内容审核开关失败")
}
res.AliyunEnabled = tenantConfig.AliyunModerationEnabled == 1
res.LocalWordsEnabled = tenantConfig.LocalModerationEnabled == 1
res.Enabled = res.AliyunEnabled || res.LocalWordsEnabled
columns := dao.ImSecurityPolicy.Columns()
var row struct {
PolicyVersion uint `json:"policy_version"`
SensitiveWordPolicy string `json:"sensitive_word_policy"`
}
if err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
Fields(columns.PolicyVersion, "CAST("+columns.SensitiveWordPolicy+" AS CHAR) sensitive_word_policy").
Where(columns.TenantId, tenant.Id).Where(columns.PublishStatus, consts.ImSecurityPublishPublished).
WhereNull(columns.DeletedAt).OrderDesc(columns.PolicyVersion).Limit(1).Scan(&row); err != nil {
return nil, gerror.Wrap(err, "获取商户内容审核策略失败")
}
if row.PolicyVersion == 0 || strings.TrimSpace(row.SensitiveWordPolicy) == "" {
return res, nil
}
var policy struct {
Scenes map[string]bool `json:"scenes"`
RiskActions map[string]string `json:"risk_actions"`
FailureMode string `json:"failure_mode"`
BlockMessage string `json:"block_message"`
}
if err = json.Unmarshal([]byte(row.SensitiveWordPolicy), &policy); err != nil {
return nil, gerror.Wrap(err, "商户内容审核策略格式错误")
}
res.PolicyVersion = row.PolicyVersion
for scene, enabled := range policy.Scenes {
res.Scenes[scene] = enabled
}
for risk, action := range policy.RiskActions {
if action == "allow" || action == "block" || action == "audit_only" {
res.RiskActions[risk] = action
}
}
res.FailureMode = moderationFailureMode(policy.FailureMode)
if strings.TrimSpace(policy.BlockMessage) != "" {
res.BlockMessage = strings.TrimSpace(policy.BlockMessage)
}
return res, nil
}
func (c *cConfig) SensitiveWords(ctx context.Context, req *api.SensitiveWordsReq) (res *api.SensitiveWordsRes, err error) {
tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code))
if err != nil {
return nil, err
}
res = &api.SensitiveWordsRes{TenantID: tenant.Id, TenantCode: tenant.Code, Items: []*api.SensitiveWordModel{}}
columns := dao.ImSensitiveWords.Columns()
groupColumns := dao.ImSensitiveWordGroups.Columns()
var rows []struct {
ID int64 `json:"id"`
Word string `json:"word"`
MatchType string `json:"match_type"`
SceneJSON string `json:"scene_json"`
Action string `json:"action"`
Replacement string `json:"replacement"`
RiskLevel string `json:"risk_level"`
Priority int `json:"priority"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
err = imtenant.RegistryDB().Model(dao.ImSensitiveWords.Table()+" w").Safe().Ctx(ctx).
LeftJoin(dao.ImSensitiveWordGroups.Table()+" g", "g."+groupColumns.Id+"=w."+columns.GroupId).
Fields("w."+columns.Id, "w."+columns.Word, "w."+columns.MatchType, "CAST(w."+columns.SceneJson+" AS CHAR) scene_json", "w."+columns.Action, "w."+columns.Replacement, "w."+columns.RiskLevel, "w."+columns.Priority, "w."+columns.UpdatedAt).
Where("w."+columns.TenantId, tenant.Id).Where("w."+columns.Status, consts.StatusEnabled).WhereNull("w."+columns.DeletedAt).
Where("g."+groupColumns.Status, consts.StatusEnabled).WhereNull("g." + groupColumns.DeletedAt).
OrderDesc("w." + columns.Priority).OrderAsc("w." + columns.Id).Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "获取商户有效敏感词失败")
}
for _, row := range rows {
var scenes []string
_ = json.Unmarshal([]byte(row.SceneJSON), &scenes)
res.Items = append(res.Items, &api.SensitiveWordModel{ID: row.ID, Word: row.Word, MatchType: row.MatchType, Scenes: scenes, Action: row.Action, Replacement: row.Replacement, RiskLevel: row.RiskLevel, Priority: row.Priority})
if row.UpdatedAt != nil && row.UpdatedAt.TimestampMilli() > res.Version {
res.Version = row.UpdatedAt.TimestampMilli()
}
}
return res, nil
}
func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes {
return &api.ModerationPolicyRes{TenantID: tenant.Id, TenantCode: tenant.Code, Enabled: false,
AliyunEnabled: false, LocalWordsEnabled: false, Scenes: cloneBoolMap(defaultModerationScenes),
RiskActions: cloneStringMap(defaultModerationRiskActions), FailureMode: "allow",
BlockMessage: "内容不符合平台规范,请修改后重试"}
}
func cloneBoolMap(src map[string]bool) map[string]bool {
dst := make(map[string]bool, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func cloneStringMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func stringValue(values map[string]any, key, fallback string) string {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
func intValue(values map[string]any, key string, fallback int) int {
value := gconv.Int(values[key])
if value <= 0 {
return fallback
}
return value
}
func moderationFailureMode(value string) string {
if strings.TrimSpace(value) == "block" {
return "block"
}
return "allow"
}
func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLoginIPWhitelistReq) (res *api.TenantLoginIPWhitelistRes, err error) {
if _, err = getTenant(ctx, dao.ImTenant.Columns().Id, req.Id); err != nil {
return nil, err