feat: 将安全策略模板化,新建商户时自动复制初始策略
This commit is contained in:
@@ -27,11 +27,6 @@ type SaveReq struct {
|
||||
type SaveRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
type PublishReq struct {
|
||||
g.Meta `path:"/imSecurityPolicy/publish" method:"post" tags:"IM安全策略" summary:"发布安全策略"`
|
||||
sysin.ImSecurityPolicyPublishInp
|
||||
}
|
||||
type PublishRes struct{}
|
||||
type RestoreDefaultReq struct {
|
||||
g.Meta `path:"/imSecurityPolicy/restoreDefault" method:"post" tags:"IM安全策略" summary:"恢复默认安全策略"`
|
||||
sysin.ImSecurityPolicyRestoreInp
|
||||
|
||||
@@ -38,10 +38,6 @@ func (c *cImSecurityPolicy) Save(ctx context.Context, req *imsecuritypolicy.Save
|
||||
}
|
||||
return &imsecuritypolicy.SaveRes{Id: id}, nil
|
||||
}
|
||||
func (c *cImSecurityPolicy) Publish(ctx context.Context, req *imsecuritypolicy.PublishReq) (res *imsecuritypolicy.PublishRes, err error) {
|
||||
err = service.SysImSecurityPolicy().Publish(ctx, &req.ImSecurityPolicyPublishInp)
|
||||
return
|
||||
}
|
||||
func (c *cImSecurityPolicy) RestoreDefault(ctx context.Context, req *imsecuritypolicy.RestoreDefaultReq) (res *imsecuritypolicy.RestoreDefaultRes, err error) {
|
||||
id, err := service.SysImSecurityPolicy().RestoreDefault(ctx, &req.ImSecurityPolicyRestoreInp)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
@@ -43,6 +45,38 @@ func policyJSON(value *gjson.Json, fallback string) string {
|
||||
return value.String()
|
||||
}
|
||||
|
||||
// imPolicyTemplateTable 安全策略模板表(平台级单行,新建商户初始策略来源)
|
||||
const imPolicyTemplateTable = "im_security_policy_template"
|
||||
|
||||
// loadPolicyTemplateRow 读取安全策略模板单行(取最小 id),返回 nil 表示模板未初始化。
|
||||
func loadPolicyTemplateRow(model *gdb.Model) (gdb.Record, error) {
|
||||
row, err := model.Order("id ASC").Limit(1).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// templateCurfewRules 解析模板行 curfew_rules JSON 为宵禁规则输入列表
|
||||
func templateCurfewRules(tpl gdb.Record) []*sysin.ImMessageCurfewRuleInp {
|
||||
rules := make([]*sysin.ImMessageCurfewRuleInp, 0)
|
||||
for _, item := range gjson.New(tpl["curfew_rules"].String()).Array() {
|
||||
j := gjson.New(item)
|
||||
rules = append(rules, &sysin.ImMessageCurfewRuleInp{
|
||||
RuleCode: j.Get("ruleCode").String(), RuleName: j.Get("ruleName").String(), DateType: j.Get("dateType").String(),
|
||||
CustomDates: gjson.New(j.Get("customDates").String()), Weekdays: gjson.New(j.Get("weekdays").String()),
|
||||
Timezone: j.Get("timezone").String(), StartTime: j.Get("startTime").String(), EndTime: j.Get("endTime").String(),
|
||||
Scope: j.Get("scope").String(),
|
||||
WhitelistUserIds: gjson.New(j.Get("whitelistUserIds").String()), WhitelistGroupIds: gjson.New(j.Get("whitelistGroupIds").String()),
|
||||
Message: j.Get("message").String(), Status: j.Get("status").Int(), Sort: j.Get("sort").Int(),
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func publishImPolicyEvent(ctx context.Context, tenantID int64, tenantCode, eventType, group string, version uint) error {
|
||||
return imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{
|
||||
TenantID: tenantID,
|
||||
|
||||
@@ -65,8 +65,34 @@ func (s *sSysImSecurityPolicy) View(ctx context.Context, in *sysin.ImSecurityPol
|
||||
err = baseModel.Where(c.TenantId, tenantID).
|
||||
Where(c.PublishStatus, consts.ImSecurityPublishPublished).
|
||||
OrderDesc(c.PolicyVersion).Scan(&res)
|
||||
if err == nil && res == nil {
|
||||
err = baseModel.Where(c.TenantId, tenantID).OrderDesc(c.PolicyVersion).Scan(&res)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取安全策略失败")
|
||||
}
|
||||
if res == nil {
|
||||
// 无自建策略时回落默认模板展示,保存后成为本商户自己的策略
|
||||
tpl, tplErr := loadPolicyTemplateRow(imtenant.RegistryDB().Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if tplErr != nil {
|
||||
return nil, gerror.Wrap(tplErr, "读取安全策略模板失败")
|
||||
}
|
||||
if tpl != nil {
|
||||
res = &sysin.ImSecurityPolicyModel{
|
||||
TenantId: tenantID,
|
||||
PolicyName: tpl["policy_name"].String(),
|
||||
PasswordPolicy: tpl["password_policy"].String(),
|
||||
LoginRateLimit: tpl["login_rate_limit"].String(),
|
||||
SensitiveWordPolicy: tpl["sensitive_word_policy"].String(),
|
||||
FileReviewPolicy: tpl["file_review_policy"].String(),
|
||||
RealnamePolicy: tpl["realname_policy"].String(),
|
||||
RiskChallengePolicy: tpl["risk_challenge_policy"].String(),
|
||||
GlobalFriendEnabled: tpl["global_friend_enabled"].Int(),
|
||||
FriendDisabledPolicy: tpl["friend_disabled_policy"].String(),
|
||||
MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(),
|
||||
}
|
||||
for _, rule := range templateCurfewRules(tpl) {
|
||||
res.CurfewRules = append(res.CurfewRules, &sysin.ImMessageCurfewRuleModel{RuleCode: rule.RuleCode, RuleName: rule.RuleName, DateType: rule.DateType, CustomDates: rule.CustomDates.String(), Weekdays: rule.Weekdays.String(), Timezone: rule.Timezone, StartTime: rule.StartTime, EndTime: rule.EndTime, Scope: rule.Scope, WhitelistUserIds: rule.WhitelistUserIds.String(), WhitelistGroupIds: rule.WhitelistGroupIds.String(), Message: rule.Message, Status: rule.Status, Sort: rule.Sort})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -88,6 +114,8 @@ func (s *sSysImSecurityPolicy) curfewRules(ctx context.Context, policyID int64)
|
||||
return
|
||||
}
|
||||
|
||||
// Save 保存安全策略并立即生效:每租户仅一条当前生效记录(单记录模型,无草稿/发布工作流)。
|
||||
// 已有记录则原地更新(版本号自增仅作审计),无则创建;同步 im_tenant 冗余开关并投递策略变更事件。
|
||||
func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPolicySaveInp) (id int64, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
@@ -96,55 +124,52 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if tenantID == 0 {
|
||||
// 默认模板不可通过商户策略保存入口修改
|
||||
return 0, gerror.New("默认模板不可修改,请选择具体商户")
|
||||
}
|
||||
db := imtenant.RegistryDB()
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
rc := dao.ImMessageCurfewRules.Columns()
|
||||
tc := dao.ImTenant.Columns()
|
||||
operator := contexts.GetUserId(ctx)
|
||||
now := gtime.Now()
|
||||
version := uint(1)
|
||||
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
version := uint(0)
|
||||
if in.Id > 0 {
|
||||
current, readErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if current.IsEmpty() || current[c.TenantId].Int64() != tenantID {
|
||||
return gerror.New("安全策略不存在")
|
||||
}
|
||||
if current[c.PublishStatus].String() != consts.ImSecurityPublishDraft {
|
||||
return gerror.New("只有草稿状态的安全策略可以编辑")
|
||||
}
|
||||
version = current[c.PolicyVersion].Uint()
|
||||
id = in.Id
|
||||
} else {
|
||||
maxValue, maxErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.TenantId, tenantID).Max(c.PolicyVersion)
|
||||
if maxErr != nil {
|
||||
return maxErr
|
||||
}
|
||||
version = uint(maxValue) + 1
|
||||
current, readErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).
|
||||
WhereNull(c.DeletedAt).OrderDesc(c.PolicyVersion).One()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
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 && in.LoginRateLimit == nil {
|
||||
// 未提交登录失败策略时保留原值(登录失败锁定已迁移至独立配置入口,避免保存草稿清空配置)
|
||||
data := g.Map{c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishPublished, 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.LoginRateLimit == nil {
|
||||
// 未提交登录失败策略时保留原值(登录失败锁定由独立配置入口管理,避免保存时清空配置)
|
||||
delete(data, c.LoginRateLimit)
|
||||
}
|
||||
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
|
||||
}
|
||||
} else {
|
||||
if current.IsEmpty() {
|
||||
version = 1
|
||||
data[c.TenantId] = tenantID
|
||||
data[c.PolicyVersion] = version
|
||||
data[c.CreatedAt] = now
|
||||
data[c.CreatedBy] = operator
|
||||
result, insertErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Data(data).Insert()
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
id, insertErr = result.LastInsertId()
|
||||
if insertErr != nil {
|
||||
if id, insertErr = result.LastInsertId(); insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
} else {
|
||||
id = current[c.Id].Int64()
|
||||
version = current[c.PolicyVersion].Uint() + 1
|
||||
data[c.PolicyVersion] = version
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, id).Data(data).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
}
|
||||
if _, deleteErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Where(rc.PolicyId, id).Delete(); deleteErr != nil {
|
||||
// 规则整体重建:uk_im_message_curfew_rule 唯一键含 tenant_id+rule_code,软删行仍占键会冲突,必须硬删
|
||||
if _, deleteErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Where(rc.PolicyId, id).Unscoped().Delete(); deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
for _, rule := range in.CurfewRules {
|
||||
@@ -153,12 +178,23 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol
|
||||
return insertErr
|
||||
}
|
||||
}
|
||||
if tenantID > 0 {
|
||||
// 上游已将商户配置并入 im_tenant 表,此处同步冗余开关与策略版本(商户行必然存在,无需补建)
|
||||
if _, cfgErr := tx.Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(tc.Id, tenantID).
|
||||
Data(g.Map{tc.SecurityPolicyVersion: version, tc.GlobalFriendEnabled: in.GlobalFriendEnabled, tc.MessageCurfewEnabled: in.MessageCurfewEnabled, tc.UpdatedAt: now, tc.UpdatedBy: operator}).Update(); cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "保存安全策略草稿失败")
|
||||
return 0, gerror.Wrap(err, "保存安全策略失败")
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_save", "im_security_policy", id, "success", g.Map{"version": version, "global_friend_enabled": in.GlobalFriendEnabled, "message_curfew_enabled": in.MessageCurfewEnabled})
|
||||
// 保存即生效:投递策略变更事件,IM 端消费后失效策略缓存并回源拉取新配置
|
||||
if err = publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version); err != nil {
|
||||
return id, err
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_save", "im_security_policy", id, "success", g.Map{"global_friend_enabled": in.GlobalFriendEnabled, "message_curfew_enabled": in.MessageCurfewEnabled})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -176,51 +212,6 @@ func normalizedModerationPolicy(value *gjson.Json, _ string) string {
|
||||
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
|
||||
}
|
||||
db := imtenant.RegistryDB()
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
var row gdb.Record
|
||||
var err error
|
||||
if row, err = db.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return gerror.New("安全策略不存在")
|
||||
}
|
||||
if row[c.PublishStatus].String() != consts.ImSecurityPublishDraft {
|
||||
return gerror.New("只有草稿状态可以发布")
|
||||
}
|
||||
tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operator := contexts.GetUserId(ctx)
|
||||
now := gtime.Now()
|
||||
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.TenantId, tenantID).Where(c.PublishStatus, consts.ImSecurityPublishPublished).WhereNot(c.Id, in.Id).WhereNull(c.DeletedAt).Data(g.Map{c.PublishStatus: consts.ImSecurityPublishArchived, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(g.Map{c.PublishStatus: consts.ImSecurityPublishPublished, c.PublishedAt: now, c.PublishedBy: operator, c.UpdatedAt: now, c.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if tenantID > 0 {
|
||||
tc := dao.ImTenant.Columns()
|
||||
if _, updateErr := tx.Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(tc.Id, tenantID).Data(g.Map{tc.SecurityPolicyVersion: row[c.PolicyVersion].Uint(), tc.GlobalFriendEnabled: row[c.GlobalFriendEnabled].Int(), tc.MessageCurfewEnabled: row[c.MessageCurfewEnabled].Int(), tc.UpdatedAt: now, tc.UpdatedBy: operator}).Update(); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "发布安全策略失败")
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_publish", "im_security_policy", in.Id, "success", g.Map{"reason": in.Reason, "version": row[c.PolicyVersion].Uint()})
|
||||
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", row[c.PolicyVersion].Uint())
|
||||
}
|
||||
|
||||
func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImSecurityPolicyRestoreInp) (int64, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return 0, err
|
||||
@@ -229,27 +220,15 @@ func (s *sSysImSecurityPolicy) RestoreDefault(ctx context.Context, in *sysin.ImS
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c := dao.ImSecurityPolicy.Columns()
|
||||
var source *sysin.ImSecurityPolicyModel
|
||||
err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Fields(securityPolicyFields("")...).Where(c.TenantId, 0).
|
||||
Where(c.PublishStatus, consts.ImSecurityPublishPublished).WhereNull(c.DeletedAt).
|
||||
OrderDesc(c.PolicyVersion).Scan(&source)
|
||||
// 读取安全策略模板,将模板内容覆盖为当前商户的策略(复制后商户可自行修改)
|
||||
tpl, err := loadPolicyTemplateRow(imtenant.RegistryDB().Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "读取全局默认安全策略失败")
|
||||
return 0, gerror.Wrap(err, "读取安全策略模板失败")
|
||||
}
|
||||
if source == nil {
|
||||
return 0, gerror.New("全局默认安全策略不存在")
|
||||
if tpl == nil {
|
||||
return 0, gerror.New("安全策略模板未初始化")
|
||||
}
|
||||
source.CurfewRules, err = s.curfewRules(ctx, source.Id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rules := make([]*sysin.ImMessageCurfewRuleInp, 0, len(source.CurfewRules))
|
||||
for _, rule := range source.CurfewRules {
|
||||
rules = append(rules, &sysin.ImMessageCurfewRuleInp{RuleCode: rule.RuleCode, RuleName: rule.RuleName, DateType: rule.DateType, CustomDates: gjson.New(rule.CustomDates), Weekdays: gjson.New(rule.Weekdays), Timezone: rule.Timezone, StartTime: rule.StartTime, EndTime: rule.EndTime, Scope: rule.Scope, WhitelistUserIds: gjson.New(rule.WhitelistUserIds), WhitelistGroupIds: gjson.New(rule.WhitelistGroupIds), Message: rule.Message, Status: rule.Status, Sort: rule.Sort})
|
||||
}
|
||||
id, err := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "恢复默认安全策略", PasswordPolicy: gjson.New(source.PasswordPolicy), LoginRateLimit: gjson.New(source.LoginRateLimit), SensitiveWordPolicy: gjson.New(source.SensitiveWordPolicy), FileReviewPolicy: gjson.New(source.FileReviewPolicy), RealnamePolicy: gjson.New(source.RealnamePolicy), RiskChallengePolicy: gjson.New(source.RiskChallengePolicy), GlobalFriendEnabled: source.GlobalFriendEnabled, FriendDisabledPolicy: gjson.New(source.FriendDisabledPolicy), MessageCurfewEnabled: source.MessageCurfewEnabled, Remark: in.Reason, CurfewRules: rules})
|
||||
id, err := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: tpl["policy_name"].String(), PasswordPolicy: gjson.New(tpl["password_policy"].String()), LoginRateLimit: gjson.New(tpl["login_rate_limit"].String()), SensitiveWordPolicy: gjson.New(tpl["sensitive_word_policy"].String()), FileReviewPolicy: gjson.New(tpl["file_review_policy"].String()), RealnamePolicy: gjson.New(tpl["realname_policy"].String()), RiskChallengePolicy: gjson.New(tpl["risk_challenge_policy"].String()), GlobalFriendEnabled: tpl["global_friend_enabled"].Int(), FriendDisabledPolicy: gjson.New(tpl["friend_disabled_policy"].String()), MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(), Remark: in.Reason, CurfewRules: templateCurfewRules(tpl)})
|
||||
if err == nil {
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_restore_default", "im_security_policy", id, "success", g.Map{"reason": in.Reason})
|
||||
}
|
||||
@@ -328,14 +307,11 @@ func (s *sSysImSecurityPolicy) SaveLoginLockoutConfig(ctx context.Context, in *s
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", policyID, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
|
||||
return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", version)
|
||||
}
|
||||
// 租户尚无已发布策略:创建默认策略草稿并直接发布(Publish 内部已发送策略变更事件)
|
||||
// 租户尚无策略:保存默认策略,保存即生效并投递策略变更事件
|
||||
id, saveErr := s.Save(ctx, &sysin.ImSecurityPolicySaveInp{TenantId: tenantID, PolicyName: "默认安全策略", LoginRateLimit: gjson.New(value), GlobalFriendEnabled: 1, MessageCurfewEnabled: 0})
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
if err = s.Publish(ctx, &sysin.ImSecurityPolicyPublishInp{Id: id, Reason: "登录失败策略首次配置"}); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantID, "im_security_policy_login_lockout_save", "im_security_policy", id, "success", g.Map{"enabled": in.Enabled, "password_max_attempts": in.PasswordMaxAttempts, "lock_minutes": in.LockMinutes, "window_minutes": in.WindowMinutes})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type sSysImTenant struct{}
|
||||
@@ -210,6 +211,10 @@ func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp
|
||||
return gerror.Wrap(err, "新增IM租户失败,请稍后重试!")
|
||||
}
|
||||
tenantID = in.Id
|
||||
// 新建商户时复制安全策略模板为本商户初始策略(保存即生效模型)
|
||||
if err = copyDefaultPolicyForTenant(ctx, tx, in.Id, userId); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -345,7 +350,94 @@ func (s *sSysImTenant) resetTenantAdminPassword(ctx context.Context, tenantId in
|
||||
|
||||
// rollbackTenantCreation 商户创建补偿:删除已写入的商户及配置记录
|
||||
func (s *sSysImTenant) rollbackTenantCreation(ctx context.Context, tenantId int64) {
|
||||
_, _ = s.Model(ctx, &handler.Option{FilterAuth: false}).WherePri(tenantId).Delete()
|
||||
_ = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).
|
||||
WherePri(tenantId).Delete(); err != nil {
|
||||
return
|
||||
}
|
||||
// 清理建户时复制的安全策略与宵禁规则,避免残留孤儿数据(商户未建成,硬删不留软删垃圾)
|
||||
if _, err = tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImSecurityPolicy.Columns().TenantId, tenantId).Unscoped().Delete(); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.ImMessageCurfewRules.Columns().TenantId, tenantId).Unscoped().Delete()
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
// copyDefaultPolicyForTenant 新建商户时复制安全策略模板为本商户自己的初始策略。
|
||||
// 模板未初始化时跳过(运行时由内部配置接口的默认值兜底),不阻断建户。
|
||||
func copyDefaultPolicyForTenant(ctx context.Context, tx gdb.TX, tenantID, operator int64) (err error) {
|
||||
tpl, err := loadPolicyTemplateRow(tx.Model(imPolicyTemplateTable).Safe().Ctx(ctx))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取安全策略模板失败")
|
||||
}
|
||||
if tpl == nil {
|
||||
return nil
|
||||
}
|
||||
pc := dao.ImSecurityPolicy.Columns()
|
||||
now := gtime.Now()
|
||||
data := g.Map{
|
||||
pc.TenantId: tenantID,
|
||||
pc.PolicyName: tpl["policy_name"].String(),
|
||||
pc.PolicyVersion: 1,
|
||||
pc.PublishStatus: consts.ImSecurityPublishPublished,
|
||||
pc.PasswordPolicy: tpl["password_policy"].String(),
|
||||
pc.LoginRateLimit: tpl["login_rate_limit"].String(),
|
||||
pc.SensitiveWordPolicy: tpl["sensitive_word_policy"].String(),
|
||||
pc.FileReviewPolicy: tpl["file_review_policy"].String(),
|
||||
pc.RealnamePolicy: tpl["realname_policy"].String(),
|
||||
pc.RiskChallengePolicy: tpl["risk_challenge_policy"].String(),
|
||||
pc.GlobalFriendEnabled: tpl["global_friend_enabled"].Int(),
|
||||
pc.FriendDisabledPolicy: tpl["friend_disabled_policy"].String(),
|
||||
pc.MessageCurfewEnabled: tpl["message_curfew_enabled"].Int(),
|
||||
pc.Remark: "由默认模板复制",
|
||||
pc.PublishedAt: now,
|
||||
pc.PublishedBy: operator,
|
||||
pc.CreatedAt: now,
|
||||
pc.CreatedBy: operator,
|
||||
pc.UpdatedAt: now,
|
||||
pc.UpdatedBy: operator,
|
||||
}
|
||||
result, err := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Data(data).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "复制安全策略模板失败")
|
||||
}
|
||||
policyID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步复制模板宵禁规则
|
||||
rc := dao.ImMessageCurfewRules.Columns()
|
||||
for _, rule := range templateCurfewRules(tpl) {
|
||||
ruleData := g.Map{
|
||||
rc.TenantId: tenantID,
|
||||
rc.PolicyId: policyID,
|
||||
rc.RuleCode: rule.RuleCode,
|
||||
rc.RuleName: rule.RuleName,
|
||||
rc.DateType: rule.DateType,
|
||||
rc.CustomDates: policyJSON(rule.CustomDates, "[]"),
|
||||
rc.Weekdays: policyJSON(rule.Weekdays, "[]"),
|
||||
rc.Timezone: rule.Timezone,
|
||||
rc.StartTime: rule.StartTime,
|
||||
rc.EndTime: rule.EndTime,
|
||||
rc.Scope: rule.Scope,
|
||||
rc.WhitelistUserIds: policyJSON(rule.WhitelistUserIds, "[]"),
|
||||
rc.WhitelistGroupIds: policyJSON(rule.WhitelistGroupIds, "[]"),
|
||||
rc.Message: rule.Message,
|
||||
rc.Status: rule.Status,
|
||||
rc.Sort: rule.Sort,
|
||||
rc.CreatedAt: now,
|
||||
rc.CreatedBy: operator,
|
||||
rc.UpdatedAt: now,
|
||||
rc.UpdatedBy: operator,
|
||||
}
|
||||
if _, err = tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Data(ruleData).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "复制模板宵禁规则失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) {
|
||||
|
||||
@@ -78,16 +78,6 @@ func TestImSecurityPolicySaveFilterForcesEveryFriendEntryOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImSecurityPolicyPublishFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := (&ImSecurityPolicyPublishInp{Id: 1, Reason: " "}).Filter(ctx); err == nil {
|
||||
t.Fatal("空发布原因应被拒绝")
|
||||
}
|
||||
if err := (&ImSecurityPolicyPublishInp{Id: 1, Reason: "管理员发布"}).Filter(ctx); err != nil {
|
||||
t.Fatalf("合法发布参数不应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImSecurityIPRuleEditFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
valid := &ImSecurityIPRuleEditInp{
|
||||
|
||||
@@ -255,25 +255,6 @@ func normalizeLoginRateLimit(value *gjson.Json) (*gjson.Json, error) {
|
||||
return gjson.New(map[string]any{"enabled": true, "password_max_attempts": maxAttempts, "lock_seconds": lockSeconds, "window_seconds": windowSeconds}), nil
|
||||
}
|
||||
|
||||
type ImSecurityPolicyPublishInp struct {
|
||||
Id int64 `json:"id" v:"required|min:1#策略ID不能为空|策略ID不合法"`
|
||||
Reason string `json:"reason" v:"required#发布原因不能为空"`
|
||||
}
|
||||
|
||||
func (in *ImSecurityPolicyPublishInp) Filter(ctx context.Context) error {
|
||||
in.Reason = strings.TrimSpace(in.Reason)
|
||||
if in.Id <= 0 {
|
||||
return gerror.New("策略ID不合法")
|
||||
}
|
||||
if in.Reason == "" {
|
||||
return gerror.New("发布原因不能为空")
|
||||
}
|
||||
if len([]rune(in.Reason)) > 500 {
|
||||
return gerror.New("发布原因不能超过500个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ImSecurityPolicyRestoreInp struct {
|
||||
TenantId int64 `json:"tenantId"`
|
||||
Reason string `json:"reason" v:"required#恢复原因不能为空"`
|
||||
|
||||
@@ -424,7 +424,6 @@ type (
|
||||
List(ctx context.Context, in *sysin.ImSecurityPolicyListInp) (list []*sysin.ImSecurityPolicyModel, totalCount int, err error)
|
||||
View(ctx context.Context, in *sysin.ImSecurityPolicyViewInp) (res *sysin.ImSecurityPolicyModel, err error)
|
||||
Save(ctx context.Context, in *sysin.ImSecurityPolicySaveInp) (id int64, err error)
|
||||
Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) (err error)
|
||||
RestoreDefault(ctx context.Context, in *sysin.ImSecurityPolicyRestoreInp) (id int64, err error)
|
||||
GetLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigGetInp) (res *sysin.ImLoginLockoutConfigModel, err error)
|
||||
SaveLoginLockoutConfig(ctx context.Context, in *sysin.ImLoginLockoutConfigSaveInp) (err error)
|
||||
|
||||
@@ -35,18 +35,37 @@ FROM (
|
||||
) p WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=p.name);
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`,`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
|
||||
SELECT NULL,@imRootId,'安全策略','imSecurityPolicyIndex','security-policy/index','','2','','/imSecurityPolicy/list','','/imSecurityPolicy/index','1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),'95','','1',@now,@now
|
||||
SELECT NULL,@imRootId,'IP黑白名单','imSecurityPolicyIndex','security-policy/index','','2','','/imSecurityPolicy/list','','/imSecurityPolicy/index','1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),'95','','1',@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='imSecurityPolicyIndex');
|
||||
SET @securityPolicyId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imSecurityPolicyIndex' LIMIT 1);
|
||||
UPDATE `hg_admin_menu` SET `title`='IP黑白名单', `updated_at`=@now WHERE `name`='imSecurityPolicyIndex' AND `title`<>'IP黑白名单';
|
||||
|
||||
-- Policy version page (flattened strategy form) split out of the security policy page.
|
||||
INSERT INTO `hg_admin_menu` (`id`,`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
|
||||
SELECT NULL,@imRootId,'安全策略','imPolicyVersionIndex','policy-version/index','','2','','/imSecurityPolicy/list','','/imPolicyVersion/index','1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),'96','','1',@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='imPolicyVersionIndex');
|
||||
SET @policyVersionId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imPolicyVersionIndex' LIMIT 1);
|
||||
UPDATE `hg_admin_menu` SET `title`='安全策略', `updated_at`=@now WHERE `name`='imPolicyVersionIndex' AND `title`<>'安全策略';
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`,`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
|
||||
SELECT NULL,@securityPolicyId,p.title,p.name,'','','3','',p.permissions,'','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@securityPolicyId,' '),p.sort,'','1',@now,@now
|
||||
SELECT NULL,@policyVersionId,p.title,p.name,'','','3','',p.permissions,'','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@policyVersionId,' '),p.sort,'','1',@now,@now
|
||||
FROM (
|
||||
SELECT '策略详情' title,'imSecurityPolicyView' name,'/imSecurityPolicy/view' permissions,10 sort UNION ALL
|
||||
SELECT '保存草稿','imSecurityPolicySave','/imSecurityPolicy/save',20 UNION ALL
|
||||
SELECT '发布策略','imSecurityPolicyPublish','/imSecurityPolicy/publish',30 UNION ALL
|
||||
SELECT '恢复默认','imSecurityPolicyRestoreDefault','/imSecurityPolicy/restoreDefault',40 UNION ALL
|
||||
SELECT '登录锁定配置','imSecurityPolicyLoginLockoutConfig','/imSecurityPolicy/loginLockoutConfig',45 UNION ALL
|
||||
SELECT 'IP规则列表','imSecurityPolicyIpRuleList','/imSecurityPolicy/ipRuleList',50 UNION ALL
|
||||
SELECT 'IP规则编辑','imSecurityPolicyIpRuleEdit','/imSecurityPolicy/ipRuleEdit',60 UNION ALL
|
||||
SELECT 'IP规则删除','imSecurityPolicyIpRuleDelete','/imSecurityPolicy/ipRuleDelete',70
|
||||
) p WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=p.name);
|
||||
|
||||
-- Existing installs: policy buttons move to the policy version page, IP rule buttons stay put.
|
||||
UPDATE `hg_admin_menu` SET `pid`=@policyVersionId, `updated_at`=@now
|
||||
WHERE `name` IN ('imSecurityPolicyView','imSecurityPolicySave','imSecurityPolicyPublish','imSecurityPolicyRestoreDefault','imSecurityPolicyLoginLockoutConfig');
|
||||
INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`,`menu_id`)
|
||||
SELECT `role_id`, @policyVersionId FROM `hg_admin_role_menu`
|
||||
WHERE `menu_id` IN (SELECT `id` FROM `hg_admin_menu` WHERE `name` IN ('imSecurityPolicyView','imSecurityPolicySave','imSecurityPolicyPublish','imSecurityPolicyRestoreDefault','imSecurityPolicyLoginLockoutConfig'));
|
||||
|
||||
-- Deprecate the publish button: policy save takes effect immediately (single-record model).
|
||||
UPDATE `hg_admin_menu` SET `status`=2, `hidden`=1, `updated_at`=@now WHERE `name`='imSecurityPolicyPublish';
|
||||
DELETE FROM `hg_admin_role_menu` WHERE `menu_id` IN (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imSecurityPolicyPublish');
|
||||
|
||||
@@ -60,12 +60,36 @@ UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='task-dashboard/index', `level
|
||||
|
||||
-- System and tenant settings.
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `title`='商户管理', `path`='tenant/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=10, `updated_at`=@now WHERE `name`='imTenantIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `path`='security-policy/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=20, `updated_at`=@now WHERE `name`='imSecurityPolicyIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `title`='IP黑白名单', `path`='security-policy/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=20, `updated_at`=@now WHERE `name`='imSecurityPolicyIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `path`='file-policy/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=30, `updated_at`=@now WHERE `name`='imFilePolicyIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `path`='external-config/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=50, `updated_at`=@now WHERE `name`='imExternalConfigIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `path`='notification/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=60, `updated_at`=@now WHERE `name`='imNotificationIndex';
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `path`='app-release/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=70, `updated_at`=@now WHERE `name`='imAppReleaseIndex';
|
||||
|
||||
-- Policy version page: flattened strategy form moved out of the (now IP-focused) security policy page.
|
||||
INSERT INTO `hg_admin_menu` (`pid`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`level`,`tree`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
|
||||
SELECT @settingsId,'安全策略','imPolicyVersionIndex','policy-version/index','','2','','/imSecurityPolicy/list','','/imPolicyVersion/index','1','imManager','0','0','','0','0','0','3',CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '),'25','','1',@now,@now
|
||||
WHERE @settingsId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imPolicyVersionIndex');
|
||||
SET @policyVersionId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imPolicyVersionIndex' LIMIT 1);
|
||||
UPDATE `hg_admin_menu` SET `pid`=@settingsId, `title`='安全策略', `path`='policy-version/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), `sort`=25, `updated_at`=@now WHERE `id`=@policyVersionId;
|
||||
|
||||
-- Move policy buttons from the security policy page to the policy version page.
|
||||
UPDATE `hg_admin_menu` SET `pid`=@policyVersionId, `updated_at`=@now
|
||||
WHERE `name` IN ('imSecurityPolicyView','imSecurityPolicySave','imSecurityPolicyPublish','imSecurityPolicyRestoreDefault','imSecurityPolicyLoginLockoutConfig');
|
||||
|
||||
-- Deprecate the publish button: policy save takes effect immediately (single-record model).
|
||||
UPDATE `hg_admin_menu` SET `status`=2, `hidden`=1, `updated_at`=@now WHERE `name`='imSecurityPolicyPublish';
|
||||
DELETE FROM `hg_admin_role_menu` WHERE `menu_id` IN (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imSecurityPolicyPublish');
|
||||
|
||||
-- Grant the policy version page to roles that already own any migrated policy button.
|
||||
INSERT INTO `hg_admin_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT DISTINCT rm.`role_id`, @policyVersionId
|
||||
FROM `hg_admin_role_menu` rm
|
||||
JOIN `hg_admin_menu` btn ON btn.`id` = rm.`menu_id`
|
||||
LEFT JOIN `hg_admin_role_menu` existing ON existing.`role_id` = rm.`role_id` AND existing.`menu_id` = @policyVersionId
|
||||
WHERE btn.`name` IN ('imSecurityPolicyView','imSecurityPolicySave','imSecurityPolicyPublish','imSecurityPolicyRestoreDefault','imSecurityPolicyLoginLockoutConfig')
|
||||
AND existing.`menu_id` IS NULL;
|
||||
|
||||
-- Remove one-page wrapper directories after their page has been moved.
|
||||
UPDATE `hg_admin_menu` SET `status`=2, `hidden`=1, `updated_at`=@now
|
||||
WHERE `name` IN ('imUser', 'imGroup', 'imTenant', 'imRealname', 'imWorkOrder', 'imAuditLog', 'imOutbox');
|
||||
@@ -76,7 +100,7 @@ JOIN `hg_admin_menu` page ON page.`id` = child.`pid`
|
||||
SET child.`level` = page.`level` + 1,
|
||||
child.`tree` = CONCAT(page.`tree`, 'tr_', page.`id`, ' '),
|
||||
child.`updated_at` = @now
|
||||
WHERE page.`pid` IN (@peopleId, @contentId, @auditId, @settingsId)
|
||||
WHERE (page.`pid` IN (@peopleId, @contentId, @auditId, @settingsId) OR page.`id` = @policyVersionId)
|
||||
AND child.`type` = 3;
|
||||
|
||||
-- Give roles that already own pages in a section access to the new directory.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- 安全策略"去版本化"数据迁移:每租户收敛为一条 published 记录(幂等可重跑)
|
||||
-- 执行前已备份 im_security_policy / im_message_curfew_rules 全量至 /tmp/policy-backup-20260922.txt
|
||||
|
||||
-- 1. 全局默认(tenant_id=0)不存在则插入 published 模板
|
||||
INSERT INTO `im_security_policy` (`tenant_id`,`policy_version`,`policy_name`,`publish_status`,`password_policy`,`login_rate_limit`,`sensitive_word_policy`,`file_review_policy`,`realname_policy`,`risk_challenge_policy`,`global_friend_enabled`,`friend_disabled_policy`,`message_curfew_enabled`,`remark`,`published_at`,`published_by`,`created_by`,`updated_by`)
|
||||
SELECT 0,1,'全局默认安全策略','published','{}','{"enabled":false}','{"scenes":{"user_nickname":true,"user_signature":true,"group_name":true,"group_nickname":true,"private_message":true,"group_message":true,"group_announcement":true},"risk_actions":{"high":"block","medium":"block","low":"audit_only","none":"allow"},"failure_mode":"allow","block_message":"内容不符合平台规范,请修改后重试"}','{}','{}','{}',1,'{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}',0,'全局默认模板',NOW(3),1,1,1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `im_security_policy` WHERE `tenant_id`=0 AND `publish_status`='published' AND `deleted_at` IS NULL) x);
|
||||
|
||||
-- 2. 全局默认无宵禁规则则补一条默认规则模板
|
||||
INSERT INTO `im_message_curfew_rules` (`tenant_id`,`policy_id`,`rule_code`,`rule_name`,`date_type`,`custom_dates`,`weekdays`,`timezone`,`start_time`,`end_time`,`scope`,`whitelist_user_ids`,`whitelist_group_ids`,`message`,`status`,`sort`,`created_by`,`updated_by`)
|
||||
SELECT 0, p.`id`,'work_off_1','下班时段','weekday','[]','[1,2,3,4,5]','Asia/Shanghai','18:00','09:00','private_message','[]','[]','当前时段暂不能发送消息',1,10,1,1
|
||||
FROM `im_security_policy` p
|
||||
WHERE p.`tenant_id`=0 AND p.`publish_status`='published' AND p.`deleted_at` IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `im_message_curfew_rules` WHERE `policy_id`=p.`id` AND `deleted_at` IS NULL) y);
|
||||
|
||||
-- 3. 无 published 的租户:将其最大版本 draft 提升为 published
|
||||
UPDATE `im_security_policy` sp
|
||||
JOIN (
|
||||
SELECT `tenant_id`, MAX(`policy_version`) AS mv
|
||||
FROM `im_security_policy`
|
||||
WHERE `deleted_at` IS NULL AND `publish_status`='draft'
|
||||
GROUP BY `tenant_id`
|
||||
) t ON sp.`tenant_id`=t.`tenant_id` AND sp.`policy_version`=t.`mv` AND sp.`publish_status`='draft'
|
||||
SET sp.`publish_status`='published', sp.`published_at`=NOW(3), sp.`published_by`=1, sp.`updated_at`=NOW(3)
|
||||
WHERE sp.`deleted_at` IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `im_security_policy` x WHERE x.`tenant_id`=sp.`tenant_id` AND x.`publish_status`='published' AND x.`deleted_at` IS NULL) z);
|
||||
|
||||
-- 4. 每租户仅保留版本最大的 published 记录,其余行软删
|
||||
UPDATE `im_security_policy` sp
|
||||
LEFT JOIN (
|
||||
SELECT `tenant_id`, MAX(`policy_version`) AS mv
|
||||
FROM `im_security_policy`
|
||||
WHERE `deleted_at` IS NULL AND `publish_status`='published'
|
||||
GROUP BY `tenant_id`
|
||||
) keep ON sp.`tenant_id`=keep.`tenant_id` AND sp.`policy_version`=keep.`mv` AND sp.`publish_status`='published'
|
||||
SET sp.`deleted_at`=NOW(3), sp.`deleted_by`=1
|
||||
WHERE sp.`deleted_at` IS NULL AND keep.`mv` IS NULL;
|
||||
|
||||
-- 5. 宵禁规则孤儿软删(挂在不保留/已删策略下的规则)
|
||||
UPDATE `im_message_curfew_rules` r
|
||||
LEFT JOIN `im_security_policy` sp ON r.`policy_id`=sp.`id` AND sp.`deleted_at` IS NULL
|
||||
SET r.`deleted_at`=NOW(3), r.`deleted_by`=1
|
||||
WHERE r.`deleted_at` IS NULL AND sp.`id` IS NULL;
|
||||
|
||||
-- 6. im_tenant_config 与保留记录对齐(版本 + 全局好友开关 + 宵禁开关)
|
||||
UPDATE `im_tenant_config` tc
|
||||
JOIN `im_security_policy` sp ON tc.`tenant_id`=sp.`tenant_id` AND sp.`publish_status`='published' AND sp.`deleted_at` IS NULL
|
||||
SET tc.`security_policy_version`=sp.`policy_version`,
|
||||
tc.`global_friend_enabled`=sp.`global_friend_enabled`,
|
||||
tc.`message_curfew_enabled`=sp.`message_curfew_enabled`
|
||||
WHERE tc.`security_policy_version`<>sp.`policy_version`
|
||||
OR tc.`global_friend_enabled`<>sp.`global_friend_enabled`
|
||||
OR tc.`message_curfew_enabled`<>sp.`message_curfew_enabled`;
|
||||
|
||||
-- 7. 有策略但缺 im_tenant_config 行的租户补建配置行
|
||||
INSERT INTO `im_tenant_config` (`tenant_id`,`security_policy_version`,`global_friend_enabled`,`message_curfew_enabled`,`created_by`,`updated_by`)
|
||||
SELECT sp.`tenant_id`, sp.`policy_version`, sp.`global_friend_enabled`, sp.`message_curfew_enabled`, 1, 1
|
||||
FROM `im_security_policy` sp
|
||||
WHERE sp.`publish_status`='published' AND sp.`deleted_at` IS NULL AND sp.`tenant_id`>0
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `im_tenant_config` tc WHERE tc.`tenant_id`=sp.`tenant_id`) w);
|
||||
@@ -0,0 +1,115 @@
|
||||
-- IM 安全策略模板表迁移(幂等,可重复执行)
|
||||
-- 背景:全局默认策略从 im_security_policy.tenant_id=0 记录改为独立模板表 im_security_policy_template(单行)。
|
||||
-- 每次新增商户时从模板复制一份为商户初始策略,商户后续修改只改副本。
|
||||
|
||||
-- 1. 建模板表(永远单行 id=1,平台级,宵禁规则以 JSON 数组存储)
|
||||
CREATE TABLE IF NOT EXISTS `im_security_policy_template` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`policy_name` varchar(100) NOT NULL DEFAULT '默认安全策略模板',
|
||||
`password_policy` text,
|
||||
`login_rate_limit` text,
|
||||
`sensitive_word_policy` text,
|
||||
`file_review_policy` text,
|
||||
`realname_policy` text,
|
||||
`risk_challenge_policy` text,
|
||||
`global_friend_enabled` tinyint NOT NULL DEFAULT 1,
|
||||
`friend_disabled_policy` text,
|
||||
`message_curfew_enabled` tinyint NOT NULL DEFAULT 0,
|
||||
`curfew_rules` text COMMENT '宵禁规则JSON数组',
|
||||
`remark` varchar(255) DEFAULT '',
|
||||
`created_at` datetime DEFAULT NULL,
|
||||
`created_by` bigint DEFAULT 0,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
`updated_by` bigint DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='IM安全策略模板(新建商户初始策略来源)';
|
||||
|
||||
-- 2. 从 tenant_id=0 published 记录迁入模板字段(仅当模板表为空)
|
||||
INSERT INTO `im_security_policy_template`
|
||||
(`policy_name`, `password_policy`, `login_rate_limit`, `sensitive_word_policy`, `file_review_policy`, `realname_policy`, `risk_challenge_policy`, `global_friend_enabled`, `friend_disabled_policy`, `message_curfew_enabled`, `curfew_rules`, `remark`, `created_at`, `created_by`)
|
||||
SELECT
|
||||
IFNULL(sp.policy_name, '默认安全策略模板'),
|
||||
IFNULL(CAST(sp.password_policy AS CHAR), '{}'),
|
||||
IFNULL(CAST(sp.login_rate_limit AS CHAR), '{"enabled":false}'),
|
||||
IFNULL(CAST(sp.sensitive_word_policy AS CHAR), '{}'),
|
||||
IFNULL(CAST(sp.file_review_policy AS CHAR), '{}'),
|
||||
IFNULL(CAST(sp.realname_policy AS CHAR), '{}'),
|
||||
IFNULL(CAST(sp.risk_challenge_policy AS CHAR), '{}'),
|
||||
IFNULL(sp.global_friend_enabled, 1),
|
||||
IFNULL(CAST(sp.friend_disabled_policy AS CHAR), '{}'),
|
||||
IFNULL(sp.message_curfew_enabled, 0),
|
||||
'[]',
|
||||
'由全局默认策略迁移',
|
||||
NOW(), 0
|
||||
FROM (SELECT 1) dummy
|
||||
LEFT JOIN `im_security_policy` sp ON sp.tenant_id = 0 AND sp.publish_status = 'published' AND sp.deleted_at IS NULL
|
||||
WHERE (SELECT COUNT(*) FROM `im_security_policy_template`) = 0
|
||||
ORDER BY sp.policy_version DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- 3. 模板宵禁规则:从 tenant_id=0 记录的宵禁规则聚合为 JSON(仅当模板 curfew_rules 为空数组)
|
||||
UPDATE `im_security_policy_template` t
|
||||
SET t.curfew_rules = (
|
||||
SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT(
|
||||
'ruleCode', r.rule_code, 'ruleName', r.rule_name, 'dateType', r.date_type,
|
||||
'customDates', CAST(r.custom_dates AS JSON), 'weekdays', CAST(r.weekdays AS JSON),
|
||||
'timezone', r.timezone,
|
||||
'startTime', TIME_FORMAT(r.start_time, '%H:%i'), 'endTime', TIME_FORMAT(r.end_time, '%H:%i'),
|
||||
'scope', r.scope,
|
||||
'whitelistUserIds', CAST(r.whitelist_user_ids AS JSON), 'whitelistGroupIds', CAST(r.whitelist_group_ids AS JSON),
|
||||
'message', r.message, 'status', r.status, 'sort', r.sort
|
||||
)), JSON_ARRAY())
|
||||
FROM `im_message_curfew_rules` r
|
||||
WHERE r.tenant_id = 0 AND r.deleted_at IS NULL
|
||||
AND r.policy_id = (SELECT sp.id FROM `im_security_policy` sp WHERE sp.tenant_id = 0 AND sp.publish_status = 'published' AND sp.deleted_at IS NULL ORDER BY sp.policy_version DESC LIMIT 1)
|
||||
)
|
||||
WHERE (t.curfew_rules IS NULL OR JSON_LENGTH(t.curfew_rules) = 0);
|
||||
|
||||
-- 4. 存量商户补齐:tenant_id>0 且无 published 记录的商户,从模板复制策略行(version=1, published)
|
||||
INSERT INTO `im_security_policy`
|
||||
(`tenant_id`, `policy_version`, `policy_name`, `publish_status`, `password_policy`, `login_rate_limit`, `sensitive_word_policy`, `file_review_policy`, `realname_policy`, `risk_challenge_policy`, `global_friend_enabled`, `friend_disabled_policy`, `message_curfew_enabled`, `remark`, `published_at`, `published_by`, `created_at`, `created_by`, `updated_at`, `updated_by`)
|
||||
SELECT
|
||||
tt.id, 1, t.policy_name, 'published',
|
||||
t.password_policy, t.login_rate_limit, t.sensitive_word_policy, t.file_review_policy, t.realname_policy, t.risk_challenge_policy,
|
||||
t.global_friend_enabled, t.friend_disabled_policy, t.message_curfew_enabled,
|
||||
'由默认模板补齐', NOW(), 0, NOW(), 0, NOW(), 0
|
||||
FROM `im_tenant` tt
|
||||
JOIN `im_security_policy_template` t ON t.id = (SELECT MIN(x.id) FROM `im_security_policy_template` x)
|
||||
WHERE tt.id > 0 AND tt.state = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `im_security_policy` sp
|
||||
WHERE sp.tenant_id = tt.id AND sp.publish_status = 'published' AND sp.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 5. 存量商户补齐宵禁规则:从模板 JSON 展开(仅对当前无任何宵禁规则的商户策略,已有配置的商户不覆盖)
|
||||
INSERT INTO `im_message_curfew_rules`
|
||||
(`tenant_id`, `policy_id`, `rule_code`, `rule_name`, `date_type`, `custom_dates`, `weekdays`, `timezone`, `start_time`, `end_time`, `scope`, `whitelist_user_ids`, `whitelist_group_ids`, `message`, `status`, `sort`, `created_at`, `created_by`, `updated_at`, `updated_by`)
|
||||
SELECT
|
||||
sp.tenant_id, sp.id,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.ruleCode')),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.ruleName')),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.dateType')),
|
||||
JSON_EXTRACT(jt.r, '$.customDates'),
|
||||
JSON_EXTRACT(jt.r, '$.weekdays'),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.timezone')),
|
||||
STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.startTime')), '%H:%i'),
|
||||
STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.endTime')), '%H:%i'),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.scope')),
|
||||
JSON_EXTRACT(jt.r, '$.whitelistUserIds'),
|
||||
JSON_EXTRACT(jt.r, '$.whitelistGroupIds'),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(jt.r, '$.message')),
|
||||
CAST(JSON_EXTRACT(jt.r, '$.status') AS SIGNED),
|
||||
CAST(JSON_EXTRACT(jt.r, '$.sort') AS SIGNED),
|
||||
NOW(), 0, NOW(), 0
|
||||
FROM `im_security_policy` sp
|
||||
JOIN `im_security_policy_template` tpl
|
||||
JOIN JSON_TABLE(tpl.curfew_rules, '$[*]' COLUMNS (r JSON PATH '$')) jt
|
||||
WHERE tpl.id = (SELECT MIN(x.id) FROM `im_security_policy_template` x)
|
||||
AND sp.tenant_id > 0 AND sp.publish_status = 'published' AND sp.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM `im_message_curfew_rules` r WHERE r.policy_id = sp.id AND r.deleted_at IS NULL);
|
||||
|
||||
-- 6. 软删 tenant_id=0 的策略记录与其宵禁规则(模板化后不再使用,保留软删可回溯)
|
||||
UPDATE `im_security_policy` SET deleted_at = NOW(), deleted_by = 0
|
||||
WHERE tenant_id = 0 AND deleted_at IS NULL;
|
||||
UPDATE `im_message_curfew_rules` SET deleted_at = NOW(), deleted_by = 0
|
||||
WHERE tenant_id = 0 AND deleted_at IS NULL;
|
||||
@@ -6,8 +6,6 @@ export const View = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/view', method: 'GET', params });
|
||||
export const Save = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/save', method: 'POST', params });
|
||||
export const Publish = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/publish', method: 'POST', params });
|
||||
export const RestoreDefault = (params) =>
|
||||
http.request({ url: '/imSecurityPolicy/restoreDefault', method: 'POST', params });
|
||||
export const GetLoginLockoutConfig = (params) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ const tenantOnlyOptions = ref<any[]>([]);
|
||||
|
||||
export async function loadPolicyTenantOptions(includeGlobal = true) {
|
||||
const tenants = await loadImTenantOptions(tenantOnlyOptions, { page: 1, perPage: 100 });
|
||||
// 商户绑定账号不显示“全局默认”,仅平台管理员(未绑定租户)可配置全局策略
|
||||
// 商户绑定账号不显示“全局默认”,仅平台管理员(未绑定租户)可见;安全策略页已模板化,传 false 仅列商户
|
||||
const canUseGlobal = includeGlobal && !isTenantBound();
|
||||
policyTenantOptions.value = canUseGlobal
|
||||
? [{ label: '全局默认', value: 0 }, ...tenants]
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="安全策略" /></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<n-space align="center" :size="12" class="mb-3">
|
||||
<n-text>商户</n-text>
|
||||
<n-select
|
||||
v-model:value="tenantId"
|
||||
:options="policyTenantOptions"
|
||||
:clearable="false"
|
||||
:disabled="isTenantBound()"
|
||||
style="width: 280px"
|
||||
placeholder="请选择商户"
|
||||
@update:value="loadPolicy"
|
||||
/>
|
||||
<n-tag v-if="policyStatus === 'fallback'" type="info">未配置,沿用默认模板</n-tag>
|
||||
<n-tag v-else-if="policyStatus === 'current'" type="success">修改保存后即时生效</n-tag>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 固定操作条:锚点导航 + 保存按钮(表单滚动时保持固定) -->
|
||||
<div ref="barHolder">
|
||||
<div ref="barEl" class="policy-sticky-bar">
|
||||
<n-space justify="space-between" align="center" :wrap="false" :size="12">
|
||||
<n-space :size="2">
|
||||
<n-button
|
||||
v-for="section in policySections"
|
||||
:key="section.id"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="scrollTo(section.id)"
|
||||
>{{ section.label }}</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-space :size="12" :wrap="false">
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/save'])"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="policyLoading"
|
||||
@click="savePolicy"
|
||||
>保存</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/restoreDefault'])"
|
||||
:disabled="policyLoading"
|
||||
@click="restoreDefault"
|
||||
>恢复默认</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<n-form
|
||||
ref="policyFormRef"
|
||||
:model="policyForm"
|
||||
:rules="policyRules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
:disabled="policyLoading"
|
||||
>
|
||||
<n-grid cols="1 l:2" :x-gap="12" :y-gap="12">
|
||||
<!-- 好友控制 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-friend" size="small" title="好友控制" class="policy-section h-full">
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="策略名称" path="policyName" span="2">
|
||||
<n-input v-model:value="policyForm.policyName" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="允许添加好友">
|
||||
<n-switch
|
||||
v-model:value="policyForm.globalFriendEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
@update:value="onGlobalFriendChange"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<template v-if="!policyForm.globalFriendEnabled">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-form-item-gi label="隐藏资料">
|
||||
<n-switch v-model:value="policyForm.friendPolicy.hide_profile" disabled />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="禁二维码">
|
||||
<n-switch v-model:value="policyForm.friendPolicy.block_qrcode" disabled />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="禁群成员">
|
||||
<n-switch
|
||||
v-model:value="policyForm.friendPolicy.block_group_member_entry"
|
||||
disabled
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="禁用入口">
|
||||
<n-checkbox-group v-model:value="policyForm.friendPolicy.blocked_entries" disabled>
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in friendEntryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="移动端提示">
|
||||
<n-input v-model:value="policyForm.friendPolicy.message" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 内容审核 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-moderation" size="small" title="内容审核" class="policy-section h-full">
|
||||
<n-alert type="info" :bordered="false" class="mb-3">
|
||||
阿里云内容审核和本地内容审核的启用状态在“商户管理”中按商户设置。
|
||||
</n-alert>
|
||||
<n-form-item label="审核场景">
|
||||
<n-checkbox-group v-model:value="enabledModerationScenes">
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in moderationSceneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-form-item-gi
|
||||
v-for="risk in moderationRiskOptions"
|
||||
:key="risk.value"
|
||||
:label="risk.label"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="policyForm.moderationPolicy.risk_actions[risk.value]"
|
||||
:options="moderationActionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="第三方异常">
|
||||
<n-radio-group v-model:value="policyForm.moderationPolicy.failure_mode">
|
||||
<n-space
|
||||
><n-radio value="allow">放行并告警</n-radio
|
||||
><n-radio value="block">拦截并稍后重试</n-radio></n-space
|
||||
>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="拦截提示">
|
||||
<n-input
|
||||
v-model:value="policyForm.moderationPolicy.block_message"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 消息宵禁(规则较多,跨双列) -->
|
||||
<n-grid-item span="1 l:2">
|
||||
<n-card id="sec-curfew" size="small" title="消息宵禁" class="policy-section">
|
||||
<n-form-item label="启用宵禁">
|
||||
<n-switch
|
||||
v-model:value="policyForm.messageCurfewEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space vertical size="small">
|
||||
<n-card
|
||||
v-for="(rule, index) in policyForm.curfewRules"
|
||||
:key="rule.ruleCode"
|
||||
size="small"
|
||||
:title="`规则 ${Number(index) + 1}`"
|
||||
>
|
||||
<template #header-extra
|
||||
><n-button
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="policyLoading"
|
||||
@click="removeCurfew(index)"
|
||||
>删除</n-button
|
||||
></template
|
||||
>
|
||||
<n-grid :cols="4" :x-gap="12">
|
||||
<n-form-item-gi label="规则编码">
|
||||
<n-input v-model:value="rule.ruleCode" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="规则名称">
|
||||
<n-input v-model:value="rule.ruleName" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="日期类型">
|
||||
<n-select v-model:value="rule.dateType" :options="dateOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="管控范围">
|
||||
<n-select v-model:value="rule.scope" :options="scopeOptions" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="开始时间">
|
||||
<n-time-picker
|
||||
v-model:formatted-value="rule.startTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="结束时间">
|
||||
<n-time-picker
|
||||
v-model:formatted-value="rule.endTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="时区">
|
||||
<n-input v-model:value="rule.timezone" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="状态">
|
||||
<n-select v-model:value="rule.status" :options="statusOptions" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-form-item-gi label="提示文案">
|
||||
<n-input v-model:value="rule.message" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="豁免用户ID">
|
||||
<n-input v-model:value="rule.whitelistUserText" placeholder="多个ID用逗号分隔" />
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
<n-button dashed block :disabled="policyLoading" @click="addCurfew"
|
||||
>添加宵禁规则</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 登录失败策略(独立配置,保存即时生效) -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-lockout" size="small" title="登录失败策略" class="policy-section h-full">
|
||||
<n-form-item label="登录失败锁定">
|
||||
<n-switch v-model:value="lockoutForm.enabled" />
|
||||
</n-form-item>
|
||||
<template v-if="lockoutForm.enabled">
|
||||
<n-grid :cols="3" :x-gap="12">
|
||||
<n-form-item-gi label="最大失败次数">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.passwordMaxAttempts"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="锁定(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.lockMinutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="窗口(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.windowMinutes"
|
||||
:min="1"
|
||||
:max="240"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<n-form-item label=" ">
|
||||
<n-text depth="3">
|
||||
同一账号在计数窗口内连续输错密码达到最大次数后锁定,可在“日志审计-登录失败日志”中解锁。点击顶部“保存”后立即生效。
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 其他策略 -->
|
||||
<n-grid-item>
|
||||
<n-card id="sec-other" size="small" title="其他策略" class="policy-section h-full">
|
||||
<n-form-item label="密码策略JSON">
|
||||
<n-input
|
||||
v-model:value="policyForm.passwordPolicy"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="实名策略JSON">
|
||||
<n-input
|
||||
v-model:value="policyForm.realnamePolicy"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="policyForm.remark" type="textarea" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import {
|
||||
GetLoginLockoutConfig,
|
||||
List,
|
||||
RestoreDefault,
|
||||
Save,
|
||||
SaveLoginLockoutConfig,
|
||||
View,
|
||||
} from '@/api/imSecurityPolicy';
|
||||
import { getDefaultTenantValue, loadPolicyTenantOptions, parseJSON, policyTenantOptions } from '@/views/imPolicyShared';
|
||||
import { isTenantBound } from '@/views/imTenantOptions';
|
||||
|
||||
const dict = useDictStore(),
|
||||
message = useMessage(),
|
||||
dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const policyFormRef = ref();
|
||||
const tenantId = ref(0),
|
||||
policyStatus = ref(''),
|
||||
policyLoading = ref(false),
|
||||
submitting = ref(false);
|
||||
|
||||
const policySections = [
|
||||
{ id: 'sec-friend', label: '好友控制' },
|
||||
{ id: 'sec-moderation', label: '内容审核' },
|
||||
{ id: 'sec-curfew', label: '消息宵禁' },
|
||||
{ id: 'sec-lockout', label: '登录失败' },
|
||||
{ id: 'sec-other', label: '其他策略' },
|
||||
];
|
||||
function scrollTo(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// 操作条固定:页面滚动在 naive-ui 内部滚动容器中且布局嵌套多层 overflow 容器,CSS sticky 不可靠,
|
||||
// 改用滚动监听 + fixed 定位;barHolder 作占位避免 fixed 后表单上跳
|
||||
const barHolder = ref<HTMLElement>();
|
||||
const barEl = ref<HTMLElement>();
|
||||
const FIXED_TOP = 64; // 顶部 header 高度
|
||||
let barRaf = 0;
|
||||
function updateBarFixed() {
|
||||
barRaf = 0;
|
||||
const holder = barHolder.value,
|
||||
bar = barEl.value;
|
||||
if (!holder || !bar) return;
|
||||
const rect = holder.getBoundingClientRect();
|
||||
if (rect.top <= FIXED_TOP) {
|
||||
if (!bar.classList.contains('is-fixed')) {
|
||||
holder.style.height = `${bar.offsetHeight}px`;
|
||||
bar.style.width = `${holder.offsetWidth}px`;
|
||||
bar.classList.add('is-fixed');
|
||||
}
|
||||
bar.style.left = `${rect.left}px`;
|
||||
} else if (bar.classList.contains('is-fixed')) {
|
||||
bar.classList.remove('is-fixed');
|
||||
bar.style.width = '';
|
||||
bar.style.left = '';
|
||||
holder.style.height = '';
|
||||
}
|
||||
}
|
||||
function onDocScroll() {
|
||||
if (!barRaf) barRaf = requestAnimationFrame(updateBarFixed);
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('scroll', onDocScroll, true);
|
||||
window.addEventListener('resize', onDocScroll);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', onDocScroll, true);
|
||||
window.removeEventListener('resize', onDocScroll);
|
||||
if (barRaf) cancelAnimationFrame(barRaf);
|
||||
});
|
||||
|
||||
const dateOptions = dict.getOption('ImCurfewDateTypeOptions'),
|
||||
scopeOptions = dict.getOption('ImCurfewScopeOptions'),
|
||||
statusOptions = dict.getOption('sys_normal_disable'),
|
||||
friendEntryOptions = dict.getOption('ImFriendEntryOptions');
|
||||
dict.loadOptions([
|
||||
'ImCurfewDateTypeOptions',
|
||||
'ImCurfewScopeOptions',
|
||||
'sys_normal_disable',
|
||||
'ImFriendEntryOptions',
|
||||
]);
|
||||
|
||||
const newCurfew = (i = 1) => ({
|
||||
ruleCode: `work_off_${i}`,
|
||||
ruleName: '下班时段',
|
||||
dateType: 'weekday',
|
||||
customDates: [],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
timezone: 'Asia/Shanghai',
|
||||
startTime: '18:00',
|
||||
endTime: '09:00',
|
||||
scope: 'private_message',
|
||||
whitelistUserIds: [],
|
||||
whitelistGroupIds: [],
|
||||
whitelistUserText: '',
|
||||
message: '当前时段暂不能发送消息',
|
||||
status: 1,
|
||||
sort: i * 10,
|
||||
});
|
||||
const allFriendEntries = [
|
||||
'search',
|
||||
'profile_card',
|
||||
'group_member',
|
||||
'qrcode',
|
||||
'invite_auto_add',
|
||||
'recommendation',
|
||||
];
|
||||
const defaultFriendDisabledPolicy = () => ({
|
||||
hide_profile: true,
|
||||
block_qrcode: true,
|
||||
block_group_member_entry: true,
|
||||
blocked_entries: [...allFriendEntries],
|
||||
message: '当前时段暂不允许添加好友',
|
||||
});
|
||||
const moderationSceneOptions = [
|
||||
{ label: '用户昵称', value: 'user_nickname' },
|
||||
{ label: '用户签名', value: 'user_signature' },
|
||||
{ label: '群名称', value: 'group_name' },
|
||||
{ label: '群昵称', value: 'group_nickname' },
|
||||
{ label: '单聊消息', value: 'private_message' },
|
||||
{ label: '群聊消息', value: 'group_message' },
|
||||
{ label: '群公告', value: 'group_announcement' },
|
||||
];
|
||||
const moderationRiskOptions = [
|
||||
{ label: '高风险动作', value: 'high' },
|
||||
{ label: '中风险动作', value: 'medium' },
|
||||
{ label: '低风险动作', value: 'low' },
|
||||
{ label: '无风险动作', value: 'none' },
|
||||
];
|
||||
const moderationActionOptions = [
|
||||
{ label: '拦截', value: 'block' },
|
||||
{ label: '仅审计', value: 'audit_only' },
|
||||
{ label: '放行', value: 'allow' },
|
||||
];
|
||||
const defaultModerationPolicy = () => ({
|
||||
scenes: Object.fromEntries(moderationSceneOptions.map((item) => [item.value, true])),
|
||||
risk_actions: { high: 'block', medium: 'block', low: 'audit_only', none: 'allow' },
|
||||
failure_mode: 'allow',
|
||||
block_message: '内容不符合平台规范,请修改后重试',
|
||||
});
|
||||
const emptyPolicy = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId.value,
|
||||
policyName: '安全策略',
|
||||
passwordPolicy: '{}',
|
||||
sensitiveWordPolicy: '{}',
|
||||
moderationPolicy: defaultModerationPolicy(),
|
||||
fileReviewPolicy: '{}',
|
||||
realnamePolicy: '{}',
|
||||
riskChallengePolicy: '{}',
|
||||
globalFriendEnabled: 1,
|
||||
friendPolicy: defaultFriendDisabledPolicy(),
|
||||
messageCurfewEnabled: 0,
|
||||
remark: '',
|
||||
curfewRules: [] as any[],
|
||||
});
|
||||
const policyForm = ref<any>(emptyPolicy());
|
||||
const enabledModerationScenes = computed({
|
||||
get: () =>
|
||||
Object.keys(policyForm.value.moderationPolicy?.scenes || {}).filter(
|
||||
(key) => policyForm.value.moderationPolicy.scenes[key]
|
||||
),
|
||||
set: (values: string[]) => {
|
||||
policyForm.value.moderationPolicy.scenes = Object.fromEntries(
|
||||
moderationSceneOptions.map((item) => [item.value, values.includes(item.value)])
|
||||
);
|
||||
},
|
||||
});
|
||||
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
|
||||
const emptyLockout = () => ({
|
||||
enabled: false,
|
||||
passwordMaxAttempts: 5,
|
||||
lockMinutes: 1440,
|
||||
windowMinutes: 60,
|
||||
policyId: 0,
|
||||
});
|
||||
const lockoutForm = ref<any>(emptyLockout());
|
||||
|
||||
// 将接口返回的策略数据映射为平铺表单
|
||||
function applyPolicyData(data: any) {
|
||||
policyForm.value = {
|
||||
...data,
|
||||
friendPolicy: {
|
||||
...defaultFriendDisabledPolicy(),
|
||||
...parseJSON(data.friendDisabledPolicy, {}),
|
||||
},
|
||||
passwordPolicy: data.passwordPolicy || '{}',
|
||||
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
|
||||
moderationPolicy: {
|
||||
...defaultModerationPolicy(),
|
||||
...parseJSON(data.sensitiveWordPolicy, {}),
|
||||
scenes: {
|
||||
...defaultModerationPolicy().scenes,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).scenes || {}),
|
||||
},
|
||||
risk_actions: {
|
||||
...defaultModerationPolicy().risk_actions,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).risk_actions || {}),
|
||||
},
|
||||
},
|
||||
fileReviewPolicy: data.fileReviewPolicy || '{}',
|
||||
realnamePolicy: data.realnamePolicy || '{}',
|
||||
riskChallengePolicy: data.riskChallengePolicy || '{}',
|
||||
curfewRules: (data.curfewRules || []).map((v) => ({
|
||||
...v,
|
||||
customDates: parseJSON(v.customDates, []),
|
||||
weekdays: parseJSON(v.weekdays, []),
|
||||
whitelistUserIds: parseJSON(v.whitelistUserIds, []),
|
||||
whitelistGroupIds: parseJSON(v.whitelistGroupIds, []),
|
||||
whitelistUserText: parseJSON(v.whitelistUserIds, []).join(','),
|
||||
})),
|
||||
};
|
||||
// 登录失败锁定配置已独立卡片管理,避免随策略保存回传覆盖
|
||||
delete policyForm.value.loginRateLimit;
|
||||
}
|
||||
|
||||
// 页面加载策略:加载商户当前生效的唯一策略记录;无记录时由后端回落全局默认配置展示
|
||||
// 选择商户事件后先禁用整页表单,数据加载完成才恢复可编辑;序号守卫防止连续切换时旧响应覆盖新数据
|
||||
let loadSeq = 0;
|
||||
async function loadPolicy() {
|
||||
const tid = Number(tenantId.value || 0);
|
||||
if (!tid) return;
|
||||
const seq = ++loadSeq;
|
||||
policyLoading.value = true;
|
||||
policyStatus.value = '';
|
||||
try {
|
||||
let published: any = null;
|
||||
try {
|
||||
// List 对无记录返回空数组而非报错,避免 system 等无策略商户误弹“安全策略不存在”
|
||||
const res = await List({ tenantId: tid, publishStatus: 'published', page: 1, perPage: 1 });
|
||||
published = res?.list?.[0];
|
||||
} catch {
|
||||
published = null;
|
||||
}
|
||||
if (published?.id) {
|
||||
const data = await View({ id: published.id });
|
||||
if (seq !== loadSeq) return;
|
||||
applyPolicyData(data);
|
||||
policyStatus.value = 'current';
|
||||
loadLockoutConfig(policyForm.value.tenantId || tid);
|
||||
return;
|
||||
}
|
||||
// 无自建策略:展示全局默认配置(后端 View 回落 tenant_id=0 记录),保存后成为本商户自己的策略
|
||||
try {
|
||||
const data = await View({ tenantId: tid });
|
||||
if (seq !== loadSeq) return;
|
||||
applyPolicyData(data);
|
||||
policyStatus.value = 'fallback';
|
||||
loadLockoutConfig(policyForm.value.tenantId || tid);
|
||||
return;
|
||||
} catch {
|
||||
// 全局默认也不存在时落空表单
|
||||
}
|
||||
if (seq !== loadSeq) return;
|
||||
policyForm.value = emptyPolicy();
|
||||
loadLockoutConfig(tid);
|
||||
} finally {
|
||||
if (seq === loadSeq) policyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLockoutConfig(tenant = 0) {
|
||||
try {
|
||||
const data = await GetLoginLockoutConfig({ tenantId: tenant });
|
||||
lockoutForm.value = {
|
||||
enabled: !!data.enabled,
|
||||
passwordMaxAttempts: data.passwordMaxAttempts || 5,
|
||||
lockMinutes: data.lockMinutes || 1440,
|
||||
windowMinutes: data.windowMinutes || 60,
|
||||
policyId: data.policyId || 0,
|
||||
};
|
||||
} catch {
|
||||
lockoutForm.value = emptyLockout();
|
||||
}
|
||||
}
|
||||
|
||||
function addCurfew() {
|
||||
policyForm.value.curfewRules.push(newCurfew(policyForm.value.curfewRules.length + 1));
|
||||
}
|
||||
function onGlobalFriendChange(enabled: number) {
|
||||
if (enabled === 0) {
|
||||
policyForm.value.friendPolicy = {
|
||||
...policyForm.value.friendPolicy,
|
||||
hide_profile: true,
|
||||
block_qrcode: true,
|
||||
block_group_member_entry: true,
|
||||
blocked_entries: [...allFriendEntries],
|
||||
};
|
||||
}
|
||||
}
|
||||
function removeCurfew(i) {
|
||||
policyForm.value.curfewRules.splice(i, 1);
|
||||
}
|
||||
function jsonObject(text, label) {
|
||||
try {
|
||||
return JSON.parse(text || '{}');
|
||||
} catch {
|
||||
throw new Error(`${label}不是合法JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存即生效:后端原地更新当前生效记录并投递策略变更事件,IM 端消费后刷新缓存
|
||||
async function savePolicy() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await policyFormRef.value?.validate();
|
||||
const f = policyForm.value;
|
||||
await Save({
|
||||
tenantId: f.tenantId || tenantId.value,
|
||||
policyName: f.policyName,
|
||||
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
|
||||
sensitiveWordPolicy: f.moderationPolicy,
|
||||
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
|
||||
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),
|
||||
riskChallengePolicy: jsonObject(f.riskChallengePolicy, '风控策略'),
|
||||
globalFriendEnabled: f.globalFriendEnabled,
|
||||
friendDisabledPolicy: f.friendPolicy,
|
||||
messageCurfewEnabled: f.messageCurfewEnabled,
|
||||
remark: f.remark,
|
||||
curfewRules: f.curfewRules.map((v) => ({
|
||||
...v,
|
||||
whitelistUserIds: v.whitelistUserText
|
||||
.split(',')
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter(Boolean),
|
||||
})),
|
||||
});
|
||||
// 登录失败策略随顶部保存一并提交(独立接口,保存即时生效)
|
||||
try {
|
||||
await SaveLoginLockoutConfig({
|
||||
tenantId: f.tenantId || tenantId.value,
|
||||
enabled: lockoutForm.value.enabled,
|
||||
passwordMaxAttempts: lockoutForm.value.passwordMaxAttempts,
|
||||
lockMinutes: lockoutForm.value.lockMinutes,
|
||||
windowMinutes: lockoutForm.value.windowMinutes,
|
||||
});
|
||||
} catch {
|
||||
message.error('策略已保存,但登录失败策略保存失败');
|
||||
}
|
||||
message.success('已保存并即时生效');
|
||||
await loadPolicy();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDefault() {
|
||||
dialog.warning({
|
||||
title: '恢复默认策略',
|
||||
content: '将把当前商户的安全策略重置为平台默认模板配置,并立即生效。',
|
||||
positiveText: '重置并生效',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await RestoreDefault({ tenantId: tenantId.value, reason: '管理员恢复默认' });
|
||||
message.success('已重置为默认模板配置');
|
||||
await loadPolicy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 安全策略已模板化:不提供“全局默认”选项,仅针对具体商户配置
|
||||
await loadPolicyTenantOptions(false);
|
||||
tenantId.value = Number(getDefaultTenantValue(policyTenantOptions.value) || 0);
|
||||
await loadPolicy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.policy-sticky-bar {
|
||||
background: #fff;
|
||||
padding: 8px 0;
|
||||
margin: 12px 0;
|
||||
border-bottom: 1px solid #efeff5;
|
||||
}
|
||||
.policy-sticky-bar.is-fixed {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
}
|
||||
.policy-section {
|
||||
scroll-margin-top: 128px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,291 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="安全策略" /></div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="IP黑白名单" /></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadAll" @reset="reloadAll" />
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="policy" tab="策略版本">
|
||||
<BasicTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
:request="loadTable"
|
||||
:row-key="(r) => r.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="policyScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
>
|
||||
<template #tableTitle
|
||||
><n-space
|
||||
><n-button
|
||||
type="primary"
|
||||
:disabled="!hasPermission(['/imSecurityPolicy/save'])"
|
||||
@click="openPolicy()"
|
||||
>新建草稿</n-button
|
||||
><n-button
|
||||
:disabled="!hasPermission(['/imSecurityPolicy/restoreDefault'])"
|
||||
@click="restoreDefault"
|
||||
>恢复默认</n-button
|
||||
></n-space
|
||||
></template
|
||||
>
|
||||
</BasicTable>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="ip" tab="IP黑白名单">
|
||||
<BasicTable
|
||||
ref="ipTableRef"
|
||||
:columns="ipColumns"
|
||||
:request="loadIPTable"
|
||||
:row-key="(r) => r.id"
|
||||
:actionColumn="ipActionColumn"
|
||||
:scroll-x="ipScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
>
|
||||
<template #tableTitle
|
||||
><n-button
|
||||
type="primary"
|
||||
:disabled="!hasPermission(['/imSecurityPolicy/ipRuleEdit'])"
|
||||
@click="openIPRule()"
|
||||
>新增IP规则</n-button
|
||||
></template
|
||||
>
|
||||
</BasicTable>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable
|
||||
ref="ipTableRef"
|
||||
:columns="ipColumns"
|
||||
:request="loadIPTable"
|
||||
:row-key="(r) => r.id"
|
||||
:actionColumn="ipActionColumn"
|
||||
:scroll-x="ipScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
>
|
||||
<template #tableTitle
|
||||
><n-button
|
||||
type="primary"
|
||||
:disabled="!hasPermission(['/imSecurityPolicy/ipRuleEdit'])"
|
||||
@click="openIPRule()"
|
||||
>新增IP规则</n-button
|
||||
></template
|
||||
>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
|
||||
<n-modal
|
||||
v-model:show="policyVisible"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:title="policyForm.id ? '编辑安全策略草稿' : '新建安全策略草稿'"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-form
|
||||
ref="policyFormRef"
|
||||
:model="policyForm"
|
||||
:rules="policyRules"
|
||||
:disabled="policyReadOnly && policyActiveTab !== 'lockout'"
|
||||
label-placement="left"
|
||||
label-width="132"
|
||||
>
|
||||
<n-tabs v-model:value="policyActiveTab" type="segment" animated>
|
||||
<n-tab-pane name="friend" tab="好友控制">
|
||||
<n-form-item label="策略名称" path="policyName"
|
||||
><n-input v-model:value="policyForm.policyName"
|
||||
/></n-form-item>
|
||||
<n-form-item label="允许添加好友"
|
||||
><n-switch
|
||||
v-model:value="policyForm.globalFriendEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
@update:value="onGlobalFriendChange"
|
||||
/></n-form-item>
|
||||
<template v-if="!policyForm.globalFriendEnabled">
|
||||
<n-form-item label="隐藏个人资料"
|
||||
><n-switch v-model:value="policyForm.friendPolicy.hide_profile" disabled
|
||||
/></n-form-item>
|
||||
<n-form-item label="禁止二维码添加"
|
||||
><n-switch v-model:value="policyForm.friendPolicy.block_qrcode" disabled
|
||||
/></n-form-item>
|
||||
<n-form-item label="禁止群成员入口"
|
||||
><n-switch
|
||||
v-model:value="policyForm.friendPolicy.block_group_member_entry"
|
||||
disabled
|
||||
/></n-form-item>
|
||||
<n-form-item label="禁用加好友入口">
|
||||
<n-checkbox-group v-model:value="policyForm.friendPolicy.blocked_entries" disabled>
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in friendEntryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="移动端提示"
|
||||
><n-input v-model:value="policyForm.friendPolicy.message"
|
||||
/></n-form-item>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="curfew" tab="消息宵禁">
|
||||
<n-form-item label="启用消息宵禁"
|
||||
><n-switch
|
||||
v-model:value="policyForm.messageCurfewEnabled"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
/></n-form-item>
|
||||
<n-space vertical size="large">
|
||||
<n-card
|
||||
v-for="(rule, index) in policyForm.curfewRules"
|
||||
:key="rule.ruleCode"
|
||||
size="small"
|
||||
:title="`规则 ${index + 1}`"
|
||||
>
|
||||
<template v-if="!policyReadOnly" #header-extra
|
||||
><n-button quaternary type="error" @click="removeCurfew(index)"
|
||||
>删除</n-button
|
||||
></template
|
||||
>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi label="规则编码"
|
||||
><n-input v-model:value="rule.ruleCode" /></n-form-item-gi
|
||||
><n-form-item-gi label="规则名称"
|
||||
><n-input v-model:value="rule.ruleName"
|
||||
/></n-form-item-gi>
|
||||
<n-form-item-gi label="日期类型"
|
||||
><n-select
|
||||
v-model:value="rule.dateType"
|
||||
:options="dateOptions" /></n-form-item-gi
|
||||
><n-form-item-gi label="管控范围"
|
||||
><n-select v-model:value="rule.scope" :options="scopeOptions"
|
||||
/></n-form-item-gi>
|
||||
<n-form-item-gi label="开始时间"
|
||||
><n-time-picker
|
||||
v-model:formatted-value="rule.startTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm" /></n-form-item-gi
|
||||
><n-form-item-gi label="结束时间"
|
||||
><n-time-picker
|
||||
v-model:formatted-value="rule.endTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
/></n-form-item-gi>
|
||||
<n-form-item-gi label="时区"
|
||||
><n-input v-model:value="rule.timezone" /></n-form-item-gi
|
||||
><n-form-item-gi label="状态"
|
||||
><n-select v-model:value="rule.status" :options="statusOptions"
|
||||
/></n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="提示文案"><n-input v-model:value="rule.message" /></n-form-item>
|
||||
<n-form-item label="豁免用户ID"
|
||||
><n-input v-model:value="rule.whitelistUserText" placeholder="多个ID用逗号分隔"
|
||||
/></n-form-item>
|
||||
</n-card>
|
||||
<n-button v-if="!policyReadOnly" dashed block @click="addCurfew"
|
||||
>添加宵禁规则</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="moderation" tab="内容审核">
|
||||
<n-alert type="info" :bordered="false">
|
||||
阿里云内容审核和本地内容审核的启用状态在“商户管理”中按商户设置。
|
||||
</n-alert>
|
||||
<n-form-item label="审核场景">
|
||||
<n-checkbox-group v-model:value="enabledModerationScenes">
|
||||
<n-space>
|
||||
<n-checkbox
|
||||
v-for="option in moderationSceneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi
|
||||
v-for="risk in moderationRiskOptions"
|
||||
:key="risk.value"
|
||||
:label="risk.label"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="policyForm.moderationPolicy.risk_actions[risk.value]"
|
||||
:options="moderationActionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="第三方异常">
|
||||
<n-radio-group v-model:value="policyForm.moderationPolicy.failure_mode">
|
||||
<n-space
|
||||
><n-radio value="allow">放行并告警</n-radio
|
||||
><n-radio value="block">拦截并稍后重试</n-radio></n-space
|
||||
>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="拦截提示">
|
||||
<n-input
|
||||
v-model:value="policyForm.moderationPolicy.block_message"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="lockout" tab="登录失败策略">
|
||||
<n-form-item label="登录失败锁定"
|
||||
><n-switch v-model:value="lockoutForm.enabled"
|
||||
/></n-form-item>
|
||||
<template v-if="lockoutForm.enabled">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi label="最大失败次数">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.passwordMaxAttempts"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:precision="0"
|
||||
/> </n-form-item-gi
|
||||
><n-form-item-gi label="锁定时长(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.lockMinutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:precision="0"
|
||||
/> </n-form-item-gi
|
||||
><n-form-item-gi label="计数窗口(分钟)">
|
||||
<n-input-number
|
||||
v-model:value="lockoutForm.windowMinutes"
|
||||
:min="1"
|
||||
:max="240"
|
||||
:precision="0"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<n-form-item label=" ">
|
||||
<n-text depth="3">
|
||||
同一账号在计数窗口内连续输错密码达到最大次数后锁定,锁定期间登录将被拒绝,可在“日志审计-登录失败日志”中解锁。保存后立即生效,无需发布策略。
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button
|
||||
v-if="hasPermission(['/imSecurityPolicy/loginLockoutConfig'])"
|
||||
type="primary"
|
||||
:loading="lockoutSaving"
|
||||
@click="saveLockoutConfig"
|
||||
>保存</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="advanced" tab="其他策略">
|
||||
<n-form-item label="密码策略JSON"
|
||||
><n-input
|
||||
v-model:value="policyForm.passwordPolicy"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
/></n-form-item>
|
||||
<n-form-item label="实名策略JSON"
|
||||
><n-input
|
||||
v-model:value="policyForm.realnamePolicy"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
/></n-form-item>
|
||||
<n-form-item label="备注"
|
||||
><n-input v-model:value="policyForm.remark" type="textarea"
|
||||
/></n-form-item>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form>
|
||||
<template #action
|
||||
><n-space
|
||||
><n-button @click="policyVisible = false">取消</n-button
|
||||
><n-button v-if="!policyReadOnly" type="primary" :loading="submitting" @click="savePolicy"
|
||||
>保存草稿</n-button
|
||||
></n-space
|
||||
></template
|
||||
>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="ipVisible"
|
||||
preset="dialog"
|
||||
@@ -337,21 +74,9 @@
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
|
||||
import {
|
||||
GetLoginLockoutConfig,
|
||||
IPRuleDelete,
|
||||
IPRuleEdit,
|
||||
IPRuleList,
|
||||
List,
|
||||
Publish,
|
||||
RestoreDefault,
|
||||
Save,
|
||||
SaveLoginLockoutConfig,
|
||||
View,
|
||||
} from '@/api/imSecurityPolicy';
|
||||
import { columns, ipColumns, loadOptions, schemas } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { IPRuleDelete, IPRuleEdit, IPRuleList } from '@/api/imSecurityPolicy';
|
||||
import { ipColumns, loadOptions, schemas } from './model';
|
||||
import {
|
||||
getDefaultTenantValue,
|
||||
loadPolicyTenantOptions,
|
||||
@@ -360,26 +85,16 @@
|
||||
} from '@/views/imPolicyShared';
|
||||
const dict = useDictStore(),
|
||||
message = useMessage(),
|
||||
dialog = useDialog(),
|
||||
userStore = useUserStore();
|
||||
dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const searchFormRef = ref<any>({}),
|
||||
tableRef = ref(),
|
||||
ipTableRef = ref(),
|
||||
policyFormRef = ref(),
|
||||
ipFormRef = ref();
|
||||
const activeTab = ref('policy'),
|
||||
policyActiveTab = ref('friend'),
|
||||
policyVisible = ref(false),
|
||||
ipVisible = ref(false),
|
||||
const ipVisible = ref(false),
|
||||
submitting = ref(false);
|
||||
const dialogWidth = computed(() => adaModalWidth(900));
|
||||
const tenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0);
|
||||
const dateOptions = dict.getOption('ImCurfewDateTypeOptions'),
|
||||
scopeOptions = dict.getOption('ImCurfewScopeOptions'),
|
||||
statusOptions = dict.getOption('sys_normal_disable'),
|
||||
const statusOptions = dict.getOption('sys_normal_disable'),
|
||||
actionOptions = dict.getOption('ImSecurityActionOptions'),
|
||||
friendEntryOptions = dict.getOption('ImFriendEntryOptions'),
|
||||
riskOptions = dict.getOption('ImRiskLevelOptions');
|
||||
const ipTypeOptions = [
|
||||
{ label: '白名单', value: 'whitelist' },
|
||||
@@ -391,138 +106,6 @@
|
||||
{ label: '发消息', value: 'message' },
|
||||
{ label: '上传文件', value: 'upload' },
|
||||
];
|
||||
const newCurfew = (i = 1) => ({
|
||||
ruleCode: `work_off_${i}`,
|
||||
ruleName: '下班时段',
|
||||
dateType: 'weekday',
|
||||
customDates: [],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
timezone: 'Asia/Shanghai',
|
||||
startTime: '18:00',
|
||||
endTime: '09:00',
|
||||
scope: 'private_message',
|
||||
whitelistUserIds: [],
|
||||
whitelistGroupIds: [],
|
||||
whitelistUserText: '',
|
||||
message: '当前时段暂不能发送消息',
|
||||
status: 1,
|
||||
sort: i * 10,
|
||||
});
|
||||
const allFriendEntries = [
|
||||
'search',
|
||||
'profile_card',
|
||||
'group_member',
|
||||
'qrcode',
|
||||
'invite_auto_add',
|
||||
'recommendation',
|
||||
];
|
||||
const defaultFriendDisabledPolicy = () => ({
|
||||
hide_profile: true,
|
||||
block_qrcode: true,
|
||||
block_group_member_entry: true,
|
||||
blocked_entries: [...allFriendEntries],
|
||||
message: '当前时段暂不允许添加好友',
|
||||
});
|
||||
const moderationSceneOptions = [
|
||||
{ label: '用户昵称', value: 'user_nickname' },
|
||||
{ label: '用户签名', value: 'user_signature' },
|
||||
{ label: '群名称', value: 'group_name' },
|
||||
{ label: '群昵称', value: 'group_nickname' },
|
||||
{ label: '单聊消息', value: 'private_message' },
|
||||
{ label: '群聊消息', value: 'group_message' },
|
||||
{ label: '群公告', value: 'group_announcement' },
|
||||
];
|
||||
const moderationRiskOptions = [
|
||||
{ label: '高风险动作', value: 'high' },
|
||||
{ label: '中风险动作', value: 'medium' },
|
||||
{ label: '低风险动作', value: 'low' },
|
||||
{ label: '无风险动作', value: 'none' },
|
||||
];
|
||||
const moderationActionOptions = [
|
||||
{ label: '拦截', value: 'block' },
|
||||
{ label: '仅审计', value: 'audit_only' },
|
||||
{ label: '放行', value: 'allow' },
|
||||
];
|
||||
const defaultModerationPolicy = () => ({
|
||||
scenes: Object.fromEntries(moderationSceneOptions.map((item) => [item.value, true])),
|
||||
risk_actions: { high: 'block', medium: 'block', low: 'audit_only', none: 'allow' },
|
||||
failure_mode: 'allow',
|
||||
block_message: '内容不符合平台规范,请修改后重试',
|
||||
});
|
||||
const emptyPolicy = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId(),
|
||||
policyName: '安全策略',
|
||||
passwordPolicy: '{}',
|
||||
sensitiveWordPolicy: '{}',
|
||||
moderationPolicy: defaultModerationPolicy(),
|
||||
fileReviewPolicy: '{}',
|
||||
realnamePolicy: '{}',
|
||||
riskChallengePolicy: '{}',
|
||||
globalFriendEnabled: 1,
|
||||
friendPolicy: defaultFriendDisabledPolicy(),
|
||||
messageCurfewEnabled: 0,
|
||||
remark: '',
|
||||
curfewRules: [] as any[],
|
||||
});
|
||||
const policyForm = ref<any>(emptyPolicy());
|
||||
const enabledModerationScenes = computed({
|
||||
get: () =>
|
||||
Object.keys(policyForm.value.moderationPolicy?.scenes || {}).filter(
|
||||
(key) => policyForm.value.moderationPolicy.scenes[key]
|
||||
),
|
||||
set: (values: string[]) => {
|
||||
policyForm.value.moderationPolicy.scenes = Object.fromEntries(
|
||||
moderationSceneOptions.map((item) => [item.value, values.includes(item.value)])
|
||||
);
|
||||
},
|
||||
});
|
||||
const policyReadOnly = computed(
|
||||
() => policyForm.value.id > 0 && policyForm.value.publishStatus !== 'draft'
|
||||
);
|
||||
const policyRules = { policyName: { required: true, message: '请输入策略名称' } };
|
||||
const emptyLockout = () => ({
|
||||
enabled: false,
|
||||
passwordMaxAttempts: 5,
|
||||
lockMinutes: 1440,
|
||||
windowMinutes: 60,
|
||||
policyId: 0,
|
||||
});
|
||||
const lockoutForm = ref<any>(emptyLockout());
|
||||
const lockoutSaving = ref(false);
|
||||
async function loadLockoutConfig(tenant = 0) {
|
||||
try {
|
||||
const data = await GetLoginLockoutConfig({ tenantId: tenant });
|
||||
lockoutForm.value = {
|
||||
enabled: !!data.enabled,
|
||||
passwordMaxAttempts: data.passwordMaxAttempts || 5,
|
||||
lockMinutes: data.lockMinutes || 1440,
|
||||
windowMinutes: data.windowMinutes || 60,
|
||||
policyId: data.policyId || 0,
|
||||
};
|
||||
} catch {
|
||||
lockoutForm.value = emptyLockout();
|
||||
}
|
||||
}
|
||||
async function saveLockoutConfig() {
|
||||
lockoutSaving.value = true;
|
||||
try {
|
||||
await SaveLoginLockoutConfig({
|
||||
tenantId: policyForm.value.tenantId || tenantId(),
|
||||
enabled: lockoutForm.value.enabled,
|
||||
passwordMaxAttempts: lockoutForm.value.passwordMaxAttempts,
|
||||
lockMinutes: lockoutForm.value.lockMinutes,
|
||||
windowMinutes: lockoutForm.value.windowMinutes,
|
||||
});
|
||||
message.success('已保存,配置即时生效');
|
||||
policyVisible.value = false;
|
||||
reloadAll();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
lockoutSaving.value = false;
|
||||
}
|
||||
}
|
||||
const emptyIP = () => ({
|
||||
id: 0,
|
||||
tenantId: tenantId(),
|
||||
@@ -537,45 +120,25 @@
|
||||
const ipForm = ref<any>(emptyIP());
|
||||
const ipRules = { ipCidr: { required: true, message: '请输入IP或CIDR' } };
|
||||
const tenantSchema = schemas.value.find((v) => v.field === 'tenantId');
|
||||
if (tenantSchema) tenantSchema.ifShow = () => true;
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => true;
|
||||
// 商户下拉选中即自动触发查询(无需查询/重置按钮)
|
||||
const props: any = (tenantSchema.componentProps = tenantSchema.componentProps || {});
|
||||
props.onChange = () => ipTableRef.value?.reload();
|
||||
}
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
// 商户下拉选中即自动查询,不需要查询/重置按钮
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
// 表格挂载时自动请求早于租户默认值就绪(tenantId 尚为 0),此时跳过请求,由 onMounted 的 reloadAll 加载首屏
|
||||
const loadTable = (res) =>
|
||||
tenantId() ? List({ ...searchFormRef.value?.formModel, ...res }) : Promise.resolve({}),
|
||||
loadIPTable = (res) =>
|
||||
tenantId() ? IPRuleList({ tenantId: tenantId(), ...res }) : Promise.resolve({});
|
||||
const reloadAll = () => {
|
||||
tableRef.value?.reload();
|
||||
// 表格挂载时自动请求早于租户默认值就绪(tenantId 尚为 0),此时跳过请求,由 onMounted 的 reload 加载首屏
|
||||
const loadIPTable = (res) =>
|
||||
tenantId() ? IPRuleList({ tenantId: tenantId(), ...res }) : Promise.resolve({});
|
||||
function reloadTable() {
|
||||
ipTableRef.value?.reload();
|
||||
};
|
||||
const actionColumn = reactive({
|
||||
width: 220,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (row) =>
|
||||
h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: row.publishStatus === 'draft' ? '编辑' : '查看',
|
||||
auth: ['/imSecurityPolicy/view'],
|
||||
onClick: () => openPolicy(row),
|
||||
},
|
||||
{
|
||||
label: '发布',
|
||||
type: 'warning',
|
||||
auth: ['/imSecurityPolicy/publish'],
|
||||
ifShow: () => row.publishStatus === 'draft',
|
||||
onClick: () => publishPolicy(row),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
const ipActionColumn = reactive({
|
||||
width: 160,
|
||||
title: '操作',
|
||||
@@ -595,134 +158,7 @@
|
||||
],
|
||||
}),
|
||||
});
|
||||
const policyScrollX = computed(() => adaTableScrollX(columns, actionColumn.width)),
|
||||
ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
|
||||
async function openPolicy(row?: any) {
|
||||
policyActiveTab.value = 'friend';
|
||||
if (!row) {
|
||||
policyForm.value = emptyPolicy();
|
||||
policyVisible.value = true;
|
||||
loadLockoutConfig(tenantId());
|
||||
return;
|
||||
}
|
||||
const data = await View({ id: row.id });
|
||||
policyForm.value = {
|
||||
...data,
|
||||
friendPolicy: {
|
||||
...defaultFriendDisabledPolicy(),
|
||||
...parseJSON(data.friendDisabledPolicy, {}),
|
||||
},
|
||||
passwordPolicy: data.passwordPolicy || '{}',
|
||||
sensitiveWordPolicy: data.sensitiveWordPolicy || '{}',
|
||||
moderationPolicy: {
|
||||
...defaultModerationPolicy(),
|
||||
...parseJSON(data.sensitiveWordPolicy, {}),
|
||||
scenes: {
|
||||
...defaultModerationPolicy().scenes,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).scenes || {}),
|
||||
},
|
||||
risk_actions: {
|
||||
...defaultModerationPolicy().risk_actions,
|
||||
...(parseJSON(data.sensitiveWordPolicy, {}).risk_actions || {}),
|
||||
},
|
||||
},
|
||||
fileReviewPolicy: data.fileReviewPolicy || '{}',
|
||||
realnamePolicy: data.realnamePolicy || '{}',
|
||||
riskChallengePolicy: data.riskChallengePolicy || '{}',
|
||||
curfewRules: (data.curfewRules || []).map((v) => ({
|
||||
...v,
|
||||
customDates: parseJSON(v.customDates, []),
|
||||
weekdays: parseJSON(v.weekdays, []),
|
||||
whitelistUserIds: parseJSON(v.whitelistUserIds, []),
|
||||
whitelistGroupIds: parseJSON(v.whitelistGroupIds, []),
|
||||
whitelistUserText: parseJSON(v.whitelistUserIds, []).join(','),
|
||||
})),
|
||||
};
|
||||
// 登录失败锁定配置已迁移至独立tab管理,避免随草稿保存回传覆盖
|
||||
delete policyForm.value.loginRateLimit;
|
||||
policyVisible.value = true;
|
||||
loadLockoutConfig(policyForm.value.tenantId);
|
||||
}
|
||||
function addCurfew() {
|
||||
policyForm.value.curfewRules.push(newCurfew(policyForm.value.curfewRules.length + 1));
|
||||
}
|
||||
function onGlobalFriendChange(enabled: number) {
|
||||
if (enabled === 0) {
|
||||
policyForm.value.friendPolicy = {
|
||||
...policyForm.value.friendPolicy,
|
||||
hide_profile: true,
|
||||
block_qrcode: true,
|
||||
block_group_member_entry: true,
|
||||
blocked_entries: [...allFriendEntries],
|
||||
};
|
||||
}
|
||||
}
|
||||
function removeCurfew(i) {
|
||||
policyForm.value.curfewRules.splice(i, 1);
|
||||
}
|
||||
function jsonObject(text, label) {
|
||||
try {
|
||||
return JSON.parse(text || '{}');
|
||||
} catch {
|
||||
throw new Error(`${label}不是合法JSON`);
|
||||
}
|
||||
}
|
||||
async function savePolicy() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await policyFormRef.value?.validate();
|
||||
const f = policyForm.value;
|
||||
await Save({
|
||||
...f,
|
||||
passwordPolicy: jsonObject(f.passwordPolicy, '密码策略'),
|
||||
sensitiveWordPolicy: f.moderationPolicy,
|
||||
fileReviewPolicy: jsonObject(f.fileReviewPolicy, '文件审核策略'),
|
||||
realnamePolicy: jsonObject(f.realnamePolicy, '实名策略'),
|
||||
riskChallengePolicy: jsonObject(f.riskChallengePolicy, '风控策略'),
|
||||
friendDisabledPolicy: f.friendPolicy,
|
||||
curfewRules: f.curfewRules.map((v) => ({
|
||||
...v,
|
||||
whitelistUserIds: v.whitelistUserText
|
||||
.split(',')
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter(Boolean),
|
||||
})),
|
||||
});
|
||||
message.success('草稿已保存');
|
||||
policyVisible.value = false;
|
||||
reloadAll();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
function publishPolicy(row) {
|
||||
dialog.warning({
|
||||
title: '发布安全策略',
|
||||
content: `发布版本 ${row.policyVersion} 后将由IM后端执行,确认发布?`,
|
||||
positiveText: '发布',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Publish({ id: row.id, reason: '管理员发布' });
|
||||
message.success('发布成功');
|
||||
reloadAll();
|
||||
},
|
||||
});
|
||||
}
|
||||
function restoreDefault() {
|
||||
dialog.warning({
|
||||
title: '恢复默认策略',
|
||||
content: '将根据全局默认创建一个新草稿,不会直接发布。',
|
||||
positiveText: '创建草稿',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await RestoreDefault({ tenantId: tenantId(), reason: '管理员恢复默认' });
|
||||
message.success('默认策略草稿已创建');
|
||||
reloadAll();
|
||||
},
|
||||
});
|
||||
}
|
||||
const ipScrollX = computed(() => adaTableScrollX(ipColumns, ipActionColumn.width));
|
||||
function openIPRule(row?: any) {
|
||||
ipForm.value = row ? { ...row, scenes: parseJSON(row.sceneJson, []) } : emptyIP();
|
||||
ipVisible.value = true;
|
||||
@@ -734,7 +170,7 @@
|
||||
await IPRuleEdit({ ...ipForm.value, sceneJson: ipForm.value.scenes });
|
||||
message.success('保存成功');
|
||||
ipVisible.value = false;
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -748,7 +184,7 @@
|
||||
onPositiveClick: async () => {
|
||||
await IPRuleDelete({ id: row.id });
|
||||
message.success('删除成功');
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -756,6 +192,6 @@
|
||||
loadOptions();
|
||||
await loadPolicyTenantOptions(true);
|
||||
await setFieldsValue({ tenantId: getDefaultTenantValue(policyTenantOptions.value) });
|
||||
reloadAll();
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -12,41 +12,7 @@ export const schemas = ref<FormSchema[]>([
|
||||
label: '商户',
|
||||
componentProps: { options: computed(() => policyTenantOptions.value), clearable: false, disabled: isTenantBound() },
|
||||
},
|
||||
{
|
||||
field: 'publishStatus',
|
||||
component: 'NSelect',
|
||||
label: '发布状态',
|
||||
componentProps: {
|
||||
options: dict.getOption('ImSecurityPublishStatusOptions'),
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const columns = [
|
||||
{ title: '版本', key: 'policyVersion', width: 90 },
|
||||
{ title: '策略名称', key: 'policyName', width: 180 },
|
||||
{
|
||||
title: '发布状态',
|
||||
key: 'publishStatus',
|
||||
width: 110,
|
||||
render: (r) => dict.getLabel('ImSecurityPublishStatusOptions', r.publishStatus),
|
||||
},
|
||||
{
|
||||
title: '允许加好友',
|
||||
key: 'globalFriendEnabled',
|
||||
width: 110,
|
||||
render: (r) => (r.globalFriendEnabled ? '允许' : '禁止'),
|
||||
},
|
||||
{
|
||||
title: '消息宵禁',
|
||||
key: 'messageCurfewEnabled',
|
||||
width: 100,
|
||||
render: (r) => (r.messageCurfewEnabled ? '启用' : '关闭'),
|
||||
},
|
||||
{ title: '发布时间', key: 'publishedAt', width: 180 },
|
||||
{ title: '更新时间', key: 'updatedAt', width: 180 },
|
||||
{ title: '备注', key: 'remark', width: 220 },
|
||||
];
|
||||
export const ipColumns = [
|
||||
{
|
||||
title: '类型',
|
||||
@@ -78,11 +44,7 @@ export const ipColumns = [
|
||||
];
|
||||
export function loadOptions() {
|
||||
dict.loadOptions([
|
||||
'ImSecurityPublishStatusOptions',
|
||||
'ImCurfewDateTypeOptions',
|
||||
'ImCurfewScopeOptions',
|
||||
'ImSecurityActionOptions',
|
||||
'ImFriendEntryOptions',
|
||||
'ImRiskLevelOptions',
|
||||
'sys_normal_disable',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user