From dc3c45cd53bd7c8a66571e0753896f0632bfe912 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 22 Sep 2026 18:27:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E5=AE=89=E5=85=A8=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=A8=A1=E6=9D=BF=E5=8C=96=EF=BC=8C=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E5=95=86=E6=88=B7=E6=97=B6=E8=87=AA=E5=8A=A8=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imsecuritypolicy/im_security_policy.go | 5 - .../admin/sys/im_security_policy.go | 4 - .../internal/logic/sys/im_policy_support.go | 34 + .../internal/logic/sys/im_security_policy.go | 180 ++--- server/internal/logic/sys/im_tenant.go | 94 ++- .../model/input/sysin/im_policy_test.go | 10 - .../model/input/sysin/im_security_policy.go | 19 - server/internal/service/sys.go | 1 - .../data/generate/im_adfahi_policy_menu.sql | 25 +- .../data/generate/im_menu_hierarchy.sql | 28 +- .../data/generate/im_policy_single_record.sql | 60 ++ .../data/generate/im_policy_template.sql | 115 +++ web/src/api/imSecurityPolicy/index.ts | 2 - web/src/views/imPolicyShared.ts | 2 +- web/src/views/imPolicyVersion/index.vue | 700 ++++++++++++++++++ web/src/views/imSecurityPolicy/index.vue | 650 ++-------------- web/src/views/imSecurityPolicy/model.ts | 38 - 17 files changed, 1172 insertions(+), 795 deletions(-) create mode 100644 server/storage/data/generate/im_policy_single_record.sql create mode 100644 server/storage/data/generate/im_policy_template.sql create mode 100644 web/src/views/imPolicyVersion/index.vue diff --git a/server/api/admin/imsecuritypolicy/im_security_policy.go b/server/api/admin/imsecuritypolicy/im_security_policy.go index fb66336..cbac1ae 100644 --- a/server/api/admin/imsecuritypolicy/im_security_policy.go +++ b/server/api/admin/imsecuritypolicy/im_security_policy.go @@ -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 diff --git a/server/internal/controller/admin/sys/im_security_policy.go b/server/internal/controller/admin/sys/im_security_policy.go index 7f30cf1..88bc887 100644 --- a/server/internal/controller/admin/sys/im_security_policy.go +++ b/server/internal/controller/admin/sys/im_security_policy.go @@ -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 { diff --git a/server/internal/logic/sys/im_policy_support.go b/server/internal/logic/sys/im_policy_support.go index ab6c025..602f127 100644 --- a/server/internal/logic/sys/im_policy_support.go +++ b/server/internal/logic/sys/im_policy_support.go @@ -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, diff --git a/server/internal/logic/sys/im_security_policy.go b/server/internal/logic/sys/im_security_policy.go index d30d029..3ddef3f 100644 --- a/server/internal/logic/sys/im_security_policy.go +++ b/server/internal/logic/sys/im_security_policy.go @@ -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 } diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index f6613fd..0a84235 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -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) { diff --git a/server/internal/model/input/sysin/im_policy_test.go b/server/internal/model/input/sysin/im_policy_test.go index 1cbbd13..958ba39 100644 --- a/server/internal/model/input/sysin/im_policy_test.go +++ b/server/internal/model/input/sysin/im_policy_test.go @@ -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{ diff --git a/server/internal/model/input/sysin/im_security_policy.go b/server/internal/model/input/sysin/im_security_policy.go index e62fe78..f43cb62 100644 --- a/server/internal/model/input/sysin/im_security_policy.go +++ b/server/internal/model/input/sysin/im_security_policy.go @@ -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#恢复原因不能为空"` diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index f7075f1..65b6486 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -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) diff --git a/server/storage/data/generate/im_adfahi_policy_menu.sql b/server/storage/data/generate/im_adfahi_policy_menu.sql index 5c6dbde..00d5017 100644 --- a/server/storage/data/generate/im_adfahi_policy_menu.sql +++ b/server/storage/data/generate/im_adfahi_policy_menu.sql @@ -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'); diff --git a/server/storage/data/generate/im_menu_hierarchy.sql b/server/storage/data/generate/im_menu_hierarchy.sql index 40185d9..6da84ba 100644 --- a/server/storage/data/generate/im_menu_hierarchy.sql +++ b/server/storage/data/generate/im_menu_hierarchy.sql @@ -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. diff --git a/server/storage/data/generate/im_policy_single_record.sql b/server/storage/data/generate/im_policy_single_record.sql new file mode 100644 index 0000000..fca86b3 --- /dev/null +++ b/server/storage/data/generate/im_policy_single_record.sql @@ -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); diff --git a/server/storage/data/generate/im_policy_template.sql b/server/storage/data/generate/im_policy_template.sql new file mode 100644 index 0000000..268f88f --- /dev/null +++ b/server/storage/data/generate/im_policy_template.sql @@ -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; diff --git a/web/src/api/imSecurityPolicy/index.ts b/web/src/api/imSecurityPolicy/index.ts index 1da2e83..cf076fa 100644 --- a/web/src/api/imSecurityPolicy/index.ts +++ b/web/src/api/imSecurityPolicy/index.ts @@ -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) => diff --git a/web/src/views/imPolicyShared.ts b/web/src/views/imPolicyShared.ts index b13ea74..2d510d9 100644 --- a/web/src/views/imPolicyShared.ts +++ b/web/src/views/imPolicyShared.ts @@ -8,7 +8,7 @@ const tenantOnlyOptions = ref([]); 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] diff --git a/web/src/views/imPolicyVersion/index.vue b/web/src/views/imPolicyVersion/index.vue new file mode 100644 index 0000000..aff2e48 --- /dev/null +++ b/web/src/views/imPolicyVersion/index.vue @@ -0,0 +1,700 @@ + + + + + diff --git a/web/src/views/imSecurityPolicy/index.vue b/web/src/views/imSecurityPolicy/index.vue index 8a745db..0bd13ac 100644 --- a/web/src/views/imSecurityPolicy/index.vue +++ b/web/src/views/imSecurityPolicy/index.vue @@ -1,291 +1,28 @@