From 5c3f6ae58d4dc0c4f35da5d2bdfab89f8add0db9 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 21 Aug 2026 23:07:06 +0800 Subject: [PATCH] feat: add IM file policy and org exports --- docs/backstage/IM-未实现增强项.md | 7 +- .../api/admin/imfilepolicy/im_file_policy.go | 25 ++ server/api/admin/imorg/im_org.go | 14 ++ server/internal/consts/im_file_policy.go | 20 ++ .../controller/admin/sys/im_file_policy.go | 31 +++ .../internal/controller/admin/sys/im_org.go | 10 + server/internal/logic/sys/im_file_policy.go | 169 +++++++++++++ server/internal/logic/sys/im_org.go | 47 ++++ .../model/input/sysin/im_file_policy.go | 75 ++++++ server/internal/model/input/sysin/im_org.go | 31 +++ .../router/genrouter/im_file_policy.go | 7 + server/internal/service/sys.go | 22 ++ .../data/generate/im_file_policy_menu.sql | 31 +++ server/storage/data/generate/im_org_menu.sql | 8 + web/src/api/imFilePolicy/index.ts | 9 + web/src/api/imOrg/index.ts | 10 +- web/src/views/imFilePolicy/index.vue | 225 ++++++++++++++++++ web/src/views/imFilePolicy/model.ts | 136 +++++++++++ web/src/views/imOrg/index.vue | 38 +++ 19 files changed, 912 insertions(+), 3 deletions(-) create mode 100644 server/api/admin/imfilepolicy/im_file_policy.go create mode 100644 server/internal/consts/im_file_policy.go create mode 100644 server/internal/controller/admin/sys/im_file_policy.go create mode 100644 server/internal/logic/sys/im_file_policy.go create mode 100644 server/internal/model/input/sysin/im_file_policy.go create mode 100644 server/internal/router/genrouter/im_file_policy.go create mode 100644 server/storage/data/generate/im_file_policy_menu.sql create mode 100644 web/src/api/imFilePolicy/index.ts create mode 100644 web/src/views/imFilePolicy/index.vue create mode 100644 web/src/views/imFilePolicy/model.ts diff --git a/docs/backstage/IM-未实现增强项.md b/docs/backstage/IM-未实现增强项.md index 6caa5a7..47b1e70 100644 --- a/docs/backstage/IM-未实现增强项.md +++ b/docs/backstage/IM-未实现增强项.md @@ -135,11 +135,12 @@ 6. 成员添加、编辑、调岗、移除。 7. 组织搜索基础接口,覆盖部门和成员。 8. 组织操作写入 IM 后台审计。 +9. 部门导出和成员导出。 待实现/配合: 1. 组织数据权限:当前后台按租户选库隔离,部门级 `org_admin` 数据范围尚未建模。 -2. 导入/导出:需要定义 Excel 字段、失败明细和幂等更新策略。 +2. 组织导入:需要定义 Excel 字段、冲突覆盖规则、失败明细和幂等更新策略。 3. 离职用户处理策略:当前支持 `employment_state` 和 `contactable`,客户端/IM 服务端需按该状态裁剪通讯录和可联系入口。 4. 组织版本增量同步:当前维护部门/成员版本号,客户端增量同步协议需由 IM 服务端继续承接。 @@ -179,13 +180,15 @@ 4. 文件详情查看审计。 5. 孤儿文件手动清理。 6. 孤儿文件定时任务 `imFileCleanupOrphan`,参数为保留天数、单租户处理上限。 +7. 租户级文件用途策略配置:大小上限、保留天数、MIME 白名单、自动清理开关、病毒扫描开关。 待实现/配合: 1. 下载审计:需要 IM 服务端文件下载链路或后台下载代理在实际下载时写入审计。 2. 底层对象物理删除:当前后台将 `im_fileuploads.status` 标记为已删除,物理对象清理需由文件服务或 IM 服务端清理器消费 `location`。 -3. 过期文件策略:需要先定义不同 `purpose` 的保留周期和是否允许自动删除。 +3. 过期文件执行:后台已提供策略配置,IM 服务端/文件清理器需读取 `im_file_policy` 后执行过期扫描、状态更新和物理清理。 4. 文件用途识别目前基于 `im_filemsglinks`/群文件索引做基础归类,头像、举报证据、反馈附件等用途需要 IM 服务端补齐绑定关系或独立索引。 +5. 病毒扫描执行:后台已提供开关,实际扫描任务、扫描结果回写和用户通知需要 IM 服务端或文件服务承接。 ### P1-3 外部服务配置 diff --git a/server/api/admin/imfilepolicy/im_file_policy.go b/server/api/admin/imfilepolicy/im_file_policy.go new file mode 100644 index 0000000..f483acc --- /dev/null +++ b/server/api/admin/imfilepolicy/im_file_policy.go @@ -0,0 +1,25 @@ +package imfilepolicy + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imFilePolicy/list" method:"get" tags:"IM文件策略" summary:"获取IM文件策略列表"` + sysin.ImFilePolicyListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImFilePolicyListModel `json:"list" dc:"数据列表"` +} + +type UpdateReq struct { + g.Meta `path:"/imFilePolicy/update" method:"post" tags:"IM文件策略" summary:"更新IM文件策略"` + sysin.ImFilePolicyUpdateInp +} + +type UpdateRes struct{} diff --git a/server/api/admin/imorg/im_org.go b/server/api/admin/imorg/im_org.go index ed4183a..2ea7aa1 100644 --- a/server/api/admin/imorg/im_org.go +++ b/server/api/admin/imorg/im_org.go @@ -17,6 +17,13 @@ type DepartmentListRes struct { List []*sysin.ImOrgDepartmentModel `json:"list" dc:"数据列表"` } +type DepartmentExportReq struct { + g.Meta `path:"/imOrg/departmentExport" method:"get" tags:"IM组织架构" summary:"导出IM组织部门列表"` + sysin.ImOrgDepartmentListInp +} + +type DepartmentExportRes struct{} + type DepartmentEditReq struct { g.Meta `path:"/imOrg/departmentEdit" method:"post" tags:"IM组织架构" summary:"新增/编辑IM组织部门"` sysin.ImOrgDepartmentEditInp @@ -41,6 +48,13 @@ type MemberListRes struct { List []*sysin.ImOrgMemberModel `json:"list" dc:"数据列表"` } +type MemberExportReq struct { + g.Meta `path:"/imOrg/memberExport" method:"get" tags:"IM组织架构" summary:"导出IM组织成员列表"` + sysin.ImOrgMemberListInp +} + +type MemberExportRes struct{} + type MemberEditReq struct { g.Meta `path:"/imOrg/memberEdit" method:"post" tags:"IM组织架构" summary:"新增/编辑IM组织成员"` sysin.ImOrgMemberEditInp diff --git a/server/internal/consts/im_file_policy.go b/server/internal/consts/im_file_policy.go new file mode 100644 index 0000000..2ad61e3 --- /dev/null +++ b/server/internal/consts/im_file_policy.go @@ -0,0 +1,20 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImFilePolicySwitchOptions", "IM文件策略开关选项", ImFilePolicySwitchOptions) +} + +const ( + ImFilePolicySwitchOff = 0 + ImFilePolicySwitchOn = 1 +) + +var ImFilePolicySwitchOptions = []*model.Option{ + dict.GenWarningOption(ImFilePolicySwitchOff, "关闭"), + dict.GenSuccessOption(ImFilePolicySwitchOn, "开启"), +} diff --git a/server/internal/controller/admin/sys/im_file_policy.go b/server/internal/controller/admin/sys/im_file_policy.go new file mode 100644 index 0000000..572eda2 --- /dev/null +++ b/server/internal/controller/admin/sys/im_file_policy.go @@ -0,0 +1,31 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imfilepolicy" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImFilePolicy = cImFilePolicy{} + +type cImFilePolicy struct{} + +func (c *cImFilePolicy) List(ctx context.Context, req *imfilepolicy.ListReq) (res *imfilepolicy.ListRes, err error) { + list, totalCount, err := service.SysImFilePolicy().List(ctx, &req.ImFilePolicyListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImFilePolicyListModel{} + } + res = new(imfilepolicy.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImFilePolicy) Update(ctx context.Context, req *imfilepolicy.UpdateReq) (res *imfilepolicy.UpdateRes, err error) { + err = service.SysImFilePolicy().Update(ctx, &req.ImFilePolicyUpdateInp) + return +} diff --git a/server/internal/controller/admin/sys/im_org.go b/server/internal/controller/admin/sys/im_org.go index 13397f1..88dece9 100644 --- a/server/internal/controller/admin/sys/im_org.go +++ b/server/internal/controller/admin/sys/im_org.go @@ -30,6 +30,11 @@ func (c *cImOrg) DepartmentEdit(ctx context.Context, req *imorg.DepartmentEditRe return } +func (c *cImOrg) DepartmentExport(ctx context.Context, req *imorg.DepartmentExportReq) (res *imorg.DepartmentExportRes, err error) { + err = service.SysImOrg().DepartmentExport(ctx, &req.ImOrgDepartmentListInp) + return +} + func (c *cImOrg) DepartmentDelete(ctx context.Context, req *imorg.DepartmentDeleteReq) (res *imorg.DepartmentDeleteRes, err error) { err = service.SysImOrg().DepartmentDelete(ctx, &req.ImOrgDepartmentDeleteInp) return @@ -54,6 +59,11 @@ func (c *cImOrg) MemberEdit(ctx context.Context, req *imorg.MemberEditReq) (res return } +func (c *cImOrg) MemberExport(ctx context.Context, req *imorg.MemberExportReq) (res *imorg.MemberExportRes, err error) { + err = service.SysImOrg().MemberExport(ctx, &req.ImOrgMemberListInp) + return +} + func (c *cImOrg) MemberDelete(ctx context.Context, req *imorg.MemberDeleteReq) (res *imorg.MemberDeleteRes, err error) { err = service.SysImOrg().MemberDelete(ctx, &req.ImOrgMemberDeleteInp) return diff --git a/server/internal/logic/sys/im_file_policy.go b/server/internal/logic/sys/im_file_policy.go new file mode 100644 index 0000000..dd35e9d --- /dev/null +++ b/server/internal/logic/sys/im_file_policy.go @@ -0,0 +1,169 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "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/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +type sSysImFilePolicy struct{} + +func NewSysImFilePolicy() *sSysImFilePolicy { + return &sSysImFilePolicy{} +} + +func init() { + service.RegisterSysImFilePolicy(NewSysImFilePolicy()) +} + +func (s *sSysImFilePolicy) List(ctx context.Context, in *sysin.ImFilePolicyListInp) (list []*sysin.ImFilePolicyListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := s.resolveTenant(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureRows(ctx, tenant.Id); err != nil { + return + } + mod := s.model(ctx).Where("tenant_id", tenant.Id) + if in.Purpose != "" { + mod = mod.Where("purpose", in.Purpose) + } + if in.AutoCleanup != nil { + mod = mod.Where("auto_cleanup", *in.AutoCleanup) + } + if in.ScanEnabled != nil { + mod = mod.Where("scan_enabled", *in.ScanEnabled) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM文件策略失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImFilePolicy) Update(ctx context.Context, in *sysin.ImFilePolicyUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := s.resolveTenant(ctx, in.TenantId) + if err != nil { + return err + } + if err = s.ensureRows(ctx, tenant.Id); err != nil { + return err + } + allowedMimes := "[]" + if in.AllowedMimes != nil && in.AllowedMimes.String() != "" && in.AllowedMimes.String() != "null" { + allowedMimes = in.AllowedMimes.String() + } + if _, err = imtenant.RegistryDB().Model("im_file_policy").Safe().Ctx(ctx). + Where("tenant_id", tenant.Id). + Where("purpose", in.Purpose). + Data(g.Map{ + "max_size": in.MaxSize, + "retention_days": in.RetentionDays, + "allowed_mimes": allowedMimes, + "auto_cleanup": in.AutoCleanup, + "scan_enabled": in.ScanEnabled, + "remark": in.Remark, + "updated_at": gtime.Now(), + "updated_by": contexts.GetUserId(ctx), + }).Update(); err != nil { + return gerror.Wrap(err, "更新IM文件策略失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_file_policy_update", "im_file_policy", 0, "success", g.Map{ + "purpose": in.Purpose, "auto_cleanup": in.AutoCleanup, "scan_enabled": in.ScanEnabled, + }) + return nil +} + +func (s *sSysImFilePolicy) model(ctx context.Context) *gdb.Model { + return imtenant.RegistryDB().Model("im_file_policy").Safe().Ctx(ctx).Fields( + "id", + "tenant_id", + "purpose", + "max_size", + "retention_days", + "CAST(allowed_mimes AS CHAR) allowed_mimes", + "auto_cleanup", + "scan_enabled", + "remark", + "updated_at", + ) +} + +func (s *sSysImFilePolicy) resolveTenant(ctx context.Context, selectedTenantId int64) (tenant *entity.ImTenant, err error) { + scope, err := resolveImAdminScope(ctx, selectedTenantId) + if err != nil { + return nil, err + } + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(scope.TenantId).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil { + return nil, gerror.New("IM租户不存在") + } + return tenant, nil +} + +func (s *sSysImFilePolicy) ensureRows(ctx context.Context, tenantId int64) error { + for _, item := range defaultFilePolicies() { + count, err := imtenant.RegistryDB().Model("im_file_policy").Safe().Ctx(ctx). + Where("tenant_id", tenantId). + Where("purpose", item.Purpose). + Count() + if err != nil { + return gerror.Wrap(err, "检查IM文件策略失败") + } + if count > 0 { + continue + } + _, err = imtenant.RegistryDB().Exec(ctx, `INSERT INTO im_file_policy + (tenant_id,purpose,max_size,retention_days,allowed_mimes,auto_cleanup,scan_enabled,remark,created_by,updated_by) + VALUES(?,?,?,?,CAST(? AS JSON),?,?,?,?,?)`, + tenantId, item.Purpose, item.MaxSize, item.RetentionDays, item.AllowedMimes, item.AutoCleanup, item.ScanEnabled, item.Remark, contexts.GetUserId(ctx), contexts.GetUserId(ctx)) + if err != nil { + return gerror.Wrap(err, "初始化IM文件策略失败") + } + } + return nil +} + +type filePolicySeed struct { + Purpose string + MaxSize int64 + RetentionDays int + AllowedMimes string + AutoCleanup int + ScanEnabled int + Remark string +} + +func defaultFilePolicies() []*filePolicySeed { + const mb = 1024 * 1024 + return []*filePolicySeed{ + {Purpose: consts.ImFilePurposeChat, MaxSize: 100 * mb, RetentionDays: 365, AllowedMimes: "[]", AutoCleanup: 0, ScanEnabled: 0, Remark: "聊天附件默认策略"}, + {Purpose: consts.ImFilePurposeUserAvatar, MaxSize: 5 * mb, RetentionDays: 0, AllowedMimes: `["image/jpeg","image/png","image/webp"]`, AutoCleanup: 0, ScanEnabled: 0, Remark: "用户头像长期保留"}, + {Purpose: consts.ImFilePurposeGroupAvatar, MaxSize: 5 * mb, RetentionDays: 0, AllowedMimes: `["image/jpeg","image/png","image/webp"]`, AutoCleanup: 0, ScanEnabled: 0, Remark: "群头像长期保留"}, + {Purpose: consts.ImFilePurposeGroupFile, MaxSize: 200 * mb, RetentionDays: 365, AllowedMimes: "[]", AutoCleanup: 0, ScanEnabled: 0, Remark: "群文件默认策略"}, + {Purpose: consts.ImFilePurposeAbuseEvidence, MaxSize: 50 * mb, RetentionDays: 730, AllowedMimes: "[]", AutoCleanup: 0, ScanEnabled: 0, Remark: "举报证据默认策略"}, + {Purpose: consts.ImFilePurposeFeedback, MaxSize: 50 * mb, RetentionDays: 365, AllowedMimes: "[]", AutoCleanup: 0, ScanEnabled: 0, Remark: "反馈附件默认策略"}, + } +} diff --git a/server/internal/logic/sys/im_org.go b/server/internal/logic/sys/im_org.go index 034cc32..8ceb6d4 100644 --- a/server/internal/logic/sys/im_org.go +++ b/server/internal/logic/sys/im_org.go @@ -2,16 +2,21 @@ package sys import ( "context" + "fmt" "hotgo/internal/consts" "hotgo/internal/model/input/form" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "hotgo/utility/convert" + "hotgo/utility/excel" "strings" "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/gctx" "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" "github.com/gogf/gf/v2/util/guid" ) @@ -58,6 +63,27 @@ func (s *sSysImOrg) DepartmentList(ctx context.Context, in *sysin.ImOrgDepartmen return } +func (s *sSysImOrg) DepartmentExport(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (err error) { + list, totalCount, err := s.DepartmentList(ctx, in) + if err != nil { + return + } + tags, err := convert.GetEntityDescTags(sysin.ImOrgDepartmentExportModel{}) + if err != nil { + return + } + var exports []sysin.ImOrgDepartmentExportModel + if err = gconv.Scan(list, &exports); err != nil { + return + } + fileName := "导出IM组织部门-" + gctx.CtxId(ctx) + sheetName := fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, form.CalPageCount(totalCount, in.PerPage), in.Page, len(list)) + if len(list) > 0 { + _ = writeImAdminAudit(ctx, list[0].TenantId, "im_org_department_export", "im_org_department", 0, "success", g.Map{"count": len(list)}) + } + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} + func (s *sSysImOrg) DepartmentEdit(ctx context.Context, in *sysin.ImOrgDepartmentEditInp) (err error) { if err = in.Filter(ctx); err != nil { return @@ -199,6 +225,27 @@ func (s *sSysImOrg) MemberList(ctx context.Context, in *sysin.ImOrgMemberListInp return } +func (s *sSysImOrg) MemberExport(ctx context.Context, in *sysin.ImOrgMemberListInp) (err error) { + list, totalCount, err := s.MemberList(ctx, in) + if err != nil { + return + } + tags, err := convert.GetEntityDescTags(sysin.ImOrgMemberExportModel{}) + if err != nil { + return + } + var exports []sysin.ImOrgMemberExportModel + if err = gconv.Scan(list, &exports); err != nil { + return + } + fileName := "导出IM组织成员-" + gctx.CtxId(ctx) + sheetName := fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, form.CalPageCount(totalCount, in.PerPage), in.Page, len(list)) + if len(list) > 0 { + _ = writeImAdminAudit(ctx, list[0].TenantId, "im_org_member_export", "im_org_member", 0, "success", g.Map{"count": len(list), "department_id": in.DepartmentId}) + } + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} + func (s *sSysImOrg) MemberEdit(ctx context.Context, in *sysin.ImOrgMemberEditInp) (err error) { if err = in.Filter(ctx); err != nil { return diff --git a/server/internal/model/input/sysin/im_file_policy.go b/server/internal/model/input/sysin/im_file_policy.go new file mode 100644 index 0000000..03cdb43 --- /dev/null +++ b/server/internal/model/input/sysin/im_file_policy.go @@ -0,0 +1,75 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImFilePolicyListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Purpose string `json:"purpose" dc:"用途"` + AutoCleanup *int `json:"autoCleanup" dc:"自动清理"` + ScanEnabled *int `json:"scanEnabled" dc:"病毒扫描"` +} + +func (in *ImFilePolicyListInp) Filter(ctx context.Context) (err error) { return } + +type ImFilePolicyListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Purpose string `json:"purpose" dc:"用途"` + MaxSize int64 `json:"maxSize" dc:"单文件大小上限"` + RetentionDays int `json:"retentionDays" dc:"保留天数"` + AllowedMimes string `json:"allowedMimes" dc:"允许MIME JSON"` + AutoCleanup int `json:"autoCleanup" dc:"自动清理"` + ScanEnabled int `json:"scanEnabled" dc:"病毒扫描"` + Remark string `json:"remark" dc:"备注"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImFilePolicyUpdateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Purpose string `json:"purpose" v:"required#用途不能为空" dc:"用途"` + MaxSize int64 `json:"maxSize" dc:"单文件大小上限"` + RetentionDays int `json:"retentionDays" dc:"保留天数"` + AllowedMimes *gjson.Json `json:"allowedMimes" dc:"允许MIME JSON"` + AutoCleanup int `json:"autoCleanup" dc:"自动清理"` + ScanEnabled int `json:"scanEnabled" dc:"病毒扫描"` + Remark string `json:"remark" dc:"备注"` +} + +func (in *ImFilePolicyUpdateInp) Filter(ctx context.Context) (err error) { + in.Purpose = strings.TrimSpace(in.Purpose) + in.Remark = strings.TrimSpace(in.Remark) + if !dict.HasOptionKey(consts.ImFilePurposeOptions, in.Purpose) { + return gerror.New("文件用途不合法") + } + if in.Purpose == consts.ImFilePurposeUnknown || in.Purpose == consts.ImFilePurposeOrphan { + return gerror.New("该文件用途不允许配置策略") + } + if in.MaxSize <= 0 { + return gerror.New("单文件大小上限必须大于0") + } + if in.RetentionDays < 0 { + return gerror.New("保留天数不能小于0") + } + if !dict.HasOptionKey(consts.ImFilePolicySwitchOptions, in.AutoCleanup) { + return gerror.New("自动清理开关不合法") + } + if !dict.HasOptionKey(consts.ImFilePolicySwitchOptions, in.ScanEnabled) { + return gerror.New("病毒扫描开关不合法") + } + if len([]rune(in.Remark)) > 200 { + return gerror.New("备注不能超过200个字符") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_org.go b/server/internal/model/input/sysin/im_org.go index 6c2b1da..1621750 100644 --- a/server/internal/model/input/sysin/im_org.go +++ b/server/internal/model/input/sysin/im_org.go @@ -38,6 +38,20 @@ type ImOrgDepartmentModel struct { UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` } +type ImOrgDepartmentExportModel struct { + TenantName string `json:"tenantName" dc:"租户名称"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + Name string `json:"name" dc:"部门名称"` + ParentId string `json:"parentId" dc:"上级部门ID"` + SortOrder int `json:"sortOrder" dc:"排序"` + Version int `json:"version" dc:"版本"` + State string `json:"state" dc:"状态"` + ChildCount int `json:"childCount" dc:"子部门数"` + MemberCount int `json:"memberCount" dc:"成员数"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + type ImOrgDepartmentEditInp struct { TenantId int64 `json:"tenantId" dc:"租户ID"` Id int64 `json:"id" dc:"ID"` @@ -112,6 +126,23 @@ type ImOrgMemberModel struct { UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` } +type ImOrgMemberExportModel struct { + TenantName string `json:"tenantName" dc:"租户名称"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + DepartmentName string `json:"departmentName" dc:"部门名称"` + UserId int64 `json:"userId" dc:"用户ID"` + DisplayName string `json:"displayName" dc:"展示名称"` + Position string `json:"position" dc:"岗位"` + Mobile string `json:"mobile" dc:"手机号"` + Email string `json:"email" dc:"邮箱"` + EmploymentState string `json:"employmentState" dc:"在职状态"` + Contactable int `json:"contactable" dc:"是否可联系"` + SortOrder int `json:"sortOrder" dc:"排序"` + Version int `json:"version" dc:"版本"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + type ImOrgMemberEditInp struct { TenantId int64 `json:"tenantId" dc:"租户ID"` Id int64 `json:"id" dc:"ID"` diff --git a/server/internal/router/genrouter/im_file_policy.go b/server/internal/router/genrouter/im_file_policy.go new file mode 100644 index 0000000..8a93511 --- /dev/null +++ b/server/internal/router/genrouter/im_file_policy.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImFilePolicy) // IM文件策略 +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 5402c35..8c3bb26 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -296,12 +296,16 @@ type ( ISysImOrg interface { // DepartmentList 获取IM组织部门列表 DepartmentList(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (list []*sysin.ImOrgDepartmentModel, totalCount int, err error) + // DepartmentExport 导出IM组织部门 + DepartmentExport(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (err error) // DepartmentEdit 新增/编辑IM组织部门 DepartmentEdit(ctx context.Context, in *sysin.ImOrgDepartmentEditInp) (err error) // DepartmentDelete 删除IM组织部门 DepartmentDelete(ctx context.Context, in *sysin.ImOrgDepartmentDeleteInp) (err error) // MemberList 获取IM组织成员列表 MemberList(ctx context.Context, in *sysin.ImOrgMemberListInp) (list []*sysin.ImOrgMemberModel, totalCount int, err error) + // MemberExport 导出IM组织成员 + MemberExport(ctx context.Context, in *sysin.ImOrgMemberListInp) (err error) // MemberEdit 新增/编辑IM组织成员 MemberEdit(ctx context.Context, in *sysin.ImOrgMemberEditInp) (err error) // MemberDelete 删除IM组织成员 @@ -321,6 +325,12 @@ type ( // CleanupOrphan 清理IM孤儿文件 CleanupOrphan(ctx context.Context, in *sysin.ImFileCleanupOrphanInp) (res *sysin.ImFileCleanupOrphanModel, err error) } + ISysImFilePolicy interface { + // List 获取IM文件策略列表 + List(ctx context.Context, in *sysin.ImFilePolicyListInp) (list []*sysin.ImFilePolicyListModel, totalCount int, err error) + // Update 更新IM文件策略 + Update(ctx context.Context, in *sysin.ImFilePolicyUpdateInp) (err error) + } ISysImExternalConfig interface { // List 获取IM外部服务配置列表 List(ctx context.Context, in *sysin.ImExternalConfigListInp) (list []*sysin.ImExternalConfigListModel, totalCount int, err error) @@ -602,6 +612,7 @@ var ( localSysImOutbox ISysImOutbox localSysImOrg ISysImOrg localSysImFile ISysImFile + localSysImFilePolicy ISysImFilePolicy localSysImExternalConfig ISysImExternalConfig localSysImNotification ISysImNotification localSysImTaskDashboard ISysImTaskDashboard @@ -819,6 +830,17 @@ func RegisterSysImFile(i ISysImFile) { localSysImFile = i } +func SysImFilePolicy() ISysImFilePolicy { + if localSysImFilePolicy == nil { + panic("implement not found for interface ISysImFilePolicy, forgot register?") + } + return localSysImFilePolicy +} + +func RegisterSysImFilePolicy(i ISysImFilePolicy) { + localSysImFilePolicy = i +} + func SysImExternalConfig() ISysImExternalConfig { if localSysImExternalConfig == nil { panic("implement not found for interface ISysImExternalConfig, forgot register?") diff --git a/server/storage/data/generate/im_file_policy_menu.sql b/server/storage/data/generate/im_file_policy_menu.sql new file mode 100644 index 0000000..f1e2591 --- /dev/null +++ b/server/storage/data/generate/im_file_policy_menu.sql @@ -0,0 +1,31 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +CREATE TABLE IF NOT EXISTS `im_file_policy` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `tenant_id` BIGINT NOT NULL, + `purpose` VARCHAR(64) NOT NULL, + `max_size` BIGINT NOT NULL DEFAULT 0, + `retention_days` INT NOT NULL DEFAULT 0, + `allowed_mimes` JSON DEFAULT NULL, + `auto_cleanup` TINYINT NOT NULL DEFAULT 0, + `scan_enabled` TINYINT NOT NULL DEFAULT 0, + `remark` VARCHAR(255) NOT NULL DEFAULT '', + `created_by` BIGINT DEFAULT NULL, + `updated_by` BIGINT DEFAULT NULL, + `created_at` DATETIME DEFAULT NULL, + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_file_policy_tenant_purpose` (`tenant_id`, `purpose`), + KEY `idx_im_file_policy_tenant_cleanup` (`tenant_id`, `auto_cleanup`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM文件用途策略'; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, '文件策略', 'imFilePolicyIndex', 'file-policy/index', '', '2', '', '/imFilePolicy/list', '', '/imFilePolicy/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '73', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFilePolicyIndex'); + +SET @imFilePolicyId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imFilePolicyIndex' LIMIT 1); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) +SELECT NULL, @imFilePolicyId, '策略更新', 'imFilePolicyUpdate', '', '', '3', '', '/imFilePolicy/update', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imFilePolicyId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFilePolicyUpdate'); diff --git a/server/storage/data/generate/im_org_menu.sql b/server/storage/data/generate/im_org_menu.sql index 501c8ec..f9fcfa0 100644 --- a/server/storage/data/generate/im_org_menu.sql +++ b/server/storage/data/generate/im_org_menu.sql @@ -11,6 +11,10 @@ INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type SELECT NULL, @imOrgId, '部门编辑', 'imOrgDepartmentEdit', '', '', '3', '', '/imOrg/departmentEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentEdit'); +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) +SELECT NULL, @imOrgId, '部门导出', 'imOrgDepartmentExport', '', '', '3', '', '/imOrg/departmentExport', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '15', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentExport'); + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) SELECT NULL, @imOrgId, '部门删除', 'imOrgDepartmentDelete', '', '', '3', '', '/imOrg/departmentDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '20', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentDelete'); @@ -23,6 +27,10 @@ INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type SELECT NULL, @imOrgId, '成员编辑', 'imOrgMemberEdit', '', '', '3', '', '/imOrg/memberEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '40', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberEdit'); +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) +SELECT NULL, @imOrgId, '成员导出', 'imOrgMemberExport', '', '', '3', '', '/imOrg/memberExport', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '45', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberExport'); + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`) SELECT NULL, @imOrgId, '成员移除', 'imOrgMemberDelete', '', '', '3', '', '/imOrg/memberDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '50', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberDelete'); diff --git a/web/src/api/imFilePolicy/index.ts b/web/src/api/imFilePolicy/index.ts new file mode 100644 index 0000000..b2ae4bf --- /dev/null +++ b/web/src/api/imFilePolicy/index.ts @@ -0,0 +1,9 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imFilePolicy/list', method: 'GET', params }); +} + +export function Update(params) { + return http.request({ url: '/imFilePolicy/update', method: 'POST', params }); +} diff --git a/web/src/api/imOrg/index.ts b/web/src/api/imOrg/index.ts index 96b4ffd..0a0dcfb 100644 --- a/web/src/api/imOrg/index.ts +++ b/web/src/api/imOrg/index.ts @@ -1,9 +1,13 @@ -import { http } from '@/utils/http/axios'; +import { http, jumpExport } from '@/utils/http/axios'; export function DepartmentList(params) { return http.request({ url: '/imOrg/departmentList', method: 'GET', params }); } +export function DepartmentExport(params) { + jumpExport('/imOrg/departmentExport', params); +} + export function DepartmentEdit(params) { return http.request({ url: '/imOrg/departmentEdit', method: 'POST', params }); } @@ -16,6 +20,10 @@ export function MemberList(params) { return http.request({ url: '/imOrg/memberList', method: 'GET', params }); } +export function MemberExport(params) { + jumpExport('/imOrg/memberExport', params); +} + export function MemberEdit(params) { return http.request({ url: '/imOrg/memberEdit', method: 'POST', params }); } diff --git a/web/src/views/imFilePolicy/index.vue b/web/src/views/imFilePolicy/index.vue new file mode 100644 index 0000000..c66d8cd --- /dev/null +++ b/web/src/views/imFilePolicy/index.vue @@ -0,0 +1,225 @@ + + + diff --git a/web/src/views/imFilePolicy/model.ts b/web/src/views/imFilePolicy/model.ts new file mode 100644 index 0000000..88854df --- /dev/null +++ b/web/src/views/imFilePolicy/model.ts @@ -0,0 +1,136 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { useDictStore } from '@/store/modules/dict'; +import { List as TenantList } from '@/api/imTenant'; + +const dict = useDictStore(); + +export const tenantOptions = ref([]); + +export class State { + public tenantId = 0; + public tenantName = ''; + public id = 0; + public purpose = ''; + public maxSize = 0; + public retentionDays = 0; + public allowedMimes = '[]'; + public autoCleanup = 0; + public scanEnabled = 0; + public remark = ''; + public updatedAt = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'purpose', + component: 'NSelect', + label: '用途', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImFilePurposeOptions')), + placeholder: '请选择用途', + }, + }, + { + field: 'autoCleanup', + component: 'NSelect', + label: '自动清理', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImFilePolicySwitchOptions')), + placeholder: '请选择开关', + }, + }, + { + field: 'scanEnabled', + component: 'NSelect', + label: '病毒扫描', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImFilePolicySwitchOptions')), + placeholder: '请选择开关', + }, + }, +]); + +export const columns = [ + { + title: '用途', + key: 'purpose', + align: 'left', + width: 130, + render(row: State) { + return dict.getLabel('ImFilePurposeOptions', row.purpose) || row.purpose; + }, + }, + { title: '大小上限', key: 'maxSize', align: 'left', width: 120 }, + { title: '保留天数', key: 'retentionDays', align: 'left', width: 100 }, + { + title: '自动清理', + key: 'autoCleanup', + align: 'left', + width: 100, + render(row: State) { + return dict.getLabel('ImFilePolicySwitchOptions', row.autoCleanup); + }, + }, + { + title: '病毒扫描', + key: 'scanEnabled', + align: 'left', + width: 100, + render(row: State) { + return dict.getLabel('ImFilePolicySwitchOptions', row.scanEnabled); + }, + }, + { title: 'MIME白名单', key: 'allowedMimes', align: 'left', width: 260 }, + { title: '备注', key: 'remark', align: 'left', width: 180 }, + { title: '更新时间', key: 'updatedAt', align: 'left', width: 180 }, +]; + +export const rules = { + purpose: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择用途' }, + maxSize: { + required: true, + trigger: ['blur', 'input'], + type: 'number', + message: '请输入大小上限', + }, +}; + +export function loadOptions() { + dict.loadOptions(['ImFilePurposeOptions', 'ImFilePolicySwitchOptions']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 100 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imOrg/index.vue b/web/src/views/imOrg/index.vue index 821e04a..04cbcbc 100644 --- a/web/src/views/imOrg/index.vue +++ b/web/src/views/imOrg/index.vue @@ -21,6 +21,13 @@ > 新增部门 + + 导出部门 + 添加成员 + + 导出成员 + 0)