diff --git a/server/api/admin/imabusereason/im_abuse_reason.go b/server/api/admin/imabusereason/im_abuse_reason.go new file mode 100644 index 0000000..f82997c --- /dev/null +++ b/server/api/admin/imabusereason/im_abuse_reason.go @@ -0,0 +1,32 @@ +package imabusereason + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imAbuseReason/list" method:"get" tags:"IM举报原因" summary:"获取IM举报原因列表"` + sysin.ImAbuseReasonListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImAbuseReasonModel `json:"list" dc:"数据列表"` +} + +type EditReq struct { + g.Meta `path:"/imAbuseReason/edit" method:"post" tags:"IM举报原因" summary:"新增/编辑IM举报原因"` + sysin.ImAbuseReasonEditInp +} + +type EditRes struct{} + +type DeleteReq struct { + g.Meta `path:"/imAbuseReason/delete" method:"post" tags:"IM举报原因" summary:"禁用IM举报原因"` + sysin.ImAbuseReasonDeleteInp +} + +type DeleteRes struct{} diff --git a/server/api/admin/imapprelease/im_app_release.go b/server/api/admin/imapprelease/im_app_release.go new file mode 100644 index 0000000..65b63c6 --- /dev/null +++ b/server/api/admin/imapprelease/im_app_release.go @@ -0,0 +1,25 @@ +package imapprelease + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imAppRelease/list" method:"get" tags:"IM版本更新" summary:"获取IM客户端版本策略列表"` + sysin.ImAppReleaseListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImAppReleaseModel `json:"list" dc:"数据列表"` +} + +type EditReq struct { + g.Meta `path:"/imAppRelease/edit" method:"post" tags:"IM版本更新" summary:"新增/编辑IM客户端版本策略"` + sysin.ImAppReleaseEditInp +} + +type EditRes struct{} diff --git a/server/api/admin/imauditlog/im_audit_log.go b/server/api/admin/imauditlog/im_audit_log.go index 492f527..01b9b36 100644 --- a/server/api/admin/imauditlog/im_audit_log.go +++ b/server/api/admin/imauditlog/im_audit_log.go @@ -22,6 +22,14 @@ type ListRes struct { List []*sysin.ImAuditLogListModel `json:"list" dc:"数据列表"` } +// ExportReq 导出IM审计日志列表 +type ExportReq struct { + g.Meta `path:"/imAuditLog/export" method:"get" tags:"IM审计日志" summary:"导出IM审计日志列表"` + sysin.ImAuditLogListInp +} + +type ExportRes struct{} + // ViewReq 获取IM审计日志指定信息 type ViewReq struct { g.Meta `path:"/imAuditLog/view" method:"get" tags:"IM审计日志" summary:"获取IM审计日志指定信息"` diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 4b01c5c..4633ef2 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -57,3 +57,37 @@ type ThirdPartyReq struct { type ThirdPartyRes struct { Values map[string]string `json:"values" dc:"配置值"` } + +type TenantLoginIPWhitelistReq struct { + g.Meta `path:"/internal/im-config/tenants/by-id/{id}/login-ip-whitelist" method:"get" tags:"IM内部配置" summary:"获取租户渠道登录IP白名单"` + Id int64 `json:"id" in:"path" dc:"租户ID"` +} + +type TenantLoginIPWhitelistRes struct { + TenantId int64 `json:"tenant_id" dc:"租户ID"` + AppList []string `json:"app_list" dc:"APP登录IP白名单"` + WebList []string `json:"web_list" dc:"Web登录IP白名单"` +} + +type AppReleaseLatestReq struct { + g.Meta `path:"/internal/im-config/app/releases/latest" method:"get" tags:"IM内部配置" summary:"获取客户端最新版本策略"` + Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"` + Channel string `json:"channel" dc:"渠道"` +} + +type AppReleaseLatestRes struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Channel string `json:"channel"` + LatestVersion string `json:"latest_version"` + MinSupportedVersion string `json:"min_supported_version"` + UpdateType string `json:"update_type"` + ReleaseNotes string `json:"release_notes"` + StoreURL string `json:"store_url"` + PackageURL string `json:"package_url"` + Checksum string `json:"checksum"` + RemindAfterSeconds int `json:"remind_after_seconds"` + PublishedAt string `json:"published_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/server/api/admin/imdashboard/im_dashboard.go b/server/api/admin/imdashboard/im_dashboard.go new file mode 100644 index 0000000..81bbc59 --- /dev/null +++ b/server/api/admin/imdashboard/im_dashboard.go @@ -0,0 +1,25 @@ +package imdashboard + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type OverviewReq struct { + g.Meta `path:"/imDashboard/overview" method:"get" tags:"IM数据概览" summary:"获取IM数据概览"` + sysin.ImDashboardOverviewInp +} + +type OverviewRes struct { + *sysin.ImDashboardOverviewModel +} + +type TrendsReq struct { + g.Meta `path:"/imDashboard/trends" method:"get" tags:"IM数据概览" summary:"获取IM增长趋势"` + sysin.ImDashboardTrendsInp +} + +type TrendsRes struct { + List []*sysin.ImDashboardTrendModel `json:"list" dc:"趋势数据"` +} diff --git a/server/api/admin/imexternalconfig/im_external_config.go b/server/api/admin/imexternalconfig/im_external_config.go new file mode 100644 index 0000000..f5d5a64 --- /dev/null +++ b/server/api/admin/imexternalconfig/im_external_config.go @@ -0,0 +1,41 @@ +package imexternalconfig + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imExternalConfig/list" method:"get" tags:"IM外部服务配置" summary:"获取IM外部服务配置列表"` + sysin.ImExternalConfigListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImExternalConfigListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imExternalConfig/view" method:"get" tags:"IM外部服务配置" summary:"获取IM外部服务配置详情"` + sysin.ImExternalConfigViewInp +} + +type ViewRes struct { + *sysin.ImExternalConfigViewModel +} + +type UpdateReq struct { + g.Meta `path:"/imExternalConfig/update" method:"post" tags:"IM外部服务配置" summary:"更新IM外部服务配置"` + sysin.ImExternalConfigUpdateInp +} + +type UpdateRes struct{} + +type CheckReq struct { + g.Meta `path:"/imExternalConfig/check" method:"post" tags:"IM外部服务配置" summary:"检查IM外部服务配置"` + sysin.ImExternalConfigCheckInp +} + +type CheckRes struct{} diff --git a/server/api/admin/imfeedbackconfig/im_feedback_config.go b/server/api/admin/imfeedbackconfig/im_feedback_config.go new file mode 100644 index 0000000..1474e0a --- /dev/null +++ b/server/api/admin/imfeedbackconfig/im_feedback_config.go @@ -0,0 +1,23 @@ +package imfeedbackconfig + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetReq struct { + g.Meta `path:"/imFeedbackConfig/get" method:"get" tags:"IM意见反馈配置" summary:"获取IM意见反馈配置"` + sysin.ImFeedbackConfigGetInp +} + +type GetRes struct { + *sysin.ImFeedbackConfigModel +} + +type UpdateReq struct { + g.Meta `path:"/imFeedbackConfig/update" method:"post" tags:"IM意见反馈配置" summary:"更新IM意见反馈配置"` + sysin.ImFeedbackConfigUpdateInp +} + +type UpdateRes struct{} diff --git a/server/api/admin/imfile/im_file.go b/server/api/admin/imfile/im_file.go new file mode 100644 index 0000000..370d533 --- /dev/null +++ b/server/api/admin/imfile/im_file.go @@ -0,0 +1,60 @@ +// Package imfile +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imfile + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM文件列表 +type ListReq struct { + g.Meta `path:"/imFile/list" method:"get" tags:"IM文件治理" summary:"获取IM文件列表"` + sysin.ImFileListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImFileListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM文件详情 +type ViewReq struct { + g.Meta `path:"/imFile/view" method:"get" tags:"IM文件治理" summary:"获取IM文件详情"` + sysin.ImFileViewInp +} + +type ViewRes struct { + *sysin.ImFileViewModel +} + +// QuarantineReq 隔离IM文件 +type QuarantineReq struct { + g.Meta `path:"/imFile/quarantine" method:"post" tags:"IM文件治理" summary:"隔离IM文件"` + sysin.ImFileActionInp +} + +type QuarantineRes struct{} + +// DeleteReq 删除IM文件 +type DeleteReq struct { + g.Meta `path:"/imFile/delete" method:"post" tags:"IM文件治理" summary:"删除IM文件"` + sysin.ImFileActionInp +} + +type DeleteRes struct{} + +// CleanupOrphanReq 清理IM孤儿文件 +type CleanupOrphanReq struct { + g.Meta `path:"/imFile/cleanupOrphan" method:"post" tags:"IM文件治理" summary:"清理IM孤儿文件"` + sysin.ImFileCleanupOrphanInp +} + +type CleanupOrphanRes struct { + *sysin.ImFileCleanupOrphanModel +} 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/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go index 8074034..4f9a7d0 100644 --- a/server/api/admin/imgroup/im_group.go +++ b/server/api/admin/imgroup/im_group.go @@ -54,6 +54,142 @@ type GovernanceRes struct { *sysin.ImGroupGovernanceModel } +// GovernanceUpdateReq 更新IM群治理配置 +type GovernanceUpdateReq struct { + g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"更新IM群治理配置"` + sysin.ImGroupGovernanceUpdateInp +} + +type GovernanceUpdateRes struct { + *sysin.ImGroupCommandModel +} + +// ProfileUpdateReq 修改IM群资料 +type ProfileUpdateReq struct { + g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"修改IM群资料"` + sysin.ImGroupProfileUpdateInp +} + +type ProfileUpdateRes struct { + *sysin.ImGroupCommandModel +} + +// DissolveReq 解散IM群 +type DissolveReq struct { + g.Meta `path:"/imGroup/dissolve" method:"post" tags:"IM群组" summary:"解散IM群"` + sysin.ImGroupDissolveInp +} + +type DissolveRes struct { + *sysin.ImGroupCommandModel +} + +// MuteReq 禁言IM群成员 +type MuteReq struct { + g.Meta `path:"/imGroup/mute" method:"post" tags:"IM群组" summary:"禁言IM群成员"` + sysin.ImGroupMuteInp +} + +type MuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberMuteReq Kafka禁言IM群成员 +type MemberMuteReq struct { + g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"` + sysin.ImGroupMuteInp +} + +type MemberMuteRes struct { + *sysin.ImGroupCommandModel +} + +// UnmuteReq 解除IM群成员禁言 +type UnmuteReq struct { + g.Meta `path:"/imGroup/unmute" method:"post" tags:"IM群组" summary:"解除IM群成员禁言"` + sysin.ImGroupUnmuteInp +} + +type UnmuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberUnmuteReq Kafka解除IM群成员禁言 +type MemberUnmuteReq struct { + g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"` + sysin.ImGroupUnmuteInp +} + +type MemberUnmuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberRemoveReq 移出IM群成员 +type MemberRemoveReq struct { + g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"移出IM群成员"` + sysin.ImGroupMemberRemoveInp +} + +type MemberRemoveRes struct { + *sysin.ImGroupCommandModel +} + +// JoinRequestApproveReq 通过IM入群申请 +type JoinRequestApproveReq struct { + g.Meta `path:"/imGroup/joinRequestApprove" method:"post" tags:"IM群组" summary:"通过IM入群申请"` + sysin.ImGroupJoinRequestResolveInp +} + +type JoinRequestApproveRes struct{} + +// JoinRequestRejectReq 拒绝IM入群申请 +type JoinRequestRejectReq struct { + g.Meta `path:"/imGroup/joinRequestReject" method:"post" tags:"IM群组" summary:"拒绝IM入群申请"` + sysin.ImGroupJoinRequestResolveInp +} + +type JoinRequestRejectRes struct{} + +// RoleUpdateReq 更新IM群成员角色 +type RoleUpdateReq struct { + g.Meta `path:"/imGroup/roleUpdate" method:"post" tags:"IM群组" summary:"更新IM群成员角色"` + sysin.ImGroupRoleUpdateInp +} + +type RoleUpdateRes struct { + *sysin.ImGroupCommandModel +} + +// AdminSetReq 设置IM群管理员 +type AdminSetReq struct { + g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"设置IM群管理员"` + sysin.ImGroupRoleUpdateInp +} + +type AdminSetRes struct { + *sysin.ImGroupCommandModel +} + +// AdminRemoveReq 删除IM群管理员 +type AdminRemoveReq struct { + g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"删除IM群管理员"` + sysin.ImGroupRoleUpdateInp +} + +type AdminRemoveRes struct { + *sysin.ImGroupCommandModel +} + +// OwnerTransferReq 转让IM群主 +type OwnerTransferReq struct { + g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"转让IM群主"` + sysin.ImGroupOwnerTransferInp +} + +type OwnerTransferRes struct { + *sysin.ImGroupCommandModel +} + // JoinRequestListReq 查询IM入群申请列表 type JoinRequestListReq struct { g.Meta `path:"/imGroup/joinRequestList" method:"get" tags:"IM群组" summary:"获取IM入群申请列表"` @@ -65,6 +201,25 @@ type JoinRequestListRes struct { List []*sysin.ImGroupJoinRequestListModel `json:"list" dc:"数据列表"` } +// FileListReq 查询IM群文件列表 +type FileListReq struct { + g.Meta `path:"/imGroup/fileList" method:"get" tags:"IM群组" summary:"获取IM群文件列表"` + sysin.ImGroupFileListInp +} + +type FileListRes struct { + form.PageRes + List []*sysin.ImGroupFileListModel `json:"list" dc:"数据列表"` +} + +// FileDeleteReq 删除IM群文件 +type FileDeleteReq struct { + g.Meta `path:"/imGroup/fileDelete" method:"post" tags:"IM群组" summary:"删除IM群文件"` + sysin.ImGroupFileDeleteInp +} + +type FileDeleteRes struct{} + // InvitationListReq 查询IM群邀请列表 type InvitationListReq struct { g.Meta `path:"/imGroup/invitationList" method:"get" tags:"IM群组" summary:"获取IM群邀请列表"` diff --git a/server/api/admin/imgroupmessage/im_group_message.go b/server/api/admin/imgroupmessage/im_group_message.go new file mode 100644 index 0000000..bda8530 --- /dev/null +++ b/server/api/admin/imgroupmessage/im_group_message.go @@ -0,0 +1,37 @@ +package imgroupmessage + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imGroupMessage/list" method:"get" tags:"IM群聊消息" summary:"获取群聊消息列表"` + sysin.ImGroupMessageListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImMessageListModel `json:"list" dc:"数据列表"` +} +type ViewReq struct { + g.Meta `path:"/imGroupMessage/view" method:"get" tags:"IM群聊消息" summary:"获取群聊消息详情"` + sysin.ImMessageViewInp +} +type ViewRes struct{ *sysin.ImMessageViewModel } +type HideReq struct { + g.Meta `path:"/imGroupMessage/hide" method:"post" tags:"IM群聊消息" summary:"隐藏群聊消息"` + sysin.ImMessageActionInp +} +type HideRes struct{ *sysin.ImMessageCommandModel } +type DeleteReq struct { + g.Meta `path:"/imGroupMessage/delete" method:"post" tags:"IM群聊消息" summary:"删除群聊消息"` + sysin.ImMessageActionInp +} +type DeleteRes struct{ *sysin.ImMessageCommandModel } +type ExportReq struct { + g.Meta `path:"/imGroupMessage/export" method:"get" tags:"IM群聊消息" summary:"导出群聊消息"` + sysin.ImGroupMessageListInp +} +type ExportRes struct{} diff --git a/server/api/admin/iminvite/im_invite.go b/server/api/admin/iminvite/im_invite.go index 13116c5..45ebe03 100644 --- a/server/api/admin/iminvite/im_invite.go +++ b/server/api/admin/iminvite/im_invite.go @@ -51,6 +51,13 @@ type RevokeReq struct { type RevokeRes struct{} +type BindUserReq struct { + g.Meta `path:"/imInvite/bindUser" method:"post" tags:"IM邀请码" summary:"绑定邀请码用户"` + sysin.ImInviteBindUserInp +} + +type BindUserRes struct{} + type BatchRevokeReq struct { g.Meta `path:"/imInvite/batchRevoke" method:"post" tags:"IM邀请码" summary:"批量撤销IM注册邀请码"` sysin.ImInviteBatchRevokeInp diff --git a/server/api/admin/imloginfailurelog/im_login_failure_log.go b/server/api/admin/imloginfailurelog/im_login_failure_log.go new file mode 100644 index 0000000..ce95a03 --- /dev/null +++ b/server/api/admin/imloginfailurelog/im_login_failure_log.go @@ -0,0 +1,37 @@ +package imloginfailurelog + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imLoginFailureLog/list" method:"get" tags:"IM登录失败日志" summary:"获取登录失败日志"` + sysin.ImLoginFailureLogListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImLoginFailureLogModel `json:"list" dc:"数据列表"` +} +type ViewReq struct { + g.Meta `path:"/imLoginFailureLog/view" method:"get" tags:"IM登录失败日志" summary:"获取登录失败详情"` + sysin.ImLoginFailureViewInp +} +type ViewRes struct{ *sysin.ImLoginFailureLogModel } +type ExportReq struct { + g.Meta `path:"/imLoginFailureLog/export" method:"get" tags:"IM登录失败日志" summary:"导出登录失败日志"` + sysin.ImLoginFailureLogListInp +} +type ExportRes struct{} +type AddBlacklistReq struct { + g.Meta `path:"/imLoginFailureLog/addBlacklist" method:"post" tags:"IM登录失败日志" summary:"将IP加入黑名单"` + sysin.ImLoginFailureAddBlacklistInp +} +type AddBlacklistRes struct{} +type BanUserReq struct { + g.Meta `path:"/imLoginFailureLog/banUser" method:"post" tags:"IM登录失败日志" summary:"封禁关联用户"` + sysin.ImLoginFailureBanUserInp +} +type BanUserRes struct{ *sysin.ImUserCommandModel } diff --git a/server/api/admin/immailboxconfig/im_mailbox_config.go b/server/api/admin/immailboxconfig/im_mailbox_config.go new file mode 100644 index 0000000..07831eb --- /dev/null +++ b/server/api/admin/immailboxconfig/im_mailbox_config.go @@ -0,0 +1,23 @@ +package immailboxconfig + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetReq struct { + g.Meta `path:"/imMailboxConfig/get" method:"get" tags:"IM总经理信箱配置" summary:"获取IM总经理信箱配置"` + sysin.ImMailboxConfigGetInp +} + +type GetRes struct { + *sysin.ImMailboxConfigModel +} + +type UpdateReq struct { + g.Meta `path:"/imMailboxConfig/update" method:"post" tags:"IM总经理信箱配置" summary:"更新IM总经理信箱配置"` + sysin.ImMailboxConfigUpdateInp +} + +type UpdateRes struct{} diff --git a/server/api/admin/imnotification/im_notification.go b/server/api/admin/imnotification/im_notification.go new file mode 100644 index 0000000..1d23120 --- /dev/null +++ b/server/api/admin/imnotification/im_notification.go @@ -0,0 +1,25 @@ +package imnotification + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type TemplateListReq struct { + g.Meta `path:"/imNotification/templateList" method:"get" tags:"IM通知模板" summary:"获取IM通知模板列表"` + sysin.ImNotificationTemplateListInp +} + +type TemplateListRes struct { + form.PageRes + List []*sysin.ImNotificationTemplateListModel `json:"list" dc:"数据列表"` +} + +type TemplateUpdateReq struct { + g.Meta `path:"/imNotification/templateUpdate" method:"post" tags:"IM通知模板" summary:"更新IM通知模板"` + sysin.ImNotificationTemplateUpdateInp +} + +type TemplateUpdateRes struct{} diff --git a/server/api/admin/imorg/im_org.go b/server/api/admin/imorg/im_org.go new file mode 100644 index 0000000..2ea7aa1 --- /dev/null +++ b/server/api/admin/imorg/im_org.go @@ -0,0 +1,79 @@ +package imorg + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type DepartmentListReq struct { + g.Meta `path:"/imOrg/departmentList" method:"get" tags:"IM组织架构" summary:"获取IM组织部门列表"` + sysin.ImOrgDepartmentListInp +} + +type DepartmentListRes struct { + form.PageRes + 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 +} + +type DepartmentEditRes struct{} + +type DepartmentDeleteReq struct { + g.Meta `path:"/imOrg/departmentDelete" method:"post" tags:"IM组织架构" summary:"删除IM组织部门"` + sysin.ImOrgDepartmentDeleteInp +} + +type DepartmentDeleteRes struct{} + +type MemberListReq struct { + g.Meta `path:"/imOrg/memberList" method:"get" tags:"IM组织架构" summary:"获取IM组织成员列表"` + sysin.ImOrgMemberListInp +} + +type MemberListRes struct { + form.PageRes + 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 +} + +type MemberEditRes struct{} + +type MemberDeleteReq struct { + g.Meta `path:"/imOrg/memberDelete" method:"post" tags:"IM组织架构" summary:"删除IM组织成员"` + sysin.ImOrgMemberDeleteInp +} + +type MemberDeleteRes struct{} + +type SearchReq struct { + g.Meta `path:"/imOrg/search" method:"get" tags:"IM组织架构" summary:"搜索IM组织"` + sysin.ImOrgSearchInp +} + +type SearchRes struct { + *sysin.ImOrgSearchModel +} diff --git a/server/api/admin/imoutbox/im_outbox.go b/server/api/admin/imoutbox/im_outbox.go new file mode 100644 index 0000000..04bae80 --- /dev/null +++ b/server/api/admin/imoutbox/im_outbox.go @@ -0,0 +1,46 @@ +// Package imoutbox +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imoutbox + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imOutbox/list" method:"get" tags:"IM Outbox" summary:"获取IM Outbox列表"` + sysin.ImOutboxListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImOutboxListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imOutbox/view" method:"get" tags:"IM Outbox" summary:"获取IM Outbox详情"` + sysin.ImOutboxViewInp +} + +type ViewRes struct { + *sysin.ImOutboxViewModel +} + +type RetryReq struct { + g.Meta `path:"/imOutbox/retry" method:"post" tags:"IM Outbox" summary:"重试IM Outbox事件"` + sysin.ImOutboxActionInp +} + +type RetryRes struct{} + +type DeadReq struct { + g.Meta `path:"/imOutbox/dead" method:"post" tags:"IM Outbox" summary:"标记IM Outbox死信"` + sysin.ImOutboxActionInp +} + +type DeadRes struct{} diff --git a/server/api/admin/imprivatemessage/im_private_message.go b/server/api/admin/imprivatemessage/im_private_message.go new file mode 100644 index 0000000..f3acd48 --- /dev/null +++ b/server/api/admin/imprivatemessage/im_private_message.go @@ -0,0 +1,37 @@ +package imprivatemessage + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imPrivateMessage/list" method:"get" tags:"IM单聊消息" summary:"获取单聊消息列表"` + sysin.ImPrivateMessageListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImMessageListModel `json:"list" dc:"数据列表"` +} +type ViewReq struct { + g.Meta `path:"/imPrivateMessage/view" method:"get" tags:"IM单聊消息" summary:"获取单聊消息详情"` + sysin.ImMessageViewInp +} +type ViewRes struct{ *sysin.ImMessageViewModel } +type HideReq struct { + g.Meta `path:"/imPrivateMessage/hide" method:"post" tags:"IM单聊消息" summary:"隐藏单聊消息"` + sysin.ImMessageActionInp +} +type HideRes struct{ *sysin.ImMessageCommandModel } +type DeleteReq struct { + g.Meta `path:"/imPrivateMessage/delete" method:"post" tags:"IM单聊消息" summary:"删除单聊消息"` + sysin.ImMessageActionInp +} +type DeleteRes struct{ *sysin.ImMessageCommandModel } +type ExportReq struct { + g.Meta `path:"/imPrivateMessage/export" method:"get" tags:"IM单聊消息" summary:"导出单聊消息"` + sysin.ImPrivateMessageListInp +} +type ExportRes struct{} diff --git a/server/api/admin/imsecuritypolicy/im_security_policy.go b/server/api/admin/imsecuritypolicy/im_security_policy.go new file mode 100644 index 0000000..5042a88 --- /dev/null +++ b/server/api/admin/imsecuritypolicy/im_security_policy.go @@ -0,0 +1,59 @@ +package imsecuritypolicy + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imSecurityPolicy/list" method:"get" tags:"IM安全策略" summary:"获取安全策略历史"` + sysin.ImSecurityPolicyListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImSecurityPolicyModel `json:"list"` +} +type ViewReq struct { + g.Meta `path:"/imSecurityPolicy/view" method:"get" tags:"IM安全策略" summary:"获取安全策略详情"` + sysin.ImSecurityPolicyViewInp +} +type ViewRes struct{ *sysin.ImSecurityPolicyModel } +type SaveReq struct { + g.Meta `path:"/imSecurityPolicy/save" method:"post" tags:"IM安全策略" summary:"保存安全策略草稿"` + sysin.ImSecurityPolicySaveInp +} +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 +} +type RestoreDefaultRes struct { + Id int64 `json:"id"` +} +type IPRuleListReq struct { + g.Meta `path:"/imSecurityPolicy/ipRuleList" method:"get" tags:"IM安全策略" summary:"获取IP黑白名单"` + sysin.ImSecurityIPRuleListInp +} +type IPRuleListRes struct { + form.PageRes + List []*sysin.ImSecurityIPRuleModel `json:"list"` +} +type IPRuleEditReq struct { + g.Meta `path:"/imSecurityPolicy/ipRuleEdit" method:"post" tags:"IM安全策略" summary:"新增或编辑IP规则"` + sysin.ImSecurityIPRuleEditInp +} +type IPRuleEditRes struct{} +type IPRuleDeleteReq struct { + g.Meta `path:"/imSecurityPolicy/ipRuleDelete" method:"post" tags:"IM安全策略" summary:"删除IP规则"` + sysin.ImSecurityIPRuleDeleteInp +} +type IPRuleDeleteRes struct{} diff --git a/server/api/admin/imsensitiveword/im_sensitive_word.go b/server/api/admin/imsensitiveword/im_sensitive_word.go new file mode 100644 index 0000000..2b993cb --- /dev/null +++ b/server/api/admin/imsensitiveword/im_sensitive_word.go @@ -0,0 +1,51 @@ +package imsensitiveword + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imSensitiveWord/list" method:"get" tags:"IM敏感词" summary:"获取敏感词列表"` + sysin.ImSensitiveWordListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImSensitiveWordListModel `json:"list"` +} +type EditReq struct { + g.Meta `path:"/imSensitiveWord/edit" method:"post" tags:"IM敏感词" summary:"新增或编辑敏感词"` + sysin.ImSensitiveWordEditInp +} +type EditRes struct{} +type DeleteReq struct { + g.Meta `path:"/imSensitiveWord/delete" method:"post" tags:"IM敏感词" summary:"删除敏感词"` + sysin.ImSensitiveWordDeleteInp +} +type DeleteRes struct{} +type StatusReq struct { + g.Meta `path:"/imSensitiveWord/status" method:"post" tags:"IM敏感词" summary:"更新敏感词状态"` + sysin.ImSensitiveWordStatusInp +} +type StatusRes struct{} +type BatchImportReq struct { + g.Meta `path:"/imSensitiveWord/batchImport" method:"post" tags:"IM敏感词" summary:"批量导入敏感词"` + sysin.ImSensitiveWordBatchImportInp +} +type BatchImportRes struct { + *sysin.ImSensitiveWordBatchImportModel +} +type GroupListReq struct { + g.Meta `path:"/imSensitiveWord/groupList" method:"get" tags:"IM敏感词" summary:"获取敏感词分组"` + sysin.ImSensitiveWordGroupListInp +} +type GroupListRes struct { + List []*sysin.ImSensitiveWordGroupModel `json:"list"` +} +type GroupEditReq struct { + g.Meta `path:"/imSensitiveWord/groupEdit" method:"post" tags:"IM敏感词" summary:"新增或编辑敏感词分组"` + sysin.ImSensitiveWordGroupEditInp +} +type GroupEditRes struct{} diff --git a/server/api/admin/imtaskdashboard/im_task_dashboard.go b/server/api/admin/imtaskdashboard/im_task_dashboard.go new file mode 100644 index 0000000..2585862 --- /dev/null +++ b/server/api/admin/imtaskdashboard/im_task_dashboard.go @@ -0,0 +1,18 @@ +package imtaskdashboard + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imTaskDashboard/list" method:"get" tags:"IM任务看板" summary:"获取IM任务看板列表"` + sysin.ImTaskDashboardListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImTaskDashboardListModel `json:"list" dc:"数据列表"` +} diff --git a/server/api/admin/imtenant/im_tenant.go b/server/api/admin/imtenant/im_tenant.go index 01af2d3..a5eedb5 100644 --- a/server/api/admin/imtenant/im_tenant.go +++ b/server/api/admin/imtenant/im_tenant.go @@ -48,3 +48,35 @@ type StatusReq struct { } type StatusRes struct{} + +type UsageReq struct { + g.Meta `path:"/imTenant/usage" method:"get" tags:"IM租户" summary:"获取IM租户用量和健康状态"` + sysin.ImTenantUsageInp +} + +type UsageRes struct { + *sysin.ImTenantUsageModel +} + +type DeletionCheckReq struct { + g.Meta `path:"/imTenant/deletionCheck" method:"get" tags:"IM租户" summary:"IM租户删除前检查"` + sysin.ImTenantDeletionCheckInp +} + +type DeletionCheckRes struct { + *sysin.ImTenantDeletionCheckModel +} + +type EnterDeletingReq struct { + g.Meta `path:"/imTenant/enterDeleting" method:"post" tags:"IM租户" summary:"IM租户进入删除流程"` + sysin.ImTenantLifecycleInp +} + +type EnterDeletingRes struct{} + +type RestoreReq struct { + g.Meta `path:"/imTenant/restore" method:"post" tags:"IM租户" summary:"恢复IM租户"` + sysin.ImTenantLifecycleInp +} + +type RestoreRes struct{} diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go index c8edfbb..ec9f5a2 100644 --- a/server/api/admin/imuser/im_user.go +++ b/server/api/admin/imuser/im_user.go @@ -10,6 +10,7 @@ import ( "hotgo/internal/model/input/sysin" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" ) // ListReq 查询IM用户列表 @@ -23,6 +24,41 @@ type ListRes struct { List []*sysin.ImUserListModel `json:"list" dc:"数据列表"` } +type CreateReq struct { + g.Meta `path:"/imUser/create" method:"post" tags:"IM用户" summary:"后台创建IM用户"` + sysin.ImUserCreateInp +} + +type CreateRes struct { + *sysin.ImUserCreateModel +} + +type BatchCreateReq struct { + g.Meta `path:"/imUser/batchCreate" method:"post" tags:"IM用户" summary:"批量创建IM用户"` + sysin.ImUserBatchCreateInp +} + +type BatchCreateRes struct { + *sysin.ImUserBatchCreateModel +} + +type ImportReq struct { + g.Meta `path:"/imUser/import" method:"post" tags:"IM用户" summary:"Excel导入IM用户"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + GroupId int64 `json:"groupId" dc:"默认用户策略分组ID"` + File *ghttp.UploadFile `json:"file" type:"file" v:"required#请选择Excel文件" dc:"Excel文件"` +} + +type ImportRes struct { + *sysin.ImUserBatchCreateModel +} + +type DownloadTemplateReq struct { + g.Meta `path:"/imUser/downloadTemplate" method:"get" tags:"IM用户" summary:"下载IM用户导入模板"` +} + +type DownloadTemplateRes struct{} + // ViewReq 获取IM用户指定信息 type ViewReq struct { g.Meta `path:"/imUser/view" method:"get" tags:"IM用户" summary:"获取IM用户指定信息"` @@ -33,8 +69,64 @@ type ViewRes struct { *sysin.ImUserViewModel } +type ResetPasswordReq struct { + g.Meta `path:"/imUser/resetPassword" method:"post" tags:"IM用户" summary:"重置IM用户密码"` + sysin.ImUserPasswordResetInp +} + +type ResetPasswordRes struct { + *sysin.ImUserCommandModel +} + +type RevokeSessionsReq struct { + g.Meta `path:"/imUser/revokeSessions" method:"post" tags:"IM用户" summary:"撤销IM用户会话"` + sysin.ImUserRevokeSessionsInp +} + +type RevokeSessionsRes struct{} + +type StatusReq struct { + g.Meta `path:"/imUser/status" method:"post" tags:"IM用户" summary:"更新IM用户状态"` + sysin.ImUserStatusInp +} + +type StatusRes struct{} + +type AdjustGroupReq struct { + g.Meta `path:"/imUser/adjustGroup" method:"post" tags:"IM用户" summary:"调整IM用户分组"` + sysin.ImUserAdjustGroupInp +} + +type AdjustGroupRes struct{} + +type IPWhitelistReq struct { + g.Meta `path:"/imUser/ipWhitelist" method:"get" tags:"IM用户" summary:"获取IM用户IP白名单"` + sysin.ImUserIPWhitelistInp +} + +type IPWhitelistRes struct { + *sysin.ImUserIPWhitelistModel +} + +type IPWhitelistSaveReq struct { + g.Meta `path:"/imUser/ipWhitelistSave" method:"post" tags:"IM用户" summary:"保存IM用户IP白名单"` + sysin.ImUserIPWhitelistInp +} + +type IPWhitelistSaveRes struct{} + +type DevicesReq struct { + g.Meta `path:"/imUser/devices" method:"get" tags:"IM用户" summary:"获取IM用户设备列表"` + sysin.ImUserDeviceListInp +} + +type DevicesRes struct { + form.PageRes + List []*sysin.ImUserDeviceListModel `json:"list" dc:"设备列表"` +} + type BanReq struct { - g.Meta `path:"/imUser/ban" method:"post" tags:"IM用户管理" summary:"封禁IM用户"` + g.Meta `path:"/imUser/ban" method:"post" tags:"IM用户" summary:"封禁IM用户"` sysin.ImUserGovernanceInp } @@ -43,7 +135,7 @@ type BanRes struct { } type UnbanReq struct { - g.Meta `path:"/imUser/unban" method:"post" tags:"IM用户管理" summary:"解封IM用户"` + g.Meta `path:"/imUser/unban" method:"post" tags:"IM用户" summary:"解封IM用户"` sysin.ImUserGovernanceInp } @@ -52,7 +144,7 @@ type UnbanRes struct { } type FreezeReq struct { - g.Meta `path:"/imUser/freeze" method:"post" tags:"IM用户管理" summary:"冻结IM用户"` + g.Meta `path:"/imUser/freeze" method:"post" tags:"IM用户" summary:"冻结IM用户"` sysin.ImUserGovernanceInp } @@ -61,7 +153,7 @@ type FreezeRes struct { } type UnfreezeReq struct { - g.Meta `path:"/imUser/unfreeze" method:"post" tags:"IM用户管理" summary:"解冻IM用户"` + g.Meta `path:"/imUser/unfreeze" method:"post" tags:"IM用户" summary:"解冻IM用户"` sysin.ImUserGovernanceInp } @@ -70,7 +162,7 @@ type UnfreezeRes struct { } type DeviceKickReq struct { - g.Meta `path:"/imUser/deviceKick" method:"post" tags:"IM用户管理" summary:"踢出IM用户设备"` + g.Meta `path:"/imUser/deviceKick" method:"post" tags:"IM用户" summary:"踢出IM用户设备"` sysin.ImUserGovernanceInp } @@ -79,7 +171,7 @@ type DeviceKickRes struct { } type LogoutAllReq struct { - g.Meta `path:"/imUser/logoutAll" method:"post" tags:"IM用户管理" summary:"踢出IM用户全部在线会话"` + g.Meta `path:"/imUser/logoutAll" method:"post" tags:"IM用户" summary:"踢出IM用户全部会话"` sysin.ImUserGovernanceInp } @@ -88,7 +180,7 @@ type LogoutAllRes struct { } type AccountCancelReq struct { - g.Meta `path:"/imUser/accountCancel" method:"post" tags:"IM用户管理" summary:"注销IM用户账号"` + g.Meta `path:"/imUser/accountCancel" method:"post" tags:"IM用户" summary:"注销IM用户账号"` sysin.ImUserGovernanceInp } @@ -97,7 +189,7 @@ type AccountCancelRes struct { } type AccountDeleteReq struct { - g.Meta `path:"/imUser/accountDelete" method:"post" tags:"IM用户管理" summary:"删除IM用户账号"` + g.Meta `path:"/imUser/accountDelete" method:"post" tags:"IM用户" summary:"删除IM用户账号"` sysin.ImUserGovernanceInp } diff --git a/server/api/admin/imuserloginhistory/im_user_login_history.go b/server/api/admin/imuserloginhistory/im_user_login_history.go new file mode 100644 index 0000000..3665840 --- /dev/null +++ b/server/api/admin/imuserloginhistory/im_user_login_history.go @@ -0,0 +1,22 @@ +package imuserloginhistory + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imUserLoginHistory/list" method:"get" tags:"IM用户登录历史" summary:"获取用户登录历史"` + sysin.ImUserLoginHistoryListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImUserLoginHistoryModel `json:"list" dc:"数据列表"` +} +type ExportReq struct { + g.Meta `path:"/imUserLoginHistory/export" method:"get" tags:"IM用户登录历史" summary:"导出用户登录历史"` + sysin.ImUserLoginHistoryListInp +} +type ExportRes struct{} diff --git a/server/api/admin/imuserpolicygroup/im_user_policy_group.go b/server/api/admin/imuserpolicygroup/im_user_policy_group.go new file mode 100644 index 0000000..9398965 --- /dev/null +++ b/server/api/admin/imuserpolicygroup/im_user_policy_group.go @@ -0,0 +1,70 @@ +package imuserpolicygroup + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imUserPolicyGroup/list" method:"get" tags:"IM用户策略分组" summary:"获取用户策略分组列表"` + sysin.ImUserPolicyGroupListInp +} +type ListRes struct { + form.PageRes + List []*sysin.ImUserPolicyGroupListModel `json:"list"` +} +type ViewReq struct { + g.Meta `path:"/imUserPolicyGroup/view" method:"get" tags:"IM用户策略分组" summary:"获取用户策略分组详情"` + sysin.ImUserPolicyGroupViewInp +} +type ViewRes struct { + *sysin.ImUserPolicyGroupViewModel +} +type EditReq struct { + g.Meta `path:"/imUserPolicyGroup/edit" method:"post" tags:"IM用户策略分组" summary:"新增或编辑用户策略分组"` + sysin.ImUserPolicyGroupEditInp +} +type EditRes struct{} +type DeleteReq struct { + g.Meta `path:"/imUserPolicyGroup/delete" method:"post" tags:"IM用户策略分组" summary:"删除用户策略分组"` + sysin.ImUserPolicyGroupDeleteInp +} +type DeleteRes struct{} +type StatusReq struct { + g.Meta `path:"/imUserPolicyGroup/status" method:"post" tags:"IM用户策略分组" summary:"更新用户策略分组状态"` + sysin.ImUserPolicyGroupStatusInp +} +type StatusRes struct{} +type MemberListReq struct { + g.Meta `path:"/imUserPolicyGroup/memberList" method:"get" tags:"IM用户策略分组" summary:"获取策略分组成员"` + sysin.ImUserPolicyGroupMemberListInp +} +type MemberListRes struct { + form.PageRes + List []*sysin.ImUserPolicyGroupMemberModel `json:"list"` +} +type MemberAddReq struct { + g.Meta `path:"/imUserPolicyGroup/memberAdd" method:"post" tags:"IM用户策略分组" summary:"添加策略分组成员"` + sysin.ImUserPolicyGroupMemberAddInp +} +type MemberAddRes struct{} +type MemberRemoveReq struct { + g.Meta `path:"/imUserPolicyGroup/memberRemove" method:"post" tags:"IM用户策略分组" summary:"移除策略分组成员"` + sysin.ImUserPolicyGroupMemberRemoveInp +} +type MemberRemoveRes struct{} + +type LoginIPWhitelistReq struct { + g.Meta `path:"/imUserPolicyGroup/loginIPWhitelist" method:"get" tags:"IM用户策略分组" summary:"获取租户登录IP白名单"` + sysin.ImTenantLoginIPWhitelistInp +} +type LoginIPWhitelistRes struct { + *sysin.ImTenantLoginIPWhitelistModel +} +type LoginIPWhitelistSaveReq struct { + g.Meta `path:"/imUserPolicyGroup/loginIPWhitelistSave" method:"post" tags:"IM用户策略分组" summary:"保存租户登录IP白名单"` + sysin.ImTenantLoginIPWhitelistSaveInp +} +type LoginIPWhitelistSaveRes struct{} diff --git a/server/api/admin/imworkbench/im_workbench.go b/server/api/admin/imworkbench/im_workbench.go new file mode 100644 index 0000000..3e6db33 --- /dev/null +++ b/server/api/admin/imworkbench/im_workbench.go @@ -0,0 +1,39 @@ +package imworkbench + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imWorkbench/list" method:"get" tags:"IM工作台" summary:"获取IM工作台模块列表"` + sysin.ImWorkbenchAppListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImWorkbenchAppModel `json:"list" dc:"数据列表"` +} + +type EditReq struct { + g.Meta `path:"/imWorkbench/edit" method:"post" tags:"IM工作台" summary:"新增/编辑IM工作台模块"` + sysin.ImWorkbenchAppEditInp +} + +type EditRes struct{} + +type DeleteReq struct { + g.Meta `path:"/imWorkbench/delete" method:"post" tags:"IM工作台" summary:"禁用IM工作台模块"` + sysin.ImWorkbenchAppDeleteInp +} + +type DeleteRes struct{} + +type ReorderReq struct { + g.Meta `path:"/imWorkbench/reorder" method:"post" tags:"IM工作台" summary:"调整IM工作台模块排序"` + sysin.ImWorkbenchAppReorderInp +} + +type ReorderRes struct{} diff --git a/server/internal/cmd/http.go b/server/internal/cmd/http.go index c00f2c6..88bf396 100644 --- a/server/internal/cmd/http.go +++ b/server/internal/cmd/http.go @@ -11,6 +11,7 @@ import ( "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/util/gmode" + "hotgo/internal/controller/health" "hotgo/internal/library/addons" "hotgo/internal/library/casbin" "hotgo/internal/library/hggen" @@ -33,6 +34,7 @@ var ( // 请求响应结束后回调 s.BindHookHandler("/*any", ghttp.HookAfterOutput, service.Hook().AfterOutput) + s.BindHandler("/healthz", health.Serve) // 注册全局中间件 s.BindMiddleware("/*any", []ghttp.HandlerFunc{ diff --git a/server/internal/consts/im_content_audit.go b/server/internal/consts/im_content_audit.go new file mode 100644 index 0000000..7ad3e2d --- /dev/null +++ b/server/internal/consts/im_content_audit.go @@ -0,0 +1,84 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +const ( + ImMessageContentText = "text" + ImMessageContentImage = "image" + ImMessageContentVideo = "video" + ImMessageContentAudio = "audio" + ImMessageContentVoice = "voice" + ImMessageContentFile = "file" + ImMessageContentCard = "card" + ImMessageContentSystem = "system" + + ImMessageStatusNormal = "normal" + ImMessageStatusDeleted = "deleted" + ImMessageStatusRecalled = "recalled" + ImMessageStatusBlocked = "blocked" + ImMessageStatusHidden = "hidden" +) + +var ImMessageContentTypeOptions = []*model.Option{ + dict.GenInfoOption(ImMessageContentText, "文本"), + dict.GenPrimaryOption(ImMessageContentImage, "图片"), + dict.GenPrimaryOption(ImMessageContentVideo, "视频"), + dict.GenPrimaryOption(ImMessageContentAudio, "语音"), + dict.GenPrimaryOption(ImMessageContentVoice, "语音"), + dict.GenDefaultOption(ImMessageContentFile, "文件"), + dict.GenDefaultOption(ImMessageContentCard, "名片"), + dict.GenWarningOption(ImMessageContentSystem, "系统"), +} + +var ImMessageStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImMessageStatusNormal, "正常"), + dict.GenErrorOption(ImMessageStatusDeleted, "后台删除"), + dict.GenWarningOption(ImMessageStatusRecalled, "用户撤回"), + dict.GenErrorOption(ImMessageStatusBlocked, "风控拦截"), + dict.GenWarningOption(ImMessageStatusHidden, "已隐藏"), +} + +var ImLoginTypeOptions = []*model.Option{ + dict.GenPrimaryOption("password", "密码"), + dict.GenPrimaryOption("sms_code", "短信验证码"), + dict.GenInfoOption("sso", "单点登录"), + dict.GenInfoOption("qr_code", "二维码"), + dict.GenDefaultOption("token_refresh", "令牌刷新"), +} + +var ImLoginEventTypeOptions = []*model.Option{ + dict.GenSuccessOption("login", "登录"), + dict.GenInfoOption("refresh", "刷新会话"), + dict.GenDefaultOption("logout", "主动退出"), + dict.GenWarningOption("kicked", "被踢下线"), + dict.GenWarningOption("session_expired", "会话过期"), +} + +var ImClientPlatformOptions = []*model.Option{ + dict.GenPrimaryOption("ios", "iOS"), + dict.GenSuccessOption("android", "Android"), + dict.GenInfoOption("web", "Web"), + dict.GenDefaultOption("desktop", "桌面端"), + dict.GenDefaultOption("unknown", "未知"), +} + +var ImLoginFailureTypeOptions = []*model.Option{ + dict.GenErrorOption("password_error", "密码错误"), + dict.GenErrorOption("code_error", "验证码错误"), + dict.GenWarningOption("account_not_found", "账号不存在"), + dict.GenErrorOption("account_disabled", "账号已禁用"), + dict.GenWarningOption("rate_limited", "访问频率受限"), + dict.GenErrorOption("risk_blocked", "风控拦截"), +} + +func init() { + dict.RegisterEnums("ImMessageContentTypeOptions", "IM消息内容类型选项", ImMessageContentTypeOptions) + dict.RegisterEnums("ImMessageStatusOptions", "IM消息状态选项", ImMessageStatusOptions) + dict.RegisterEnums("ImLoginTypeOptions", "IM登录方式选项", ImLoginTypeOptions) + dict.RegisterEnums("ImLoginEventTypeOptions", "IM登录事件选项", ImLoginEventTypeOptions) + dict.RegisterEnums("ImClientPlatformOptions", "IM客户端平台选项", ImClientPlatformOptions) + dict.RegisterEnums("ImLoginFailureTypeOptions", "IM登录失败类型选项", ImLoginFailureTypeOptions) +} diff --git a/server/internal/consts/im_external_config.go b/server/internal/consts/im_external_config.go new file mode 100644 index 0000000..4418b9d --- /dev/null +++ b/server/internal/consts/im_external_config.go @@ -0,0 +1,35 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImExternalServiceOptions", "IM外部服务选项", ImExternalServiceOptions) + dict.RegisterEnums("ImExternalConfigStatusOptions", "IM外部配置状态选项", ImExternalConfigStatusOptions) +} + +const ( + ImExternalServiceSms = "sms" + ImExternalServicePush = "push" + ImExternalServiceTurn = "turn" + ImExternalServiceStorage = "storage" + ImExternalServiceModeration = "moderation" + + ImExternalConfigStatusConfigured = "configured" + ImExternalConfigStatusUnconfigured = "unconfigured" +) + +var ImExternalServiceOptions = []*model.Option{ + dict.GenSuccessOption(ImExternalServiceSms, "SMS"), + dict.GenPrimaryOption(ImExternalServicePush, "Push"), + dict.GenInfoOption(ImExternalServiceTurn, "TURN/STUN"), + dict.GenWarningOption(ImExternalServiceStorage, "对象存储"), + dict.GenDefaultOption(ImExternalServiceModeration, "审核插件"), +} + +var ImExternalConfigStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImExternalConfigStatusConfigured, "已配置"), + dict.GenWarningOption(ImExternalConfigStatusUnconfigured, "未配置"), +} diff --git a/server/internal/consts/im_file.go b/server/internal/consts/im_file.go new file mode 100644 index 0000000..e35fe46 --- /dev/null +++ b/server/internal/consts/im_file.go @@ -0,0 +1,52 @@ +// Package consts +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImFileStatusOptions", "IM文件状态选项", ImFileStatusOptions) + dict.RegisterEnums("ImFilePurposeOptions", "IM文件用途选项", ImFilePurposeOptions) +} + +const ( + ImFileStatusUploading = 0 + ImFileStatusCompleted = 1 + ImFileStatusFailed = 2 + ImFileStatusDeleted = 3 + ImFileStatusQuarantined = -1 + + ImFilePurposeUnknown = "unknown" + ImFilePurposeChat = "chat" + ImFilePurposeUserAvatar = "user_avatar" + ImFilePurposeGroupAvatar = "group_avatar" + ImFilePurposeGroupFile = "group_file" + ImFilePurposeAbuseEvidence = "abuse_evidence" + ImFilePurposeFeedback = "feedback" + ImFilePurposeOrphan = "orphan" +) + +var ImFileStatusOptions = []*model.Option{ + dict.GenWarningOption(ImFileStatusUploading, "上传中"), + dict.GenSuccessOption(ImFileStatusCompleted, "已完成"), + dict.GenErrorOption(ImFileStatusFailed, "失败"), + dict.GenDefaultOption(ImFileStatusDeleted, "已删除"), + dict.GenErrorOption(ImFileStatusQuarantined, "已隔离"), +} + +var ImFilePurposeOptions = []*model.Option{ + dict.GenDefaultOption(ImFilePurposeUnknown, "未知"), + dict.GenSuccessOption(ImFilePurposeChat, "聊天附件"), + dict.GenInfoOption(ImFilePurposeUserAvatar, "用户头像"), + dict.GenInfoOption(ImFilePurposeGroupAvatar, "群头像"), + dict.GenPrimaryOption(ImFilePurposeGroupFile, "群文件"), + dict.GenWarningOption(ImFilePurposeAbuseEvidence, "举报证据"), + dict.GenWarningOption(ImFilePurposeFeedback, "反馈附件"), + dict.GenDefaultOption(ImFilePurposeOrphan, "孤儿文件"), +} 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/consts/im_group.go b/server/internal/consts/im_group.go index 1649dd9..d555c60 100644 --- a/server/internal/consts/im_group.go +++ b/server/internal/consts/im_group.go @@ -19,6 +19,8 @@ func init() { dict.RegisterEnums("ImGroupMuteStatusOptions", "IM禁言状态选项", ImGroupMuteStatusOptions) dict.RegisterEnums("ImGroupAnnouncementStatusOptions", "IM群公告状态选项", ImGroupAnnouncementStatusOptions) dict.RegisterEnums("ImGroupOutboxStatusOptions", "IM群事件状态选项", ImGroupOutboxStatusOptions) + dict.RegisterEnums("ImGroupFileStatusOptions", "IM群文件状态选项", ImGroupFileStatusOptions) + dict.RegisterEnums("ImGroupFileTypeOptions", "IM群文件类型选项", ImGroupFileTypeOptions) } var ImGroupStateOptions = []*model.Option{ @@ -58,6 +60,14 @@ const ( ImGroupOutboxStatusPending = "pending" ImGroupOutboxStatusDelivered = "delivered" ImGroupOutboxStatusFailed = "failed" + + ImGroupFileStatusActive = "active" + ImGroupFileStatusDeleted = "deleted" + + ImGroupFileTypeFile = "file" + ImGroupFileTypeImage = "image" + ImGroupFileTypeVideo = "video" + ImGroupFileTypeAudio = "audio" ) var ImGroupStatusOptions = []*model.Option{ @@ -103,3 +113,15 @@ var ImGroupOutboxStatusOptions = []*model.Option{ dict.GenSuccessOption(ImGroupOutboxStatusDelivered, "已投递"), dict.GenErrorOption(ImGroupOutboxStatusFailed, "投递失败"), } + +var ImGroupFileStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImGroupFileStatusActive, "正常"), + dict.GenDefaultOption(ImGroupFileStatusDeleted, "已删除"), +} + +var ImGroupFileTypeOptions = []*model.Option{ + dict.GenDefaultOption(ImGroupFileTypeFile, "文件"), + dict.GenInfoOption(ImGroupFileTypeImage, "图片"), + dict.GenPrimaryOption(ImGroupFileTypeVideo, "视频"), + dict.GenWarningOption(ImGroupFileTypeAudio, "音频"), +} diff --git a/server/internal/consts/im_kafka.go b/server/internal/consts/im_kafka.go index 474b5c9..3709b9c 100644 --- a/server/internal/consts/im_kafka.go +++ b/server/internal/consts/im_kafka.go @@ -38,6 +38,7 @@ const ( ImAdminCommandGroupMemberUnmute = "group.member.unmute" ImAdminCommandGroupMemberRemove = "group.member.remove" ImAdminCommandGroupOwnerTransfer = "group.owner.transfer" + ImAdminCommandGroupDissolve = "group.dissolve" ImAdminCommandUserBan = "user.ban" ImAdminCommandUserUnban = "user.unban" ImAdminCommandUserFreeze = "user.freeze" @@ -46,6 +47,9 @@ const ( ImAdminCommandUserLogoutAll = "user.logout_all" ImAdminCommandUserCancel = "user.account.cancel" ImAdminCommandUserDelete = "user.account.delete" + ImAdminCommandUserPasswordReset = "user.password.reset" + ImAdminCommandMessageHide = "message.hide" + ImAdminCommandMessageDelete = "message.delete" ImAdminCommandKafkaDeadReplay = "kafka.dead_letter.replay" ImAdminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore" ImAdminCommandKafkaDeadResolve = "kafka.dead_letter.resolve" diff --git a/server/internal/consts/im_notification.go b/server/internal/consts/im_notification.go new file mode 100644 index 0000000..5ebba84 --- /dev/null +++ b/server/internal/consts/im_notification.go @@ -0,0 +1,39 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImNotificationSceneOptions", "IM通知场景选项", ImNotificationSceneOptions) + dict.RegisterEnums("ImNotificationTemplateStatusOptions", "IM通知模板状态选项", ImNotificationTemplateStatusOptions) +} + +const ( + ImNotificationSceneAccountSecurity = "account_security" + ImNotificationSceneFriendRequest = "friend_request" + ImNotificationSceneGroupEvent = "group_event" + ImNotificationSceneFileEvent = "file_event" + ImNotificationSceneMeetingReminder = "meeting_reminder" + ImNotificationSceneReportReminder = "report_reminder" + ImNotificationSceneWorkOrder = "work_order" + + ImNotificationTemplateEnabled = 1 + ImNotificationTemplateDisabled = 0 +) + +var ImNotificationSceneOptions = []*model.Option{ + dict.GenPrimaryOption(ImNotificationSceneAccountSecurity, "账号安全"), + dict.GenInfoOption(ImNotificationSceneFriendRequest, "好友申请"), + dict.GenInfoOption(ImNotificationSceneGroupEvent, "群事件"), + dict.GenDefaultOption(ImNotificationSceneFileEvent, "文件事件"), + dict.GenWarningOption(ImNotificationSceneMeetingReminder, "会议提醒"), + dict.GenWarningOption(ImNotificationSceneReportReminder, "日报月报提醒"), + dict.GenErrorOption(ImNotificationSceneWorkOrder, "工单处理"), +} + +var ImNotificationTemplateStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImNotificationTemplateEnabled, "启用"), + dict.GenWarningOption(ImNotificationTemplateDisabled, "禁用"), +} diff --git a/server/internal/consts/im_org.go b/server/internal/consts/im_org.go new file mode 100644 index 0000000..24f7d5f --- /dev/null +++ b/server/internal/consts/im_org.go @@ -0,0 +1,38 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImOrgDepartmentStateOptions", "IM组织部门状态选项", ImOrgDepartmentStateOptions) + dict.RegisterEnums("ImOrgEmploymentStateOptions", "IM组织成员在职状态选项", ImOrgEmploymentStateOptions) + dict.RegisterEnums("ImOrgContactableOptions", "IM组织成员可联系选项", ImOrgContactableOptions) +} + +const ( + ImOrgDepartmentStateActive = "active" + ImOrgDepartmentStateDisabled = "disabled" + + ImOrgEmploymentStateActive = "active" + ImOrgEmploymentStateLeft = "left" + + ImOrgContactableYes = 1 + ImOrgContactableNo = 0 +) + +var ImOrgDepartmentStateOptions = []*model.Option{ + dict.GenSuccessOption(ImOrgDepartmentStateActive, "启用"), + dict.GenWarningOption(ImOrgDepartmentStateDisabled, "停用"), +} + +var ImOrgEmploymentStateOptions = []*model.Option{ + dict.GenSuccessOption(ImOrgEmploymentStateActive, "在职"), + dict.GenDefaultOption(ImOrgEmploymentStateLeft, "离职"), +} + +var ImOrgContactableOptions = []*model.Option{ + dict.GenSuccessOption(ImOrgContactableYes, "可联系"), + dict.GenWarningOption(ImOrgContactableNo, "不可联系"), +} diff --git a/server/internal/consts/im_outbox.go b/server/internal/consts/im_outbox.go new file mode 100644 index 0000000..9cc17d0 --- /dev/null +++ b/server/internal/consts/im_outbox.go @@ -0,0 +1,38 @@ +// Package consts +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImOutboxSourceOptions", "IM Outbox来源选项", ImOutboxSourceOptions) + dict.RegisterEnums("ImOutboxStatusOptions", "IM Outbox状态选项", ImOutboxStatusOptions) +} + +const ( + ImOutboxSourceBusiness = "business" + ImOutboxSourceGroup = "group" + + ImOutboxStatusPending = "pending" + ImOutboxStatusDelivered = "delivered" + ImOutboxStatusFailed = "failed" + ImOutboxStatusDead = "dead" +) + +var ImOutboxSourceOptions = []*model.Option{ + dict.GenPrimaryOption(ImOutboxSourceBusiness, "业务事件"), + dict.GenInfoOption(ImOutboxSourceGroup, "群事件"), +} + +var ImOutboxStatusOptions = []*model.Option{ + dict.GenWarningOption(ImOutboxStatusPending, "待投递"), + dict.GenSuccessOption(ImOutboxStatusDelivered, "已投递"), + dict.GenErrorOption(ImOutboxStatusFailed, "失败"), + dict.GenDefaultOption(ImOutboxStatusDead, "死信"), +} diff --git a/server/internal/consts/im_policy.go b/server/internal/consts/im_policy.go new file mode 100644 index 0000000..0d2814b --- /dev/null +++ b/server/internal/consts/im_policy.go @@ -0,0 +1,118 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +const ( + ImPolicyActionAllow = "allow" + ImPolicyActionDeny = "deny" + ImPolicyActionChallenge = "challenge" + ImPolicyActionAuditOnly = "audit_only" + ImSecurityActionBlock = "block" + + ImSensitiveMatchExact = "exact" + ImSensitiveMatchContains = "contains" + ImSensitiveMatchRegex = "regex" + ImSensitiveMatchPinyin = "pinyin" + + ImSensitiveActionReplace = "replace" + ImSensitiveActionBlock = "block" + ImSensitiveActionAuditOnly = "audit_only" + ImSensitiveActionManualReview = "manual_review" + + ImRiskLevelLow = "low" + ImRiskLevelMedium = "medium" + ImRiskLevelHigh = "high" + + ImSecurityPublishDraft = "draft" + ImSecurityPublishPublished = "published" + ImSecurityPublishArchived = "archived" + ImSecurityPublishFailed = "failed" + + ImCurfewDateDaily = "daily" + ImCurfewDateWeekday = "weekday" + ImCurfewDateWeekend = "weekend" + ImCurfewDateCustom = "custom" + + ImCurfewScopePrivate = "private_message" + ImCurfewScopeGroup = "group_message" +) + +var ( + ImPolicyActionOptions = []*model.Option{ + dict.GenSuccessOption(ImPolicyActionAllow, "放行"), + dict.GenErrorOption(ImPolicyActionDeny, "拦截"), + dict.GenWarningOption(ImPolicyActionChallenge, "二次验证"), + dict.GenInfoOption(ImPolicyActionAuditOnly, "仅审计"), + } + ImSecurityActionOptions = []*model.Option{ + dict.GenSuccessOption(ImPolicyActionAllow, "放行"), + dict.GenWarningOption(ImPolicyActionChallenge, "二次验证"), + dict.GenErrorOption(ImSecurityActionBlock, "阻断"), + dict.GenInfoOption(ImPolicyActionAuditOnly, "仅审计"), + } + ImSensitiveMatchTypeOptions = []*model.Option{ + dict.GenPrimaryOption(ImSensitiveMatchExact, "精确匹配"), + dict.GenDefaultOption(ImSensitiveMatchContains, "包含匹配"), + dict.GenWarningOption(ImSensitiveMatchRegex, "正则匹配"), + dict.GenInfoOption(ImSensitiveMatchPinyin, "拼音匹配"), + } + ImSensitiveActionOptions = []*model.Option{ + dict.GenWarningOption(ImSensitiveActionReplace, "替换"), + dict.GenErrorOption(ImSensitiveActionBlock, "拦截"), + dict.GenInfoOption(ImSensitiveActionAuditOnly, "仅审计"), + dict.GenPrimaryOption(ImSensitiveActionManualReview, "人工审核"), + } + ImRiskLevelOptions = []*model.Option{ + dict.GenSuccessOption(ImRiskLevelLow, "低风险"), + dict.GenWarningOption(ImRiskLevelMedium, "中风险"), + dict.GenErrorOption(ImRiskLevelHigh, "高风险"), + } + ImSecurityPublishStatusOptions = []*model.Option{ + dict.GenWarningOption(ImSecurityPublishDraft, "草稿"), + dict.GenSuccessOption(ImSecurityPublishPublished, "已发布"), + dict.GenDefaultOption(ImSecurityPublishArchived, "已归档"), + dict.GenErrorOption(ImSecurityPublishFailed, "发布失败"), + } + ImCurfewDateTypeOptions = []*model.Option{ + dict.GenDefaultOption(ImCurfewDateDaily, "每天"), + dict.GenPrimaryOption(ImCurfewDateWeekday, "工作日"), + dict.GenInfoOption(ImCurfewDateWeekend, "周末"), + dict.GenWarningOption(ImCurfewDateCustom, "自定义日期"), + } + ImCurfewScopeOptions = []*model.Option{ + dict.GenErrorOption(ImCurfewScopePrivate, "单聊/私聊"), + dict.GenWarningOption(ImCurfewScopeGroup, "群聊"), + } + ImSensitiveSceneOptions = []*model.Option{ + dict.GenPrimaryOption("private_message", "单聊消息"), + dict.GenPrimaryOption("group_message", "群聊消息"), + dict.GenInfoOption("user_profile", "用户资料"), + dict.GenInfoOption("group_profile", "群资料"), + dict.GenWarningOption("feedback", "用户反馈"), + dict.GenWarningOption("report", "举报内容"), + } + ImFriendEntryOptions = []*model.Option{ + dict.GenDefaultOption("search", "用户搜索"), + dict.GenDefaultOption("profile_card", "个人名片"), + dict.GenDefaultOption("group_member", "群成员"), + dict.GenDefaultOption("qrcode", "二维码"), + dict.GenDefaultOption("invite_auto_add", "邀请注册自动添加"), + dict.GenDefaultOption("recommendation", "推荐联系人"), + } +) + +func init() { + dict.RegisterEnums("ImPolicyActionOptions", "IM策略动作", ImPolicyActionOptions) + dict.RegisterEnums("ImSecurityActionOptions", "IM安全策略动作", ImSecurityActionOptions) + dict.RegisterEnums("ImSensitiveMatchTypeOptions", "IM敏感词匹配类型", ImSensitiveMatchTypeOptions) + dict.RegisterEnums("ImSensitiveActionOptions", "IM敏感词动作", ImSensitiveActionOptions) + dict.RegisterEnums("ImRiskLevelOptions", "IM风险等级", ImRiskLevelOptions) + dict.RegisterEnums("ImSecurityPublishStatusOptions", "IM安全策略发布状态", ImSecurityPublishStatusOptions) + dict.RegisterEnums("ImCurfewDateTypeOptions", "IM宵禁日期类型", ImCurfewDateTypeOptions) + dict.RegisterEnums("ImCurfewScopeOptions", "IM宵禁范围", ImCurfewScopeOptions) + dict.RegisterEnums("ImSensitiveSceneOptions", "IM敏感词场景", ImSensitiveSceneOptions) + dict.RegisterEnums("ImFriendEntryOptions", "IM好友入口", ImFriendEntryOptions) +} diff --git a/server/internal/consts/im_product_config.go b/server/internal/consts/im_product_config.go new file mode 100644 index 0000000..a8503ec --- /dev/null +++ b/server/internal/consts/im_product_config.go @@ -0,0 +1,52 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImAppPlatformOptions", "IM客户端平台选项", ImAppPlatformOptions) + dict.RegisterEnums("ImAppUpdateTypeOptions", "IM客户端更新类型选项", ImAppUpdateTypeOptions) + dict.RegisterEnums("ImAbuseTargetTypeOptions", "IM举报对象类型选项", ImAbuseTargetTypeOptions) + dict.RegisterEnums("ImAbuseReasonEnabledOptions", "IM举报原因启用状态", ImAbuseReasonEnabledOptions) +} + +const ( + ImAppPlatformIos = "ios" + ImAppPlatformAndroid = "android" +) + +const ( + ImAppUpdateTypeNone = "none" + ImAppUpdateTypeRecommended = "recommended" + ImAppUpdateTypeForce = "force" +) + +const ( + ImAbuseTargetTypeUser = "user" + ImAbuseTargetTypeGroup = "group" + ImAbuseTargetTypeMessage = "message" +) + +var ImAppPlatformOptions = []*model.Option{ + dict.GenPrimaryOption(ImAppPlatformIos, "iOS"), + dict.GenSuccessOption(ImAppPlatformAndroid, "Android"), +} + +var ImAppUpdateTypeOptions = []*model.Option{ + dict.GenDefaultOption(ImAppUpdateTypeNone, "无需更新"), + dict.GenWarningOption(ImAppUpdateTypeRecommended, "建议更新"), + dict.GenErrorOption(ImAppUpdateTypeForce, "强制更新"), +} + +var ImAbuseTargetTypeOptions = []*model.Option{ + dict.GenPrimaryOption(ImAbuseTargetTypeUser, "用户"), + dict.GenSuccessOption(ImAbuseTargetTypeGroup, "群组"), + dict.GenWarningOption(ImAbuseTargetTypeMessage, "消息"), +} + +var ImAbuseReasonEnabledOptions = []*model.Option{ + dict.GenWarningOption(0, "禁用"), + dict.GenSuccessOption(1, "启用"), +} diff --git a/server/internal/consts/im_task_dashboard.go b/server/internal/consts/im_task_dashboard.go new file mode 100644 index 0000000..8ce5654 --- /dev/null +++ b/server/internal/consts/im_task_dashboard.go @@ -0,0 +1,22 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImTaskDashboardStatusOptions", "IM任务看板状态选项", ImTaskDashboardStatusOptions) +} + +const ( + ImTaskDashboardStatusHealthy = "healthy" + ImTaskDashboardStatusWarning = "warning" + ImTaskDashboardStatusError = "error" +) + +var ImTaskDashboardStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImTaskDashboardStatusHealthy, "正常"), + dict.GenWarningOption(ImTaskDashboardStatusWarning, "待处理"), + dict.GenErrorOption(ImTaskDashboardStatusError, "异常"), +} diff --git a/server/internal/consts/im_user.go b/server/internal/consts/im_user.go index c6b7aa4..96aab16 100644 --- a/server/internal/consts/im_user.go +++ b/server/internal/consts/im_user.go @@ -12,6 +12,22 @@ import ( func init() { dict.RegisterEnums("ImUserStateOptions", "IM用户状态选项", ImUserStateOptions) + dict.RegisterEnums("ImUserListStateOptions", "IM用户列表状态选项", ImUserListStateOptions) + dict.RegisterEnums("ImUserRegisterTypeOptions", "IM用户注册方式选项", ImUserRegisterTypeOptions) +} + +const ( + ImUserRegisterTypeAdmin = "admin" + ImUserRegisterTypeMobile = "mobile" + ImUserRegisterTypeInviteCode = "invite_code" + ImLoginChannelApp = "app" + ImLoginChannelWeb = "web" +) + +var ImUserRegisterTypeOptions = []*model.Option{ + dict.GenPrimaryOption(ImUserRegisterTypeAdmin, "后台创建"), + dict.GenSuccessOption(ImUserRegisterTypeMobile, "手机号注册"), + dict.GenInfoOption(ImUserRegisterTypeInviteCode, "邀请码注册"), } const ( @@ -22,8 +38,14 @@ const ( ) var ImUserStateOptions = []*model.Option{ - dict.GenSuccessOption(ImUserStateOk, "正常"), - dict.GenErrorOption(ImUserStateSuspended, "已停用"), - dict.GenDefaultOption(ImUserStateDeleted, "已删除"), + dict.GenSuccessOption(ImUserStateOk, "启用"), + dict.GenErrorOption(ImUserStateSuspended, "封禁"), + dict.GenDefaultOption(ImUserStateDeleted, "已注销"), dict.GenWarningOption(ImUserStateUndefined, "未定义"), } + +var ImUserListStateOptions = []*model.Option{ + dict.GenSuccessOption(ImUserStateOk, "启用"), + dict.GenErrorOption(ImUserStateSuspended, "封禁"), + dict.GenDefaultOption(ImUserStateDeleted, "已注销"), +} diff --git a/server/internal/consts/im_workbench.go b/server/internal/consts/im_workbench.go new file mode 100644 index 0000000..42bdc8f --- /dev/null +++ b/server/internal/consts/im_workbench.go @@ -0,0 +1,50 @@ +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImWorkbenchAppEnabledOptions", "IM工作台模块启用状态", ImWorkbenchAppEnabledOptions) + dict.RegisterEnums("ImWorkbenchPermissionOptions", "IM工作台模块权限策略", ImWorkbenchPermissionOptions) + dict.RegisterEnums("ImWorkbenchRouteOptions", "IM工作台模块路由选项", ImWorkbenchRouteOptions) +} + +const ( + ImWorkbenchAppDisabled = 0 + ImWorkbenchAppEnabled = 1 +) + +const ( + ImWorkbenchPermissionEnabled = "enabled" + ImWorkbenchPermissionRoleRequired = "role_required" + ImWorkbenchPermissionDisabled = "disabled" +) + +const ( + ImWorkbenchRouteOrganization = "/organization" + ImWorkbenchRouteMeetings = "/meetings" + ImWorkbenchRouteDailyReports = "/reports/daily" + ImWorkbenchRouteMonthlyReports = "/reports/monthly" + ImWorkbenchRouteExecutiveMailbox = "/executive-mailbox" +) + +var ImWorkbenchAppEnabledOptions = []*model.Option{ + dict.GenWarningOption(ImWorkbenchAppDisabled, "禁用"), + dict.GenSuccessOption(ImWorkbenchAppEnabled, "启用"), +} + +var ImWorkbenchPermissionOptions = []*model.Option{ + dict.GenSuccessOption(ImWorkbenchPermissionEnabled, "全部可见"), + dict.GenPrimaryOption(ImWorkbenchPermissionRoleRequired, "按角色权限"), + dict.GenWarningOption(ImWorkbenchPermissionDisabled, "不可见"), +} + +var ImWorkbenchRouteOptions = []*model.Option{ + dict.GenPrimaryOption(ImWorkbenchRouteOrganization, "组织架构"), + dict.GenPrimaryOption(ImWorkbenchRouteMeetings, "会议通知"), + dict.GenPrimaryOption(ImWorkbenchRouteDailyReports, "日报"), + dict.GenPrimaryOption(ImWorkbenchRouteMonthlyReports, "月报"), + dict.GenPrimaryOption(ImWorkbenchRouteExecutiveMailbox, "总经理信箱"), +} diff --git a/server/internal/controller/admin/sys/im_abuse_reason.go b/server/internal/controller/admin/sys/im_abuse_reason.go new file mode 100644 index 0000000..8fcc3c8 --- /dev/null +++ b/server/internal/controller/admin/sys/im_abuse_reason.go @@ -0,0 +1,36 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imabusereason" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImAbuseReason = cImAbuseReason{} + +type cImAbuseReason struct{} + +func (c *cImAbuseReason) List(ctx context.Context, req *imabusereason.ListReq) (res *imabusereason.ListRes, err error) { + list, totalCount, err := service.SysImAbuseReason().List(ctx, &req.ImAbuseReasonListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImAbuseReasonModel{} + } + res = new(imabusereason.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImAbuseReason) Edit(ctx context.Context, req *imabusereason.EditReq) (res *imabusereason.EditRes, err error) { + err = service.SysImAbuseReason().Edit(ctx, &req.ImAbuseReasonEditInp) + return +} + +func (c *cImAbuseReason) Delete(ctx context.Context, req *imabusereason.DeleteReq) (res *imabusereason.DeleteRes, err error) { + err = service.SysImAbuseReason().Delete(ctx, &req.ImAbuseReasonDeleteInp) + return +} diff --git a/server/internal/controller/admin/sys/im_app_release.go b/server/internal/controller/admin/sys/im_app_release.go new file mode 100644 index 0000000..2b5023c --- /dev/null +++ b/server/internal/controller/admin/sys/im_app_release.go @@ -0,0 +1,31 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imapprelease" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImAppRelease = cImAppRelease{} + +type cImAppRelease struct{} + +func (c *cImAppRelease) List(ctx context.Context, req *imapprelease.ListReq) (res *imapprelease.ListRes, err error) { + list, totalCount, err := service.SysImAppRelease().List(ctx, &req.ImAppReleaseListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImAppReleaseModel{} + } + res = new(imapprelease.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImAppRelease) Edit(ctx context.Context, req *imapprelease.EditReq) (res *imapprelease.EditRes, err error) { + err = service.SysImAppRelease().Edit(ctx, &req.ImAppReleaseEditInp) + return +} diff --git a/server/internal/controller/admin/sys/im_audit_log.go b/server/internal/controller/admin/sys/im_audit_log.go index 460eba1..45ba525 100644 --- a/server/internal/controller/admin/sys/im_audit_log.go +++ b/server/internal/controller/admin/sys/im_audit_log.go @@ -31,6 +31,12 @@ func (c *cImAuditLog) List(ctx context.Context, req *imauditlog.ListReq) (res *i return } +// Export 导出IM审计日志列表 +func (c *cImAuditLog) Export(ctx context.Context, req *imauditlog.ExportReq) (res *imauditlog.ExportRes, err error) { + err = service.SysImAuditLog().Export(ctx, &req.ImAuditLogListInp) + return +} + // View 获取指定IM审计日志信息 func (c *cImAuditLog) View(ctx context.Context, req *imauditlog.ViewReq) (res *imauditlog.ViewRes, err error) { data, err := service.SysImAuditLog().View(ctx, &req.ImAuditLogViewInp) diff --git a/server/internal/controller/admin/sys/im_dashboard.go b/server/internal/controller/admin/sys/im_dashboard.go new file mode 100644 index 0000000..40bac67 --- /dev/null +++ b/server/internal/controller/admin/sys/im_dashboard.go @@ -0,0 +1,31 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imdashboard" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImDashboard = cImDashboard{} + +type cImDashboard struct{} + +func (c *cImDashboard) Overview(ctx context.Context, req *imdashboard.OverviewReq) (res *imdashboard.OverviewRes, err error) { + data, err := service.SysImDashboard().Overview(ctx, &req.ImDashboardOverviewInp) + if err != nil { + return nil, err + } + return &imdashboard.OverviewRes{ImDashboardOverviewModel: data}, nil +} + +func (c *cImDashboard) Trends(ctx context.Context, req *imdashboard.TrendsReq) (res *imdashboard.TrendsRes, err error) { + list, err := service.SysImDashboard().Trends(ctx, &req.ImDashboardTrendsInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImDashboardTrendModel{} + } + return &imdashboard.TrendsRes{List: list}, nil +} diff --git a/server/internal/controller/admin/sys/im_external_config.go b/server/internal/controller/admin/sys/im_external_config.go new file mode 100644 index 0000000..e78f728 --- /dev/null +++ b/server/internal/controller/admin/sys/im_external_config.go @@ -0,0 +1,46 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imexternalconfig" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImExternalConfig = cImExternalConfig{} + +type cImExternalConfig struct{} + +func (c *cImExternalConfig) List(ctx context.Context, req *imexternalconfig.ListReq) (res *imexternalconfig.ListRes, err error) { + list, totalCount, err := service.SysImExternalConfig().List(ctx, &req.ImExternalConfigListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImExternalConfigListModel{} + } + res = new(imexternalconfig.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImExternalConfig) View(ctx context.Context, req *imexternalconfig.ViewReq) (res *imexternalconfig.ViewRes, err error) { + data, err := service.SysImExternalConfig().View(ctx, &req.ImExternalConfigViewInp) + if err != nil { + return + } + res = new(imexternalconfig.ViewRes) + res.ImExternalConfigViewModel = data + return +} + +func (c *cImExternalConfig) Update(ctx context.Context, req *imexternalconfig.UpdateReq) (res *imexternalconfig.UpdateRes, err error) { + err = service.SysImExternalConfig().Update(ctx, &req.ImExternalConfigUpdateInp) + return +} + +func (c *cImExternalConfig) Check(ctx context.Context, req *imexternalconfig.CheckReq) (res *imexternalconfig.CheckRes, err error) { + err = service.SysImExternalConfig().Check(ctx, &req.ImExternalConfigCheckInp) + return +} diff --git a/server/internal/controller/admin/sys/im_feedback_config.go b/server/internal/controller/admin/sys/im_feedback_config.go new file mode 100644 index 0000000..11f84f7 --- /dev/null +++ b/server/internal/controller/admin/sys/im_feedback_config.go @@ -0,0 +1,25 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imfeedbackconfig" + "hotgo/internal/service" +) + +var ImFeedbackConfig = cImFeedbackConfig{} + +type cImFeedbackConfig struct{} + +func (c *cImFeedbackConfig) Get(ctx context.Context, req *imfeedbackconfig.GetReq) (res *imfeedbackconfig.GetRes, err error) { + data, err := service.SysImFeedbackConfig().Get(ctx, &req.ImFeedbackConfigGetInp) + if err != nil { + return + } + res = &imfeedbackconfig.GetRes{ImFeedbackConfigModel: data} + return +} + +func (c *cImFeedbackConfig) Update(ctx context.Context, req *imfeedbackconfig.UpdateReq) (res *imfeedbackconfig.UpdateRes, err error) { + err = service.SysImFeedbackConfig().Update(ctx, &req.ImFeedbackConfigUpdateInp) + return +} diff --git a/server/internal/controller/admin/sys/im_file.go b/server/internal/controller/admin/sys/im_file.go new file mode 100644 index 0000000..14df010 --- /dev/null +++ b/server/internal/controller/admin/sys/im_file.go @@ -0,0 +1,68 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imfile" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImFile = cImFile{} +) + +type cImFile struct{} + +// List 查看IM文件列表 +func (c *cImFile) List(ctx context.Context, req *imfile.ListReq) (res *imfile.ListRes, err error) { + list, totalCount, err := service.SysImFile().List(ctx, &req.ImFileListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImFileListModel{} + } + res = new(imfile.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取IM文件详情 +func (c *cImFile) View(ctx context.Context, req *imfile.ViewReq) (res *imfile.ViewRes, err error) { + data, err := service.SysImFile().View(ctx, &req.ImFileViewInp) + if err != nil { + return + } + res = new(imfile.ViewRes) + res.ImFileViewModel = data + return +} + +// Quarantine 隔离IM文件 +func (c *cImFile) Quarantine(ctx context.Context, req *imfile.QuarantineReq) (res *imfile.QuarantineRes, err error) { + err = service.SysImFile().Quarantine(ctx, &req.ImFileActionInp) + return +} + +// Delete 删除IM文件 +func (c *cImFile) Delete(ctx context.Context, req *imfile.DeleteReq) (res *imfile.DeleteRes, err error) { + err = service.SysImFile().Delete(ctx, &req.ImFileActionInp) + return +} + +// CleanupOrphan 清理IM孤儿文件 +func (c *cImFile) CleanupOrphan(ctx context.Context, req *imfile.CleanupOrphanReq) (res *imfile.CleanupOrphanRes, err error) { + data, err := service.SysImFile().CleanupOrphan(ctx, &req.ImFileCleanupOrphanInp) + if err != nil { + return + } + res = new(imfile.CleanupOrphanRes) + res.ImFileCleanupOrphanModel = data + return +} 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_group.go b/server/internal/controller/admin/sys/im_group.go index ab7a31e..08ec085 100644 --- a/server/internal/controller/admin/sys/im_group.go +++ b/server/internal/controller/admin/sys/im_group.go @@ -75,6 +75,150 @@ func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) ( return } +// GovernanceUpdate 更新IM群治理配置 +func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) { + data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp) + if err != nil { + return + } + res = new(imgroup.GovernanceUpdateRes) + res.ImGroupCommandModel = data + return +} + +// ProfileUpdate 修改IM群资料 +func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdateReq) (res *imgroup.ProfileUpdateRes, err error) { + data, err := service.SysImGroup().ProfileUpdate(ctx, &req.ImGroupProfileUpdateInp) + if err != nil { + return + } + res = new(imgroup.ProfileUpdateRes) + res.ImGroupCommandModel = data + return +} + +// Dissolve 解散IM群 +func (c *cImGroup) Dissolve(ctx context.Context, req *imgroup.DissolveReq) (res *imgroup.DissolveRes, err error) { + data, err := service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp) + if err != nil { + return + } + res = new(imgroup.DissolveRes) + res.ImGroupCommandModel = data + return +} + +// Mute 禁言IM群成员 +func (c *cImGroup) Mute(ctx context.Context, req *imgroup.MuteReq) (res *imgroup.MuteRes, err error) { + data, err := service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp) + if err != nil { + return + } + res = new(imgroup.MuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberMute Kafka禁言IM群成员 +func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) (res *imgroup.MemberMuteRes, err error) { + data, err := service.SysImGroup().MemberMute(ctx, &req.ImGroupMuteInp) + if err != nil { + return + } + res = new(imgroup.MemberMuteRes) + res.ImGroupCommandModel = data + return +} + +// Unmute 解除IM群成员禁言 +func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *imgroup.UnmuteRes, err error) { + data, err := service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp) + if err != nil { + return + } + res = new(imgroup.UnmuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberUnmute Kafka解除IM群成员禁言 +func (c *cImGroup) MemberUnmute(ctx context.Context, req *imgroup.MemberUnmuteReq) (res *imgroup.MemberUnmuteRes, err error) { + data, err := service.SysImGroup().MemberUnmute(ctx, &req.ImGroupUnmuteInp) + if err != nil { + return + } + res = new(imgroup.MemberUnmuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberRemove 移出IM群成员 +func (c *cImGroup) MemberRemove(ctx context.Context, req *imgroup.MemberRemoveReq) (res *imgroup.MemberRemoveRes, err error) { + data, err := service.SysImGroup().MemberRemove(ctx, &req.ImGroupMemberRemoveInp) + if err != nil { + return + } + res = new(imgroup.MemberRemoveRes) + res.ImGroupCommandModel = data + return +} + +// JoinRequestApprove 通过IM入群申请 +func (c *cImGroup) JoinRequestApprove(ctx context.Context, req *imgroup.JoinRequestApproveReq) (res *imgroup.JoinRequestApproveRes, err error) { + err = service.SysImGroup().JoinRequestApprove(ctx, &req.ImGroupJoinRequestResolveInp) + return +} + +// JoinRequestReject 拒绝IM入群申请 +func (c *cImGroup) JoinRequestReject(ctx context.Context, req *imgroup.JoinRequestRejectReq) (res *imgroup.JoinRequestRejectRes, err error) { + err = service.SysImGroup().JoinRequestReject(ctx, &req.ImGroupJoinRequestResolveInp) + return +} + +// RoleUpdate 更新IM群成员角色 +func (c *cImGroup) RoleUpdate(ctx context.Context, req *imgroup.RoleUpdateReq) (res *imgroup.RoleUpdateRes, err error) { + data, err := service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.RoleUpdateRes) + res.ImGroupCommandModel = data + return +} + +// AdminSet 设置IM群管理员 +func (c *cImGroup) AdminSet(ctx context.Context, req *imgroup.AdminSetReq) (res *imgroup.AdminSetRes, err error) { + data, err := service.SysImGroup().AdminSet(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.AdminSetRes) + res.ImGroupCommandModel = data + return +} + +// AdminRemove 删除IM群管理员 +func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) (res *imgroup.AdminRemoveRes, err error) { + data, err := service.SysImGroup().AdminRemove(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.AdminRemoveRes) + res.ImGroupCommandModel = data + return +} + +// OwnerTransfer 转让IM群主 +func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) { + data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) + if err != nil { + return + } + res = new(imgroup.OwnerTransferRes) + res.ImGroupCommandModel = data + return +} + // JoinRequestList 查看IM入群申请列表 func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequestListReq) (res *imgroup.JoinRequestListRes, err error) { list, totalCount, err := service.SysImGroup().JoinRequestList(ctx, &req.ImGroupJoinRequestListInp) @@ -90,6 +234,27 @@ func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequest return } +// FileList 查看IM群文件列表 +func (c *cImGroup) FileList(ctx context.Context, req *imgroup.FileListReq) (res *imgroup.FileListRes, err error) { + list, totalCount, err := service.SysImGroup().FileList(ctx, &req.ImGroupFileListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupFileListModel{} + } + res = new(imgroup.FileListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// FileDelete 删除IM群文件 +func (c *cImGroup) FileDelete(ctx context.Context, req *imgroup.FileDeleteReq) (res *imgroup.FileDeleteRes, err error) { + err = service.SysImGroup().FileDelete(ctx, &req.ImGroupFileDeleteInp) + return +} + // InvitationList 查看IM群邀请列表 func (c *cImGroup) InvitationList(ctx context.Context, req *imgroup.InvitationListReq) (res *imgroup.InvitationListRes, err error) { list, totalCount, err := service.SysImGroup().InvitationList(ctx, &req.ImGroupInvitationListInp) diff --git a/server/internal/controller/admin/sys/im_group_message.go b/server/internal/controller/admin/sys/im_group_message.go new file mode 100644 index 0000000..2e4aa0b --- /dev/null +++ b/server/internal/controller/admin/sys/im_group_message.go @@ -0,0 +1,40 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imgroupmessage" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImGroupMessage = cImGroupMessage{} + +type cImGroupMessage struct{} + +func (c *cImGroupMessage) List(ctx context.Context, req *imgroupmessage.ListReq) (res *imgroupmessage.ListRes, err error) { + list, total, err := service.SysImGroupMessage().List(ctx, &req.ImGroupMessageListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImMessageListModel{} + } + res = &imgroupmessage.ListRes{List: list} + res.PageRes.Pack(req, total) + return +} +func (c *cImGroupMessage) View(ctx context.Context, req *imgroupmessage.ViewReq) (*imgroupmessage.ViewRes, error) { + data, err := service.SysImGroupMessage().View(ctx, &req.ImMessageViewInp) + return &imgroupmessage.ViewRes{ImMessageViewModel: data}, err +} +func (c *cImGroupMessage) Hide(ctx context.Context, req *imgroupmessage.HideReq) (*imgroupmessage.HideRes, error) { + data, err := service.SysImGroupMessage().Hide(ctx, &req.ImMessageActionInp) + return &imgroupmessage.HideRes{ImMessageCommandModel: data}, err +} +func (c *cImGroupMessage) Delete(ctx context.Context, req *imgroupmessage.DeleteReq) (*imgroupmessage.DeleteRes, error) { + data, err := service.SysImGroupMessage().Delete(ctx, &req.ImMessageActionInp) + return &imgroupmessage.DeleteRes{ImMessageCommandModel: data}, err +} +func (c *cImGroupMessage) Export(ctx context.Context, req *imgroupmessage.ExportReq) (*imgroupmessage.ExportRes, error) { + return &imgroupmessage.ExportRes{}, service.SysImGroupMessage().Export(ctx, &req.ImGroupMessageListInp) +} diff --git a/server/internal/controller/admin/sys/im_invite.go b/server/internal/controller/admin/sys/im_invite.go index f9761dc..3968c65 100644 --- a/server/internal/controller/admin/sys/im_invite.go +++ b/server/internal/controller/admin/sys/im_invite.go @@ -60,6 +60,11 @@ func (c *cImInvite) Revoke(ctx context.Context, req *iminvite.RevokeReq) (res *i return } +func (c *cImInvite) BindUser(ctx context.Context, req *iminvite.BindUserReq) (res *iminvite.BindUserRes, err error) { + err = service.SysImInvite().BindUser(ctx, &req.ImInviteBindUserInp) + return +} + func (c *cImInvite) BatchRevoke(ctx context.Context, req *iminvite.BatchRevokeReq) (res *iminvite.BatchRevokeRes, err error) { data, err := service.SysImInvite().BatchRevoke(ctx, &req.ImInviteBatchRevokeInp) if err != nil { diff --git a/server/internal/controller/admin/sys/im_login_failure_log.go b/server/internal/controller/admin/sys/im_login_failure_log.go new file mode 100644 index 0000000..e6693b1 --- /dev/null +++ b/server/internal/controller/admin/sys/im_login_failure_log.go @@ -0,0 +1,39 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imloginfailurelog" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImLoginFailureLog = cImLoginFailureLog{} + +type cImLoginFailureLog struct{} + +func (c *cImLoginFailureLog) List(ctx context.Context, req *imloginfailurelog.ListReq) (res *imloginfailurelog.ListRes, err error) { + list, total, err := service.SysImLoginFailureLog().List(ctx, &req.ImLoginFailureLogListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImLoginFailureLogModel{} + } + res = &imloginfailurelog.ListRes{List: list} + res.PageRes.Pack(req, total) + return +} +func (c *cImLoginFailureLog) View(ctx context.Context, req *imloginfailurelog.ViewReq) (*imloginfailurelog.ViewRes, error) { + data, err := service.SysImLoginFailureLog().View(ctx, &req.ImLoginFailureViewInp) + return &imloginfailurelog.ViewRes{ImLoginFailureLogModel: data}, err +} +func (c *cImLoginFailureLog) Export(ctx context.Context, req *imloginfailurelog.ExportReq) (*imloginfailurelog.ExportRes, error) { + return &imloginfailurelog.ExportRes{}, service.SysImLoginFailureLog().Export(ctx, &req.ImLoginFailureLogListInp) +} +func (c *cImLoginFailureLog) AddBlacklist(ctx context.Context, req *imloginfailurelog.AddBlacklistReq) (*imloginfailurelog.AddBlacklistRes, error) { + return &imloginfailurelog.AddBlacklistRes{}, service.SysImLoginFailureLog().AddBlacklist(ctx, &req.ImLoginFailureAddBlacklistInp) +} +func (c *cImLoginFailureLog) BanUser(ctx context.Context, req *imloginfailurelog.BanUserReq) (*imloginfailurelog.BanUserRes, error) { + data, err := service.SysImLoginFailureLog().BanUser(ctx, &req.ImLoginFailureBanUserInp) + return &imloginfailurelog.BanUserRes{ImUserCommandModel: data}, err +} diff --git a/server/internal/controller/admin/sys/im_mailbox_config.go b/server/internal/controller/admin/sys/im_mailbox_config.go new file mode 100644 index 0000000..2f8e5ee --- /dev/null +++ b/server/internal/controller/admin/sys/im_mailbox_config.go @@ -0,0 +1,25 @@ +package sys + +import ( + "context" + "hotgo/api/admin/immailboxconfig" + "hotgo/internal/service" +) + +var ImMailboxConfig = cImMailboxConfig{} + +type cImMailboxConfig struct{} + +func (c *cImMailboxConfig) Get(ctx context.Context, req *immailboxconfig.GetReq) (res *immailboxconfig.GetRes, err error) { + data, err := service.SysImMailboxConfig().Get(ctx, &req.ImMailboxConfigGetInp) + if err != nil { + return + } + res = &immailboxconfig.GetRes{ImMailboxConfigModel: data} + return +} + +func (c *cImMailboxConfig) Update(ctx context.Context, req *immailboxconfig.UpdateReq) (res *immailboxconfig.UpdateRes, err error) { + err = service.SysImMailboxConfig().Update(ctx, &req.ImMailboxConfigUpdateInp) + return +} diff --git a/server/internal/controller/admin/sys/im_notification.go b/server/internal/controller/admin/sys/im_notification.go new file mode 100644 index 0000000..935f700 --- /dev/null +++ b/server/internal/controller/admin/sys/im_notification.go @@ -0,0 +1,31 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imnotification" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImNotification = cImNotification{} + +type cImNotification struct{} + +func (c *cImNotification) TemplateList(ctx context.Context, req *imnotification.TemplateListReq) (res *imnotification.TemplateListRes, err error) { + list, totalCount, err := service.SysImNotification().TemplateList(ctx, &req.ImNotificationTemplateListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImNotificationTemplateListModel{} + } + res = new(imnotification.TemplateListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImNotification) TemplateUpdate(ctx context.Context, req *imnotification.TemplateUpdateReq) (res *imnotification.TemplateUpdateRes, err error) { + err = service.SysImNotification().TemplateUpdate(ctx, &req.ImNotificationTemplateUpdateInp) + return +} diff --git a/server/internal/controller/admin/sys/im_org.go b/server/internal/controller/admin/sys/im_org.go new file mode 100644 index 0000000..88dece9 --- /dev/null +++ b/server/internal/controller/admin/sys/im_org.go @@ -0,0 +1,79 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imorg" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImOrg = cImOrg{} + +type cImOrg struct{} + +func (c *cImOrg) DepartmentList(ctx context.Context, req *imorg.DepartmentListReq) (res *imorg.DepartmentListRes, err error) { + list, totalCount, err := service.SysImOrg().DepartmentList(ctx, &req.ImOrgDepartmentListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImOrgDepartmentModel{} + } + res = new(imorg.DepartmentListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImOrg) DepartmentEdit(ctx context.Context, req *imorg.DepartmentEditReq) (res *imorg.DepartmentEditRes, err error) { + err = service.SysImOrg().DepartmentEdit(ctx, &req.ImOrgDepartmentEditInp) + 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 +} + +func (c *cImOrg) MemberList(ctx context.Context, req *imorg.MemberListReq) (res *imorg.MemberListRes, err error) { + list, totalCount, err := service.SysImOrg().MemberList(ctx, &req.ImOrgMemberListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImOrgMemberModel{} + } + res = new(imorg.MemberListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImOrg) MemberEdit(ctx context.Context, req *imorg.MemberEditReq) (res *imorg.MemberEditRes, err error) { + err = service.SysImOrg().MemberEdit(ctx, &req.ImOrgMemberEditInp) + 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 +} + +func (c *cImOrg) Search(ctx context.Context, req *imorg.SearchReq) (res *imorg.SearchRes, err error) { + data, err := service.SysImOrg().Search(ctx, &req.ImOrgSearchInp) + if err != nil { + return + } + res = &imorg.SearchRes{ImOrgSearchModel: data} + return +} diff --git a/server/internal/controller/admin/sys/im_outbox.go b/server/internal/controller/admin/sys/im_outbox.go new file mode 100644 index 0000000..8be67cd --- /dev/null +++ b/server/internal/controller/admin/sys/im_outbox.go @@ -0,0 +1,55 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imoutbox" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImOutbox = cImOutbox{} +) + +type cImOutbox struct{} + +func (c *cImOutbox) List(ctx context.Context, req *imoutbox.ListReq) (res *imoutbox.ListRes, err error) { + list, totalCount, err := service.SysImOutbox().List(ctx, &req.ImOutboxListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImOutboxListModel{} + } + + res = new(imoutbox.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImOutbox) View(ctx context.Context, req *imoutbox.ViewReq) (res *imoutbox.ViewRes, err error) { + data, err := service.SysImOutbox().View(ctx, &req.ImOutboxViewInp) + if err != nil { + return + } + + res = new(imoutbox.ViewRes) + res.ImOutboxViewModel = data + return +} + +func (c *cImOutbox) Retry(ctx context.Context, req *imoutbox.RetryReq) (res *imoutbox.RetryRes, err error) { + err = service.SysImOutbox().Retry(ctx, &req.ImOutboxActionInp) + return +} + +func (c *cImOutbox) Dead(ctx context.Context, req *imoutbox.DeadReq) (res *imoutbox.DeadRes, err error) { + err = service.SysImOutbox().Dead(ctx, &req.ImOutboxActionInp) + return +} diff --git a/server/internal/controller/admin/sys/im_private_message.go b/server/internal/controller/admin/sys/im_private_message.go new file mode 100644 index 0000000..01d2c15 --- /dev/null +++ b/server/internal/controller/admin/sys/im_private_message.go @@ -0,0 +1,40 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imprivatemessage" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImPrivateMessage = cImPrivateMessage{} + +type cImPrivateMessage struct{} + +func (c *cImPrivateMessage) List(ctx context.Context, req *imprivatemessage.ListReq) (res *imprivatemessage.ListRes, err error) { + list, total, err := service.SysImPrivateMessage().List(ctx, &req.ImPrivateMessageListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImMessageListModel{} + } + res = &imprivatemessage.ListRes{List: list} + res.PageRes.Pack(req, total) + return +} +func (c *cImPrivateMessage) View(ctx context.Context, req *imprivatemessage.ViewReq) (*imprivatemessage.ViewRes, error) { + data, err := service.SysImPrivateMessage().View(ctx, &req.ImMessageViewInp) + return &imprivatemessage.ViewRes{ImMessageViewModel: data}, err +} +func (c *cImPrivateMessage) Hide(ctx context.Context, req *imprivatemessage.HideReq) (*imprivatemessage.HideRes, error) { + data, err := service.SysImPrivateMessage().Hide(ctx, &req.ImMessageActionInp) + return &imprivatemessage.HideRes{ImMessageCommandModel: data}, err +} +func (c *cImPrivateMessage) Delete(ctx context.Context, req *imprivatemessage.DeleteReq) (*imprivatemessage.DeleteRes, error) { + data, err := service.SysImPrivateMessage().Delete(ctx, &req.ImMessageActionInp) + return &imprivatemessage.DeleteRes{ImMessageCommandModel: data}, err +} +func (c *cImPrivateMessage) Export(ctx context.Context, req *imprivatemessage.ExportReq) (*imprivatemessage.ExportRes, error) { + return &imprivatemessage.ExportRes{}, service.SysImPrivateMessage().Export(ctx, &req.ImPrivateMessageListInp) +} diff --git a/server/internal/controller/admin/sys/im_security_policy.go b/server/internal/controller/admin/sys/im_security_policy.go new file mode 100644 index 0000000..a1e87cb --- /dev/null +++ b/server/internal/controller/admin/sys/im_security_policy.go @@ -0,0 +1,72 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imsecuritypolicy" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImSecurityPolicy = cImSecurityPolicy{} + +type cImSecurityPolicy struct{} + +func (c *cImSecurityPolicy) List(ctx context.Context, req *imsecuritypolicy.ListReq) (res *imsecuritypolicy.ListRes, err error) { + list, total, err := service.SysImSecurityPolicy().List(ctx, &req.ImSecurityPolicyListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImSecurityPolicyModel{} + } + res = new(imsecuritypolicy.ListRes) + res.List = list + res.PageRes.Pack(req, total) + return +} +func (c *cImSecurityPolicy) View(ctx context.Context, req *imsecuritypolicy.ViewReq) (res *imsecuritypolicy.ViewRes, err error) { + data, err := service.SysImSecurityPolicy().View(ctx, &req.ImSecurityPolicyViewInp) + if err != nil { + return nil, err + } + return &imsecuritypolicy.ViewRes{ImSecurityPolicyModel: data}, nil +} +func (c *cImSecurityPolicy) Save(ctx context.Context, req *imsecuritypolicy.SaveReq) (res *imsecuritypolicy.SaveRes, err error) { + id, err := service.SysImSecurityPolicy().Save(ctx, &req.ImSecurityPolicySaveInp) + if err != nil { + return nil, err + } + 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 { + return nil, err + } + return &imsecuritypolicy.RestoreDefaultRes{Id: id}, nil +} +func (c *cImSecurityPolicy) IPRuleList(ctx context.Context, req *imsecuritypolicy.IPRuleListReq) (res *imsecuritypolicy.IPRuleListRes, err error) { + list, total, err := service.SysImSecurityPolicy().IPRuleList(ctx, &req.ImSecurityIPRuleListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImSecurityIPRuleModel{} + } + res = new(imsecuritypolicy.IPRuleListRes) + res.List = list + res.PageRes.Pack(req, total) + return +} +func (c *cImSecurityPolicy) IPRuleEdit(ctx context.Context, req *imsecuritypolicy.IPRuleEditReq) (res *imsecuritypolicy.IPRuleEditRes, err error) { + err = service.SysImSecurityPolicy().IPRuleEdit(ctx, &req.ImSecurityIPRuleEditInp) + return +} +func (c *cImSecurityPolicy) IPRuleDelete(ctx context.Context, req *imsecuritypolicy.IPRuleDeleteReq) (res *imsecuritypolicy.IPRuleDeleteRes, err error) { + err = service.SysImSecurityPolicy().IPRuleDelete(ctx, &req.ImSecurityIPRuleDeleteInp) + return +} diff --git a/server/internal/controller/admin/sys/im_sensitive_word.go b/server/internal/controller/admin/sys/im_sensitive_word.go new file mode 100644 index 0000000..cee5752 --- /dev/null +++ b/server/internal/controller/admin/sys/im_sensitive_word.go @@ -0,0 +1,59 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imsensitiveword" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImSensitiveWord = cImSensitiveWord{} + +type cImSensitiveWord struct{} + +func (c *cImSensitiveWord) List(ctx context.Context, req *imsensitiveword.ListReq) (res *imsensitiveword.ListRes, err error) { + list, total, err := service.SysImSensitiveWord().List(ctx, &req.ImSensitiveWordListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImSensitiveWordListModel{} + } + res = new(imsensitiveword.ListRes) + res.List = list + res.PageRes.Pack(req, total) + return +} +func (c *cImSensitiveWord) Edit(ctx context.Context, req *imsensitiveword.EditReq) (res *imsensitiveword.EditRes, err error) { + err = service.SysImSensitiveWord().Edit(ctx, &req.ImSensitiveWordEditInp) + return +} +func (c *cImSensitiveWord) Delete(ctx context.Context, req *imsensitiveword.DeleteReq) (res *imsensitiveword.DeleteRes, err error) { + err = service.SysImSensitiveWord().Delete(ctx, &req.ImSensitiveWordDeleteInp) + return +} +func (c *cImSensitiveWord) Status(ctx context.Context, req *imsensitiveword.StatusReq) (res *imsensitiveword.StatusRes, err error) { + err = service.SysImSensitiveWord().Status(ctx, &req.ImSensitiveWordStatusInp) + return +} +func (c *cImSensitiveWord) BatchImport(ctx context.Context, req *imsensitiveword.BatchImportReq) (res *imsensitiveword.BatchImportRes, err error) { + data, err := service.SysImSensitiveWord().BatchImport(ctx, &req.ImSensitiveWordBatchImportInp) + if err != nil { + return nil, err + } + return &imsensitiveword.BatchImportRes{ImSensitiveWordBatchImportModel: data}, nil +} +func (c *cImSensitiveWord) GroupList(ctx context.Context, req *imsensitiveword.GroupListReq) (res *imsensitiveword.GroupListRes, err error) { + list, err := service.SysImSensitiveWord().GroupList(ctx, &req.ImSensitiveWordGroupListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImSensitiveWordGroupModel{} + } + return &imsensitiveword.GroupListRes{List: list}, nil +} +func (c *cImSensitiveWord) GroupEdit(ctx context.Context, req *imsensitiveword.GroupEditReq) (res *imsensitiveword.GroupEditRes, err error) { + err = service.SysImSensitiveWord().GroupEdit(ctx, &req.ImSensitiveWordGroupEditInp) + return +} diff --git a/server/internal/controller/admin/sys/im_task_dashboard.go b/server/internal/controller/admin/sys/im_task_dashboard.go new file mode 100644 index 0000000..7e1e629 --- /dev/null +++ b/server/internal/controller/admin/sys/im_task_dashboard.go @@ -0,0 +1,26 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imtaskdashboard" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImTaskDashboard = cImTaskDashboard{} + +type cImTaskDashboard struct{} + +func (c *cImTaskDashboard) List(ctx context.Context, req *imtaskdashboard.ListReq) (res *imtaskdashboard.ListRes, err error) { + list, totalCount, err := service.SysImTaskDashboard().List(ctx, &req.ImTaskDashboardListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImTaskDashboardListModel{} + } + res = new(imtaskdashboard.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} diff --git a/server/internal/controller/admin/sys/im_tenant.go b/server/internal/controller/admin/sys/im_tenant.go index 3acd490..197fdd5 100644 --- a/server/internal/controller/admin/sys/im_tenant.go +++ b/server/internal/controller/admin/sys/im_tenant.go @@ -58,3 +58,35 @@ func (c *cImTenant) Status(ctx context.Context, req *imtenant.StatusReq) (res *i err = service.SysImTenant().Status(ctx, &req.ImTenantStatusInp) return } + +func (c *cImTenant) Usage(ctx context.Context, req *imtenant.UsageReq) (res *imtenant.UsageRes, err error) { + data, err := service.SysImTenant().Usage(ctx, &req.ImTenantUsageInp) + if err != nil { + return + } + + res = new(imtenant.UsageRes) + res.ImTenantUsageModel = data + return +} + +func (c *cImTenant) DeletionCheck(ctx context.Context, req *imtenant.DeletionCheckReq) (res *imtenant.DeletionCheckRes, err error) { + data, err := service.SysImTenant().DeletionCheck(ctx, &req.ImTenantDeletionCheckInp) + if err != nil { + return + } + + res = new(imtenant.DeletionCheckRes) + res.ImTenantDeletionCheckModel = data + return +} + +func (c *cImTenant) EnterDeleting(ctx context.Context, req *imtenant.EnterDeletingReq) (res *imtenant.EnterDeletingRes, err error) { + err = service.SysImTenant().EnterDeleting(ctx, &req.ImTenantLifecycleInp) + return +} + +func (c *cImTenant) Restore(ctx context.Context, req *imtenant.RestoreReq) (res *imtenant.RestoreRes, err error) { + err = service.SysImTenant().Restore(ctx, &req.ImTenantLifecycleInp) + return +} diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go index 9159714..d66289e 100644 --- a/server/internal/controller/admin/sys/im_user.go +++ b/server/internal/controller/admin/sys/im_user.go @@ -10,6 +10,8 @@ import ( "hotgo/api/admin/imuser" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" ) var ( @@ -18,6 +20,40 @@ var ( type cImUser struct{} +func (c *cImUser) Create(ctx context.Context, req *imuser.CreateReq) (res *imuser.CreateRes, err error) { + data, err := service.SysImUser().Create(ctx, &req.ImUserCreateInp) + if err != nil { + return nil, err + } + return &imuser.CreateRes{ImUserCreateModel: data}, nil +} + +func (c *cImUser) BatchCreate(ctx context.Context, req *imuser.BatchCreateReq) (res *imuser.BatchCreateRes, err error) { + data, err := service.SysImUser().BatchCreate(ctx, &req.ImUserBatchCreateInp) + if err != nil { + return nil, err + } + return &imuser.BatchCreateRes{ImUserBatchCreateModel: data}, nil +} + +func (c *cImUser) Import(ctx context.Context, req *imuser.ImportReq) (res *imuser.ImportRes, err error) { + file, err := req.File.Open() + if err != nil { + return nil, gerror.Wrap(err, "打开Excel文件失败") + } + defer file.Close() + data, err := service.SysImUser().ImportExcel(ctx, req.TenantId, req.GroupId, file) + if err != nil { + return nil, err + } + return &imuser.ImportRes{ImUserBatchCreateModel: data}, nil +} + +func (c *cImUser) DownloadTemplate(ctx context.Context, req *imuser.DownloadTemplateReq) (res *imuser.DownloadTemplateRes, err error) { + err = service.SysImUser().DownloadTemplate(ctx) + return +} + // List 查看IM用户列表 func (c *cImUser) List(ctx context.Context, req *imuser.ListReq) (res *imuser.ListRes, err error) { list, totalCount, err := service.SysImUser().List(ctx, &req.ImUserListInp) @@ -47,6 +83,60 @@ func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.Vi return } +func (c *cImUser) ResetPassword(ctx context.Context, req *imuser.ResetPasswordReq) (res *imuser.ResetPasswordRes, err error) { + data, err := service.SysImUser().ResetPassword(ctx, &req.ImUserPasswordResetInp) + if err != nil { + return + } + res = new(imuser.ResetPasswordRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) RevokeSessions(ctx context.Context, req *imuser.RevokeSessionsReq) (res *imuser.RevokeSessionsRes, err error) { + err = service.SysImUser().RevokeSessions(ctx, &req.ImUserRevokeSessionsInp) + return +} + +func (c *cImUser) Status(ctx context.Context, req *imuser.StatusReq) (res *imuser.StatusRes, err error) { + err = service.SysImUser().Status(ctx, &req.ImUserStatusInp) + return +} + +func (c *cImUser) AdjustGroup(ctx context.Context, req *imuser.AdjustGroupReq) (res *imuser.AdjustGroupRes, err error) { + err = service.SysImUser().AdjustGroup(ctx, &req.ImUserAdjustGroupInp) + return +} + +func (c *cImUser) IPWhitelist(ctx context.Context, req *imuser.IPWhitelistReq) (res *imuser.IPWhitelistRes, err error) { + data, err := service.SysImUser().IPWhitelist(ctx, &req.ImUserIPWhitelistInp) + if err != nil { + return nil, err + } + return &imuser.IPWhitelistRes{ImUserIPWhitelistModel: data}, nil +} + +func (c *cImUser) IPWhitelistSave(ctx context.Context, req *imuser.IPWhitelistSaveReq) (res *imuser.IPWhitelistSaveRes, err error) { + err = service.SysImUser().IPWhitelistSave(ctx, &req.ImUserIPWhitelistInp) + return +} + +func (c *cImUser) Devices(ctx context.Context, req *imuser.DevicesReq) (res *imuser.DevicesRes, err error) { + list, totalCount, err := service.SysImUser().Devices(ctx, &req.ImUserDeviceListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImUserDeviceListModel{} + } + + res = new(imuser.DevicesRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + func (c *cImUser) Ban(ctx context.Context, req *imuser.BanReq) (res *imuser.BanRes, err error) { data, err := service.SysImUser().Ban(ctx, &req.ImUserGovernanceInp) if err != nil { diff --git a/server/internal/controller/admin/sys/im_user_login_history.go b/server/internal/controller/admin/sys/im_user_login_history.go new file mode 100644 index 0000000..27c4464 --- /dev/null +++ b/server/internal/controller/admin/sys/im_user_login_history.go @@ -0,0 +1,28 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imuserloginhistory" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImUserLoginHistory = cImUserLoginHistory{} + +type cImUserLoginHistory struct{} + +func (c *cImUserLoginHistory) List(ctx context.Context, req *imuserloginhistory.ListReq) (res *imuserloginhistory.ListRes, err error) { + list, total, err := service.SysImUserLoginHistory().List(ctx, &req.ImUserLoginHistoryListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImUserLoginHistoryModel{} + } + res = &imuserloginhistory.ListRes{List: list} + res.PageRes.Pack(req, total) + return +} +func (c *cImUserLoginHistory) Export(ctx context.Context, req *imuserloginhistory.ExportReq) (*imuserloginhistory.ExportRes, error) { + return &imuserloginhistory.ExportRes{}, service.SysImUserLoginHistory().Export(ctx, &req.ImUserLoginHistoryListInp) +} diff --git a/server/internal/controller/admin/sys/im_user_policy_group.go b/server/internal/controller/admin/sys/im_user_policy_group.go new file mode 100644 index 0000000..baa0e85 --- /dev/null +++ b/server/internal/controller/admin/sys/im_user_policy_group.go @@ -0,0 +1,79 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imuserpolicygroup" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImUserPolicyGroup = cImUserPolicyGroup{} + +type cImUserPolicyGroup struct{} + +func (c *cImUserPolicyGroup) List(ctx context.Context, req *imuserpolicygroup.ListReq) (res *imuserpolicygroup.ListRes, err error) { + list, total, err := service.SysImUserPolicyGroup().List(ctx, &req.ImUserPolicyGroupListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImUserPolicyGroupListModel{} + } + res = new(imuserpolicygroup.ListRes) + res.List = list + res.PageRes.Pack(req, total) + return +} +func (c *cImUserPolicyGroup) View(ctx context.Context, req *imuserpolicygroup.ViewReq) (res *imuserpolicygroup.ViewRes, err error) { + data, err := service.SysImUserPolicyGroup().View(ctx, &req.ImUserPolicyGroupViewInp) + if err != nil { + return nil, err + } + return &imuserpolicygroup.ViewRes{ImUserPolicyGroupViewModel: data}, nil +} +func (c *cImUserPolicyGroup) Edit(ctx context.Context, req *imuserpolicygroup.EditReq) (res *imuserpolicygroup.EditRes, err error) { + err = service.SysImUserPolicyGroup().Edit(ctx, &req.ImUserPolicyGroupEditInp) + return +} +func (c *cImUserPolicyGroup) Delete(ctx context.Context, req *imuserpolicygroup.DeleteReq) (res *imuserpolicygroup.DeleteRes, err error) { + err = service.SysImUserPolicyGroup().Delete(ctx, &req.ImUserPolicyGroupDeleteInp) + return +} +func (c *cImUserPolicyGroup) Status(ctx context.Context, req *imuserpolicygroup.StatusReq) (res *imuserpolicygroup.StatusRes, err error) { + err = service.SysImUserPolicyGroup().Status(ctx, &req.ImUserPolicyGroupStatusInp) + return +} +func (c *cImUserPolicyGroup) MemberList(ctx context.Context, req *imuserpolicygroup.MemberListReq) (res *imuserpolicygroup.MemberListRes, err error) { + list, total, err := service.SysImUserPolicyGroup().MemberList(ctx, &req.ImUserPolicyGroupMemberListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImUserPolicyGroupMemberModel{} + } + res = new(imuserpolicygroup.MemberListRes) + res.List = list + res.PageRes.Pack(req, total) + return +} +func (c *cImUserPolicyGroup) MemberAdd(ctx context.Context, req *imuserpolicygroup.MemberAddReq) (res *imuserpolicygroup.MemberAddRes, err error) { + err = service.SysImUserPolicyGroup().MemberAdd(ctx, &req.ImUserPolicyGroupMemberAddInp) + return +} +func (c *cImUserPolicyGroup) MemberRemove(ctx context.Context, req *imuserpolicygroup.MemberRemoveReq) (res *imuserpolicygroup.MemberRemoveRes, err error) { + err = service.SysImUserPolicyGroup().MemberRemove(ctx, &req.ImUserPolicyGroupMemberRemoveInp) + return +} + +func (c *cImUserPolicyGroup) LoginIPWhitelist(ctx context.Context, req *imuserpolicygroup.LoginIPWhitelistReq) (res *imuserpolicygroup.LoginIPWhitelistRes, err error) { + data, err := service.SysImUserPolicyGroup().LoginIPWhitelist(ctx, &req.ImTenantLoginIPWhitelistInp) + if err != nil { + return nil, err + } + return &imuserpolicygroup.LoginIPWhitelistRes{ImTenantLoginIPWhitelistModel: data}, nil +} + +func (c *cImUserPolicyGroup) LoginIPWhitelistSave(ctx context.Context, req *imuserpolicygroup.LoginIPWhitelistSaveReq) (res *imuserpolicygroup.LoginIPWhitelistSaveRes, err error) { + err = service.SysImUserPolicyGroup().LoginIPWhitelistSave(ctx, &req.ImTenantLoginIPWhitelistSaveInp) + return +} diff --git a/server/internal/controller/admin/sys/im_workbench.go b/server/internal/controller/admin/sys/im_workbench.go new file mode 100644 index 0000000..79f6c76 --- /dev/null +++ b/server/internal/controller/admin/sys/im_workbench.go @@ -0,0 +1,41 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imworkbench" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImWorkbench = cImWorkbench{} + +type cImWorkbench struct{} + +func (c *cImWorkbench) List(ctx context.Context, req *imworkbench.ListReq) (res *imworkbench.ListRes, err error) { + list, totalCount, err := service.SysImWorkbench().List(ctx, &req.ImWorkbenchAppListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImWorkbenchAppModel{} + } + res = new(imworkbench.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImWorkbench) Edit(ctx context.Context, req *imworkbench.EditReq) (res *imworkbench.EditRes, err error) { + err = service.SysImWorkbench().Edit(ctx, &req.ImWorkbenchAppEditInp) + return +} + +func (c *cImWorkbench) Delete(ctx context.Context, req *imworkbench.DeleteReq) (res *imworkbench.DeleteRes, err error) { + err = service.SysImWorkbench().Delete(ctx, &req.ImWorkbenchAppDeleteInp) + return +} + +func (c *cImWorkbench) Reorder(ctx context.Context, req *imworkbench.ReorderReq) (res *imworkbench.ReorderRes, err error) { + err = service.SysImWorkbench().Reorder(ctx, &req.ImWorkbenchAppReorderInp) + return +} diff --git a/server/internal/controller/health/health.go b/server/internal/controller/health/health.go new file mode 100644 index 0000000..9da288d --- /dev/null +++ b/server/internal/controller/health/health.go @@ -0,0 +1,69 @@ +package health + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + "hotgo/internal/library/queue" +) + +type response struct { + Status string `json:"status"` + Checks map[string]string `json:"checks"` +} + +// Serve exposes an unauthenticated dependency health endpoint for container probes. +func Serve(r *ghttp.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + r.Response.Header().Set("Allow", http.MethodGet+", "+http.MethodHead) + r.Response.WriteStatusExit(http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + checks := map[string]string{} + statusCode := http.StatusOK + + if err := g.DB().Ctx(ctx).Raw("SELECT 1").Scan(new(int)); err != nil { + checks["mysql"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["mysql"] = "ok" + } + + if _, err := g.Redis().Do(ctx, "PING"); err != nil { + checks["redis"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["redis"] = "ok" + } + + if strings.EqualFold(strings.TrimSpace(g.Cfg().MustGet(ctx, "queue.driver").String()), "kafka") { + if err := queue.KafkaHealthCheck(ctx); err != nil { + checks["kafka"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["kafka"] = "ok" + } + } else { + checks["kafka"] = "disabled" + } + + body := response{Status: "ok", Checks: checks} + if statusCode != http.StatusOK { + body.Status = "error" + } + r.Response.Header().Set("Content-Type", "application/json; charset=utf-8") + r.Response.WriteHeader(statusCode) + if r.Method != http.MethodHead { + r.Response.WriteJsonExit(body) + return + } + r.Exit() +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index 49ca3ed..32a1a55 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -6,10 +6,10 @@ import ( "hotgo/internal/dao" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" + "hotgo/internal/service" "strings" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -39,7 +39,7 @@ func (c *cConfig) TenantList(ctx context.Context, req *api.TenantListReq) (res * if limit <= 0 || limit > 1000 { limit = 1000 } - mod := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Limit(limit) + mod := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Limit(limit) if req.State != "" { if req.State == "active" { mod = mod.Where(dao.ImTenant.Columns().State, 1) @@ -59,7 +59,7 @@ func (c *cConfig) TenantList(ctx context.Context, req *api.TenantListReq) (res * } func (c *cConfig) RegistrationSms(ctx context.Context, req *api.RegistrationSmsReq) (res *api.RegistrationSmsRes, err error) { - var record g.Map + var record *entity.SysConfig if err = dao.SysConfig.Ctx(ctx). Where(dao.SysConfig.Columns().Group, "im"). Where(dao.SysConfig.Columns().Key, "register_sms_enabled"). @@ -69,7 +69,7 @@ func (c *cConfig) RegistrationSms(ctx context.Context, req *api.RegistrationSmsR } enabled := true if record != nil { - enabled = gconv.String(record[dao.SysConfig.Columns().Value]) != "0" + enabled = gconv.String(record.Value) != "0" } return &api.RegistrationSmsRes{Enabled: enabled}, nil } @@ -91,9 +91,65 @@ func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res * return &api.ThirdPartyRes{Values: values}, nil } +func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLoginIPWhitelistReq) (res *api.TenantLoginIPWhitelistRes, err error) { + if _, err = getTenant(ctx, dao.ImTenant.Columns().Id, req.Id); err != nil { + return nil, err + } + data, err := service.SysImUserPolicyGroup().LoginIPWhitelistByTenant(ctx, req.Id) + if err != nil { + return nil, err + } + return &api.TenantLoginIPWhitelistRes{TenantId: data.TenantId, AppList: data.AppList, WebList: data.WebList}, nil +} + +func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLatestReq) (res *api.AppReleaseLatestRes, err error) { + platform := strings.ToLower(strings.TrimSpace(req.Platform)) + channel := strings.ToLower(strings.TrimSpace(req.Channel)) + if platform != "ios" && platform != "android" { + return nil, gerror.New("客户端平台不合法") + } + if channel == "" { + channel = "stable" + } + if strings.ContainsAny(channel, " \t\r\n/") || len(channel) > 32 { + return nil, gerror.New("客户端渠道不合法") + } + var row struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Channel string `json:"channel"` + LatestVersion string `json:"latest_version"` + MinSupportedVersion string `json:"min_supported_version"` + UpdateType string `json:"update_type"` + ReleaseNotes string `json:"release_notes"` + StoreURL string `json:"store_url"` + PackageURL string `json:"package_url"` + Checksum string `json:"checksum"` + RemindAfterSeconds int `json:"remind_after_seconds"` + PublishedAt *gtime.Time `json:"published_at"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` + } + err = imtenant.RegistryDB().Model("im_app_releases").Safe().Ctx(ctx). + Where("platform", platform).Where("channel", channel).Limit(1).Scan(&row) + if err != nil { + return nil, gerror.Wrap(err, "获取IM客户端版本策略失败") + } + if row.ID <= 0 { + return nil, gerror.New("IM客户端版本策略不存在") + } + return &api.AppReleaseLatestRes{ + ID: row.ID, Platform: row.Platform, Channel: row.Channel, + LatestVersion: row.LatestVersion, MinSupportedVersion: row.MinSupportedVersion, + UpdateType: row.UpdateType, ReleaseNotes: row.ReleaseNotes, StoreURL: row.StoreURL, + PackageURL: row.PackageURL, Checksum: row.Checksum, RemindAfterSeconds: row.RemindAfterSeconds, + PublishedAt: gtimeToRFC3339(row.PublishedAt), CreatedAt: gtimeToRFC3339(row.CreatedAt), UpdatedAt: gtimeToRFC3339(row.UpdatedAt), + }, nil +} + func getTenant(ctx context.Context, field string, value any) (*entity.ImTenant, error) { var tenant *entity.ImTenant - if err := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { + if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { return nil, gerror.Wrap(err, "获取IM租户配置失败") } if tenant == nil || tenant.Id <= 0 { diff --git a/server/internal/crons/im_file_cleanup_orphan.go b/server/internal/crons/im_file_cleanup_orphan.go new file mode 100644 index 0000000..b1364d0 --- /dev/null +++ b/server/internal/crons/im_file_cleanup_orphan.go @@ -0,0 +1,98 @@ +// Package crons +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package crons + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/cron" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +func init() { + cron.Register(ImFileCleanupOrphan) +} + +// ImFileCleanupOrphan 清理IM孤儿文件,参数:保留天数、单租户处理上限。 +var ImFileCleanupOrphan = &cImFileCleanupOrphan{name: "imFileCleanupOrphan"} + +type cImFileCleanupOrphan struct { + name string +} + +func (c *cImFileCleanupOrphan) GetName() string { + return c.name +} + +func (c *cImFileCleanupOrphan) Execute(ctx context.Context, parser *cron.Parser) (err error) { + days := 7 + limit := 100 + if len(parser.Args) > 0 { + days = gconv.Int(parser.Args[0]) + } + if len(parser.Args) > 1 { + limit = gconv.Int(parser.Args[1]) + } + if days <= 0 { + days = 7 + } + if limit <= 0 || limit > 1000 { + limit = 100 + } + + var tenants []*entity.ImTenant + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive). + Scan(&tenants); err != nil { + parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan load tenants err:%+v", err) + return err + } + + cutoff := gtime.Now().AddDate(0, 0, -days) + for _, tenant := range tenants { + db, _, dbErr := imtenant.DB(ctx, tenant.Id) + if dbErr != nil { + parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d db err:%+v", tenant.Id, dbErr) + continue + } + rows, dbErr := db.Model("im_fileuploads fu").Safe().Ctx(ctx). + LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id"). + Fields("fu.id"). + Where("fml.id IS NULL"). + WhereLT("fu.updatedat", cutoff). + WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}). + Group("fu.id"). + OrderAsc("fu.updatedat"). + Limit(limit). + All() + if dbErr != nil { + parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d scan err:%+v", tenant.Id, dbErr) + continue + } + if len(rows) == 0 { + continue + } + ids := make([]int64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row["id"].Int64()) + } + if _, dbErr = db.Model("im_fileuploads").Safe().Ctx(ctx). + WhereIn("id", ids). + Data(g.Map{"status": consts.ImFileStatusDeleted, "updatedat": gtime.Now()}). + Update(); dbErr != nil { + parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d update err:%+v", tenant.Id, dbErr) + continue + } + parser.Logger.Infof(ctx, "cron ImFileCleanupOrphan tenant:%d count:%d", tenant.Id, len(ids)) + } + return nil +} diff --git a/server/internal/dao/im_message_curfew_rules.go b/server/internal/dao/im_message_curfew_rules.go new file mode 100644 index 0000000..5120dcf --- /dev/null +++ b/server/internal/dao/im_message_curfew_rules.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imMessageCurfewRulesDao is the data access object for the table im_message_curfew_rules. +// You can define custom methods on it to extend its functionality as needed. +type imMessageCurfewRulesDao struct { + *internal.ImMessageCurfewRulesDao +} + +var ( + // ImMessageCurfewRules is a globally accessible object for table im_message_curfew_rules operations. + ImMessageCurfewRules = imMessageCurfewRulesDao{internal.NewImMessageCurfewRulesDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_security_ip_rules.go b/server/internal/dao/im_security_ip_rules.go new file mode 100644 index 0000000..9d5a7ff --- /dev/null +++ b/server/internal/dao/im_security_ip_rules.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imSecurityIpRulesDao is the data access object for the table im_security_ip_rules. +// You can define custom methods on it to extend its functionality as needed. +type imSecurityIpRulesDao struct { + *internal.ImSecurityIpRulesDao +} + +var ( + // ImSecurityIpRules is a globally accessible object for table im_security_ip_rules operations. + ImSecurityIpRules = imSecurityIpRulesDao{internal.NewImSecurityIpRulesDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_security_policy.go b/server/internal/dao/im_security_policy.go new file mode 100644 index 0000000..3fd9249 --- /dev/null +++ b/server/internal/dao/im_security_policy.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imSecurityPolicyDao is the data access object for the table im_security_policy. +// You can define custom methods on it to extend its functionality as needed. +type imSecurityPolicyDao struct { + *internal.ImSecurityPolicyDao +} + +var ( + // ImSecurityPolicy is a globally accessible object for table im_security_policy operations. + ImSecurityPolicy = imSecurityPolicyDao{internal.NewImSecurityPolicyDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_sensitive_word_groups.go b/server/internal/dao/im_sensitive_word_groups.go new file mode 100644 index 0000000..bd5d607 --- /dev/null +++ b/server/internal/dao/im_sensitive_word_groups.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imSensitiveWordGroupsDao is the data access object for the table im_sensitive_word_groups. +// You can define custom methods on it to extend its functionality as needed. +type imSensitiveWordGroupsDao struct { + *internal.ImSensitiveWordGroupsDao +} + +var ( + // ImSensitiveWordGroups is a globally accessible object for table im_sensitive_word_groups operations. + ImSensitiveWordGroups = imSensitiveWordGroupsDao{internal.NewImSensitiveWordGroupsDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_sensitive_words.go b/server/internal/dao/im_sensitive_words.go new file mode 100644 index 0000000..be86128 --- /dev/null +++ b/server/internal/dao/im_sensitive_words.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imSensitiveWordsDao is the data access object for the table im_sensitive_words. +// You can define custom methods on it to extend its functionality as needed. +type imSensitiveWordsDao struct { + *internal.ImSensitiveWordsDao +} + +var ( + // ImSensitiveWords is a globally accessible object for table im_sensitive_words operations. + ImSensitiveWords = imSensitiveWordsDao{internal.NewImSensitiveWordsDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_user_policy_group_members.go b/server/internal/dao/im_user_policy_group_members.go new file mode 100644 index 0000000..6dff14d --- /dev/null +++ b/server/internal/dao/im_user_policy_group_members.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imUserPolicyGroupMembersDao is the data access object for the table im_user_policy_group_members. +// You can define custom methods on it to extend its functionality as needed. +type imUserPolicyGroupMembersDao struct { + *internal.ImUserPolicyGroupMembersDao +} + +var ( + // ImUserPolicyGroupMembers is a globally accessible object for table im_user_policy_group_members operations. + ImUserPolicyGroupMembers = imUserPolicyGroupMembersDao{internal.NewImUserPolicyGroupMembersDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_user_policy_groups.go b/server/internal/dao/im_user_policy_groups.go new file mode 100644 index 0000000..0fffda4 --- /dev/null +++ b/server/internal/dao/im_user_policy_groups.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imUserPolicyGroupsDao is the data access object for the table im_user_policy_groups. +// You can define custom methods on it to extend its functionality as needed. +type imUserPolicyGroupsDao struct { + *internal.ImUserPolicyGroupsDao +} + +var ( + // ImUserPolicyGroups is a globally accessible object for table im_user_policy_groups operations. + ImUserPolicyGroups = imUserPolicyGroupsDao{internal.NewImUserPolicyGroupsDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/internal/im_message_curfew_rules.go b/server/internal/dao/internal/im_message_curfew_rules.go new file mode 100644 index 0000000..c796a5b --- /dev/null +++ b/server/internal/dao/internal/im_message_curfew_rules.go @@ -0,0 +1,123 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImMessageCurfewRulesDao is the data access object for the table im_message_curfew_rules. +type ImMessageCurfewRulesDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImMessageCurfewRulesColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImMessageCurfewRulesColumns defines and stores column names for the table im_message_curfew_rules. +type ImMessageCurfewRulesColumns struct { + Id string // + TenantId string // + PolicyId string // + RuleCode string // + RuleName string // + DateType string // daily, weekday, weekend, custom + CustomDates string // YYYY-MM-DD values for custom date type + Weekdays string // ISO weekdays 1 through 7 + Timezone string // + StartTime string // + EndTime string // Less than start_time means crossing midnight + Scope string // private_message or group_message + WhitelistUserIds string // + WhitelistGroupIds string // + Message string // + Status string // 1 enabled, 2 disabled + Sort string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imMessageCurfewRulesColumns holds the columns for the table im_message_curfew_rules. +var imMessageCurfewRulesColumns = ImMessageCurfewRulesColumns{ + Id: "id", + TenantId: "tenant_id", + PolicyId: "policy_id", + RuleCode: "rule_code", + RuleName: "rule_name", + DateType: "date_type", + CustomDates: "custom_dates", + Weekdays: "weekdays", + Timezone: "timezone", + StartTime: "start_time", + EndTime: "end_time", + Scope: "scope", + WhitelistUserIds: "whitelist_user_ids", + WhitelistGroupIds: "whitelist_group_ids", + Message: "message", + Status: "status", + Sort: "sort", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImMessageCurfewRulesDao creates and returns a new DAO object for table data access. +func NewImMessageCurfewRulesDao(handlers ...gdb.ModelHandler) *ImMessageCurfewRulesDao { + return &ImMessageCurfewRulesDao{ + group: "default", + table: "im_message_curfew_rules", + columns: imMessageCurfewRulesColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImMessageCurfewRulesDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImMessageCurfewRulesDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImMessageCurfewRulesDao) Columns() ImMessageCurfewRulesColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImMessageCurfewRulesDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImMessageCurfewRulesDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImMessageCurfewRulesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_security_ip_rules.go b/server/internal/dao/internal/im_security_ip_rules.go new file mode 100644 index 0000000..5e67561 --- /dev/null +++ b/server/internal/dao/internal/im_security_ip_rules.go @@ -0,0 +1,109 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImSecurityIpRulesDao is the data access object for the table im_security_ip_rules. +type ImSecurityIpRulesDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImSecurityIpRulesColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImSecurityIpRulesColumns defines and stores column names for the table im_security_ip_rules. +type ImSecurityIpRulesColumns struct { + Id string // + TenantId string // + RuleType string // whitelist or blacklist + IpCidr string // IPv4, IPv6 or CIDR + Action string // allow, challenge, block, audit_only + SceneJson string // login, register, message, upload and other scenes + RiskLevel string // + Status string // 1 enabled, 2 disabled + ExpiresAt string // + Remark string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imSecurityIpRulesColumns holds the columns for the table im_security_ip_rules. +var imSecurityIpRulesColumns = ImSecurityIpRulesColumns{ + Id: "id", + TenantId: "tenant_id", + RuleType: "rule_type", + IpCidr: "ip_cidr", + Action: "action", + SceneJson: "scene_json", + RiskLevel: "risk_level", + Status: "status", + ExpiresAt: "expires_at", + Remark: "remark", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImSecurityIpRulesDao creates and returns a new DAO object for table data access. +func NewImSecurityIpRulesDao(handlers ...gdb.ModelHandler) *ImSecurityIpRulesDao { + return &ImSecurityIpRulesDao{ + group: "default", + table: "im_security_ip_rules", + columns: imSecurityIpRulesColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImSecurityIpRulesDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImSecurityIpRulesDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImSecurityIpRulesDao) Columns() ImSecurityIpRulesColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImSecurityIpRulesDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImSecurityIpRulesDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImSecurityIpRulesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_security_policy.go b/server/internal/dao/internal/im_security_policy.go new file mode 100644 index 0000000..12b402a --- /dev/null +++ b/server/internal/dao/internal/im_security_policy.go @@ -0,0 +1,123 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImSecurityPolicyDao is the data access object for the table im_security_policy. +type ImSecurityPolicyDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImSecurityPolicyColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImSecurityPolicyColumns defines and stores column names for the table im_security_policy. +type ImSecurityPolicyColumns struct { + Id string // + TenantId string // 0 means global fallback policy + PolicyVersion string // + PolicyName string // + PublishStatus string // + PasswordPolicy string // + LoginRateLimit string // + SensitiveWordPolicy string // + FileReviewPolicy string // + RealnamePolicy string // + RiskChallengePolicy string // + GlobalFriendEnabled string // Backend master switch for every add-friend entry + FriendDisabledPolicy string // Profile masking, QR and group-member restrictions + MessageCurfewEnabled string // Backend master switch for message curfew + Remark string // + PublishedAt string // + PublishedBy string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imSecurityPolicyColumns holds the columns for the table im_security_policy. +var imSecurityPolicyColumns = ImSecurityPolicyColumns{ + Id: "id", + TenantId: "tenant_id", + PolicyVersion: "policy_version", + PolicyName: "policy_name", + PublishStatus: "publish_status", + PasswordPolicy: "password_policy", + LoginRateLimit: "login_rate_limit", + SensitiveWordPolicy: "sensitive_word_policy", + FileReviewPolicy: "file_review_policy", + RealnamePolicy: "realname_policy", + RiskChallengePolicy: "risk_challenge_policy", + GlobalFriendEnabled: "global_friend_enabled", + FriendDisabledPolicy: "friend_disabled_policy", + MessageCurfewEnabled: "message_curfew_enabled", + Remark: "remark", + PublishedAt: "published_at", + PublishedBy: "published_by", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImSecurityPolicyDao creates and returns a new DAO object for table data access. +func NewImSecurityPolicyDao(handlers ...gdb.ModelHandler) *ImSecurityPolicyDao { + return &ImSecurityPolicyDao{ + group: "default", + table: "im_security_policy", + columns: imSecurityPolicyColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImSecurityPolicyDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImSecurityPolicyDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImSecurityPolicyDao) Columns() ImSecurityPolicyColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImSecurityPolicyDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImSecurityPolicyDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImSecurityPolicyDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_sensitive_word_groups.go b/server/internal/dao/internal/im_sensitive_word_groups.go new file mode 100644 index 0000000..c130c56 --- /dev/null +++ b/server/internal/dao/internal/im_sensitive_word_groups.go @@ -0,0 +1,103 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImSensitiveWordGroupsDao is the data access object for the table im_sensitive_word_groups. +type ImSensitiveWordGroupsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImSensitiveWordGroupsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImSensitiveWordGroupsColumns defines and stores column names for the table im_sensitive_word_groups. +type ImSensitiveWordGroupsColumns struct { + Id string // + TenantId string // 0 means global dictionary + GroupCode string // + GroupName string // + Priority string // + Status string // 1 enabled, 2 disabled + Remark string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imSensitiveWordGroupsColumns holds the columns for the table im_sensitive_word_groups. +var imSensitiveWordGroupsColumns = ImSensitiveWordGroupsColumns{ + Id: "id", + TenantId: "tenant_id", + GroupCode: "group_code", + GroupName: "group_name", + Priority: "priority", + Status: "status", + Remark: "remark", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImSensitiveWordGroupsDao creates and returns a new DAO object for table data access. +func NewImSensitiveWordGroupsDao(handlers ...gdb.ModelHandler) *ImSensitiveWordGroupsDao { + return &ImSensitiveWordGroupsDao{ + group: "default", + table: "im_sensitive_word_groups", + columns: imSensitiveWordGroupsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImSensitiveWordGroupsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImSensitiveWordGroupsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImSensitiveWordGroupsDao) Columns() ImSensitiveWordGroupsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImSensitiveWordGroupsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImSensitiveWordGroupsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImSensitiveWordGroupsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_sensitive_words.go b/server/internal/dao/internal/im_sensitive_words.go new file mode 100644 index 0000000..aa1b7ed --- /dev/null +++ b/server/internal/dao/internal/im_sensitive_words.go @@ -0,0 +1,115 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImSensitiveWordsDao is the data access object for the table im_sensitive_words. +type ImSensitiveWordsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImSensitiveWordsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImSensitiveWordsColumns defines and stores column names for the table im_sensitive_words. +type ImSensitiveWordsColumns struct { + Id string // + TenantId string // 0 means global dictionary + GroupId string // + Word string // + WordHash string // SHA-256 of normalized word + MatchType string // + SceneJson string // Enabled scenes + Action string // + Replacement string // + RiskLevel string // + Priority string // + Status string // 1 enabled, 2 disabled + Remark string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imSensitiveWordsColumns holds the columns for the table im_sensitive_words. +var imSensitiveWordsColumns = ImSensitiveWordsColumns{ + Id: "id", + TenantId: "tenant_id", + GroupId: "group_id", + Word: "word", + WordHash: "word_hash", + MatchType: "match_type", + SceneJson: "scene_json", + Action: "action", + Replacement: "replacement", + RiskLevel: "risk_level", + Priority: "priority", + Status: "status", + Remark: "remark", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImSensitiveWordsDao creates and returns a new DAO object for table data access. +func NewImSensitiveWordsDao(handlers ...gdb.ModelHandler) *ImSensitiveWordsDao { + return &ImSensitiveWordsDao{ + group: "default", + table: "im_sensitive_words", + columns: imSensitiveWordsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImSensitiveWordsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImSensitiveWordsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImSensitiveWordsDao) Columns() ImSensitiveWordsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImSensitiveWordsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImSensitiveWordsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImSensitiveWordsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_tenant_config.go b/server/internal/dao/internal/im_tenant_config.go index 3e60943..16bc159 100644 --- a/server/internal/dao/internal/im_tenant_config.go +++ b/server/internal/dao/internal/im_tenant_config.go @@ -21,28 +21,36 @@ type ImTenantConfigDao struct { // ImTenantConfigColumns defines and stores column names for the table im_tenant_config. type ImTenantConfigColumns struct { - Id string // - TenantId string // - MaxUsers string // 0 means unlimited - MaxGroups string // 0 means unlimited - MaxGroupMembers string // 0 means unlimited - CreatedAt string // - CreatedBy string // - UpdatedAt string // - UpdatedBy string // + Id string // + TenantId string // + MaxUsers string // 0 means unlimited + MaxGroups string // 0 means unlimited + MaxGroupMembers string // 0 means unlimited + DefaultPolicyGroupId string // Default user policy group + SecurityPolicyVersion string // Published security policy version + GlobalFriendEnabled string // Published global friend switch + MessageCurfewEnabled string // Published message curfew switch + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // } // imTenantConfigColumns holds the columns for the table im_tenant_config. var imTenantConfigColumns = ImTenantConfigColumns{ - Id: "id", - TenantId: "tenant_id", - MaxUsers: "max_users", - MaxGroups: "max_groups", - MaxGroupMembers: "max_group_members", - CreatedAt: "created_at", - CreatedBy: "created_by", - UpdatedAt: "updated_at", - UpdatedBy: "updated_by", + Id: "id", + TenantId: "tenant_id", + MaxUsers: "max_users", + MaxGroups: "max_groups", + MaxGroupMembers: "max_group_members", + DefaultPolicyGroupId: "default_policy_group_id", + SecurityPolicyVersion: "security_policy_version", + GlobalFriendEnabled: "global_friend_enabled", + MessageCurfewEnabled: "message_curfew_enabled", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", } // NewImTenantConfigDao creates and returns a new DAO object for table data access. diff --git a/server/internal/dao/internal/im_user_policy_group_members.go b/server/internal/dao/internal/im_user_policy_group_members.go new file mode 100644 index 0000000..17b3ccd --- /dev/null +++ b/server/internal/dao/internal/im_user_policy_group_members.go @@ -0,0 +1,105 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImUserPolicyGroupMembersDao is the data access object for the table im_user_policy_group_members. +type ImUserPolicyGroupMembersDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImUserPolicyGroupMembersColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImUserPolicyGroupMembersColumns defines and stores column names for the table im_user_policy_group_members. +type ImUserPolicyGroupMembersColumns struct { + Id string // + TenantId string // + GroupId string // + UserId string // User ID in the tenant database + Status string // 1 enabled, 2 disabled + ValidFrom string // + ValidUntil string // + Remark string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imUserPolicyGroupMembersColumns holds the columns for the table im_user_policy_group_members. +var imUserPolicyGroupMembersColumns = ImUserPolicyGroupMembersColumns{ + Id: "id", + TenantId: "tenant_id", + GroupId: "group_id", + UserId: "user_id", + Status: "status", + ValidFrom: "valid_from", + ValidUntil: "valid_until", + Remark: "remark", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImUserPolicyGroupMembersDao creates and returns a new DAO object for table data access. +func NewImUserPolicyGroupMembersDao(handlers ...gdb.ModelHandler) *ImUserPolicyGroupMembersDao { + return &ImUserPolicyGroupMembersDao{ + group: "default", + table: "im_user_policy_group_members", + columns: imUserPolicyGroupMembersColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImUserPolicyGroupMembersDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImUserPolicyGroupMembersDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImUserPolicyGroupMembersDao) Columns() ImUserPolicyGroupMembersColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImUserPolicyGroupMembersDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImUserPolicyGroupMembersDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImUserPolicyGroupMembersDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_user_policy_groups.go b/server/internal/dao/internal/im_user_policy_groups.go new file mode 100644 index 0000000..8cbbe72 --- /dev/null +++ b/server/internal/dao/internal/im_user_policy_groups.go @@ -0,0 +1,111 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImUserPolicyGroupsDao is the data access object for the table im_user_policy_groups. +type ImUserPolicyGroupsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImUserPolicyGroupsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImUserPolicyGroupsColumns defines and stores column names for the table im_user_policy_groups. +type ImUserPolicyGroupsColumns struct { + Id string // + TenantId string // 0 means global default + GroupCode string // Stable policy group code + GroupName string // Policy group name + Priority string // Higher value has higher priority + Status string // 1 enabled, 2 disabled + PolicyJson string // Login, friend, message, group and file policies + IpWhitelist string // IPv4/IPv6 or CIDR list + IpBlacklist string // IPv4/IPv6 or CIDR list + Version string // Optimistic-lock/config version + Remark string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // + DeletedAt string // + DeletedBy string // +} + +// imUserPolicyGroupsColumns holds the columns for the table im_user_policy_groups. +var imUserPolicyGroupsColumns = ImUserPolicyGroupsColumns{ + Id: "id", + TenantId: "tenant_id", + GroupCode: "group_code", + GroupName: "group_name", + Priority: "priority", + Status: "status", + PolicyJson: "policy_json", + IpWhitelist: "ip_whitelist", + IpBlacklist: "ip_blacklist", + Version: "version", + Remark: "remark", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", + DeletedAt: "deleted_at", + DeletedBy: "deleted_by", +} + +// NewImUserPolicyGroupsDao creates and returns a new DAO object for table data access. +func NewImUserPolicyGroupsDao(handlers ...gdb.ModelHandler) *ImUserPolicyGroupsDao { + return &ImUserPolicyGroupsDao{ + group: "default", + table: "im_user_policy_groups", + columns: imUserPolicyGroupsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImUserPolicyGroupsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImUserPolicyGroupsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImUserPolicyGroupsDao) Columns() ImUserPolicyGroupsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImUserPolicyGroupsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImUserPolicyGroupsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImUserPolicyGroupsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/library/casbin/casbin_test.go b/server/internal/library/casbin/casbin_test.go index 6dafe7a..ef05df9 100644 --- a/server/internal/library/casbin/casbin_test.go +++ b/server/internal/library/casbin/casbin_test.go @@ -1,67 +1,66 @@ -// Package casbin -// @Link https://github.com/bufanyun/hotgo -// @Copyright Copyright (c) 2023 HotGo CLI -// @Author Ms <133814250@qq.com> -// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE package casbin import ( - "context" "net/http" "testing" + + casbinv2 "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" ) -// TestNew description -func TestNew(t *testing.T) { +const testModel = ` +[request_definition] +r = sub, obj, act - InitEnforcer(context.TODO()) +[policy_definition] +p = sub, obj, act - user := "admin" - path := "/" - method := http.MethodGet - t.Logf("\nuser:%v\npath:%v\nmethod:%v", user, path, method) +[role_definition] +g = _, _ - ok, err := Enforcer.DeletePermissionsForUser(user) +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act) +` + +func TestEnforcerPermissions(t *testing.T) { + m, err := model.NewModelFromString(testModel) if err != nil { - t.Error(err) + t.Fatalf("创建Casbin测试模型失败: %v", err) } - t.Logf("delete user premission:%v", ok) - CheckPremission(t, user, path, method) - AddPremission(t, user, "*", ActionAll) - CheckPremission(t, user, path, method) - - user1 := "user1" - path1 := "/api/v1/*" - checkPathTrue := "/api/v1/user/list" - checkPathFalse := "/api/v2/user/list" - AddPremission(t, user1, path1, ActionAll) - CheckPremission(t, user1, checkPathTrue, ActionPost) - CheckPremission(t, user1, checkPathFalse, http.MethodGet) - CheckPremission(t, user1, checkPathTrue, http.MethodGet) - CheckPremission(t, user1, "/api/v1/user/list2", http.MethodGet) - - ok, err = Enforcer.DeletePermissionsForUser(user1) + enforcer, err := casbinv2.NewEnforcer(m) if err != nil { - t.Error(err) + t.Fatalf("创建Casbin测试实例失败: %v", err) } - t.Logf("delete user premission:%v", ok) - CheckPremission(t, user1, "/api/v1/user1/list", http.MethodGet) + + assertPermission(t, enforcer, "admin", "/", http.MethodGet, false) + if ok, addErr := enforcer.AddPolicy("admin", "*", ActionAll); addErr != nil || !ok { + t.Fatalf("添加管理员策略失败: ok=%v err=%v", ok, addErr) + } + assertPermission(t, enforcer, "admin", "/", http.MethodGet, true) + + if ok, addErr := enforcer.AddPolicy("user1", "/api/v1/*", ActionAll); addErr != nil || !ok { + t.Fatalf("添加用户策略失败: ok=%v err=%v", ok, addErr) + } + assertPermission(t, enforcer, "user1", "/api/v1/user/list", http.MethodPost, true) + assertPermission(t, enforcer, "user1", "/api/v1/user/list", http.MethodGet, true) + assertPermission(t, enforcer, "user1", "/api/v2/user/list", http.MethodGet, false) + + if ok, deleteErr := enforcer.DeletePermissionsForUser("user1"); deleteErr != nil || !ok { + t.Fatalf("删除用户策略失败: ok=%v err=%v", ok, deleteErr) + } + assertPermission(t, enforcer, "user1", "/api/v1/user/list", http.MethodGet, false) } -// CheckPremission description -func CheckPremission(t *testing.T, user string, path string, method string) { - ok, err := Enforcer.Enforce(user, path, method) +func assertPermission(t *testing.T, enforcer *casbinv2.Enforcer, user, path, method string, expected bool) { + t.Helper() + actual, err := enforcer.Enforce(user, path, method) if err != nil { - t.Error(err) + t.Fatalf("检查权限失败: %v", err) } - t.Logf("check \tuser[%s] \tpremission[%s] \tpath[%s] \tallow[%v]", user, method, path, ok) -} - -// Add description -func AddPremission(t *testing.T, user string, path string, method string) { - ok, err := Enforcer.AddPolicy(user, path, method) - if err != nil { - t.Error(err) + if actual != expected { + t.Fatalf("权限结果不符 user=%s path=%s method=%s expected=%v actual=%v", user, path, method, expected, actual) } - t.Logf("add \tuser[%s] \tpremission[%s] \tpath[%s] \tresult[%v]", user, method, path, ok) } diff --git a/server/internal/library/imadmin/client.go b/server/internal/library/imadmin/client.go index 7b99cb3..032c68c 100644 --- a/server/internal/library/imadmin/client.go +++ b/server/internal/library/imadmin/client.go @@ -17,6 +17,7 @@ import ( "time" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gctx" ) @@ -198,6 +199,9 @@ func (c *Client) parseError(resp *http.Response) error { Reason string `json:"reason"` } _ = json.Unmarshal(body, &envelope) + if envelope.Message == "" && envelope.Reason == "" { + g.Log().Warningf(context.Background(), "IM API returned non-success status:%d url:%s body:%s", resp.StatusCode, resp.Request.URL.String(), string(body)) + } return stableError(envelope.Message, envelope.Reason) } diff --git a/server/internal/library/imadmin/client_test.go b/server/internal/library/imadmin/client_test.go index ef54b6e..e46d45d 100644 --- a/server/internal/library/imadmin/client_test.go +++ b/server/internal/library/imadmin/client_test.go @@ -58,6 +58,39 @@ func TestListCodesWithInviteCodeUsesSignedSearchPost(t *testing.T) { } } +func TestInternalPostUsesInternalSignatureAndBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/internal/admin-query/tenants/default/users" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(body) != `{"username":"alice"}` { + t.Fatalf("unexpected body %s", body) + } + if r.Header.Get("X-Service-Name") != "hotgo-admin" || r.Header.Get("X-Signature") == "" { + t.Fatalf("missing internal authentication headers") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"user_id":123,"username":"alice"}}`)) + })) + defer server.Close() + + client := NewClient(Config{InternalBaseUrl: server.URL, InternalServiceName: "hotgo-admin", InternalServiceSecret: "secret", Timeout: time.Second}) + var out struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + } + if err := client.InternalPost(context.Background(), "/internal/admin-query/tenants/default/users", map[string]string{"username": "alice"}, &out); err != nil { + t.Fatalf("InternalPost returned error: %v", err) + } + if out.UserID != 123 || out.Username != "alice" { + t.Fatalf("unexpected response: %+v", out) + } +} + func signedTestServer(t *testing.T, method string, path string, inspect func(t *testing.T, r *http.Request, body []byte)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/library/imadmin/internal.go b/server/internal/library/imadmin/internal.go index ee2293f..99267cb 100644 --- a/server/internal/library/imadmin/internal.go +++ b/server/internal/library/imadmin/internal.go @@ -14,15 +14,29 @@ import ( "time" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *Client) InternalGet(ctx context.Context, path string, out any) error { - req, err := c.newInternalRequest(ctx, http.MethodGet, path, nil) + return c.internalJSON(ctx, http.MethodGet, path, nil, out) +} + +func (c *Client) InternalPost(ctx context.Context, path string, in any, out any) error { + body, err := json.Marshal(in) + if err != nil { + return gerror.Wrap(err, "序列化IM内部请求失败") + } + return c.internalJSON(ctx, http.MethodPost, path, body, out) +} + +func (c *Client) internalJSON(ctx context.Context, method, path string, body []byte, out any) error { + req, err := c.newInternalRequest(ctx, method, path, body) if err != nil { return err } resp, err := c.http.Do(req) if err != nil { + g.Log().Warningf(ctx, "IM internal query request failed url:%s err:%+v", req.URL.String(), err) return gerror.New("IM内部查询服务暂不可用") } defer resp.Body.Close() @@ -32,7 +46,7 @@ func (c *Client) InternalGet(ctx context.Context, path string, out any) error { if out == nil { return nil } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { return gerror.Wrap(err, "读取IM内部查询响应失败") } @@ -42,13 +56,13 @@ func (c *Client) InternalGet(ctx context.Context, path string, out any) error { Reason string `json:"reason"` Data json.RawMessage `json:"data"` } - if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + if err = json.Unmarshal(responseBody, &envelope); err == nil && len(envelope.Data) > 0 { if envelope.Code != 0 { return stableError(envelope.Message, envelope.Reason) } return json.Unmarshal(envelope.Data, out) } - return json.Unmarshal(body, out) + return json.Unmarshal(responseBody, out) } func (c *Client) newInternalRequest(ctx context.Context, method string, path string, bodyBytes []byte) (*http.Request, error) { diff --git a/server/internal/library/imkafka/command.go b/server/internal/library/imkafka/command.go index fc38efd..5b81861 100644 --- a/server/internal/library/imkafka/command.go +++ b/server/internal/library/imkafka/command.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "hotgo/internal/consts" + "hotgo/internal/library/imtenant" "hotgo/internal/library/queue" "time" @@ -16,11 +17,6 @@ import ( "github.com/gogf/gf/v2/util/guid" ) -const ( - adminCommandTable = "im_admin_commands" - adminOutboxTable = "im_admin_outbox" -) - func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminCommandModel, err error) { if in.TenantID <= 0 || in.TenantCode == "" { return nil, gerror.New("IM租户信息不完整") @@ -65,41 +61,22 @@ func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminC return nil, gerror.Wrap(err, "序列化IM命令Envelope失败") } - db := g.DB() + db := adminDB() err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - data := g.Map{ - "command_id": commandID, - "tenant_id": in.TenantID, - "tenant_code": in.TenantCode, - "event_id": eventID, - "event_type": in.EventType, - "biz_type": in.BizType, - "biz_id": in.BizID, - "idempotency_key": in.IdempotencyKey, - "status": consts.ImAdminCommandStatusPending, - "payload": string(payloadBytes), - "envelope": string(envelopeBytes), - "created_by": in.CreatedBy, - "created_at": gtime.Now(), - "updated_at": gtime.Now(), - } - if _, err := tx.Model(adminCommandTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + nowTime := gtime.Now() + if _, err := tx.Exec(`INSERT INTO im_admin_commands + (command_id,tenant_id,tenant_code,event_id,event_type,biz_type,biz_id,idempotency_key,status,payload,envelope,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?,?,?)`, + commandID, in.TenantID, in.TenantCode, eventID, in.EventType, in.BizType, in.BizID, + in.IdempotencyKey, consts.ImAdminCommandStatusPending, string(payloadBytes), string(envelopeBytes), + in.CreatedBy, nowTime, nowTime); err != nil { return gerror.Wrap(err, "创建IM后台命令失败") } - outbox := g.Map{ - "event_id": eventID, - "command_id": commandID, - "topic": consts.QueueImAdminCommandTopic, - "event_type": in.EventType, - "biz_type": in.BizType, - "biz_id": in.BizID, - "key_value": in.BizID, - "envelope": string(envelopeBytes), - "status": consts.ImAdminOutboxStatusPending, - "created_at": gtime.Now(), - "updated_at": gtime.Now(), - } - if _, err := tx.Model(adminOutboxTable).Safe().Ctx(ctx).Data(outbox).Insert(); err != nil { + if _, err := tx.Exec(`INSERT INTO im_admin_outbox + (event_id,command_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?)`, + eventID, commandID, consts.QueueImAdminCommandTopic, in.EventType, in.BizType, in.BizID, + in.BizID, string(envelopeBytes), consts.ImAdminOutboxStatusPending, nowTime, nowTime); err != nil { return gerror.Wrap(err, "创建IM后台命令Outbox失败") } return nil @@ -125,11 +102,9 @@ func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminC } func DispatchOutboxByEvent(ctx context.Context, eventID string) error { - var row gdb.Record - if err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). - Where("event_id", eventID). - Where("status", consts.ImAdminOutboxStatusPending). - Scan(&row); err != nil { + row, err := adminDB().GetOne(ctx, `SELECT * FROM im_admin_outbox WHERE event_id=? AND status=? LIMIT 1`, + eventID, consts.ImAdminOutboxStatusPending) + if err != nil { return gerror.Wrap(err, "读取IM命令Outbox失败") } if row.IsEmpty() { @@ -171,19 +146,12 @@ func PublishConfigEvent(ctx context.Context, in ConfigEventInput) error { if err != nil { return gerror.Wrap(err, "序列化IM配置事件Envelope失败") } - _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ - "event_id": eventID, - "command_id": "", - "topic": consts.QueueImConfigEventTopic, - "event_type": in.EventType, - "biz_type": "config", - "biz_id": in.BizID, - "key_value": in.TenantCode, - "envelope": string(envelopeBytes), - "status": consts.ImAdminOutboxStatusPending, - "created_at": gtime.Now(), - "updated_at": gtime.Now(), - }).Insert() + nowTime := gtime.Now() + _, err = adminDB().Exec(ctx, `INSERT INTO im_admin_outbox + (event_id,command_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?)`, + eventID, "", consts.QueueImConfigEventTopic, in.EventType, "config", in.BizID, in.TenantCode, + string(envelopeBytes), consts.ImAdminOutboxStatusPending, nowTime, nowTime) if err != nil { return gerror.Wrap(err, "创建IM配置事件Outbox失败") } @@ -230,19 +198,12 @@ func PublishEvent(ctx context.Context, in EventInput) (eventID string, err error if err != nil { return "", gerror.Wrap(err, "序列化IM Kafka事件Envelope失败") } - _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ - "event_id": eventID, - "command_id": "", - "topic": in.Topic, - "event_type": in.EventType, - "biz_type": in.BizType, - "biz_id": in.BizID, - "key_value": in.BizID, - "envelope": string(envelopeBytes), - "status": consts.ImAdminOutboxStatusPending, - "created_at": gtime.Now(), - "updated_at": gtime.Now(), - }).Insert() + nowTime := gtime.Now() + _, err = adminDB().Exec(ctx, `INSERT INTO im_admin_outbox + (event_id,command_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?)`, + eventID, "", in.Topic, in.EventType, in.BizType, in.BizID, in.BizID, + string(envelopeBytes), consts.ImAdminOutboxStatusPending, nowTime, nowTime) if err != nil { return "", gerror.Wrap(err, "创建IM Kafka事件Outbox失败") } @@ -256,13 +217,9 @@ func DispatchPendingOutbox(ctx context.Context, limit int) (sent int, err error) if limit <= 0 { limit = 100 } - var rows []gdb.Record - err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). - WhereIn("status", []string{consts.ImAdminOutboxStatusPending, consts.ImAdminOutboxStatusFailed}). - Where("next_retry_at IS NULL OR next_retry_at <= NOW()"). - OrderAsc("id"). - Limit(limit). - Scan(&rows) + rows, err := adminDB().GetAll(ctx, `SELECT * FROM im_admin_outbox + WHERE status IN (?,?) AND (next_retry_at IS NULL OR next_retry_at <= NOW()) + ORDER BY id ASC LIMIT ?`, consts.ImAdminOutboxStatusPending, consts.ImAdminOutboxStatusFailed, limit) if err != nil { return 0, gerror.Wrap(err, "读取待投递IM命令Outbox失败") } @@ -294,27 +251,16 @@ func dispatchOutboxRecord(ctx context.Context, row gdb.Record) error { return err } now := gtime.Now() - _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). - Where("event_id", eventID). - Data(g.Map{ - "status": consts.ImAdminOutboxStatusSent, - "attempts": gdb.Raw("attempts + 1"), - "sent_at": now, - "last_reason": "", - "last_error": "", - "updated_at": now, - }).Update() + _, err := adminDB().Exec(ctx, `UPDATE im_admin_outbox + SET status=?,attempts=attempts+1,sent_at=?,last_reason='',last_error='',updated_at=? + WHERE event_id=?`, consts.ImAdminOutboxStatusSent, now, now, eventID) if err != nil { return gerror.Wrap(err, "更新IM命令Outbox投递状态失败") } if commandID != "" { - _, err = g.DB().Model(adminCommandTable).Safe().Ctx(ctx). - Where("command_id", commandID). - Data(g.Map{ - "status": consts.ImAdminCommandStatusRunning, - "sent_at": now, - "updated_at": now, - }).Update() + _, err = adminDB().Exec(ctx, `UPDATE im_admin_commands + SET status=?,sent_at=?,updated_at=? WHERE command_id=?`, + consts.ImAdminCommandStatusRunning, now, now, commandID) if err != nil { return gerror.Wrap(err, "更新IM后台命令投递状态失败") } @@ -324,16 +270,9 @@ func dispatchOutboxRecord(ctx context.Context, row gdb.Record) error { func markOutboxFailed(ctx context.Context, eventID string, reason string, message string) error { now := gtime.Now() - _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). - Where("event_id", eventID). - Data(g.Map{ - "status": consts.ImAdminOutboxStatusFailed, - "attempts": gdb.Raw("attempts + 1"), - "next_retry_at": now.Add(30 * time.Second), - "last_reason": reason, - "last_error": message, - "updated_at": now, - }).Update() + _, err := adminDB().Exec(ctx, `UPDATE im_admin_outbox + SET status=?,attempts=attempts+1,next_retry_at=?,last_reason=?,last_error=?,updated_at=? + WHERE event_id=?`, consts.ImAdminOutboxStatusFailed, now.Add(30*time.Second), reason, message, now, eventID) if err != nil { return gerror.Wrap(err, "更新IM命令Outbox失败状态失败") } @@ -364,15 +303,27 @@ func ApplyCommandResult(ctx context.Context, result CommandResult) error { if len(result.Result) > 0 { data["result"] = string(result.Result) } - mod := g.DB().Model(adminCommandTable).Safe().Ctx(ctx) + whereColumn := "event_id" + whereValue := result.CommandEventID if result.CommandID != "" { - mod = mod.Where("command_id", result.CommandID) - } else { - mod = mod.Where("event_id", result.CommandEventID) + whereColumn = "command_id" + whereValue = result.CommandID } - _, err := mod.Data(data).Update() + sql := `UPDATE im_admin_commands SET status=?,reason=?,message=?,completed_at=?,updated_at=?` + args := []any{data["status"], data["reason"], data["message"], data["completed_at"], data["updated_at"]} + if resultValue, ok := data["result"]; ok { + sql += `,result=CAST(? AS JSON)` + args = append(args, resultValue) + } + sql += ` WHERE ` + whereColumn + `=?` + args = append(args, whereValue) + _, err := adminDB().Exec(ctx, sql, args...) if err != nil { return gerror.Wrap(err, "更新IM后台命令结果失败") } return nil } + +func adminDB() gdb.DB { + return imtenant.RegistryDB() +} diff --git a/server/internal/library/imtenant/db.go b/server/internal/library/imtenant/db.go index 4541f17..8d85d39 100644 --- a/server/internal/library/imtenant/db.go +++ b/server/internal/library/imtenant/db.go @@ -7,6 +7,7 @@ import ( "hash/fnv" "hotgo/internal/dao" "hotgo/internal/model/entity" + "net/url" "strings" "sync" @@ -20,14 +21,7 @@ var tenantDbGroups sync.Map const registryDbGroup = "imRegistry" -// RegistryDB returns the tenant registry DB. It falls back to the default DB for -// single-database deployments where database.imRegistry is not configured. -func RegistryDB(ctx context.Context) gdb.DB { - if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { - return g.DB(registryDbGroup) - } - return g.DB() -} +var registryDbGroupOnce sync.Once // DB resolves a tenant registry record and returns the corresponding business DB. func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) { @@ -35,7 +29,7 @@ func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant return nil, nil, gerror.New("租户ID不能为空") } - if err = RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + if err = RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { return nil, nil, gerror.Wrap(err, "获取租户信息失败") } if tenant == nil || tenant.Id <= 0 { @@ -65,9 +59,40 @@ func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant return db, tenant, nil } +// RegistryDB returns the dedicated tenant registry connection, or the default +// HotGo database when a separate registry group is not configured. +func RegistryDB() gdb.DB { + registryDbGroupOnce.Do(func() { + ensureRegistryDBGroup() + }) + if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { + return g.DB(registryDbGroup) + } + return g.DB() +} + +func ensureRegistryDBGroup() { + if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { + configs[0].Prefix = "" + _ = gdb.SetConfigGroup(registryDbGroup, configs) + return + } + + configs, err := gdb.GetConfigGroup("default") + if err != nil || len(configs) == 0 { + return + } + cloned := make(gdb.ConfigGroup, len(configs)) + copy(cloned, configs) + for i := range cloned { + cloned[i].Prefix = "" + } + _ = gdb.SetConfigGroup(registryDbGroup, cloned) +} + func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) { if tenant.DbDsn != "" { - return tenant.DbDsn, nil + return normalizeMySQLTimeOptions(tenant.DbDsn) } if tenant.DbName == "" { return "", gerror.New("租户业务库未配置") @@ -115,10 +140,31 @@ func replaceMySQLDatabase(link string, dbName string) (string, error) { dbStart += 2 queryStart := strings.Index(link[dbStart:], "?") if queryStart < 0 { - return link[:dbStart] + dbName, nil + return normalizeMySQLTimeOptions(link[:dbStart] + dbName) } queryStart += dbStart - return link[:dbStart] + dbName + link[queryStart:], nil + return normalizeMySQLTimeOptions(link[:dbStart] + dbName + link[queryStart:]) +} + +func normalizeMySQLTimeOptions(link string) (string, error) { + if !strings.HasPrefix(link, "mysql:") { + if !strings.Contains(link, "@tcp(") { + return "", gerror.New("租户业务库仅支持 MySQL") + } + link = "mysql:" + link + } + parts := strings.SplitN(link, "?", 2) + values := make(url.Values) + if len(parts) == 2 { + var err error + values, err = url.ParseQuery(parts[1]) + if err != nil { + return "", gerror.Wrap(err, "租户业务库连接参数格式不正确") + } + } + values.Set("loc", "UTC") + values.Set("parseTime", "true") + return parts[0] + "?" + values.Encode(), nil } func hash(s string) uint64 { diff --git a/server/internal/library/imtenant/db_test.go b/server/internal/library/imtenant/db_test.go new file mode 100644 index 0000000..cbabad0 --- /dev/null +++ b/server/internal/library/imtenant/db_test.go @@ -0,0 +1,52 @@ +package imtenant + +import ( + "context" + "strings" + "testing" +) + +func TestNormalizeMySQLTimeOptions(t *testing.T) { + link, err := normalizeMySQLTimeOptions("user:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4") + if err != nil { + t.Fatalf("normalize raw mysql dsn: %v", err) + } + if !strings.HasPrefix(link, "mysql:user:pass@tcp(127.0.0.1:3306)/tinode?") { + t.Fatalf("unexpected normalized link: %s", link) + } + if !strings.Contains(link, "loc=UTC") || !strings.Contains(link, "parseTime=true") { + t.Fatalf("missing required time options: %s", link) + } +} + +func TestNormalizeMySQLTimeOptionsRejectsUnsupportedFormat(t *testing.T) { + if _, err := normalizeMySQLTimeOptions("postgres://user:pass@127.0.0.1:5432/tinode"); err == nil { + t.Fatal("expected unsupported format error") + } +} + +func TestReplaceMySQLDatabaseUsesUTC(t *testing.T) { + link, err := replaceMySQLDatabase("mysql:user:pass@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=false&charset=utf8mb4", "tinode") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"/tinode?", "loc=UTC", "parseTime=true", "charset=utf8mb4"} { + if !strings.Contains(link, expected) { + t.Fatalf("link %q does not contain %q", link, expected) + } + } +} + +func TestNextSnowflakeID(t *testing.T) { + first, err := NextSnowflakeID(context.Background()) + if err != nil { + t.Fatal(err) + } + second, err := NextSnowflakeID(context.Background()) + if err != nil { + t.Fatal(err) + } + if first <= 0 || second <= first { + t.Fatalf("Snowflake IDs are not increasing: %d, %d", first, second) + } +} diff --git a/server/internal/library/imtenant/im_table_prefix_guard_test.go b/server/internal/library/imtenant/im_table_prefix_guard_test.go new file mode 100644 index 0000000..f73cbf0 --- /dev/null +++ b/server/internal/library/imtenant/im_table_prefix_guard_test.go @@ -0,0 +1,56 @@ +package imtenant + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func TestNoDirectDefaultDBModelForIMTables(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime caller unavailable") + } + serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + internalRoot := filepath.Join(serverRoot, "internal") + patterns := []*regexp.Regexp{ + regexp.MustCompile(`g\.DB\(\)\.Model\(\s*"im_`), + regexp.MustCompile(`g\.DB\(\)\.Model\(\s*` + "`" + `im_`), + regexp.MustCompile(`g\.DB\(\)\.(Insert|InsertIgnore|Replace|Save|Update|Delete)\([^,\n]+,\s*"im_`), + regexp.MustCompile(`g\.DB\(\)\.(Insert|InsertIgnore|Replace|Save|Update|Delete)\([^,\n]+,\s*` + "`" + `im_`), + } + + var violations []string + err := filepath.WalkDir(internalRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + for _, pattern := range patterns { + if pattern.Match(content) { + rel, _ := filepath.Rel(serverRoot, path) + violations = append(violations, rel) + break + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("IM public tables must not use default g.DB() model helpers because the default DB has table prefix: %s", strings.Join(violations, ", ")) + } +} diff --git a/server/internal/library/network/tcp/client.go b/server/internal/library/network/tcp/client.go index ad0a1f9..b3f6d4d 100644 --- a/server/internal/library/network/tcp/client.go +++ b/server/internal/library/network/tcp/client.go @@ -281,6 +281,8 @@ func (client *Client) handleRoutineTask(ctx context.Context, task func()) { // Conn 获取当前连接 func (client *Client) Conn() *Conn { + client.mutex.Lock() + defer client.mutex.Unlock() return client.conn } diff --git a/server/internal/library/network/tcp/rpc.go b/server/internal/library/network/tcp/rpc.go index 2ebd5bf..5c1e3c8 100644 --- a/server/internal/library/network/tcp/rpc.go +++ b/server/internal/library/network/tcp/rpc.go @@ -43,7 +43,6 @@ func (r *RPC) Request(ctx context.Context, msgId string, send func()) (res inter defer func() { isClose.Set(true) - close(resCh) r.popCallback(msgId) }() @@ -57,8 +56,13 @@ func (r *RPC) Request(ctx context.Context, msgId string, send func()) (res inter r.task(ctx, send) + timer := time.NewTimer(time.Second * RPCTimeout) + defer timer.Stop() select { - case <-time.After(time.Second * RPCTimeout): + case <-ctx.Done(): + err = gerror.Wrap(ctx.Err(), "RPC request canceled") + return + case <-timer.C: err = gerror.New("RPC response timeout") return case got := <-resCh: diff --git a/server/internal/library/network/tcp/tcp_example_test.go b/server/internal/library/network/tcp/tcp_example_test.go index b23ef20..8d5e0ce 100644 --- a/server/internal/library/network/tcp/tcp_example_test.go +++ b/server/internal/library/network/tcp/tcp_example_test.go @@ -1,21 +1,14 @@ -// Package tcp_test -// @Link https://github.com/bufanyun/hotgo -// @Copyright Copyright (c) 2023 HotGo CLI -// @Author Ms <133814250@qq.com> -// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE package tcp_test import ( "context" "fmt" - "github.com/gogf/gf/v2/os/gctx" - "github.com/gogf/gf/v2/test/gtest" - "hotgo/internal/library/network/tcp" + "net" "testing" "time" -) -var T *testing.T // 声明一个全局的 *testing.T 变量 + "hotgo/internal/library/network/tcp" +) type TestMsgReq struct { Name string `json:"name"` @@ -33,101 +26,95 @@ type TestRPCMsgRes struct { tcp.ServerRes } -func onTestMsg(ctx context.Context, req *TestMsgReq) { - fmt.Printf("服务器收到消息 ==> onTestMsg:%+v\n", req) - conn := tcp.ConnFromCtx(ctx) - gtest.C(T, func(t *gtest.T) { - t.AssertNE(conn, nil) - }) - - res := new(TestMsgRes) - res.Message = fmt.Sprintf("你的名字:%v", req.Name) - conn.Send(ctx, res) -} - -func onResponseTestMsg(ctx context.Context, req *TestMsgRes) { - fmt.Printf("客户端收到响应消息 ==> TestMsgRes:%+v\n", req) - err := req.GetError() - gtest.C(T, func(t *gtest.T) { - t.AssertNil(err) - }) -} - -func onTestRPCMsg(ctx context.Context, req *TestRPCMsgReq) (res *TestRPCMsgRes, err error) { - fmt.Printf("服务器收到消息 ==> onTestRPCMsg:%+v\n", req) - res = new(TestRPCMsgRes) - res.Message = fmt.Sprintf("你的名字:%v", req.Name) - return -} - -func startTCPServer() { - serv := tcp.NewServer(&tcp.ServerConfig{ - Name: "hotgo", - Addr: ":8002", - }) - - // 注册路由 - serv.RegisterRouter( - onTestMsg, - ) - - // 注册RPC路由 - serv.RegisterRPCRouter( - onTestRPCMsg, - ) - - // 服务监听 - err := serv.Listen() - gtest.C(T, func(t *gtest.T) { - t.AssertNil(err) - }) -} - -// 一个基本的消息收发 func TestSendMsg(t *testing.T) { - T = t - go startTCPServer() - - ctx := gctx.New() - client := tcp.NewClient(&tcp.ClientConfig{ - Addr: "127.0.0.1:8002", + address := freeTCPAddress(t) + messageResponse := make(chan string, 1) + server := tcp.NewServer(&tcp.ServerConfig{Name: "hotgo-test", Addr: address}) + server.RegisterRouter(func(ctx context.Context, req *TestMsgReq) { + res := &TestMsgRes{} + res.SetMessage(fmt.Sprintf("你的名字:%s", req.Name)) + if err := tcp.ConnFromCtx(ctx).Send(ctx, res); err != nil { + messageResponse <- "send-error:" + err.Error() + } }) - - // 注册路由 - client.RegisterRouter( - onResponseTestMsg, - ) - + server.RegisterRPCRouter(func(ctx context.Context, req *TestRPCMsgReq) (*TestRPCMsgRes, error) { + res := &TestRPCMsgRes{} + res.SetMessage(fmt.Sprintf("你的名字:%s", req.Name)) + return res, nil + }) + serverErrors := make(chan error, 1) go func() { - err := client.Start() - gtest.C(T, func(t *gtest.T) { - t.AssertNil(err) - }) + serverErrors <- server.Listen() }() + defer server.Close() - // 确保服务都启动完成 - time.Sleep(time.Second * 1) - - // 拿到客户端的连接 - conn := client.Conn() - gtest.C(T, func(t *gtest.T) { - t.AssertNE(conn, nil) + client := tcp.NewClient(&tcp.ClientConfig{ + Addr: address, + Timeout: time.Second, + ConnectInterval: 10 * time.Millisecond, + MaxConnectCount: 100, }) - - // 向服务器发送tcp消息,不会阻塞程序执行 - err := conn.Send(ctx, &TestMsgReq{Name: "Tom"}) - gtest.C(T, func(t *gtest.T) { - t.AssertNil(err) + client.RegisterRouter(func(ctx context.Context, res *TestMsgRes) { + messageResponse <- res.Message }) + if err := client.Start(); err != nil { + t.Fatalf("启动TCP客户端失败: %v", err) + } + defer client.Stop() - // 向服务器发送rpc消息,会等待服务器响应结果,直到拿到结果或响应超时才会继续 - var res TestRPCMsgRes - if err = conn.RequestScan(ctx, &TestRPCMsgReq{Name: "Tony"}, &res); err != nil { - gtest.C(T, func(t *gtest.T) { - t.AssertNil(err) - }) + conn := waitForConnection(t, client, serverErrors) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := conn.Send(ctx, &TestMsgReq{Name: "Tom"}); err != nil { + t.Fatalf("发送TCP消息失败: %v", err) + } + select { + case message := <-messageResponse: + if message != "你的名字:Tom" { + t.Fatalf("TCP响应不符: %s", message) + } + case <-ctx.Done(): + t.Fatal("等待TCP响应超时") } - fmt.Printf("客户端收到RPC消息响应 ==> TestRPCMsgRes:%+v\n", res) - time.Sleep(time.Second * 1) + var rpcResponse TestRPCMsgRes + if err := conn.RequestScan(ctx, &TestRPCMsgReq{Name: "Tony"}, &rpcResponse); err != nil { + t.Fatalf("发送RPC消息失败: %v", err) + } + if rpcResponse.Message != "你的名字:Tony" { + t.Fatalf("RPC响应不符: %s", rpcResponse.Message) + } +} + +func freeTCPAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("申请测试端口失败: %v", err) + } + address := listener.Addr().String() + if err = listener.Close(); err != nil { + t.Fatalf("释放测试端口失败: %v", err) + } + return address +} + +func waitForConnection(t *testing.T, client *tcp.Client, serverErrors <-chan error) *tcp.Conn { + t.Helper() + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case err := <-serverErrors: + t.Fatalf("TCP服务启动失败: %v", err) + case <-deadline.C: + t.Fatal("等待TCP连接超时") + case <-ticker.C: + if conn := client.Conn(); conn != nil { + return conn + } + } + } } diff --git a/server/internal/library/queue/consumer.go b/server/internal/library/queue/consumer.go index 3955b00..a669862 100644 --- a/server/internal/library/queue/consumer.go +++ b/server/internal/library/queue/consumer.go @@ -55,7 +55,7 @@ func consumerListen(ctx context.Context, job Consumer) { ) if err != nil { - Logger().Fatalf(ctx, "InstanceConsumer %s err:%+v", topic, err) + Logger().Errorf(ctx, "InstanceConsumer %s err:%+v", topic, err) return } @@ -70,6 +70,6 @@ func consumerListen(ctx context.Context, job Consumer) { // 记录消费队列日志 ConsumerLog(ctx, topic, mqMsg, err) }); listenErr != nil { - Logger().Fatalf(ctx, "消费队列:%s 监听失败, err:%+v", topic, listenErr) + Logger().Errorf(ctx, "消费队列:%s 监听失败, err:%+v", topic, listenErr) } } diff --git a/server/internal/library/queue/kafkamq.go b/server/internal/library/queue/kafkamq.go index eeea59f..8757039 100644 --- a/server/internal/library/queue/kafkamq.go +++ b/server/internal/library/queue/kafkamq.go @@ -7,7 +7,10 @@ package queue import ( "context" - "crypto/tls" + "crypto/sha256" + "crypto/sha512" + "errors" + "hash" "strings" "time" @@ -15,6 +18,7 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/xdg-go/scram" + "hotgo/internal/consts" "hotgo/utility/simple" ) @@ -26,17 +30,37 @@ type KafkaMq struct { } type KafkaConfig struct { - ClientId string - Brokers []string - GroupID string - Partitions int32 - Replication int16 - Version string - UserName string - Password string - SASLEnabled bool - SASLMechanism string - TLSEnabled bool + ClientId string + Brokers []string + GroupID string + Partitions int32 + Replication int16 + Version string + UserName string + Password string + Mechanism string +} + +type xdgSCRAMClient struct { + *scram.ClientConversation + HashGeneratorFcn scram.HashGeneratorFcn +} + +func (x *xdgSCRAMClient) Begin(userName, password, authzID string) error { + client, err := x.HashGeneratorFcn.NewClient(userName, password, authzID) + if err != nil { + return err + } + x.ClientConversation = client.NewConversation() + return nil +} + +func (x *xdgSCRAMClient) Step(challenge string) (string, error) { + return x.ClientConversation.Step(challenge) +} + +func (x *xdgSCRAMClient) Done() bool { + return x.ClientConversation.Done() } // SendMsg 按字符串类型生产数据 @@ -100,11 +124,16 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg)) go func(consumerCtx context.Context) { for { if err = r.consumerIns.Consume(consumerCtx, []string{topic}, &consumer); err != nil { - Logger().Fatalf(ctx, "kafka Error from consumer, err%+v", err) + Logger().Errorf(ctx, "kafka error from consumer, topic:%s err:%+v", topic, err) + select { + case <-consumerCtx.Done(): + return + case <-time.After(3 * time.Second): + } } if consumerCtx.Err() != nil { - Logger().Debugf(ctx, "kafka consoumer stop : %v", consumerCtx.Err()) + Logger().Debugf(ctx, "kafka consumer stop : %v", consumerCtx.Err()) return } consumer.ready = make(chan bool) @@ -112,14 +141,18 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg)) }(consumerCtx) // await till the consumer has been set up - <-consumer.ready + select { + case <-consumer.ready: + case <-time.After(10 * time.Second): + Logger().Warningf(ctx, "kafka consumer setup timeout, topic:%s", topic) + } Logger().Debug(ctx, "kafka consumer up and running!...") simple.Event().Register(consts.EventServerClose, func(ctx context.Context, args ...interface{}) { Logger().Debug(ctx, "kafka consumer close...") cancel() if err = r.consumerIns.Close(); err != nil { - Logger().Fatalf(ctx, "kafka Error closing client, err:%+v", err) + Logger().Errorf(ctx, "kafka error closing client, err:%+v", err) } }) return @@ -128,7 +161,7 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg)) // RegisterKafkaMqConsumer 注册消费者 func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error) { mqIns := &KafkaMq{} - kfkVersion, err := sarama.ParseKafkaVersion(connOpt.Version) + kfkVersion, err := parseKafkaVersion(connOpt.Version) if err != nil { return } @@ -140,9 +173,7 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error) conf := sarama.NewConfig() conf.Consumer.Return.Errors = true conf.Version = kfkVersion - if err = configureKafkaSecurity(conf, connOpt); err != nil { - return - } + configureKafkaSASL(conf, connOpt) // 默认按随机方式消费 conf.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRange @@ -161,9 +192,7 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error) // RegisterKafkaProducer 注册并启动生产者接口实现 func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) { mqIns := &KafkaMq{} - if connOpt.ClientId == "" { - connOpt.ClientId = "hotgo-producer" - } + connOpt.ClientId = "HOTGO-Producer" // 这里如果使用go程需要处理chan同步问题 if err = doRegisterKafkaProducer(connOpt, mqIns); err != nil { @@ -175,7 +204,7 @@ func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) { // doRegisterKafkaProducer 注册同步类型实例 func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) { - kfkVersion, err := sarama.ParseKafkaVersion(connOpt.Version) + kfkVersion, err := parseKafkaVersion(connOpt.Version) if err != nil { return } @@ -197,9 +226,7 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) { conf.ClientID = connOpt.ClientId conf.Version = kfkVersion - if err = configureKafkaSecurity(conf, connOpt); err != nil { - return - } + configureKafkaSASL(conf, connOpt) mqIns.producerIns, err = sarama.NewAsyncProducer(brokers, conf) if err != nil { @@ -213,61 +240,81 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) { return } -func configureKafkaSecurity(conf *sarama.Config, connOpt KafkaConfig) error { - if connOpt.TLSEnabled { - conf.Net.TLS.Enable = true - conf.Net.TLS.Config = &tls.Config{MinVersion: tls.VersionTLS12} +func parseKafkaVersion(version string) (sarama.KafkaVersion, error) { + parts := strings.Split(version, ".") + if len(parts) == 4 && parts[0] != "0" && parts[3] == "0" { + version = strings.Join(parts[:3], ".") } - if !connOpt.SASLEnabled && connOpt.UserName == "" { - return nil - } - if connOpt.UserName == "" || connOpt.Password == "" { - return gerror.New("queue kafka sasl username or password is empty") + return sarama.ParseKafkaVersion(version) +} + +func configureKafkaSASL(conf *sarama.Config, connOpt KafkaConfig) { + if connOpt.UserName == "" { + return } conf.Net.SASL.Enable = true conf.Net.SASL.User = connOpt.UserName conf.Net.SASL.Password = connOpt.Password - mechanism := strings.ToUpper(strings.ReplaceAll(connOpt.SASLMechanism, "_", "-")) + mechanism := strings.ToUpper(strings.ReplaceAll(connOpt.Mechanism, "_", "-")) + if mechanism == "" { + mechanism = sarama.SASLTypeSCRAMSHA512 + } switch mechanism { - case "", "PLAIN": - conf.Net.SASL.Mechanism = sarama.SASLTypePlaintext - case "SCRAM-SHA-256": + case sarama.SASLTypeSCRAMSHA512: + conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512 + conf.Net.SASL.SCRAMClientGeneratorFunc = scramClientGenerator(sha512.New) + case sarama.SASLTypeSCRAMSHA256: conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256 - conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { - return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA256} - } - case "SCRAM-SHA-512": - conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512 - conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { - return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA512} - } + conf.Net.SASL.SCRAMClientGeneratorFunc = scramClientGenerator(sha256.New) default: - return gerror.New("queue kafka sasl mechanism is not support") + conf.Net.SASL.Mechanism = sarama.SASLMechanism(mechanism) } - return nil } -type xdgSCRAMClient struct { - *scram.Client - *scram.ClientConversation - scram.HashGeneratorFcn -} - -func (x *xdgSCRAMClient) Begin(userName, password, authzID string) (err error) { - x.Client, err = x.HashGeneratorFcn.NewClient(userName, password, authzID) +// KafkaHealthCheck verifies broker connectivity by creating a short-lived Sarama client. +func KafkaHealthCheck(ctx context.Context) error { + if len(config.Kafka.Address) == 0 { + return errors.New("queue kafka address is empty") + } + kfkVersion, err := parseKafkaVersion(config.Kafka.Version) if err != nil { return err } - x.ClientConversation = x.Client.NewConversation() + if !validateVersion(kfkVersion) { + kfkVersion = sarama.V2_4_0_0 + } + conf := sarama.NewConfig() + conf.Version = kfkVersion + conf.ClientID = "hotgo-healthz" + conf.Net.DialTimeout = 2 * time.Second + conf.Net.ReadTimeout = 2 * time.Second + conf.Net.WriteTimeout = 2 * time.Second + configureKafkaSASL(conf, KafkaConfig{ + UserName: config.Kafka.Username, + Password: config.Kafka.Password, + Mechanism: config.Kafka.Mechanism, + }) + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + client, err := sarama.NewClient(config.Kafka.Address, conf) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + if len(client.Brokers()) == 0 { + return errors.New("no kafka brokers available") + } return nil } -func (x *xdgSCRAMClient) Step(challenge string) (response string, err error) { - return x.ClientConversation.Step(challenge) -} - -func (x *xdgSCRAMClient) Done() bool { - return x.ClientConversation.Done() +func scramClientGenerator(hashFunc func() hash.Hash) func() sarama.SCRAMClient { + return func() sarama.SCRAMClient { + return &xdgSCRAMClient{HashGeneratorFcn: scram.HashGeneratorFcn(hashFunc)} + } } // validateVersion 验证版本是否有效 diff --git a/server/internal/library/queue/queue.go b/server/internal/library/queue/queue.go index d0c3653..595ab4c 100644 --- a/server/internal/library/queue/queue.go +++ b/server/internal/library/queue/queue.go @@ -11,8 +11,6 @@ import ( "github.com/gogf/gf/v2/os/gctx" "hotgo/internal/library/queue/disk" "hotgo/utility/charset" - "os" - "strings" "sync" "time" ) @@ -59,14 +57,11 @@ type RocketmqConf struct { type KafkaConf struct { Address []string `json:"address"` Version string `json:"version"` + Username string `json:"username"` + Password string `json:"password"` + Mechanism string `json:"mechanism"` RandClient bool `json:"randClient"` MultiConsumer bool `json:"multiConsumer"` - ClientId string `json:"clientId"` - UserName string `json:"userName"` - Password string `json:"password"` - SASLEnabled bool `json:"saslEnabled"` - SASLMechanism string `json:"saslMechanism"` - TLSEnabled bool `json:"tlsEnabled"` } type MqMsg struct { @@ -93,7 +88,6 @@ func init() { if err := g.Cfg().MustGet(ctx, "queue").Scan(&config); err != nil { Logger().Warningf(ctx, "queue init err:%+v", err) } - normalizeKafkaConf() } // InstanceConsumer 实例化消费者 @@ -130,15 +124,12 @@ func NewProducer(groupName string) (mqClient MqProducer, err error) { return } mqClient, err = RegisterKafkaProducer(KafkaConfig{ - Brokers: config.Kafka.Address, - GroupID: groupName, - Version: config.Kafka.Version, - ClientId: firstKafkaClientId(config.Kafka.ClientId, "hotgo-producer"), - UserName: config.Kafka.UserName, - Password: config.Kafka.Password, - SASLEnabled: config.Kafka.SASLEnabled, - SASLMechanism: config.Kafka.SASLMechanism, - TLSEnabled: config.Kafka.TLSEnabled, + Brokers: config.Kafka.Address, + GroupID: groupName, + Version: config.Kafka.Version, + UserName: config.Kafka.Username, + Password: config.Kafka.Password, + Mechanism: config.Kafka.Mechanism, }) case "redis": if _, err = g.Redis().Do(ctx, "ping"); err == nil { @@ -200,15 +191,13 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) { } mqClient, err = RegisterKafkaMqConsumer(KafkaConfig{ - Brokers: config.Kafka.Address, - GroupID: groupName, - Version: config.Kafka.Version, - ClientId: clientId, - UserName: config.Kafka.UserName, - Password: config.Kafka.Password, - SASLEnabled: config.Kafka.SASLEnabled, - SASLMechanism: config.Kafka.SASLMechanism, - TLSEnabled: config.Kafka.TLSEnabled, + Brokers: config.Kafka.Address, + GroupID: groupName, + Version: config.Kafka.Version, + ClientId: clientId, + UserName: config.Kafka.Username, + Password: config.Kafka.Password, + Mechanism: config.Kafka.Mechanism, }) case "redis": if _, err = g.Redis().Do(ctx, "ping"); err == nil { @@ -233,30 +222,6 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) { return } -func normalizeKafkaConf() { - config.Kafka.UserName = resolveEnvPlaceholder(config.Kafka.UserName) - config.Kafka.Password = resolveEnvPlaceholder(config.Kafka.Password) - if config.Kafka.SASLMechanism == "" { - config.Kafka.SASLMechanism = "SCRAM-SHA-512" - } -} - -func resolveEnvPlaceholder(value string) string { - value = strings.TrimSpace(value) - if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") { - return os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")) - } - return value -} - -func firstKafkaClientId(value string, fallback string) string { - value = strings.TrimSpace(value) - if value != "" { - return value - } - return fallback -} - // BodyString 返回消息体 func (m *MqMsg) BodyString() string { return string(m.Body) diff --git a/server/internal/library/storager/upload_local.go b/server/internal/library/storager/upload_local.go index 3f7fa93..a5032d9 100644 --- a/server/internal/library/storager/upload_local.go +++ b/server/internal/library/storager/upload_local.go @@ -13,6 +13,7 @@ import ( "github.com/gogf/gf/v2/os/gfile" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" + "io" "os" "path/filepath" "sort" @@ -137,20 +138,53 @@ func (d *LocalDrive) UploadPart(ctx context.Context, in *UploadPartParams) (res // MergePartFile 合并分片文件 func MergePartFile(srcPath, dstPath string) (err error) { - dir, err := os.ReadDir(srcPath) + entries, err := os.ReadDir(srcPath) if err != nil { return err } - sort.Slice(dir, func(i, j int) bool { - fiIndex, _ := strconv.Atoi(dir[i].Name()) - fjIndex, _ := strconv.Atoi(dir[j].Name()) - return fiIndex < fjIndex + type filePart struct { + index int + path string + } + parts := make([]filePart, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + index, parseErr := strconv.Atoi(entry.Name()) + if parseErr != nil || index <= 0 { + return gerror.Newf("无效的分片文件名:%s", entry.Name()) + } + parts = append(parts, filePart{index: index, path: filepath.Join(srcPath, entry.Name())}) + } + if len(parts) == 0 { + return gerror.New("没有可合并的分片文件") + } + sort.Slice(parts, func(i, j int) bool { + return parts[i].index < parts[j].index }) - for _, file := range dir { - filePath := filepath.Join(srcPath, file.Name()) - if err = gfile.PutBytesAppend(dstPath, gfile.GetBytes(filePath)); err != nil { - return err + if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return gerror.Wrap(err, "创建目标目录失败") + } + dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return gerror.Wrap(err, "创建合并文件失败") + } + defer dstFile.Close() + + for _, part := range parts { + srcFile, openErr := os.Open(part.path) + if openErr != nil { + return gerror.Wrapf(openErr, "打开分片%d失败", part.index) + } + _, copyErr := io.Copy(dstFile, srcFile) + closeErr := srcFile.Close() + if copyErr != nil { + return gerror.Wrapf(copyErr, "合并分片%d失败", part.index) + } + if closeErr != nil { + return gerror.Wrapf(closeErr, "关闭分片%d失败", part.index) } } - return + return dstFile.Sync() } diff --git a/server/internal/library/storager/upload_local_test.go b/server/internal/library/storager/upload_local_test.go index 8dea7bf..1891b37 100644 --- a/server/internal/library/storager/upload_local_test.go +++ b/server/internal/library/storager/upload_local_test.go @@ -1,61 +1,52 @@ package storager import ( - "crypto/md5" - "errors" - "fmt" - "io" + "bytes" "os" + "path/filepath" "testing" ) func TestMergePartFile(t *testing.T) { - srcFile, _ := os.Open("1.zip") - defer srcFile.Close() - fileInfo, err := srcFile.Stat() - if err != nil { - t.Error(err) + tempDir := t.TempDir() + partDir := filepath.Join(tempDir, "parts") + if err := os.MkdirAll(partDir, 0o755); err != nil { + t.Fatal(err) } - hash := md5.New() - _, err = io.Copy(hash, srcFile) - sum := hash.Sum(nil) - fileHash := fmt.Sprintf("%x", sum) - var singleSize = int64(1024 * 1024 * 100) - chunks := int(fileInfo.Size() / singleSize) - if fileInfo.Size()%singleSize != 0 { - chunks += 1 + parts := map[string][]byte{ + "1": []byte("first-"), + "2": []byte("second-"), + "10": []byte("tenth"), } - srcFile.Seek(0, io.SeekStart) - for j := 0; j < chunks; j++ { - partSize := singleSize - if j == chunks-1 { - partSize = fileInfo.Size() - int64(j)*singleSize + for name, content := range parts { + if err := os.WriteFile(filepath.Join(partDir, name), content, 0o644); err != nil { + t.Fatal(err) } - partData := make([]byte, partSize) - _, err = io.ReadFull(srcFile, partData) - pf, _ := os.Create(fmt.Sprintf("tmp/%d", j+1)) - _, err = pf.Write(partData) - if err != nil { - t.Error(err) - return - } - pf.Close() } - err = MergePartFile("tmp/", "2.zip") + destination := filepath.Join(tempDir, "merged.bin") + if err := os.WriteFile(destination, []byte("stale-data"), 0o644); err != nil { + t.Fatal(err) + } + if err := MergePartFile(partDir, destination); err != nil { + t.Fatalf("合并分片失败: %v", err) + } + + actual, err := os.ReadFile(destination) if err != nil { - t.Error(err) - return + t.Fatal(err) } + expected := []byte("first-second-tenth") + if !bytes.Equal(actual, expected) { + t.Fatalf("合并内容不符 expected=%q actual=%q", expected, actual) + } +} - f2, _ := os.Open("2.zip") - hash2 := md5.New() - _, err = io.Copy(hash2, f2) - sum2 := hash.Sum(nil) - fileHash2 := fmt.Sprintf("%x", sum2) - if fileHash != fileHash2 { - t.Error(errors.New("hash mismatch")) +func TestMergePartFileRejectsEmptyDirectory(t *testing.T) { + partDir := t.TempDir() + err := MergePartFile(partDir, filepath.Join(t.TempDir(), "merged.bin")) + if err == nil { + t.Fatal("空分片目录应返回错误") } - } diff --git a/server/internal/logic/admin/member.go b/server/internal/logic/admin/member.go index c572f21..06f5683 100644 --- a/server/internal/logic/admin/member.go +++ b/server/internal/logic/admin/member.go @@ -470,7 +470,7 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err if service.AdminMember().VerifySuperId(ctx, opMemberId) { if in.TenantId > 0 { - count, queryErr := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + count, queryErr := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). Where(dao.ImTenant.Columns().Id, in.TenantId). Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive). Count() @@ -488,7 +488,6 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err return gerror.New("当前管理员未关联租户") } } else { - // 非租户域管理员不能通过浏览器为账号指定租户。 in.TenantId = 0 } diff --git a/server/internal/logic/hook/init.go b/server/internal/logic/hook/init.go index 8bc315f..3e97b5d 100644 --- a/server/internal/logic/hook/init.go +++ b/server/internal/logic/hook/init.go @@ -8,6 +8,7 @@ package hook import ( "github.com/gogf/gf/v2/net/ghttp" "hotgo/internal/service" + "strings" ) type sHook struct { @@ -22,10 +23,23 @@ func New() *sHook { } func (s *sHook) BeforeServe(r *ghttp.Request) { - + s.setAdminStaticCacheHeader(r) } func (s *sHook) AfterOutput(r *ghttp.Request) { s.accessLog(r) s.lastAdminActive(r) } + +func (s *sHook) setAdminStaticCacheHeader(r *ghttp.Request) { + path := r.URL.Path + if path == "/admin" || path == "/admin/" || path == "/admin/index.html" || path == "/admin/app.config.js" { + r.Response.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + r.Response.Header().Set("Pragma", "no-cache") + r.Response.Header().Set("Expires", "0") + return + } + if strings.HasPrefix(path, "/admin/assets/") { + r.Response.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } +} diff --git a/server/internal/logic/sys/im_abuse_reason.go b/server/internal/logic/sys/im_abuse_reason.go new file mode 100644 index 0000000..7754e75 --- /dev/null +++ b/server/internal/logic/sys/im_abuse_reason.go @@ -0,0 +1,166 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "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" +) + +const imAbuseReasonsTable = "im_abuse_reasons" + +type sSysImAbuseReason struct{} + +func NewSysImAbuseReason() *sSysImAbuseReason { return &sSysImAbuseReason{} } + +func init() { + service.RegisterSysImAbuseReason(NewSysImAbuseReason()) +} + +func (s *sSysImAbuseReason) List(ctx context.Context, in *sysin.ImAbuseReasonListInp) (list []*sysin.ImAbuseReasonModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaults(ctx, db); err != nil { + return + } + mod := s.model(ctx, db) + if in.TargetType != "" { + mod = mod.Where("target_type", in.TargetType) + } + if in.ReasonCode != "" { + mod = mod.WhereLike("reason_code", "%"+in.ReasonCode+"%") + } + if in.Enabled != nil { + mod = mod.Where("enabled", *in.Enabled) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("target_type").OrderAsc("sort_order").OrderAsc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM举报原因失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImAbuseReason) Edit(ctx context.Context, in *sysin.ImAbuseReasonEditInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaults(ctx, db); err != nil { + return + } + data := g.Map{ + "target_type": in.TargetType, + "reason_code": in.ReasonCode, + "label": in.Label, + "requires_description": in.RequiresDescription, + "allows_evidence": in.AllowsEvidence, + "sort_order": in.SortOrder, + "enabled": in.Enabled, + "updated_at": gtime.Now(), + } + action := "im_abuse_reason_create" + targetId := in.Id + if in.Id > 0 { + if _, err = db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).WherePri(in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM举报原因失败,请稍后重试!") + } + action = "im_abuse_reason_update" + } else { + data["created_at"] = gtime.Now() + if _, err = db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "新增IM举报原因失败,请稍后重试!") + } + idValue, err := db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).Fields("id"). + Where("target_type", in.TargetType).Where("reason_code", in.ReasonCode).Value() + if err != nil { + return gerror.Wrap(err, "获取IM举报原因ID失败,请稍后重试!") + } + targetId = idValue.Int64() + } + _ = writeImAdminAudit(ctx, tenant.Id, action, "im_abuse_reason", targetId, "success", g.Map{ + "target_type": in.TargetType, "reason_code": in.ReasonCode, "enabled": in.Enabled, + }) + return nil +} + +func (s *sSysImAbuseReason) Delete(ctx context.Context, in *sysin.ImAbuseReasonDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if _, err = db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).WherePri(in.Id). + Data(g.Map{"enabled": 0, "updated_at": gtime.Now()}).Update(); err != nil { + return gerror.Wrap(err, "禁用IM举报原因失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_abuse_reason_disable", "im_abuse_reason", in.Id, "success", nil) + return nil +} + +func (s *sSysImAbuseReason) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).Fields( + "id", + "target_type", + "reason_code", + "label", + "requires_description", + "allows_evidence", + "sort_order", + "enabled", + "created_at", + "updated_at", + ) +} + +func (s *sSysImAbuseReason) ensureDefaults(ctx context.Context, db gdb.DB) (err error) { + defaults := []g.Map{ + {"target_type": consts.ImAbuseTargetTypeUser, "reason_code": "fraud", "label": "诈骗欺诈", "requires_description": 0, "allows_evidence": 1, "sort_order": 10}, + {"target_type": consts.ImAbuseTargetTypeUser, "reason_code": "harassment", "label": "骚扰辱骂", "requires_description": 0, "allows_evidence": 1, "sort_order": 20}, + {"target_type": consts.ImAbuseTargetTypeUser, "reason_code": "illegal", "label": "违法违规", "requires_description": 1, "allows_evidence": 1, "sort_order": 30}, + {"target_type": consts.ImAbuseTargetTypeGroup, "reason_code": "fraud", "label": "诈骗欺诈", "requires_description": 0, "allows_evidence": 1, "sort_order": 10}, + {"target_type": consts.ImAbuseTargetTypeGroup, "reason_code": "spam", "label": "垃圾广告", "requires_description": 0, "allows_evidence": 1, "sort_order": 20}, + {"target_type": consts.ImAbuseTargetTypeGroup, "reason_code": "illegal", "label": "违法违规", "requires_description": 1, "allows_evidence": 1, "sort_order": 30}, + {"target_type": consts.ImAbuseTargetTypeMessage, "reason_code": "fraud", "label": "诈骗欺诈", "requires_description": 0, "allows_evidence": 1, "sort_order": 10}, + {"target_type": consts.ImAbuseTargetTypeMessage, "reason_code": "spam", "label": "垃圾广告", "requires_description": 0, "allows_evidence": 1, "sort_order": 20}, + {"target_type": consts.ImAbuseTargetTypeMessage, "reason_code": "harassment", "label": "骚扰辱骂", "requires_description": 0, "allows_evidence": 1, "sort_order": 30}, + {"target_type": consts.ImAbuseTargetTypeMessage, "reason_code": "illegal", "label": "违法违规", "requires_description": 1, "allows_evidence": 1, "sort_order": 40}, + } + now := gtime.Now() + for _, item := range defaults { + var count int + if count, err = db.Model(imAbuseReasonsTable).Safe().Ctx(ctx). + Where("target_type", item["target_type"]).Where("reason_code", item["reason_code"]).Count(); err != nil { + return gerror.Wrap(err, "检查IM举报默认原因失败,请稍后重试!") + } + if count > 0 { + continue + } + item["enabled"] = 1 + item["created_at"] = now + item["updated_at"] = now + if _, err = db.Model(imAbuseReasonsTable).Safe().Ctx(ctx).Data(item).Insert(); err != nil { + return gerror.Wrap(err, "初始化IM举报默认原因失败,请稍后重试!") + } + } + return nil +} diff --git a/server/internal/logic/sys/im_admin_query_client.go b/server/internal/logic/sys/im_admin_query_client.go new file mode 100644 index 0000000..3a460d2 --- /dev/null +++ b/server/internal/logic/sys/im_admin_query_client.go @@ -0,0 +1,258 @@ +package sys + +import ( + "context" + "encoding/json" + "hotgo/internal/dao" + "hotgo/internal/library/imadmin" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type imAdminQueryPage struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func resolveImAdminTenant(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 || tenant.Id <= 0 || tenant.Code == "" { + return nil, gerror.New("IM租户不存在或编码为空") + } + return tenant, nil +} + +func imAdminQueryList[T any](ctx context.Context, tenant *entity.ImTenant, path string, values url.Values, aliases map[string]string) (list []*T, totalCount int, err error) { + if values == nil { + values = url.Values{} + } + query := values.Encode() + if query != "" { + path += "?" + query + } + var page imAdminQueryPage + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &page); err != nil { + return nil, 0, err + } + normalizeImAdminRows(page.Items, aliases) + if len(page.Items) == 0 { + return []*T{}, page.Total, nil + } + raw, err := json.Marshal(page.Items) + if err != nil { + return nil, 0, gerror.Wrap(err, "转换IM内部查询列表失败") + } + if err = json.Unmarshal(raw, &list); err != nil { + return nil, 0, gerror.Wrap(err, "解析IM内部查询列表失败") + } + fillImTenantFields(tenant, &list) + return list, page.Total, nil +} + +func imAdminQueryOne[T any](ctx context.Context, tenant *entity.ImTenant, path string, aliases map[string]string) (res *T, err error) { + row := map[string]any{} + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &row); err != nil { + return nil, err + } + normalizeImAdminRow(row, aliases) + raw, err := json.Marshal(row) + if err != nil { + return nil, gerror.Wrap(err, "转换IM内部查询详情失败") + } + if err = json.Unmarshal(raw, &res); err != nil { + return nil, gerror.Wrap(err, "解析IM内部查询详情失败") + } + fillImTenantFields(tenant, res) + return res, nil +} + +func normalizeImAdminRows(rows []map[string]any, aliases map[string]string) { + for _, row := range rows { + normalizeImAdminRow(row, aliases) + } +} + +func normalizeImAdminRow(row map[string]any, aliases map[string]string) { + for key, value := range row { + target := snakeToLowerCamel(key) + if aliases != nil { + if alias := aliases[key]; alias != "" { + target = alias + } + } + value = normalizeImAdminValue(target, value) + if target == key { + row[key] = value + continue + } + row[target] = value + delete(row, key) + } + if _, ok := row["authNames"]; !ok { + username, _ := row["username"].(string) + phone, _ := row["phone"].(string) + authNames := username + if phone != "" && phone != username { + if authNames != "" { + authNames += ", " + } + authNames += phone + } + if authNames != "" { + row["authNames"] = authNames + } + } +} + +func normalizeImAdminValue(target string, value any) any { + switch target { + case "credentials": + items, ok := value.([]any) + if !ok { + return value + } + parts := make([]string, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + method, _ := m["method"].(string) + val, _ := m["value"].(string) + if method == "" && val == "" { + continue + } + text := method + if val != "" { + if text != "" { + text += ":" + } + text += val + } + if done, ok := m["done"].(bool); ok && !done { + text += "(未验证)" + } + parts = append(parts, text) + } + return strings.Join(parts, ", ") + case "profileJson", "trustedJson", "accessJson", "head", "content", "metadata", "atUsers": + return normalizeImAdminJSONString(value) + default: + return value + } +} + +func normalizeImAdminJSONString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + encoded, err := json.Marshal(v) + if err != nil { + return "" + } + return string(encoded) + } +} + +func snakeToLowerCamel(s string) string { + if !strings.Contains(s, "_") { + return s + } + parts := strings.Split(s, "_") + var b strings.Builder + for i, part := range parts { + if part == "" { + continue + } + if i == 0 { + b.WriteString(part) + continue + } + b.WriteString(strings.ToUpper(part[:1])) + if len(part) > 1 { + b.WriteString(part[1:]) + } + } + return b.String() +} + +func fillImTenantFields(tenant *entity.ImTenant, target any) { + raw, err := json.Marshal(target) + if err != nil { + return + } + var rows []map[string]any + if err = json.Unmarshal(raw, &rows); err == nil { + for _, row := range rows { + row["tenantId"] = tenant.Id + row["tenantName"] = tenant.Name + } + updated, _ := json.Marshal(rows) + _ = json.Unmarshal(updated, target) + return + } + var row map[string]any + if err = json.Unmarshal(raw, &row); err == nil { + row["tenantId"] = tenant.Id + row["tenantName"] = tenant.Name + updated, _ := json.Marshal(row) + _ = json.Unmarshal(updated, target) + } +} + +func addPageQuery(values url.Values, page int, perPage int) url.Values { + if values == nil { + values = url.Values{} + } + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + values.Set("page", intToString(page)) + values.Set("per_page", intToString(perPage)) + return values +} + +func addTimeRangeQuery(values url.Values, prefix string, times []*gtime.Time) { + if len(times) != 2 { + return + } + if times[0] != nil { + values.Set(prefix+"_start", times[0].Time.UTC().Format(time.RFC3339Nano)) + } + if times[1] != nil { + values.Set(prefix+"_end", times[1].Time.UTC().Format(time.RFC3339Nano)) + } +} + +func intToString(v int) string { + return strconvFormatInt(int64(v)) +} + +func int64ToString(v int64) string { + return strconvFormatInt(v) +} + +func strconvFormatInt(v int64) string { + return strconv.FormatInt(v, 10) +} diff --git a/server/internal/logic/sys/im_admin_scope.go b/server/internal/logic/sys/im_admin_scope.go index df36557..99ef3f0 100644 --- a/server/internal/logic/sys/im_admin_scope.go +++ b/server/internal/logic/sys/im_admin_scope.go @@ -11,6 +11,7 @@ import ( "hotgo/internal/service" "hotgo/utility/simple" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" @@ -28,7 +29,7 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima scope.OperatorId = operatorId scope.RoleKey = contexts.GetRoleKey(ctx) scope.RequestId = gctx.CtxId(ctx) - if service.AdminMember().VerifySuperId(ctx, operatorId) { + if isImPlatformAdmin(ctx, operatorId) { if selectedTenantId <= 0 { return scope, gerror.New("请选择IM租户") } @@ -39,12 +40,12 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima return scope, nil } - if contexts.GetDeptType(ctx) != consts.DeptTypeTenant { - return scope, gerror.New("当前账号不是租户管理员,不能管理邀请码") + if !contexts.IsTenantDept(ctx) { + return scope, gerror.New("当前账号无权管理IM租户") } - tenantId := contexts.GetTenantId(ctx) - if tenantId <= 0 { - return scope, gerror.New("当前管理员未关联租户") + tenantId, err := resolveImAdminBoundTenant(ctx) + if err != nil { + return scope, err } if err = ensureImTenantActive(ctx, tenantId); err != nil { return scope, err @@ -53,6 +54,21 @@ func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope ima return scope, nil } +func isImPlatformAdmin(ctx context.Context, operatorId int64) bool { + if contexts.IsCompanyDept(ctx) { + return true + } + return service.AdminMember().VerifySuperId(ctx, operatorId) +} + +func resolveImAdminBoundTenant(ctx context.Context) (int64, error) { + tenantId := contexts.GetTenantId(ctx) + if tenantId <= 0 { + return 0, gerror.New("当前管理员未关联IM租户") + } + return tenantId, nil +} + func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) { var tenant *entity.ImTenant if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { @@ -67,6 +83,14 @@ func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) { return nil } +func resolveImTenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) { + scope, err := resolveImAdminScope(ctx, selectedTenantId) + if err != nil { + return nil, nil, err + } + return imtenant.DB(ctx, scope.TenantId) +} + func writeImAdminAudit(ctx context.Context, tenantId int64, action string, targetType string, targetId int64, result string, detail g.Map) error { operatorId := contexts.GetUserId(ctx) request := ghttp.RequestFromCtx(ctx) diff --git a/server/internal/logic/sys/im_admin_scope_test.go b/server/internal/logic/sys/im_admin_scope_test.go new file mode 100644 index 0000000..4b8e97d --- /dev/null +++ b/server/internal/logic/sys/im_admin_scope_test.go @@ -0,0 +1,57 @@ +package sys + +import ( + "context" + "testing" + + "hotgo/internal/consts" + "hotgo/internal/model" +) + +func TestCompanyDepartmentIsImPlatformAdmin(t *testing.T) { + ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{ + User: &model.Identity{Id: 14, TenantId: 1, DeptType: consts.DeptTypeCompany, RoleKey: "imManage"}, + }) + if !isImPlatformAdmin(ctx, 14) { + t.Fatal("expected company department IM manager to use platform tenant selection") + } +} + +func TestResolveImAdminBoundTenant(t *testing.T) { + tests := []struct { + name string + identity *model.Identity + want int64 + }{ + { + name: "tenant department with tenant binding", + identity: &model.Identity{ + TenantId: 2, + DeptType: consts.DeptTypeTenant, + }, + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{User: tt.identity}) + got, err := resolveImAdminBoundTenant(ctx) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("tenant binding mismatch: got %d, want %d", got, tt.want) + } + }) + } +} + +func TestResolveImAdminBoundTenantRejectsUnboundAccount(t *testing.T) { + ctx := context.WithValue(context.Background(), consts.ContextHTTPKey, &model.Context{ + User: &model.Identity{DeptType: consts.DeptTypeTenant, RoleKey: "tenantManager"}, + }) + if _, err := resolveImAdminBoundTenant(ctx); err == nil { + t.Fatal("expected unbound account to be rejected") + } +} diff --git a/server/internal/logic/sys/im_app_release.go b/server/internal/logic/sys/im_app_release.go new file mode 100644 index 0000000..85c95b0 --- /dev/null +++ b/server/internal/logic/sys/im_app_release.go @@ -0,0 +1,153 @@ +package sys + +import ( + "context" + "fmt" + + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" + "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" +) + +const imAppReleasesTable = "im_app_releases" + +type sSysImAppRelease struct{} + +func NewSysImAppRelease() *sSysImAppRelease { return &sSysImAppRelease{} } + +func init() { + service.RegisterSysImAppRelease(NewSysImAppRelease()) +} + +func (s *sSysImAppRelease) List(ctx context.Context, in *sysin.ImAppReleaseListInp) (list []*sysin.ImAppReleaseModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db := imtenant.RegistryDB() + if err = s.ensureDefaults(ctx, db); err != nil { + return + } + mod := s.model(ctx, db) + if in.Platform != "" { + mod = mod.Where("platform", in.Platform) + } + if in.Channel != "" { + mod = mod.WhereLike("channel", "%"+in.Channel+"%") + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("platform").OrderAsc("channel") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM客户端版本策略失败,请稍后重试!") + return + } + return +} + +func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db := imtenant.RegistryDB() + if err = s.ensureDefaults(ctx, db); err != nil { + return + } + data := g.Map{ + "platform": in.Platform, + "channel": in.Channel, + "latest_version": in.LatestVersion, + "min_supported_version": in.MinSupportedVersion, + "update_type": in.UpdateType, + "release_notes": in.ReleaseNotes, + "store_url": in.StoreUrl, + "package_url": in.PackageUrl, + "checksum": in.Checksum, + "remind_after_seconds": in.RemindAfterSeconds, + "published_at": gtime.Now(), + "updated_at": gtime.Now(), + } + action := "im_app_release_create" + targetId := in.Id + if in.Id > 0 { + if _, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).WherePri(in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM客户端版本策略失败,请稍后重试!") + } + action = "im_app_release_update" + } else { + data["created_at"] = gtime.Now() + if _, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "新增IM客户端版本策略失败,请稍后重试!") + } + idValue, err := db.Model(imAppReleasesTable).Safe().Ctx(ctx).Fields("id"). + Where("platform", in.Platform).Where("channel", in.Channel).Value() + if err != nil { + return gerror.Wrap(err, "获取IM客户端版本策略ID失败,请稍后重试!") + } + targetId = idValue.Int64() + } + _ = writeImAdminAudit(ctx, 0, action, "im_app_release", targetId, "success", g.Map{ + "platform": in.Platform, "channel": in.Channel, "update_type": in.UpdateType, + }) + if err = imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ + TenantCode: "*", EventType: "app.release.updated", Group: "app_release", + Keys: []string{in.Platform, in.Channel}, BizID: in.Platform + ":" + in.Channel, + Actor: &imkafka.Actor{Type: "admin", ID: fmt.Sprint(contexts.GetUserId(ctx))}, + CreatedBy: contexts.GetUserId(ctx), + }); err != nil { + g.Log().Warningf(ctx, "版本策略已保存,缓存失效事件等待 Outbox 重试: %v", err) + } + return nil +} + +func (s *sSysImAppRelease) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model(imAppReleasesTable).Safe().Ctx(ctx).Fields( + "id", + "platform", + "channel", + "latest_version", + "min_supported_version", + "update_type", + "release_notes", + "store_url", + "package_url", + "checksum", + "remind_after_seconds", + "published_at", + "created_at", + "updated_at", + ) +} + +func (s *sSysImAppRelease) ensureDefaults(ctx context.Context, db gdb.DB) (err error) { + defaults := []g.Map{ + {"platform": consts.ImAppPlatformIos, "channel": "stable", "latest_version": "1.0.0", "min_supported_version": "1.0.0", "update_type": consts.ImAppUpdateTypeNone, "release_notes": "当前已是最新版本。", "remind_after_seconds": 0}, + {"platform": consts.ImAppPlatformAndroid, "channel": "stable", "latest_version": "1.0.0", "min_supported_version": "1.0.0", "update_type": consts.ImAppUpdateTypeNone, "release_notes": "当前已是最新版本。", "remind_after_seconds": 0}, + } + now := gtime.Now() + for _, item := range defaults { + var count int + if count, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx). + Where("platform", item["platform"]).Where("channel", item["channel"]).Count(); err != nil { + return gerror.Wrap(err, "检查IM客户端默认版本策略失败,请稍后重试!") + } + if count > 0 { + continue + } + item["store_url"] = "" + item["package_url"] = "" + item["checksum"] = "" + item["published_at"] = now + item["created_at"] = now + item["updated_at"] = now + if _, err = db.Model(imAppReleasesTable).Safe().Ctx(ctx).Data(item).Insert(); err != nil { + return gerror.Wrap(err, "初始化IM客户端默认版本策略失败,请稍后重试!") + } + } + return nil +} diff --git a/server/internal/logic/sys/im_audit_log.go b/server/internal/logic/sys/im_audit_log.go index 943ef75..50b50a5 100644 --- a/server/internal/logic/sys/im_audit_log.go +++ b/server/internal/logic/sys/im_audit_log.go @@ -7,12 +7,18 @@ package sys import ( "context" + "fmt" "hotgo/internal/library/imtenant" + "hotgo/internal/model/input/form" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "hotgo/utility/convert" + "hotgo/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/util/gconv" ) type sSysImAuditLog struct{} @@ -30,7 +36,11 @@ func (s *sSysImAuditLog) List(ctx context.Context, in *sysin.ImAuditLogListInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, scope.TenantId) if err != nil { return } @@ -107,7 +117,11 @@ func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return nil, err + } + db, tenant, err := imtenant.DB(ctx, scope.TenantId) if err != nil { return nil, err } @@ -123,3 +137,23 @@ func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) res.TenantName = tenant.Name return } + +// Export 导出IM审计日志列表 +func (s *sSysImAuditLog) Export(ctx context.Context, in *sysin.ImAuditLogListInp) (err error) { + list, totalCount, err := s.List(ctx, in) + if err != nil { + return + } + tags, err := convert.GetEntityDescTags(sysin.ImAuditLogExportModel{}) + if err != nil { + return + } + + var exports []sysin.ImAuditLogExportModel + 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)) + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} diff --git a/server/internal/logic/sys/im_dashboard.go b/server/internal/logic/sys/im_dashboard.go new file mode 100644 index 0000000..b9b4e9b --- /dev/null +++ b/server/internal/logic/sys/im_dashboard.go @@ -0,0 +1,38 @@ +package sys + +import ( + "context" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "net/url" +) + +type sSysImDashboard struct{} + +func NewSysImDashboard() *sSysImDashboard { return &sSysImDashboard{} } + +func init() { service.RegisterSysImDashboard(NewSysImDashboard()) } + +func (s *sSysImDashboard) Overview(ctx context.Context, in *sysin.ImDashboardOverviewInp) (*sysin.ImDashboardOverviewModel, error) { + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + return imAdminQueryOne[sysin.ImDashboardOverviewModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/dashboard/overview", nil) +} + +func (s *sSysImDashboard) Trends(ctx context.Context, in *sysin.ImDashboardTrendsInp) ([]*sysin.ImDashboardTrendModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + values := addPageQuery(url.Values{}, 1, in.Days) + values.Set("days", intToString(in.Days)) + list, _, err := imAdminQueryList[sysin.ImDashboardTrendModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/dashboard/trends", values, nil) + return list, err +} diff --git a/server/internal/logic/sys/im_dead_letter.go b/server/internal/logic/sys/im_dead_letter.go index ace1af7..11d16f9 100644 --- a/server/internal/logic/sys/im_dead_letter.go +++ b/server/internal/logic/sys/im_dead_letter.go @@ -166,7 +166,7 @@ func (s *sSysImDeadLetter) getTenant(ctx context.Context, tenantId int64) (tenan if tenantId <= 0 { return nil, gerror.New("请选择IM租户") } - if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { return nil, gerror.Wrap(err, "获取IM租户失败") } if tenant == nil || tenant.Id <= 0 { diff --git a/server/internal/logic/sys/im_external_config.go b/server/internal/logic/sys/im_external_config.go new file mode 100644 index 0000000..e742b12 --- /dev/null +++ b/server/internal/logic/sys/im_external_config.go @@ -0,0 +1,227 @@ +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/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +type sSysImExternalConfig struct{} + +func NewSysImExternalConfig() *sSysImExternalConfig { return &sSysImExternalConfig{} } + +func init() { + service.RegisterSysImExternalConfig(NewSysImExternalConfig()) +} + +func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalConfigListInp) (list []*sysin.ImExternalConfigListModel, 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.Service != "" { + mod = mod.Where("service", in.Service) + } + if in.Provider != "" { + mod = mod.WhereLike("provider", "%"+in.Provider+"%") + } + if in.Enabled != nil { + mod = mod.Where("enabled", *in.Enabled) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("service") + 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 *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalConfigViewInp) (res *sysin.ImExternalConfigViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := s.resolveTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = s.ensureRows(ctx, tenant.Id); err != nil { + return nil, err + } + if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!") + } + if res != nil { + res.TenantName = tenant.Name + } + return +} + +func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalConfigUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if !s.validService(in.Service) { + return gerror.New("服务类型不正确") + } + tenant, err := s.resolveTenant(ctx, in.TenantId) + if err != nil { + return err + } + if err = s.ensureRows(ctx, tenant.Id); err != nil { + return err + } + configJson := "{}" + if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" { + configJson = in.ConfigJson.String() + } + data := g.Map{ + "provider": in.Provider, + "endpoint": in.Endpoint, + "enabled": in.Enabled, + "config_json": configJson, + "updated_at": gtime.Now(), + "updated_by": contexts.GetUserId(ctx), + } + if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" { + data["secret_json"] = in.SecretJson.String() + } + if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("service", in.Service).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM外部服务配置失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{ + "service": in.Service, "provider": in.Provider, "enabled": in.Enabled, + }) + return nil +} + +func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (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 + } + var row gdb.Record + if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil { + return gerror.Wrap(err, "检查IM外部服务配置失败,请稍后重试!") + } + status := consts.ImExternalConfigStatusConfigured + result := "配置完整" + if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" { + status = consts.ImExternalConfigStatusUnconfigured + result = "未启用或服务商为空" + } + if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("service", in.Service). + Data(g.Map{"status": status, "checked_at": gtime.Now(), "check_result": result, "updated_at": gtime.Now()}). + Update(); err != nil { + return gerror.Wrap(err, "更新IM外部服务检查结果失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_check", "im_external_config", 0, "success", g.Map{ + "service": in.Service, "status": status, + }) + return nil +} + +func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model { + return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields( + "tenant_id", + "id", + "service", + "provider", + "endpoint", + "enabled", + "status", + "CAST(config_json AS CHAR) config_json", + "CASE WHEN secret_json IS NULL OR CAST(secret_json AS CHAR)='{}' THEN 0 ELSE 1 END has_secret", + "checked_at", + "check_result", + "created_at", + "updated_at", + ) +} + +func (s *sSysImExternalConfig) 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 *sSysImExternalConfig) ensureRows(ctx context.Context, tenantId int64) error { + services := []string{ + consts.ImExternalServiceSms, + consts.ImExternalServicePush, + consts.ImExternalServiceTurn, + consts.ImExternalServiceStorage, + consts.ImExternalServiceModeration, + } + for _, serviceName := range services { + count, err := imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). + Where("tenant_id", tenantId).Where("service", serviceName).Count() + if err != nil { + return gerror.Wrap(err, "初始化IM外部服务配置失败") + } + if count > 0 { + continue + } + _, err = imtenant.RegistryDB().Exec(ctx, `INSERT INTO im_external_config + (tenant_id,service,provider,endpoint,enabled,status,config_json,secret_json,created_by,updated_by) + VALUES(?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?,?)`, + tenantId, serviceName, "", "", 0, consts.ImExternalConfigStatusUnconfigured, "{}", "{}", contexts.GetUserId(ctx), contexts.GetUserId(ctx)) + if err != nil { + return gerror.Wrap(err, "初始化IM外部服务配置失败") + } + } + return nil +} + +func (s *sSysImExternalConfig) validService(serviceName string) bool { + switch serviceName { + case consts.ImExternalServiceSms, consts.ImExternalServicePush, consts.ImExternalServiceTurn, consts.ImExternalServiceStorage, consts.ImExternalServiceModeration: + return true + default: + return false + } +} + +func externalConfigJson(value string) *gjson.Json { + if value == "" { + return gjson.New("{}") + } + return gjson.New(value) +} diff --git a/server/internal/logic/sys/im_feedback_config.go b/server/internal/logic/sys/im_feedback_config.go new file mode 100644 index 0000000..04c2704 --- /dev/null +++ b/server/internal/logic/sys/im_feedback_config.go @@ -0,0 +1,112 @@ +package sys + +import ( + "context" + "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" +) + +const imFeedbackConfigTable = "im_feedback_config" + +type sSysImFeedbackConfig struct{} + +func NewSysImFeedbackConfig() *sSysImFeedbackConfig { return &sSysImFeedbackConfig{} } + +func init() { + service.RegisterSysImFeedbackConfig(NewSysImFeedbackConfig()) +} + +func (s *sSysImFeedbackConfig) Get(ctx context.Context, in *sysin.ImFeedbackConfigGetInp) (res *sysin.ImFeedbackConfigModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = s.ensureDefault(ctx, db); err != nil { + return nil, err + } + if err = s.model(ctx, db).OrderAsc("id").Limit(1).Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM意见反馈配置失败,请稍后重试!") + } + if res != nil { + res.TenantId = tenant.Id + res.TenantName = tenant.Name + } + return +} + +func (s *sSysImFeedbackConfig) Update(ctx context.Context, in *sysin.ImFeedbackConfigUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + if err = s.ensureDefault(ctx, db); err != nil { + return err + } + categories := `["bug","suggestion","complaint","other"]` + if in.Categories != nil && in.Categories.String() != "" && in.Categories.String() != "null" { + categories = in.Categories.String() + } + attachmentPolicy := `{"max_files":5,"purpose":"feedback"}` + if in.AttachmentPolicy != nil && in.AttachmentPolicy.String() != "" && in.AttachmentPolicy.String() != "null" { + attachmentPolicy = in.AttachmentPolicy.String() + } + if _, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Where("id", 1).Data(g.Map{ + "categories": categories, + "max_length": in.MaxLength, + "attachment_policy": attachmentPolicy, + "allow_contact": in.AllowContact, + "diagnostics_notice": in.DiagnosticsNotice, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return gerror.Wrap(err, "更新IM意见反馈配置失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_feedback_config_update", "im_feedback_config", 1, "success", g.Map{"allow_contact": in.AllowContact}) + return nil +} + +func (s *sSysImFeedbackConfig) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Fields( + "id", + "CAST(categories AS CHAR) categories", + "max_length", + "CAST(attachment_policy AS CHAR) attachment_policy", + "allow_contact", + "diagnostics_notice", + "updated_at", + ) +} + +func (s *sSysImFeedbackConfig) ensureDefault(ctx context.Context, db gdb.DB) (err error) { + var count int + if count, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Where("id", 1).Count(); err != nil { + return gerror.Wrap(err, "检查IM意见反馈默认配置失败,请稍后重试!") + } + if count > 0 { + return nil + } + _, err = db.Model(imFeedbackConfigTable).Safe().Ctx(ctx).Data(g.Map{ + "id": 1, + "categories": `["bug","suggestion","complaint","other"]`, + "max_length": 1000, + "attachment_policy": `{"max_files":5,"purpose":"feedback"}`, + "allow_contact": 1, + "diagnostics_notice": "仅在你同意时上传脱敏诊断信息。", + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return gerror.Wrap(err, "初始化IM意见反馈默认配置失败,请稍后重试!") + } + return nil +} diff --git a/server/internal/logic/sys/im_file.go b/server/internal/logic/sys/im_file.go new file mode 100644 index 0000000..6e1adc0 --- /dev/null +++ b/server/internal/logic/sys/im_file.go @@ -0,0 +1,244 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "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 sSysImFile struct{} + +func NewSysImFile() *sSysImFile { + return &sSysImFile{} +} + +func init() { + service.RegisterSysImFile(NewSysImFile()) +} + +// List 获取IM文件列表 +func (s *sSysImFile) List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := s.fileModel(ctx, db) + mod = s.applyWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("fu.updatedat").OrderDesc("fu.id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM文件列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + if !contexts.IsCompanyDept(ctx) { + item.Location = "" + } + } + return +} + +// View 获取IM文件详情 +func (s *sSysImFile) View(ctx context.Context, in *sysin.ImFileViewInp) (res *sysin.ImFileViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = s.fileModel(ctx, db).Where("fu.id", in.Id).Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM文件详情失败,请稍后重试!") + } + if res == nil { + return nil, nil + } + res.TenantId = tenant.Id + res.TenantName = tenant.Name + if !contexts.IsCompanyDept(ctx) { + res.Location = "" + } + if err = db.Model("im_filemsglinks").Safe().Ctx(ctx). + Fields("id", "createdat created_at", "fileid file_id", "COALESCE(msgid,0) msg_id", "COALESCE(topic,'') topic", "COALESCE(userid,0) user_id"). + Where("fileid", in.Id). + OrderDesc("id"). + Limit(50). + Scan(&res.Links); err != nil { + return nil, gerror.Wrap(err, "获取IM文件引用失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_file_view", "im_file", in.Id, "success", g.Map{ + "file_id": in.Id, + }) + return +} + +// Quarantine 隔离IM文件 +func (s *sSysImFile) Quarantine(ctx context.Context, in *sysin.ImFileActionInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.updateStatus(ctx, in, consts.ImFileStatusQuarantined, "im_file_quarantine") +} + +// Delete 删除IM文件 +func (s *sSysImFile) Delete(ctx context.Context, in *sysin.ImFileActionInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.updateStatus(ctx, in, consts.ImFileStatusDeleted, "im_file_delete") +} + +// CleanupOrphan 清理IM孤儿文件 +func (s *sSysImFile) CleanupOrphan(ctx context.Context, in *sysin.ImFileCleanupOrphanInp) (res *sysin.ImFileCleanupOrphanModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return nil, err + } + cutoff := gtime.Now().AddDate(0, 0, -in.Days) + rows, err := db.Model("im_fileuploads fu").Safe().Ctx(ctx). + LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id"). + Fields("fu.id", "fu.location"). + Where("fml.id IS NULL"). + WhereLT("fu.updatedat", cutoff). + WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}). + Group("fu.id"). + OrderAsc("fu.updatedat"). + Limit(in.Limit). + All() + if err != nil { + return nil, gerror.Wrap(err, "扫描IM孤儿文件失败,请稍后重试!") + } + res = &sysin.ImFileCleanupOrphanModel{Locations: []string{}} + if len(rows) == 0 { + return res, nil + } + ids := make([]int64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row["id"].Int64()) + if location := row["location"].String(); location != "" { + res.Locations = append(res.Locations, location) + } + } + if _, err = db.Model("im_fileuploads").Safe().Ctx(ctx). + WhereIn("id", ids). + Data(g.Map{"status": consts.ImFileStatusDeleted, "updatedat": gtime.Now()}). + Update(); err != nil { + return nil, gerror.Wrap(err, "标记IM孤儿文件失败,请稍后重试!") + } + res.Count = len(ids) + if !contexts.IsCompanyDept(ctx) { + res.Locations = []string{} + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_file_cleanup_orphan", "im_file", 0, "success", g.Map{ + "days": in.Days, "limit": in.Limit, "count": res.Count, + }) + return +} + +func (s *sSysImFile) fileModel(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_fileuploads fu").Safe().Ctx(ctx). + LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id"). + Fields( + "fu.id", + "fu.createdat created_at", + "fu.updatedat updated_at", + "COALESCE(fu.userid,0) user_id", + "fu.status", + "fu.mimetype mime_type", + "fu.size", + "COALESCE(fu.etag,'') etag", + "fu.location", + "COUNT(fml.id) link_count", + "COALESCE(MIN(fml.topic),'') first_topic", + "COALESCE(MIN(fml.msgid),0) first_msg_id", + "MIN(fml.createdat) first_link_at", + `CASE + WHEN COUNT(fml.id)=0 THEN 'orphan' + WHEN SUM(CASE WHEN fml.topic LIKE 'grp%' THEN 1 ELSE 0 END)>0 THEN 'chat' + ELSE 'chat' + END purpose`, + ). + Group("fu.id") +} + +func (s *sSysImFile) applyWhere(mod *gdb.Model, in *sysin.ImFileListInp) *gdb.Model { + if in.FileId > 0 { + mod = mod.Where("fu.id", in.FileId) + } + if in.UserId > 0 { + mod = mod.Where("fu.userid", in.UserId) + } + if in.Status != nil { + mod = mod.Where("fu.status", *in.Status) + } + if in.MimeType != "" { + mod = mod.WhereLike("fu.mimetype", "%"+in.MimeType+"%") + } + if in.Topic != "" { + mod = mod.WhereLike("fml.topic", "%"+in.Topic+"%") + } + if in.Purpose == consts.ImFilePurposeOrphan { + mod = mod.Having("link_count = 0") + } + if in.Purpose != "" && in.Purpose != consts.ImFilePurposeOrphan && in.Purpose != consts.ImFilePurposeUnknown { + mod = mod.Having("purpose", in.Purpose) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("fu.createdat", in.CreatedAt[0], in.CreatedAt[1]) + } + if len(in.UpdatedAt) == 2 { + mod = mod.WhereBetween("fu.updatedat", in.UpdatedAt[0], in.UpdatedAt[1]) + } + return mod +} + +func (s *sSysImFile) updateStatus(ctx context.Context, in *sysin.ImFileActionInp, status int, action string) (err error) { + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + current, err := db.Model("im_fileuploads").Safe().Ctx(ctx).Where("id", in.Id).One() + if err != nil { + return gerror.Wrap(err, "获取IM文件失败,请稍后重试!") + } + if current == nil { + return gerror.New("IM文件不存在") + } + if current["status"].Int() == consts.ImFileStatusDeleted && status == consts.ImFileStatusDeleted { + return gerror.New("IM文件已删除") + } + if _, err = db.Model("im_fileuploads").Safe().Ctx(ctx). + Where("id", in.Id). + Data(g.Map{"status": status, "updatedat": gtime.Now()}). + Update(); err != nil { + return gerror.Wrap(err, "更新IM文件状态失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, action, "im_file", in.Id, "success", g.Map{ + "file_id": in.Id, "before_status": current["status"].Int(), "after_status": status, "reason": in.Reason, + }) + return nil +} + +func (s *sSysImFile) tenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) { + return resolveImTenantDB(ctx, selectedTenantId) +} 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_group.go b/server/internal/logic/sys/im_group.go index ca3b9c3..8925fc8 100644 --- a/server/internal/logic/sys/im_group.go +++ b/server/internal/logic/sys/im_group.go @@ -7,6 +7,8 @@ package sys import ( "context" + "encoding/json" + "fmt" "hotgo/internal/consts" "hotgo/internal/library/contexts" "hotgo/internal/library/imkafka" @@ -14,14 +16,23 @@ import ( "hotgo/internal/model/input/sysin" "hotgo/internal/service" "strconv" - "time" "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/guid" ) type sSysImGroup struct{} +const ( + imGroupModeMember = "JRWPS" + imGroupModeAdmin = "JRWPASO" + imGroupModeOwner = "JRWPASDO" +) + func NewSysImGroup() *sSysImGroup { return &sSysImGroup{} } @@ -36,7 +47,7 @@ func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } @@ -59,10 +70,14 @@ func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model { return db.Model("im_topics t").Safe().Ctx(ctx). LeftJoin("im_group_settings gs", "gs.topic = t.name"). + LeftJoin("im_users owner", "owner.id = COALESCE(gs.owner_user_id, t.owner)"). + LeftJoin("im_user_profiles owner_profile", "owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)"). Fields( "t.name topic", "t.state", "COALESCE(gs.owner_user_id, t.owner) owner_id", + "COALESCE(NULLIF(owner_profile.nickname, ''), owner.username, '') owner_nickname", + "COALESCE(owner.username, '') owner_username", "t.seqid seq_id", "t.delid del_id", "t.subcnt sub_cnt", @@ -73,7 +88,7 @@ func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model { "t.trusted trusted_json", "t.tags tags_json", "t.aux aux_json", - "COALESCE(gs.name, '') group_name", + "COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name", "COALESCE(gs.avatar_file_id, '') avatar_file_id", "COALESCE(gs.description, '') description", "COALESCE(gs.group_status, '') group_status", @@ -119,17 +134,21 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res * return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } err = db.Model("im_topics t").Safe().Ctx(ctx). LeftJoin("im_group_settings gs", "gs.topic = t.name"). + LeftJoin("im_users owner", "owner.id = COALESCE(gs.owner_user_id, t.owner)"). + LeftJoin("im_user_profiles owner_profile", "owner_profile.user_id = COALESCE(gs.owner_user_id, t.owner)"). Fields( "t.name topic", "t.state", "COALESCE(gs.owner_user_id, t.owner) owner_id", + "COALESCE(NULLIF(owner_profile.nickname, ''), owner.username, '') owner_nickname", + "COALESCE(owner.username, '') owner_username", "t.seqid seq_id", "t.delid del_id", "t.subcnt sub_cnt", @@ -142,7 +161,7 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res * "t.aux aux_json", "t.access access_json", "t.usebt use_bt", - "COALESCE(gs.name, '') group_name", + "COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name", "COALESCE(gs.avatar_file_id, '') avatar_file_id", "COALESCE(gs.description, '') description", "COALESCE(gs.group_status, '') group_status", @@ -177,7 +196,7 @@ func (s *sSysImGroup) MemberList(ctx context.Context, in *sysin.ImGroupMemberLis return } - db, _, err := imtenant.DB(ctx, in.TenantId) + db, _, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } @@ -289,7 +308,7 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return nil, err } @@ -329,303 +348,241 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp) return } -// JoinRequestList 获取IM入群申请列表 -func (s *sSysImGroup) JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return - } - mod := db.Model("im_group_join_requests").Safe().Ctx(ctx).Where("topic", in.Topic) - if in.Status != "" { - mod = mod.Where("status", in.Status) - } - if in.ApplicantUserId > 0 { - mod = mod.Where("applicant_user_id", in.ApplicantUserId) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM入群申请列表失败,请稍后重试!") - } - return -} - -// InvitationList 获取IM群邀请列表 -func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return - } - mod := db.Model("im_group_invitations").Safe().Ctx(ctx).Where("topic", in.Topic) - if in.Status != "" { - mod = mod.Where("status", in.Status) - } - if in.TargetUserId > 0 { - mod = mod.Where("target_user_id", in.TargetUserId) - } - if in.InviterUserId > 0 { - mod = mod.Where("inviter_user_id", in.InviterUserId) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM群邀请列表失败,请稍后重试!") - } - return -} - -// AnnouncementList 获取IM群公告列表 -func (s *sSysImGroup) AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return - } - mod := db.Model("im_group_announcements").Safe().Ctx(ctx).Where("topic", in.Topic) - if in.Status != "" { - mod = mod.Where("status", in.Status) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("published_at").OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM群公告列表失败,请稍后重试!") - } - return -} - -// MuteList 获取IM群禁言列表 -func (s *sSysImGroup) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return - } - mod := db.Model("im_group_mutes").Safe().Ctx(ctx).Where("topic", in.Topic) - if in.Status != "" { - mod = mod.Where("status", in.Status) - } - if in.UserId > 0 { - mod = mod.Where("user_id", in.UserId) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("muted_at").OrderDesc("user_id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM群禁言列表失败,请稍后重试!") - } - return -} - -// AdminLogList 获取IM群管理日志列表 -func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return - } - mod := db.Model("im_group_admin_logs").Safe().Ctx(ctx). - Fields( - "id", - "log_id", - "topic", - "operator_id", - "target_user_id", - "action", - "object_type", - "object_id", - "CAST(before_data AS CHAR) before_data", - "CAST(after_data AS CHAR) after_data", - "request_id", - "idempotency_key", - "created_at", - ). - Where("topic", in.Topic) - if in.Action != "" { - mod = mod.WhereLike("action", "%"+in.Action+"%") - } - if in.OperatorId > 0 { - mod = mod.Where("operator_id", in.OperatorId) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM群管理日志列表失败,请稍后重试!") - } - return -} - -// ProfileUpdate 通过Kafka修改IM群资料 -func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { - return nil, err - } - if in.ActorUserId <= 0 { - return nil, gerror.New("操作IM用户ID不能为空") - } - payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), - "topic": in.Topic, - "profile": map[string]any{}, - "version": in.Version, - } - profile := payload["profile"].(map[string]any) - if in.Name != "" { - profile["name"] = in.Name - } - if in.AvatarFileId != "" { - profile["avatar_file_id"] = in.AvatarFileId - } - if in.Description != "" { - profile["description"] = in.Description - } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupProfileUpdate, in.Topic, int64(in.Version), payload) -} - -// GovernanceUpdate 通过Kafka修改IM群治理配置 +// GovernanceUpdate 更新IM群治理配置 func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 { - return nil, gerror.New("操作IM用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "governance": map[string]any{}, - "version": in.Version, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "governance": map[string]any{ + "join_approval": in.JoinApproval == 1, + "mute_all": in.MuteAll == 1, + "member_upload_files": in.MemberUploadFiles == 1, + "member_private_chat": in.MemberPrivateChat == 1, + "member_invite": in.MemberInvite == 1, + "max_member_count": in.MaxMemberCount, + "max_admin_count": in.MaxAdminCount, + }, } - governance := payload["governance"].(map[string]any) - if in.JoinApproval != nil { - governance["join_approval"] = *in.JoinApproval - } - if in.MuteAll != nil { - governance["mute_all"] = *in.MuteAll - } - if in.MemberUploadFiles != nil { - governance["member_upload_files"] = *in.MemberUploadFiles - } - if in.MemberPrivateChat != nil { - governance["member_private_chat"] = *in.MemberPrivateChat - } - if in.MemberInvite != nil { - governance["member_invite"] = *in.MemberInvite - } - if len(governance) == 0 { - return nil, gerror.New("请至少选择一个治理配置") - } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupGovernUpdate, in.Topic, int64(in.Version), payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupGovernUpdate, payload, 0) } -// AdminSet 通过Kafka设置IM群管理员 -func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { +func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || len(in.UserIds) == 0 { - return nil, gerror.New("操作IM用户ID和用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "user_ids": imUserIds(in.UserIds), + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "profile": map[string]any{ + "name": in.Name, + "avatar_file_id": in.AvatarFileId, + "description": in.Description, + }, } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminSet, in.Topic, 0, payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupProfileUpdate, payload, in.Version) } -// AdminRemove 通过Kafka删除IM群管理员 -func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { +// Dissolve 解散IM群 +func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || in.UserId <= 0 { - return nil, gerror.New("操作IM用户ID和用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "user_id": imUserId(in.UserId), + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "reason": in.Reason, } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminRemove, in.Topic, 0, payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupDissolve, payload, 0) } -// MemberMute 通过Kafka禁言IM群成员 -func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { +// Mute 禁言IM群成员 +func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) { + return s.MemberMute(ctx, in) +} + +func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || in.UserId <= 0 { - return nil, gerror.New("操作IM用户ID和用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "user_id": imUserId(in.UserId), + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), "reason": in.Reason, } if in.ExpiresAt != nil { - payload["expires_at"] = in.ExpiresAt.Time.UTC().Format(time.RFC3339Nano) + payload["expires_at"] = in.ExpiresAt.Time.Format("2006-01-02T15:04:05.000Z07:00") } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberMute, in.Topic, 0, payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberMute, payload, 0) } -// MemberUnmute 通过Kafka解除IM群成员禁言 -func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { +// Unmute 解除IM群成员禁言 +func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { + return s.MemberUnmute(ctx, in) +} + +func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || in.UserId <= 0 { - return nil, gerror.New("操作IM用户ID和用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "user_id": imUserId(in.UserId), + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberUnmute, in.Topic, 0, payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberUnmute, payload, 0) } -// MemberRemove 通过Kafka移出IM群成员 func (s *sSysImGroup) MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || in.UserId <= 0 { - return nil, gerror.New("操作IM用户ID和用户ID不能为空") - } payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), "topic": in.Topic, - "user_id": imUserId(in.UserId), + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + "reason": in.Reason, } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberRemove, in.Topic, 0, payload) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberRemove, payload, 0) } -// OwnerTransfer 通过Kafka转让IM群主 -func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) { - if err = in.ImGroupTopicInp.Filter(ctx); err != nil { +// JoinRequestApprove 通过IM入群申请 +func (s *sSysImGroup) JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.resolveJoinRequest(ctx, in, consts.ImGroupRequestStatusAccepted) +} + +// JoinRequestReject 拒绝IM入群申请 +func (s *sSysImGroup) JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.resolveJoinRequest(ctx, in, consts.ImGroupRequestStatusRejected) +} + +func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp, status string) (err error) { + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { + return err + } + before, txErr := s.groupJoinRequestSnapshot(ctx, tx, in.Topic, in.RequestId) + if txErr != nil { + return txErr + } + if before == nil { + return gerror.New("入群申请不存在") + } + if before["status"].String() != consts.ImGroupRequestStatusPending { + return gerror.New("入群申请已处理") + } + applicantUserId := before["applicant_user_id"].Int64() + if status == consts.ImGroupRequestStatusAccepted { + if txErr = s.upsertGroupSubscription(ctx, tx, in.Topic, applicantUserId, imGroupModeMember, now); txErr != nil { + return txErr + } + if txErr = s.upsertGroupMember(ctx, tx, in.Topic, applicantUserId, consts.ImGroupRoleMember, "approval", actor, now); txErr != nil { + return txErr + } + } + result, txErr := tx.Exec(`UPDATE im_group_join_requests + SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 + WHERE topic=? AND request_id=? AND status=?`, + status, in.RejectReason, actor, now, now, in.Topic, in.RequestId, consts.ImGroupRequestStatusPending) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("入群申请已处理") + } + if txErr = s.recountGroupMembers(ctx, tx, in.Topic); txErr != nil { + return txErr + } + after, txErr := s.groupJoinRequestSnapshot(ctx, tx, in.Topic, in.RequestId) + if txErr != nil { + return txErr + } + action := "join_request_reject" + if status == consts.ImGroupRequestStatusAccepted { + action = "join_request_accept" + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, applicantUserId, action, "join_request", in.RequestId, recordMap(before), recordMap(after), requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.join_request."+status, g.Map{ + "topic": in.Topic, "request_id": in.RequestId, "applicant_user_id": applicantUserId, "status": status, "request_id_admin": requestId, + }) + }) +} + +// RoleUpdate 更新IM群成员角色 +func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { return nil, err } - if in.ActorUserId <= 0 || in.TargetUserId <= 0 { - return nil, gerror.New("操作IM用户ID和新群主ID不能为空") + switch in.Role { + case consts.ImGroupRoleAdmin: + return s.AdminSet(ctx, in) + case consts.ImGroupRoleMember: + return s.AdminRemove(ctx, in) + default: + return nil, gerror.New("只能设置管理员或普通成员") } - payload := map[string]any{ - "actor_user_id": imUserId(in.ActorUserId), - "topic": in.Topic, - "target_user_id": imUserId(in.TargetUserId), - "version": in.Version, - } - return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupOwnerTransfer, in.Topic, int64(in.Version), payload) } -func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, eventType string, topic string, version int64, payload map[string]any) (*sysin.ImGroupCommandModel, error) { - _, tenant, err := imtenant.DB(ctx, tenantId) +func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_ids": []string{strconv.FormatInt(in.UserId, 10)}, + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupAdminSet, payload, 0) +} + +func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupAdminRemove, payload, 0) +} + +// OwnerTransfer 转让IM群主 +func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "target_user_id": strconv.FormatInt(in.UserId, 10), + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupOwnerTransfer, payload, 0) +} + +func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, topic string, eventType string, payload map[string]any, version int64) (*sysin.ImGroupCommandModel, error) { + _, tenant, err := resolveImTenantDB(ctx, tenantId) if err != nil { return nil, err } @@ -663,16 +620,385 @@ func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, e }, nil } -func imUserId(id int64) string { - return strconv.FormatInt(id, 10) +func (s *sSysImGroup) groupGovernanceSnapshot(ctx context.Context, tx gdb.TX, topic string) (res gdb.Record, err error) { + return tx.Model("im_group_settings").Safe().Ctx(ctx). + Fields( + "topic", "owner_user_id", "name", "group_status", "join_approval", "mute_all", + "member_upload_files", "member_private_chat", "member_invite", "max_member_count", + "max_admin_count", "member_count", "version", "created_by", "dissolved_by", + "dissolved_at", "created_at", "updated_at", + ). + Where("topic", topic). + One() } -func imUserIds(ids []int64) []string { - list := make([]string, 0, len(ids)) - for _, id := range ids { - if id > 0 { - list = append(list, imUserId(id)) +func (s *sSysImGroup) groupJoinRequestSnapshot(ctx context.Context, tx gdb.TX, topic string, requestId string) (res gdb.Record, err error) { + return tx.Model("im_group_join_requests").Safe().Ctx(ctx). + Where("topic", topic). + Where("request_id", requestId). + One() +} + +func (s *sSysImGroup) groupMemberSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) { + return tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("user_id", userId). + One() +} + +func (s *sSysImGroup) groupFileSnapshot(ctx context.Context, tx gdb.TX, topic string, groupFileId string) (res gdb.Record, err error) { + return tx.Model("im_group_files").Safe().Ctx(ctx). + Where("topic", topic). + Where("group_file_id", groupFileId). + One() +} + +func (s *sSysImGroup) ensureActiveGroup(ctx context.Context, tx gdb.TX, topic string) error { + status, err := tx.Model("im_group_settings").Safe().Ctx(ctx).Where("topic", topic).Value("group_status") + if err != nil { + return err + } + if status.IsNil() { + return gerror.New("群治理配置不存在") + } + if status.String() == consts.ImGroupStatusDissolved { + return gerror.New("已解散群不能执行该操作") + } + return nil +} + +func (s *sSysImGroup) groupMuteSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) { + return tx.Model("im_group_mutes").Safe().Ctx(ctx). + Fields( + "topic", "user_id", "muted_by", "muted_at", "expires_at", "reason", "status", + "revoked_by", "revoked_at", "version", "created_at", "updated_at", + ). + Where("topic", topic). + Where("user_id", userId). + One() +} + +func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64) error { + count, err := tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("user_id", userId). + Where("member_status", consts.ImGroupMemberStatusActive). + Count() + if err != nil { + return err + } + if count == 0 { + return gerror.New("成员不在群内或不是正常状态") + } + return nil +} + +func (s *sSysImGroup) ensureAdminLimit(ctx context.Context, tx gdb.TX, topic string, userId int64) error { + currentRole, err := tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("user_id", userId). + Where("member_status", consts.ImGroupMemberStatusActive). + Value("role") + if err != nil { + return err + } + if currentRole.String() == consts.ImGroupRoleAdmin { + return nil + } + maxAdminCount, err := tx.Model("im_group_settings").Safe().Ctx(ctx).Where("topic", topic).Value("max_admin_count") + if err != nil { + return err + } + if maxAdminCount.Int() <= 0 { + return nil + } + adminCount, err := tx.Model("im_group_members").Safe().Ctx(ctx). + Where("topic", topic). + Where("role", consts.ImGroupRoleAdmin). + Where("member_status", consts.ImGroupMemberStatusActive). + Count() + if err != nil { + return err + } + if adminCount >= maxAdminCount.Int() { + return gerror.New("群管理员数量已达上限") + } + return nil +} + +func (s *sSysImGroup) upsertGroupSubscription(ctx context.Context, tx gdb.TX, topic string, userId int64, modeGiven string, now *gtime.Time) error { + _, err := tx.Exec(`INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modewant,modegiven,private) + VALUES(?,?,NULL,?,?,?,?,CAST('{}' AS JSON)) + ON DUPLICATE KEY UPDATE createdat=VALUES(createdat),updatedat=VALUES(updatedat),deletedat=NULL, + modewant=VALUES(modewant),modegiven=VALUES(modegiven),delid=0,recvseqid=0,readseqid=0`, + now, now, userId, topic, imGroupModeMember, modeGiven) + return err +} + +func (s *sSysImGroup) upsertGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64, role string, joinSource string, joinedBy int64, now *gtime.Time) error { + _, err := tx.Exec(`INSERT INTO im_group_members(topic,user_id,role,member_status,join_source,joined_by,joined_at,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),join_source=VALUES(join_source), + joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`, + topic, userId, role, consts.ImGroupMemberStatusActive, joinSource, joinedBy, now, 1, now, now) + return err +} + +func (s *sSysImGroup) recountGroupMembers(ctx context.Context, tx gdb.TX, topic string) error { + if _, err := tx.Exec(`UPDATE im_group_settings + SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) + WHERE topic=?`, topic, consts.ImGroupMemberStatusActive, topic); err != nil { + return err + } + _, err := tx.Exec(`UPDATE im_topics + SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic=? AND deletedat IS NULL),updatedat=CURRENT_TIMESTAMP(3) + WHERE name=?`, topic, topic) + return err +} + +func recordMap(record gdb.Record) any { + if record == nil { + return nil + } + return record.Map() +} + +func (s *sSysImGroup) insertGroupAdminLog(ctx context.Context, tx gdb.TX, topic string, operatorId int64, targetUserId int64, action string, objectType string, objectId string, beforeData any, afterData any, requestId string) error { + id, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成群管理日志ID失败") + } + logId := fmt.Sprintf("gal_%d", id) + beforeJson, _ := json.Marshal(beforeData) + afterJson, _ := json.Marshal(afterData) + _, err = tx.Exec(`INSERT INTO im_group_admin_logs + (log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key) + VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?,?)`, + logId, topic, operatorId, targetUserId, action, objectType, objectId, string(beforeJson), string(afterJson), requestId, requestId) + return err +} + +func (s *sSysImGroup) insertGroupEvent(ctx context.Context, tx gdb.TX, topic string, actorId int64, eventType string, payload any) error { + id, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成群事件ID失败") + } + payloadJson, _ := json.Marshal(payload) + _, err = tx.Exec(`INSERT INTO im_group_events_outbox + (event_id,topic,actor_user_id,event_type,payload,status) + VALUES(?,?,?,?,CAST(? AS JSON),'pending')`, + fmt.Sprintf("gev_%d", id), topic, actorId, eventType, string(payloadJson)) + return err +} + +// JoinRequestList 获取IM入群申请列表 +func (s *sSysImGroup) JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_join_requests").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.ApplicantUserId > 0 { + mod = mod.Where("applicant_user_id", in.ApplicantUserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM入群申请列表失败,请稍后重试!") + } + return +} + +// FileList 获取IM群文件列表 +func (s *sSysImGroup) FileList(ctx context.Context, in *sysin.ImGroupFileListInp) (list []*sysin.ImGroupFileListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_files").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.FileType != "" { + mod = mod.Where("file_type", in.FileType) + } + if in.FileName != "" { + mod = mod.WhereLike("file_name", "%"+in.FileName+"%") + } + if in.UploaderId > 0 { + mod = mod.Where("uploader_id", in.UploaderId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群文件列表失败,请稍后重试!") + } + return +} + +// FileDelete 删除IM群文件 +func (s *sSysImGroup) FileDelete(ctx context.Context, in *sysin.ImGroupFileDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + actor := contexts.GetUserId(ctx) + requestId := gctx.CtxId(ctx) + if requestId == "" { + requestId = guid.S() + } + now := gtime.Now() + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { + return err + } + before, txErr := s.groupFileSnapshot(ctx, tx, in.Topic, in.GroupFileId) + if txErr != nil { + return txErr + } + if before == nil { + return gerror.New("群文件不存在") + } + if before["status"].String() == consts.ImGroupFileStatusDeleted { + return gerror.New("群文件已删除") + } + result, txErr := tx.Exec(`UPDATE im_group_files + SET status=?,deleted_by=?,deleted_at=?,updated_at=? + WHERE topic=? AND group_file_id=? AND status<>?`, + consts.ImGroupFileStatusDeleted, actor, now, now, in.Topic, in.GroupFileId, consts.ImGroupFileStatusDeleted) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("群文件已删除") } + after, txErr := s.groupFileSnapshot(ctx, tx, in.Topic, in.GroupFileId) + if txErr != nil { + return txErr + } + if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, before["uploader_id"].Int64(), "group_file_delete", "group_file", in.GroupFileId, recordMap(before), recordMap(after), requestId); txErr != nil { + return txErr + } + return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.file.deleted", g.Map{ + "topic": in.Topic, "group_file_id": in.GroupFileId, "file_id": before["file_id"].String(), "request_id": requestId, + }) + }) +} + +// InvitationList 获取IM群邀请列表 +func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_invitations").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.TargetUserId > 0 { + mod = mod.Where("target_user_id", in.TargetUserId) + } + if in.InviterUserId > 0 { + mod = mod.Where("inviter_user_id", in.InviterUserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群邀请列表失败,请稍后重试!") + } + return +} + +// AnnouncementList 获取IM群公告列表 +func (s *sSysImGroup) AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_announcements").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("published_at").OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群公告列表失败,请稍后重试!") + } + return +} + +// MuteList 获取IM群禁言列表 +func (s *sSysImGroup) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_mutes").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.UserId > 0 { + mod = mod.Where("user_id", in.UserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("muted_at").OrderDesc("user_id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群禁言列表失败,请稍后重试!") + } + return +} + +// AdminLogList 获取IM群管理日志列表 +func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_admin_logs").Safe().Ctx(ctx). + Fields( + "id", + "log_id", + "topic", + "operator_id", + "target_user_id", + "action", + "object_type", + "object_id", + "CAST(before_data AS CHAR) before_data", + "CAST(after_data AS CHAR) after_data", + "request_id", + "idempotency_key", + "created_at", + ). + Where("topic", in.Topic) + if in.Action != "" { + mod = mod.WhereLike("action", "%"+in.Action+"%") + } + if in.OperatorId > 0 { + mod = mod.Where("operator_id", in.OperatorId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群管理日志列表失败,请稍后重试!") } - return list + return } diff --git a/server/internal/logic/sys/im_invite.go b/server/internal/logic/sys/im_invite.go index 13bdfd0..d2f93e5 100644 --- a/server/internal/logic/sys/im_invite.go +++ b/server/internal/logic/sys/im_invite.go @@ -2,247 +2,512 @@ package sys import ( "context" + cryptorand "crypto/rand" + "encoding/csv" + "errors" "fmt" - "hotgo/internal/library/imadmin" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/internal/service" - "io" + "net/url" "strconv" + "strings" "time" + mysqldriver "github.com/go-sql-driver/mysql" + "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/net/ghttp" "github.com/gogf/gf/v2/os/gtime" ) +const inviteGenerateRetries = 10 + +var errInviteBatchNotFound = errors.New("invite batch not found") + type sSysImInvite struct{} -func NewSysImInvite() *sSysImInvite { - return &sSysImInvite{} -} +func NewSysImInvite() *sSysImInvite { return &sSysImInvite{} } -func init() { - service.RegisterSysImInvite(NewSysImInvite()) -} +func init() { service.RegisterSysImInvite(NewSysImInvite()) } type imInviteListRequest struct { - Page int `json:"page,omitempty"` - PerPage int `json:"per_page,omitempty"` - InviteCode string `json:"invite_code,omitempty"` - Status string `json:"status,omitempty"` - BatchID int64 `json:"batch_id,omitempty"` - CreatedBy int64 `json:"created_by,omitempty"` - CreatedAtStart string `json:"created_at_start,omitempty"` - CreatedAtEnd string `json:"created_at_end,omitempty"` - ExpiresAtStart string `json:"expires_at_start,omitempty"` - ExpiresAtEnd string `json:"expires_at_end,omitempty"` -} - -type imInviteCreateRequest struct { - ExpiresAt string `json:"expires_at"` - Remark string `json:"remark,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` -} - -type imInviteBatchCreateRequest struct { - Quantity int `json:"quantity"` - ExpiresAt string `json:"expires_at"` - Remark string `json:"remark,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` + TenantID int64 + InviteCode string + Status string + BatchID int64 + CreatedBy int64 + CreatedAtStart *time.Time + CreatedAtEnd *time.Time + ExpiresAtStart *time.Time + ExpiresAtEnd *time.Time } type imInviteCodeResponse struct { - ID int64 `json:"id"` - BatchID *int64 `json:"batch_id"` - Code string `json:"code"` - Status string `json:"status"` - ExpiresAt *time.Time `json:"expires_at"` - UsedBy *int64 `json:"used_by"` - UsedAt *time.Time `json:"used_at"` - RequestID string `json:"request_id"` - CreatedBy int64 `json:"created_by"` - Remark string `json:"remark"` - CreatedAt *time.Time `json:"created_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + BatchID *int64 `json:"batch_id"` + Code string `json:"code"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + MaxUses int `json:"max_uses"` + UsageCount int `json:"usage_count"` + BoundUserID *int64 `json:"bound_user_id"` + BoundUserLabel string `json:"bound_user_label"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedBy int64 `json:"created_by"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` } type imInviteBatchResponse struct { - BatchID int64 `json:"batch_id"` - Quantity int `json:"quantity"` - ActiveCount int `json:"active_count"` - UsedCount int `json:"used_count"` - RevokedCount int `json:"revoked_count"` - ExpiredCount int `json:"expired_count"` - ExpiresAt *time.Time `json:"expires_at"` - CreatedAt *time.Time `json:"created_at"` + BatchID int64 `json:"batch_id"` + TenantID int64 `json:"tenant_id"` + Quantity int `json:"quantity"` + IdempotencyKey string `json:"idempotency_key"` + Remark string `json:"remark"` + ActiveCount int `json:"active_count"` + UsageCount int `json:"usage_count"` + RevokedCount int `json:"revoked_count"` + ExpiredCount int `json:"expired_count"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` } func (s *sSysImInvite) List(ctx context.Context, in *sysin.ImInviteListInp) (list []*sysin.ImInviteCodeModel, totalCount int, err error) { if err = in.Filter(ctx); err != nil { return } - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteQueryDB(ctx, in.TenantId) if err != nil { - return + return nil, 0, err } req, err := buildInviteListRequest(in) if err != nil { - return + return nil, 0, err } - var page imadmin.Page[imInviteCodeResponse] - if err = imadmin.DefaultClient(ctx).ListCodes(ctx, scope, req, &page); err != nil { - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, "failed", g.Map{"hasInviteCode": in.InviteCode != ""}) - return + if !validInviteStatus(req.Status) { + return nil, 0, gerror.New("邀请码状态不正确") } - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, "success", g.Map{"hasInviteCode": in.InviteCode != ""}) - list = make([]*sysin.ImInviteCodeModel, 0, len(page.Items)) - for i := range page.Items { - list = append(list, convertInviteCode(page.Items[i])) + req.TenantID = scope.TenantId + items, total, err := queryInviteCodes(ctx, db, req, in.PerPage, (in.Page-1)*in.PerPage) + result := "success" + if err != nil { + result = "failed" + } else { + list = convertInviteCodes(items) + totalCount = total } - totalCount = page.Total - return + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, result, g.Map{"hasInviteCode": in.InviteCode != ""}) + return list, totalCount, err } func (s *sSysImInvite) Create(ctx context.Context, in *sysin.ImInviteCreateInp) (res *sysin.ImInviteCodeModel, err error) { if err = in.Filter(ctx); err != nil { return } - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteDB(ctx, in.TenantId) if err != nil { - return + return nil, err + } + expiresAt := in.ExpiresAt.Time.UTC().Round(time.Millisecond) + var item imInviteCodeResponse + if key := strings.TrimSpace(in.IdempotencyKey); key != "" { + batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key) + if createErr != nil { + err = createErr + } else if len(codes) > 0 { + item = codes[0] + } else { + codes, _, err = queryInviteCodes(ctx, db, imInviteListRequest{TenantID: scope.TenantId, BatchID: batch.BatchID}, 1, 0) + if err == nil && len(codes) > 0 { + item = codes[0] + } else if err == nil { + err = gerror.New("邀请码生成结果不存在") + } + } + } else { + item, err = createSingleInvite(ctx, db, scope.TenantId, scope.OperatorId, expiresAt, in.Remark) } - req := imInviteCreateRequest{ExpiresAt: formatRFC3339(in.ExpiresAt), Remark: in.Remark, IdempotencyKey: in.IdempotencyKey} - var upstream imInviteCodeResponse - err = imadmin.DefaultClient(ctx).CreateCode(ctx, scope, req, &upstream) - res = convertInviteCode(upstream) result := "success" if err != nil { result = "failed" + } else { + res = convertInviteCode(item) } - targetId, _ := strconv.ParseInt(res.Id, 10, 64) - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_create", "invite_code", targetId, result, g.Map{"expiresAt": in.ExpiresAt, "remark": in.Remark}) - return + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_create", "invite_code", item.ID, result, g.Map{"expiresAt": in.ExpiresAt, "remark": in.Remark}) + return res, err } func (s *sSysImInvite) BatchCreate(ctx context.Context, in *sysin.ImInviteBatchCreateInp) (res *sysin.ImInviteBatchModel, err error) { if err = in.Filter(ctx); err != nil { return } - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteDB(ctx, in.TenantId) if err != nil { - return + return nil, err } - req := imInviteBatchCreateRequest{Quantity: in.Quantity, ExpiresAt: formatRFC3339(in.ExpiresAt), Remark: in.Remark, IdempotencyKey: in.IdempotencyKey} - var upstream imInviteBatchResponse - err = imadmin.DefaultClient(ctx).CreateBatch(ctx, scope, req, &upstream) - res = convertInviteBatch(upstream) + key := strings.TrimSpace(in.IdempotencyKey) + batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key) result := "success" if err != nil { result = "failed" + } else { + res = convertInviteBatch(batch) } - targetId, _ := strconv.ParseInt(res.BatchId, 10, 64) - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", targetId, result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark}) - return + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark}) + return res, err } func (s *sSysImInvite) BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error) { - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteDB(ctx, in.TenantId) if err != nil { - return + return nil, err } - batchId, err := sysin.ParseOptionalInt64(in.BatchId) + id, err := sysin.ParseOptionalInt64(in.BatchId) if err != nil { - return + return nil, err } - var upstream imInviteBatchResponse - err = imadmin.DefaultClient(ctx).GetBatch(ctx, scope, batchId, &upstream) - res = &sysin.ImInviteBatchDetailModel{ImInviteBatchModel: *convertInviteBatch(upstream)} - return + batch, err := getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id) + if err != nil { + return nil, err + } + codes, _, err := queryInviteCodes(ctx, db, imInviteListRequest{TenantID: scope.TenantId, BatchID: id}, batch.Quantity, 0) + if err != nil { + return nil, err + } + return &sysin.ImInviteBatchDetailModel{ImInviteBatchModel: *convertInviteBatch(batch), List: convertInviteCodes(codes)}, nil } func (s *sSysImInvite) Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error) { - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteDB(ctx, in.TenantId) if err != nil { - return + return err } id, err := sysin.ParseOptionalInt64(in.Id) if err != nil { - return + return err } - err = imadmin.DefaultClient(ctx).RevokeCode(ctx, scope, id) - result := "success" + now := time.Now().UTC().Round(time.Millisecond) + result, err := db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='revoked',updated_at=? + WHERE tenant_id=? AND id=? AND status='active' AND expires_at>?`, now, scope.TenantId, id, now) + if err == nil { + if affected, _ := result.RowsAffected(); affected == 0 { + err = gerror.New("有效邀请码不存在") + } + } + auditResult := "success" if err != nil { - result = "failed" + auditResult = "failed" } - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_revoke", "invite_code", id, result, nil) - return + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_revoke", "invite_code", id, auditResult, nil) + return err +} + +func (s *sSysImInvite) BindUser(ctx context.Context, in *sysin.ImInviteBindUserInp) (err error) { + if err = in.Filter(ctx); err != nil { + return err + } + scope, db, err := inviteDB(ctx, in.TenantId) + if err != nil { + return err + } + id, err := sysin.ParseOptionalInt64(in.Id) + if err != nil || id <= 0 { + return gerror.New("邀请码ID不能为空") + } + tenant, err := resolveImAdminTenant(ctx, scope.TenantId) + if err != nil { + return err + } + label, err := inviteBoundUserLabel(ctx, tenant, in.UserId) + if err != nil { + return err + } + now := time.Now().UTC().Round(time.Millisecond) + result, err := db.Exec(ctx, `UPDATE im_registration_invite_codes + SET bound_user_id=?,bound_user_label=?,updated_at=? + WHERE tenant_id=? AND id=? AND status='active' AND usage_count=0 AND (bound_user_id IS NULL OR bound_user_id=0)`, + in.UserId, label, now, scope.TenantId, id) + if err == nil { + if affected, _ := result.RowsAffected(); affected == 0 { + err = gerror.New("邀请码不存在、已使用、已绑定或不可绑定") + } + } + auditResult := "success" + if err != nil { + auditResult = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_bind_user", "invite_code", id, auditResult, g.Map{"user_id": in.UserId}) + if err != nil { + return err + } + return nil } func (s *sSysImInvite) BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error) { - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteDB(ctx, in.TenantId) if err != nil { - return + return nil, err } - batchId, err := sysin.ParseOptionalInt64(in.BatchId) + id, err := sysin.ParseOptionalInt64(in.BatchId) if err != nil { - return + return nil, err } - var upstream struct { - Batch *imInviteBatchResponse `json:"batch"` - RevokedCount int `json:"revoked_count"` + if _, err = getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id); err == nil { + now := time.Now().UTC().Round(time.Millisecond) + _, err = db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='revoked',updated_at=? + WHERE tenant_id=? AND batch_id=? AND status='active' AND expires_at>?`, now, scope.TenantId, id, now) } - err = imadmin.DefaultClient(ctx).RevokeBatch(ctx, scope, batchId, &upstream) - if upstream.Batch != nil { - res = convertInviteBatch(*upstream.Batch) + var batch *imInviteBatchResponse + if err == nil { + batch, err = getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id) + } + auditResult := "success" + if err != nil { + auditResult = "failed" } else { - res = &sysin.ImInviteBatchModel{BatchId: strconv.FormatInt(batchId, 10), RevokedCount: upstream.RevokedCount} + res = convertInviteBatch(batch) } - result := "success" - if err != nil { - result = "failed" - } - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_revoke", "invite_batch", batchId, result, nil) - return + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_revoke", "invite_batch", id, auditResult, nil) + return res, err } func (s *sSysImInvite) Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) { if err = in.Filter(ctx); err != nil { - return + return err } - scope, err := resolveImAdminScope(ctx, in.TenantId) + scope, db, err := inviteQueryDB(ctx, in.TenantId) if err != nil { - return + return err } req, err := buildInviteListRequest(in) - if err != nil { - return + if err != nil || !validInviteStatus(req.Status) { + return gerror.New("导出条件不正确") } - resp, err := imadmin.DefaultClient(ctx).ExportCodes(ctx, scope, req) - if err != nil { - _ = writeImAdminAudit(ctx, scope.TenantId, "invite_export", "invite_code", 0, "failed", g.Map{"hasInviteCode": in.InviteCode != ""}) - return - } - defer resp.Body.Close() + req.TenantID = scope.TenantId request := ghttp.RequestFromCtx(ctx) if request == nil { return gerror.New("请求上下文不存在") } - filename := resp.Header.Get("Content-Disposition") - if filename == "" { - filename = `attachment; filename="registration-invite-codes.csv"` - } - request.Response.Header().Set("Content-Type", resp.Header.Get("Content-Type")) - request.Response.Header().Set("Content-Disposition", filename) - if _, err = io.Copy(request.Response.Writer, resp.Body); err != nil { - return gerror.Wrap(err, "转发导出文件失败") + request.Response.Header().Set("Content-Type", "text/csv; charset=utf-8") + request.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=registration-invite-codes-%s.csv", time.Now().Format("20060102-150405"))) + _, _ = request.Response.Writer.Write([]byte{0xef, 0xbb, 0xbf}) + writer := csv.NewWriter(request.Response.Writer) + _ = writer.Write([]string{"tenant_id", "tenant_name", "code", "status", "expires_at", "usage_count", "available_uses", "last_used_at", "created_by", "created_at", "remark"}) + for offset := 0; ; offset += 100 { + items, total, queryErr := queryInviteCodes(ctx, db, req, 100, offset) + if queryErr != nil { + return gerror.Wrap(queryErr, "导出邀请码失败") + } + for _, item := range items { + _ = writer.Write([]string{strconv.FormatInt(item.TenantID, 10), item.TenantName, item.Code, item.Status, item.ExpiresAt.Format(time.RFC3339), strconv.Itoa(item.UsageCount), formatInviteAvailableUses(item.MaxUses, item.UsageCount), formatTime(item.LastUsedAt), strconv.FormatInt(item.CreatedBy, 10), item.CreatedAt.Format(time.RFC3339), item.Remark}) + } + writer.Flush() + if writer.Error() != nil { + return writer.Error() + } + if offset+len(items) >= total || len(items) == 0 { + break + } } _ = writeImAdminAudit(ctx, scope.TenantId, "invite_export", "invite_code", 0, "success", g.Map{"hasInviteCode": in.InviteCode != ""}) - fmt.Fprint(request.Response.Writer, "") - return + return nil +} + +func inviteDB(ctx context.Context, selectedTenantID int64) (imAdminScope, gdb.DB, error) { + scope, err := resolveImAdminScope(ctx, selectedTenantID) + if err != nil { + return scope, nil, err + } + return scope, imtenant.RegistryDB(), nil +} + +func inviteQueryDB(ctx context.Context, selectedTenantID int64) (imAdminScope, gdb.DB, error) { + scope, err := resolveImAdminScopeWithAll(ctx, selectedTenantID, true) + if err != nil { + return scope, nil, err + } + return scope, imtenant.RegistryDB(), nil +} + +func createSingleInvite(ctx context.Context, db gdb.DB, tenantID, operatorID int64, expiresAt time.Time, remark string) (imInviteCodeResponse, error) { + for attempt := 0; attempt < inviteGenerateRetries; attempt++ { + id, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return imInviteCodeResponse{}, err + } + code, err := randomInviteCode() + if err != nil { + return imInviteCodeResponse{}, err + } + now := time.Now().UTC().Round(time.Millisecond) + _, err = db.Exec(ctx, `INSERT INTO im_registration_invite_codes + (id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,NULL,?,'active',?,1,0,NULL,?,?,?,?)`, id, tenantID, code, expiresAt, operatorID, remark, now, now) + if err == nil { + return imInviteCodeResponse{ID: id, TenantID: tenantID, Code: code, Status: "active", ExpiresAt: expiresAt, MaxUses: 1, CreatedBy: operatorID, Remark: remark, CreatedAt: now}, nil + } + if !isDuplicateError(err) { + return imInviteCodeResponse{}, err + } + } + return imInviteCodeResponse{}, gerror.New("无法生成不重复的邀请码,请重试") +} + +func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string) (*imInviteBatchResponse, []imInviteCodeResponse, error) { + if existing, err := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); err == nil { + if !sameInviteBatch(existing, quantity, expiresAt, remark) { + return nil, nil, gerror.New("幂等键已被其他批量生成请求使用") + } + return existing, nil, nil + } else if !errors.Is(err, errInviteBatchNotFound) { + return nil, nil, err + } + for attempt := 0; attempt < inviteGenerateRetries; attempt++ { + batchID, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return nil, nil, err + } + codes := make([]imInviteCodeResponse, quantity) + seen := make(map[string]struct{}, quantity) + for i := range codes { + for { + code, randomErr := randomInviteCode() + if randomErr != nil { + return nil, nil, randomErr + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + codes[i].Code = code + break + } + codes[i].ID, err = imtenant.NextSnowflakeID(ctx) + if err != nil { + return nil, nil, err + } + codes[i].BatchID = &batchID + } + now := time.Now().UTC().Round(time.Millisecond) + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_batches + (id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, batchID, tenantID, quantity, key, expiresAt, operatorID, remark, now, now); txErr != nil { + return txErr + } + for i := range codes { + if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_codes + (id,tenant_id,batch_id,code,status,expires_at,max_uses,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,'active',?,0,0,NULL,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now); txErr != nil { + return txErr + } + codes[i].TenantID, codes[i].Status, codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, codes[i].CreatedAt = tenantID, "active", expiresAt, operatorID, remark, now + } + return nil + }) + if err == nil { + return &imInviteBatchResponse{BatchID: batchID, TenantID: tenantID, Quantity: quantity, IdempotencyKey: key, Remark: remark, ActiveCount: quantity, ExpiresAt: expiresAt, CreatedAt: now}, codes, nil + } + if !isDuplicateError(err) { + return nil, nil, err + } + if existing, lookupErr := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); lookupErr == nil { + if !sameInviteBatch(existing, quantity, expiresAt, remark) { + return nil, nil, gerror.New("幂等键已被其他批量生成请求使用") + } + return existing, nil, nil + } else if !errors.Is(lookupErr, errInviteBatchNotFound) { + return nil, nil, lookupErr + } + } + return nil, nil, gerror.New("无法生成不重复的邀请码,请重试") +} + +func queryInviteCodes(ctx context.Context, db gdb.DB, req imInviteListRequest, limit, offset int) ([]imInviteCodeResponse, int, error) { + now := time.Now().UTC().Round(time.Millisecond) + expireWhere := "status='active' AND expires_at<=?" + expireArgs := []any{now, now} + if req.TenantID > 0 { + expireWhere = "tenant_id=? AND " + expireWhere + expireArgs = []any{now, req.TenantID, now} + } + if _, err := db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='expired',updated_at=? WHERE `+expireWhere, expireArgs...); err != nil { + return nil, 0, err + } + where, args := inviteWhere(req) + totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_registration_invite_codes c WHERE "+where, args...) + if err != nil { + return nil, 0, err + } + queryArgs := append(append([]any{}, args...), limit, offset) + items := make([]imInviteCodeResponse, 0) + err = db.Raw(`SELECT c.id,c.tenant_id,t.name tenant_name,c.batch_id,c.code,c.status,c.expires_at,c.max_uses,c.bound_user_id,c.bound_user_label,c.created_by,c.remark,c.created_at, + c.usage_count,c.last_used_at + FROM im_registration_invite_codes c + LEFT JOIN im_tenant t ON t.id=c.tenant_id + WHERE `+where+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...).Ctx(ctx).Scan(&items) + return items, totalValue.Int(), err +} + +func inviteWhere(req imInviteListRequest) (string, []any) { + where := []string{"1=1"} + args := make([]any, 0, 8) + if req.TenantID > 0 { + where, args = append(where, "c.tenant_id=?"), append(args, req.TenantID) + } + if req.InviteCode != "" { + where, args = append(where, "c.code=?"), append(args, req.InviteCode) + } + if req.Status != "" { + where, args = append(where, "c.status=?"), append(args, req.Status) + } + if req.BatchID > 0 { + where, args = append(where, "c.batch_id=?"), append(args, req.BatchID) + } + if req.CreatedBy > 0 { + where, args = append(where, "c.created_by=?"), append(args, req.CreatedBy) + } + if req.CreatedAtStart != nil { + where, args = append(where, "c.created_at>=?"), append(args, *req.CreatedAtStart) + } + if req.CreatedAtEnd != nil { + where, args = append(where, "c.created_at<=?"), append(args, *req.CreatedAtEnd) + } + if req.ExpiresAtStart != nil { + where, args = append(where, "c.expires_at>=?"), append(args, *req.ExpiresAtStart) + } + if req.ExpiresAtEnd != nil { + where, args = append(where, "c.expires_at<=?"), append(args, *req.ExpiresAtEnd) + } + return strings.Join(where, " AND "), args +} + +func getInviteBatch(ctx context.Context, db gdb.DB, tenantID int64, where string, arg any) (*imInviteBatchResponse, error) { + items := make([]imInviteBatchResponse, 0, 1) + now := time.Now().UTC().Round(time.Millisecond) + err := db.Raw(`SELECT b.id batch_id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.remark,b.created_at, + COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) active_count, + COALESCE(SUM(c.usage_count),0) usage_count, + COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) expired_count, + COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) revoked_count + FROM im_registration_invite_batches b LEFT JOIN im_registration_invite_codes c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id + WHERE b.tenant_id=? AND `+where+` GROUP BY b.id LIMIT 1`, now, now, tenantID, arg).Ctx(ctx).Scan(&items) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, gerror.Wrap(errInviteBatchNotFound, "邀请码批次不存在") + } + return &items[0], nil } func buildInviteListRequest(in *sysin.ImInviteListInp) (imInviteListRequest, error) { - batchId, err := sysin.ParseOptionalInt64(in.BatchId) + batchID, err := sysin.ParseOptionalInt64(in.BatchId) if err != nil { return imInviteListRequest{}, err } @@ -250,32 +515,120 @@ func buildInviteListRequest(in *sysin.ImInviteListInp) (imInviteListRequest, err if err != nil { return imInviteListRequest{}, err } - return imInviteListRequest{ - Page: in.Page, - PerPage: in.PerPage, - InviteCode: in.InviteCode, - Status: in.Status, - BatchID: batchId, - CreatedBy: createdBy, - CreatedAtStart: formatRFC3339(in.CreatedAtStart), - CreatedAtEnd: formatRFC3339(in.CreatedAtEnd), - ExpiresAtStart: formatRFC3339(in.ExpiresAtStart), - ExpiresAtEnd: formatRFC3339(in.ExpiresAtEnd), - }, nil + return imInviteListRequest{InviteCode: in.InviteCode, Status: in.Status, BatchID: batchID, CreatedBy: createdBy, + CreatedAtStart: nativeTime(in.CreatedAtStart), CreatedAtEnd: nativeTime(in.CreatedAtEnd), + ExpiresAtStart: nativeTime(in.ExpiresAtStart), ExpiresAtEnd: nativeTime(in.ExpiresAtEnd)}, nil } -func formatRFC3339(t *gtime.Time) string { - if t == nil { - return "" +func randomInviteCode() (string, error) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + result := make([]byte, 5) + buffer := make([]byte, 10) + for index := 0; index < len(result); { + if _, err := cryptorand.Read(buffer); err != nil { + return "", err + } + for _, value := range buffer { + if value >= 208 { + continue + } + result[index] = alphabet[int(value)%len(alphabet)] + index++ + if index == len(result) { + break + } + } } - return t.Time.UTC().Format(time.RFC3339) + return string(result), nil } -func convertTime(t *time.Time) *gtime.Time { - if t == nil { +func isDuplicateError(err error) bool { + var mysqlErr *mysqldriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} + +func validInviteStatus(status string) bool { + return status == "" || status == "active" || status == "expired" || status == "revoked" +} + +func sameInviteBatch(batch *imInviteBatchResponse, quantity int, expiresAt time.Time, remark string) bool { + return batch != nil && batch.Quantity == quantity && + batch.ExpiresAt.UTC().Round(time.Millisecond).Equal(expiresAt.UTC().Round(time.Millisecond)) && batch.Remark == remark +} + +func convertInviteCodes(items []imInviteCodeResponse) []*sysin.ImInviteCodeModel { + list := make([]*sysin.ImInviteCodeModel, 0, len(items)) + for _, item := range items { + list = append(list, convertInviteCode(item)) + } + return list +} + +func convertInviteCode(item imInviteCodeResponse) *sysin.ImInviteCodeModel { + return &sysin.ImInviteCodeModel{Id: strconv.FormatInt(item.ID, 10), BatchId: formatOptionalInt64(item.BatchID), Code: item.Code, + TenantId: strconv.FormatInt(item.TenantID, 10), TenantName: item.TenantName, + Status: item.Status, ExpiresAt: gtime.New(item.ExpiresAt), MaxUses: item.MaxUses, UsageCount: item.UsageCount, + BoundUserId: formatOptionalInt64(item.BoundUserID), BoundUserLabel: item.BoundUserLabel, LastUsedAt: convertTime(item.LastUsedAt), + CreatedBy: strconv.FormatInt(item.CreatedBy, 10), Remark: item.Remark, CreatedAt: gtime.New(item.CreatedAt)} +} + +func inviteBoundUserLabel(ctx context.Context, tenant *entity.ImTenant, userID int64) (string, error) { + if tenant == nil || tenant.Code == "" || userID <= 0 { + return "", gerror.New("绑定用户不合法") + } + values := addPageQuery(url.Values{}, 1, 1) + values.Set("user_ids", strconv.FormatInt(userID, 10)) + users, _, err := imAdminQueryList[sysin.ImUserListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users", + values, + map[string]string{ + "public": "profileJson", + "trusted": "trustedJson", + "access": "accessJson", + }, + ) + if err != nil { + return "", err + } + if len(users) == 0 { + return "", gerror.New("绑定用户不存在") + } + user := users[0] + if user.Nickname != "" { + return fmt.Sprintf("%s(%s)", user.Nickname, user.UserId), nil + } + if user.Username != "" { + return fmt.Sprintf("%s(%s)", user.Username, user.UserId), nil + } + if user.Phone != "" { + return fmt.Sprintf("%s(%s)", user.Phone, user.UserId), nil + } + return user.UserId, nil +} + +func convertInviteBatch(item *imInviteBatchResponse) *sysin.ImInviteBatchModel { + if item == nil { return nil } - return gtime.New(t) + return &sysin.ImInviteBatchModel{BatchId: strconv.FormatInt(item.BatchID, 10), Quantity: item.Quantity, ActiveCount: item.ActiveCount, + TenantId: strconv.FormatInt(item.TenantID, 10), + UsageCount: item.UsageCount, RevokedCount: item.RevokedCount, ExpiredCount: item.ExpiredCount, + ExpiresAt: gtime.New(item.ExpiresAt), CreatedAt: gtime.New(item.CreatedAt)} +} + +func nativeTime(value *gtime.Time) *time.Time { + if value == nil { + return nil + } + t := value.Time.UTC() + return &t +} + +func convertTime(value *time.Time) *gtime.Time { + if value == nil { + return nil + } + return gtime.New(value) } func formatOptionalInt64(value *int64) string { @@ -285,31 +638,27 @@ func formatOptionalInt64(value *int64) string { return strconv.FormatInt(*value, 10) } -func convertInviteCode(item imInviteCodeResponse) *sysin.ImInviteCodeModel { - return &sysin.ImInviteCodeModel{ - Id: strconv.FormatInt(item.ID, 10), - BatchId: formatOptionalInt64(item.BatchID), - Code: item.Code, - Status: item.Status, - ExpiresAt: convertTime(item.ExpiresAt), - UsedBy: formatOptionalInt64(item.UsedBy), - UsedAt: convertTime(item.UsedAt), - RequestId: item.RequestID, - CreatedBy: strconv.FormatInt(item.CreatedBy, 10), - Remark: item.Remark, - CreatedAt: convertTime(item.CreatedAt), +func formatTime(value *time.Time) string { + if value == nil { + return "" } + return value.Format(time.RFC3339) } -func convertInviteBatch(item imInviteBatchResponse) *sysin.ImInviteBatchModel { - return &sysin.ImInviteBatchModel{ - BatchId: strconv.FormatInt(item.BatchID, 10), - Quantity: item.Quantity, - ActiveCount: item.ActiveCount, - UsedCount: item.UsedCount, - RevokedCount: item.RevokedCount, - ExpiredCount: item.ExpiredCount, - ExpiresAt: convertTime(item.ExpiresAt), - CreatedAt: convertTime(item.CreatedAt), +func formatInviteAvailableUses(maxUses, usageCount int) string { + if maxUses <= 0 { + return "不限制" } + available := maxUses - usageCount + if available < 0 { + available = 0 + } + return strconv.Itoa(available) +} + +func batchID(batch *imInviteBatchResponse) int64 { + if batch == nil { + return 0 + } + return batch.BatchID } diff --git a/server/internal/logic/sys/im_kafka_audit.go b/server/internal/logic/sys/im_kafka_audit.go index 6656bf1..59f7bf2 100644 --- a/server/internal/logic/sys/im_kafka_audit.go +++ b/server/internal/logic/sys/im_kafka_audit.go @@ -2,11 +2,12 @@ package sys import ( "context" + "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "strings" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" ) type sSysImKafkaAudit struct{} @@ -23,64 +24,118 @@ func (s *sSysImKafkaAudit) List(ctx context.Context, in *sysin.ImKafkaAuditListI if err = in.Filter(ctx); err != nil { return } - mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). - Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "occurred_at", "received_at") - if in.TenantCode != "" { - mod = mod.Where("tenant_code", in.TenantCode) + where, args := buildImKafkaAuditWhere(in) + db := imtenant.RegistryDB() + totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_audit_events "+where, args...) + if err != nil { + err = gerror.Wrap(err, "获取IM Kafka审计事件总数失败,请稍后重试!") + return } - if in.EventId != "" { - mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + totalCount = totalValue.Int() + + page := in.Page + if page <= 0 { + page = 1 } - if in.EventType != "" { - mod = mod.WhereLike("event_type", "%"+in.EventType+"%") + perPage := in.PerPage + if perPage <= 0 { + perPage = 20 } - if in.BizType != "" { - mod = mod.Where("biz_type", in.BizType) - } - if in.BizId != "" { - mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") - } - if in.ActorType != "" { - mod = mod.Where("actor_type", in.ActorType) - } - if in.ActorId != "" { - mod = mod.WhereLike("actor_id", "%"+in.ActorId+"%") - } - if in.Action != "" { - mod = mod.WhereLike("action", "%"+in.Action+"%") - } - if in.Resource != "" { - mod = mod.Where("resource", in.Resource) - } - if in.ResourceId != "" { - mod = mod.WhereLike("resource_id", "%"+in.ResourceId+"%") - } - if in.Result != "" { - mod = mod.Where("result", in.Result) - } - if len(in.CreatedAt) == 2 { - mod = mod.WhereBetween("received_at", in.CreatedAt[0], in.CreatedAt[1]) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + offset := (page - 1) * perPage + query := `SELECT id,event_id,tenant_code,event_type,biz_type,biz_id,actor_type,actor_id, + action,resource,resource_id,result,reason,occurred_at,received_at + FROM im_audit_events ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?` + queryArgs := append(append([]any{}, args...), perPage, offset) + rows, err := db.GetAll(ctx, query, queryArgs...) + if err != nil { err = gerror.Wrap(err, "获取IM Kafka审计事件列表失败,请稍后重试!") + return + } + if err = rows.Structs(&list); err != nil { + err = gerror.Wrap(err, "解析IM Kafka审计事件列表失败,请稍后重试!") } return } +func buildImKafkaAuditWhere(in *sysin.ImKafkaAuditListInp) (string, []any) { + parts := []string{"1=1"} + args := []any{} + if in.TenantCode != "" { + parts = append(parts, "tenant_code=?") + args = append(args, in.TenantCode) + } + if in.EventId != "" { + parts = append(parts, "event_id LIKE ?") + args = append(args, "%"+in.EventId+"%") + } + if in.EventType != "" { + parts = append(parts, "event_type LIKE ?") + args = append(args, "%"+in.EventType+"%") + } + if in.BizType != "" { + parts = append(parts, "biz_type=?") + args = append(args, in.BizType) + } + if in.BizId != "" { + parts = append(parts, "biz_id LIKE ?") + args = append(args, "%"+in.BizId+"%") + } + if in.ActorType != "" { + parts = append(parts, "actor_type=?") + args = append(args, in.ActorType) + } + if in.ActorId != "" { + parts = append(parts, "actor_id LIKE ?") + args = append(args, "%"+in.ActorId+"%") + } + if in.Action != "" { + parts = append(parts, "action LIKE ?") + args = append(args, "%"+in.Action+"%") + } + if in.Resource != "" { + parts = append(parts, "resource=?") + args = append(args, in.Resource) + } + if in.ResourceId != "" { + parts = append(parts, "resource_id LIKE ?") + args = append(args, "%"+in.ResourceId+"%") + } + if in.Result != "" { + parts = append(parts, "result=?") + args = append(args, in.Result) + } + if len(in.CreatedAt) == 2 { + parts = append(parts, "received_at BETWEEN ? AND ?") + args = append(args, in.CreatedAt[0], in.CreatedAt[1]) + } + return "WHERE " + strings.Join(parts, " AND "), args +} + func (s *sSysImKafkaAudit) View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error) { if err = in.Filter(ctx); err != nil { return nil, err } - mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). - Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "CAST(payload AS CHAR) payload", "CAST(envelope AS CHAR) envelope", "occurred_at", "received_at") + query := `SELECT id,event_id,tenant_code,event_type,biz_type,biz_id,actor_type,actor_id, + action,resource,resource_id,result,reason,CAST(payload AS CHAR) payload, + CAST(envelope AS CHAR) envelope,occurred_at,received_at FROM im_audit_events WHERE ` + args := []any{} if in.Id > 0 { - mod = mod.Where("id", in.Id) + query += "id=?" + args = append(args, in.Id) } else { - mod = mod.Where("event_id", in.EventId) + query += "event_id=?" + args = append(args, in.EventId) } - if err = mod.Scan(&res); err != nil { + query += " LIMIT 1" + row, err := imtenant.RegistryDB().GetOne(ctx, query, args...) + if err != nil { return nil, gerror.Wrap(err, "获取IM Kafka审计事件详情失败,请稍后重试!") } + if row.IsEmpty() { + return nil, nil + } + if err = row.Struct(&res); err != nil { + return nil, gerror.Wrap(err, "解析IM Kafka审计事件详情失败,请稍后重试!") + } return } diff --git a/server/internal/logic/sys/im_kafka_command.go b/server/internal/logic/sys/im_kafka_command.go index 620eeea..ae97609 100644 --- a/server/internal/logic/sys/im_kafka_command.go +++ b/server/internal/logic/sys/im_kafka_command.go @@ -10,11 +10,12 @@ import ( "hotgo/internal/consts" "hotgo/internal/library/contexts" "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "strings" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" ) @@ -32,53 +33,34 @@ func (s *sSysImKafkaCommand) List(ctx context.Context, in *sysin.ImKafkaCommandL if err = in.Filter(ctx); err != nil { return } - mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). - Fields( - "id", - "command_id", - "tenant_id", - "tenant_code", - "event_id", - "event_type", - "biz_type", - "biz_id", - "idempotency_key", - "status", - "reason", - "message", - "sent_at", - "completed_at", - "created_by", - "created_at", - "updated_at", - ) - if in.TenantId > 0 { - mod = mod.Where("tenant_id", in.TenantId) + where, args := buildImKafkaCommandWhere(in) + db := imtenant.RegistryDB() + totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_admin_commands "+where, args...) + if err != nil { + err = gerror.Wrap(err, "获取IM后台命令总数失败,请稍后重试!") + return } - if in.CommandId != "" { - mod = mod.WhereLike("command_id", "%"+in.CommandId+"%") + totalCount = totalValue.Int() + page := in.Page + if page <= 0 { + page = 1 } - if in.EventId != "" { - mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + perPage := in.PerPage + if perPage <= 0 { + perPage = 20 } - if in.EventType != "" { - mod = mod.WhereLike("event_type", "%"+in.EventType+"%") - } - if in.BizType != "" { - mod = mod.Where("biz_type", in.BizType) - } - if in.BizId != "" { - mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") - } - if in.Status != "" { - mod = mod.Where("status", in.Status) - } - if len(in.CreatedAt) == 2 { - mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) - } - mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + offset := (page - 1) * perPage + query := `SELECT id,command_id,tenant_id,tenant_code,event_id,event_type,biz_type,biz_id,idempotency_key, + status,reason,message,sent_at,completed_at,created_by,created_at,updated_at + FROM im_admin_commands ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?` + queryArgs := append(append([]any{}, args...), perPage, offset) + rows, err := db.GetAll(ctx, query, queryArgs...) + if err != nil { err = gerror.Wrap(err, "获取IM后台命令列表失败,请稍后重试!") + return + } + if err = rows.Structs(&list); err != nil { + err = gerror.Wrap(err, "解析IM后台命令列表失败,请稍后重试!") } return } @@ -87,40 +69,69 @@ func (s *sSysImKafkaCommand) View(ctx context.Context, in *sysin.ImKafkaCommandV if err = in.Filter(ctx); err != nil { return nil, err } - mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). - Fields( - "id", - "command_id", - "tenant_id", - "tenant_code", - "event_id", - "event_type", - "biz_type", - "biz_id", - "idempotency_key", - "status", - "reason", - "message", - "CAST(payload AS CHAR) payload", - "CAST(envelope AS CHAR) envelope", - "CAST(result AS CHAR) result", - "sent_at", - "completed_at", - "created_by", - "created_at", - "updated_at", - ) + query := `SELECT id,command_id,tenant_id,tenant_code,event_id,event_type,biz_type,biz_id,idempotency_key, + status,reason,message,CAST(payload AS CHAR) payload,CAST(envelope AS CHAR) envelope,CAST(result AS CHAR) result, + sent_at,completed_at,created_by,created_at,updated_at FROM im_admin_commands WHERE ` + args := []any{} if in.Id > 0 { - mod = mod.Where("id", in.Id) + query += "id=?" + args = append(args, in.Id) } else { - mod = mod.Where("command_id", in.CommandId) + query += "command_id=?" + args = append(args, in.CommandId) } - if err = mod.Scan(&res); err != nil { + query += " LIMIT 1" + row, err := imtenant.RegistryDB().GetOne(ctx, query, args...) + if err != nil { return nil, gerror.Wrap(err, "获取IM后台命令详情失败,请稍后重试!") } + if row.IsEmpty() { + return nil, nil + } + if err = row.Struct(&res); err != nil { + return nil, gerror.Wrap(err, "解析IM后台命令详情失败,请稍后重试!") + } return } +func buildImKafkaCommandWhere(in *sysin.ImKafkaCommandListInp) (string, []any) { + parts := []string{"1=1"} + args := []any{} + if in.TenantId > 0 { + parts = append(parts, "tenant_id=?") + args = append(args, in.TenantId) + } + if in.CommandId != "" { + parts = append(parts, "command_id LIKE ?") + args = append(args, "%"+in.CommandId+"%") + } + if in.EventId != "" { + parts = append(parts, "event_id LIKE ?") + args = append(args, "%"+in.EventId+"%") + } + if in.EventType != "" { + parts = append(parts, "event_type LIKE ?") + args = append(args, "%"+in.EventType+"%") + } + if in.BizType != "" { + parts = append(parts, "biz_type=?") + args = append(args, in.BizType) + } + if in.BizId != "" { + parts = append(parts, "biz_id LIKE ?") + args = append(args, "%"+in.BizId+"%") + } + if in.Status != "" { + parts = append(parts, "status=?") + args = append(args, in.Status) + } + if len(in.CreatedAt) == 2 { + parts = append(parts, "created_at BETWEEN ? AND ?") + args = append(args, in.CreatedAt[0], in.CreatedAt[1]) + } + return "WHERE " + strings.Join(parts, " AND "), args +} + func (s *sSysImKafkaCommand) CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error) { if err = in.Filter(ctx); err != nil { return nil, err diff --git a/server/internal/logic/sys/im_login_audit.go b/server/internal/logic/sys/im_login_audit.go new file mode 100644 index 0000000..e82854a --- /dev/null +++ b/server/internal/logic/sys/im_login_audit.go @@ -0,0 +1,155 @@ +package sys + +import ( + "context" + "fmt" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "hotgo/utility/convert" + "hotgo/utility/excel" + "net/url" + "strconv" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gctx" +) + +type sSysImUserLoginHistory struct{} +type sSysImLoginFailureLog struct{} + +func NewSysImUserLoginHistory() *sSysImUserLoginHistory { return &sSysImUserLoginHistory{} } +func NewSysImLoginFailureLog() *sSysImLoginFailureLog { return &sSysImLoginFailureLog{} } + +func init() { + service.RegisterSysImUserLoginHistory(NewSysImUserLoginHistory()) + service.RegisterSysImLoginFailureLog(NewSysImLoginFailureLog()) +} + +func (s *sSysImUserLoginHistory) List(ctx context.Context, in *sysin.ImUserLoginHistoryListInp) ([]*sysin.ImUserLoginHistoryModel, int, error) { + if err := in.Filter(ctx); err != nil { + return nil, 0, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + setQuery(values, "user_id", in.UserId) + setQuery(values, "account", in.Account) + setQuery(values, "login_type", in.LoginType) + setQuery(values, "event_type", in.EventType) + setQuery(values, "platform", in.Platform) + setQuery(values, "ip", in.Ip) + addTimeRangeQuery(values, "login_at", in.LoginAt) + return imAdminQueryList[sysin.ImUserLoginHistoryModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/user-login-logs", values, nil) +} + +func (s *sSysImUserLoginHistory) Export(ctx context.Context, in *sysin.ImUserLoginHistoryListInp) error { + list, total, err := s.List(ctx, in) + if err != nil { + return err + } + exports := make([]sysin.ImLoginAuditExportModel, 0, len(list)) + for _, item := range list { + exports = append(exports, sysin.ImLoginAuditExportModel{Id: item.Id, UserId: item.UserId, AccountMask: item.AccountMask, EventType: item.EventType, Platform: item.Platform, Ip: item.Ip, Region: item.Region, RiskLevel: item.RiskLevel, OccurredAt: item.LoginAt}) + } + return exportLoginAudit(ctx, exports, total, in.Page, in.PerPage, "用户登录历史") +} + +func (s *sSysImLoginFailureLog) List(ctx context.Context, in *sysin.ImLoginFailureLogListInp) ([]*sysin.ImLoginFailureLogModel, int, error) { + if err := in.Filter(ctx); err != nil { + return nil, 0, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + setQuery(values, "user_id", in.UserId) + setQuery(values, "account", in.Account) + setQuery(values, "failure_type", in.FailureType) + setQuery(values, "platform", in.Platform) + setQuery(values, "ip", in.Ip) + addTimeRangeQuery(values, "attempt_at", in.AttemptAt) + return imAdminQueryList[sysin.ImLoginFailureLogModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/login-failure-logs", values, nil) +} + +func (s *sSysImLoginFailureLog) View(ctx context.Context, in *sysin.ImLoginFailureViewInp) (*sysin.ImLoginFailureLogModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + return imAdminQueryOne[sysin.ImLoginFailureLogModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/login-failure-logs/"+strconv.FormatInt(in.Id, 10), nil) +} + +func (s *sSysImLoginFailureLog) Export(ctx context.Context, in *sysin.ImLoginFailureLogListInp) error { + list, total, err := s.List(ctx, in) + if err != nil { + return err + } + exports := make([]sysin.ImLoginAuditExportModel, 0, len(list)) + for _, item := range list { + exports = append(exports, sysin.ImLoginAuditExportModel{Id: item.Id, UserId: item.UserId, AccountMask: item.AccountMask, EventType: item.FailureType, Platform: item.Platform, Ip: item.Ip, Region: item.Region, RiskLevel: item.RiskLevel, Reason: item.Reason, OccurredAt: item.AttemptAt}) + } + return exportLoginAudit(ctx, exports, total, in.Page, in.PerPage, "登录失败日志") +} + +func exportLoginAudit(ctx context.Context, exports []sysin.ImLoginAuditExportModel, total int, page int, perPage int, label string) error { + tags, err := convert.GetEntityDescTags(sysin.ImLoginAuditExportModel{}) + if err != nil { + return err + } + fileName := "导出" + label + "-" + gctx.CtxId(ctx) + sheetName := fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", total, form.CalPageCount(total, perPage), page, len(exports)) + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} + +func (s *sSysImLoginFailureLog) AddBlacklist(ctx context.Context, in *sysin.ImLoginFailureAddBlacklistInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + remark := in.Remark + if remark == "" { + remark = "由登录失败日志加入" + } + return service.SysImSecurityPolicy().IPRuleEdit(ctx, &sysin.ImSecurityIPRuleEditInp{ + TenantId: in.TenantId, RuleType: "blacklist", IpCidr: in.Ip, Action: "deny", + SceneJson: gjson.New([]string{"login"}), RiskLevel: "high", Status: consts.StatusEnabled, Remark: remark, + }) +} + +func (s *sSysImLoginFailureLog) BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (*sysin.ImUserCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, TenantCode: tenant.Code, EventType: consts.ImAdminCommandUserBan, + BizType: "user", BizID: strconv.FormatInt(in.UserId, 10), + Actor: &imkafka.Actor{Type: "admin", ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), DisplayName: actorName}, + Payload: map[string]any{"user_id": strconv.FormatInt(in.UserId, 10), "target_user_id": strconv.FormatInt(in.UserId, 10), "reason": in.Reason}, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImUserCommandModel{CommandID: command.CommandID, EventID: command.EventID, Status: command.Status, TenantID: command.TenantID, TenantCode: command.TenantCode, EventType: command.EventType, IdempotencyKey: command.IdempotencyKey}, nil +} diff --git a/server/internal/logic/sys/im_mailbox_config.go b/server/internal/logic/sys/im_mailbox_config.go new file mode 100644 index 0000000..e92b2aa --- /dev/null +++ b/server/internal/logic/sys/im_mailbox_config.go @@ -0,0 +1,121 @@ +package sys + +import ( + "context" + "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" +) + +const imMailboxConfigTable = "im_mailbox_config" + +type sSysImMailboxConfig struct{} + +func NewSysImMailboxConfig() *sSysImMailboxConfig { return &sSysImMailboxConfig{} } + +func init() { + service.RegisterSysImMailboxConfig(NewSysImMailboxConfig()) +} + +func (s *sSysImMailboxConfig) Get(ctx context.Context, in *sysin.ImMailboxConfigGetInp) (res *sysin.ImMailboxConfigModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = s.ensureDefault(ctx, db); err != nil { + return nil, err + } + if err = s.model(ctx, db).Where("id", 1).Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM总经理信箱配置失败,请稍后重试!") + } + if res != nil { + res.TenantId = tenant.Id + res.TenantName = tenant.Name + } + return +} + +func (s *sSysImMailboxConfig) Update(ctx context.Context, in *sysin.ImMailboxConfigUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return err + } + if err = s.ensureDefault(ctx, db); err != nil { + return err + } + requiredFields := `["subject","content"]` + if in.RequiredFields != nil && in.RequiredFields.String() != "" && in.RequiredFields.String() != "null" { + requiredFields = in.RequiredFields.String() + } + attachmentPolicy := `{"max_files":5,"purpose":"executive_mailbox"}` + if in.AttachmentPolicy != nil && in.AttachmentPolicy.String() != "" && in.AttachmentPolicy.String() != "null" { + attachmentPolicy = in.AttachmentPolicy.String() + } + if _, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Where("id", 1).Data(g.Map{ + "enabled": in.Enabled, + "allow_anonymous": in.AllowAnonymous, + "required_fields": requiredFields, + "max_length": in.MaxLength, + "attachment_policy": attachmentPolicy, + "notice_title": in.NoticeTitle, + "notice_content": in.NoticeContent, + "notice_updated_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Update(); err != nil { + return gerror.Wrap(err, "更新IM总经理信箱配置失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_mailbox_config_update", "im_mailbox_config", 1, "success", g.Map{"enabled": in.Enabled}) + return nil +} + +func (s *sSysImMailboxConfig) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Fields( + "id", + "enabled", + "allow_anonymous", + "CAST(required_fields AS CHAR) required_fields", + "max_length", + "CAST(attachment_policy AS CHAR) attachment_policy", + "notice_title", + "notice_content", + "notice_updated_at", + "updated_at", + ) +} + +func (s *sSysImMailboxConfig) ensureDefault(ctx context.Context, db gdb.DB) (err error) { + var count int + if count, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Where("id", 1).Count(); err != nil { + return gerror.Wrap(err, "检查IM总经理信箱默认配置失败,请稍后重试!") + } + if count > 0 { + return nil + } + _, err = db.Model(imMailboxConfigTable).Safe().Ctx(ctx).Data(g.Map{ + "id": 1, + "enabled": 1, + "allow_anonymous": 1, + "required_fields": `["subject","content"]`, + "max_length": 5000, + "attachment_policy": `{"max_files":5,"purpose":"executive_mailbox"}`, + "notice_title": "总经理信箱", + "notice_content": "请如实填写诉求,匿名提交将按企业政策保护展示身份。", + "notice_updated_at": gtime.Now(), + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return gerror.Wrap(err, "初始化IM总经理信箱默认配置失败,请稍后重试!") + } + return nil +} diff --git a/server/internal/logic/sys/im_message_admin.go b/server/internal/logic/sys/im_message_admin.go new file mode 100644 index 0000000..c407094 --- /dev/null +++ b/server/internal/logic/sys/im_message_admin.go @@ -0,0 +1,179 @@ +package sys + +import ( + "context" + "fmt" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "hotgo/utility/convert" + "hotgo/utility/excel" + "net/url" + "strconv" + + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/util/gconv" +) + +type sSysImPrivateMessage struct{} +type sSysImGroupMessage struct{} + +func NewSysImPrivateMessage() *sSysImPrivateMessage { return &sSysImPrivateMessage{} } +func NewSysImGroupMessage() *sSysImGroupMessage { return &sSysImGroupMessage{} } + +func init() { + service.RegisterSysImPrivateMessage(NewSysImPrivateMessage()) + service.RegisterSysImGroupMessage(NewSysImGroupMessage()) +} + +func (s *sSysImPrivateMessage) List(ctx context.Context, in *sysin.ImPrivateMessageListInp) ([]*sysin.ImMessageListModel, int, error) { + if err := in.Filter(ctx); err != nil { + return nil, 0, err + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + setQuery(values, "topic", in.Topic) + setQuery(values, "from_user_id", in.FromUserId) + setQuery(values, "to_user_id", in.ToUserId) + setQuery(values, "content_type", in.ContentType) + setQuery(values, "status", in.Status) + setQuery(values, "keyword", in.Keyword) + addTimeRangeQuery(values, "created_at", in.CreatedAt) + return queryImMessages(ctx, in.TenantId, "private-messages", values) +} + +func (s *sSysImGroupMessage) List(ctx context.Context, in *sysin.ImGroupMessageListInp) ([]*sysin.ImMessageListModel, int, error) { + if err := in.Filter(ctx); err != nil { + return nil, 0, err + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + setQuery(values, "topic", in.Topic) + if in.SeqId > 0 { + values.Set("seq_id", strconv.FormatInt(in.SeqId, 10)) + } + setQuery(values, "sender_user_id", in.SenderUserId) + setQuery(values, "sender_username", in.SenderUsername) + setQuery(values, "sender_nickname", in.SenderNickname) + setQuery(values, "group_name", in.GroupName) + setQuery(values, "content_type", in.ContentType) + setQuery(values, "status", in.Status) + setQuery(values, "keyword", in.Keyword) + addTimeRangeQuery(values, "created_at", in.CreatedAt) + return queryImMessages(ctx, in.TenantId, "group-messages", values) +} + +func queryImMessages(ctx context.Context, tenantId int64, resource string, values url.Values) ([]*sysin.ImMessageListModel, int, error) { + tenant, err := resolveImAdminTenant(ctx, tenantId) + if err != nil { + return nil, 0, err + } + return imAdminQueryList[sysin.ImMessageListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/"+resource, values, + map[string]string{"sender_user_id": "fromUserId", "sent_at": "createdAt", "message_type": "contentType", "content_summary": "contentExcerpt"}) +} + +func (s *sSysImPrivateMessage) View(ctx context.Context, in *sysin.ImMessageViewInp) (*sysin.ImMessageViewModel, error) { + return viewImMessage(ctx, in) +} + +func (s *sSysImGroupMessage) View(ctx context.Context, in *sysin.ImMessageViewInp) (*sysin.ImMessageViewModel, error) { + return viewImMessage(ctx, in) +} + +func viewImMessage(ctx context.Context, in *sysin.ImMessageViewInp) (*sysin.ImMessageViewModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/messages/" + url.PathEscape(in.Topic) + "/" + strconv.FormatInt(in.SeqId, 10) + return imAdminQueryOne[sysin.ImMessageViewModel](ctx, tenant, path, + map[string]string{"sender_user_id": "fromUserId", "sent_at": "createdAt", "message_type": "contentType"}) +} + +func (s *sSysImPrivateMessage) Hide(ctx context.Context, in *sysin.ImMessageActionInp) (*sysin.ImMessageCommandModel, error) { + return publishMessageCommand(ctx, in, "private", consts.ImAdminCommandMessageHide) +} + +func (s *sSysImPrivateMessage) Delete(ctx context.Context, in *sysin.ImMessageActionInp) (*sysin.ImMessageCommandModel, error) { + return publishMessageCommand(ctx, in, "private", consts.ImAdminCommandMessageDelete) +} + +func (s *sSysImGroupMessage) Hide(ctx context.Context, in *sysin.ImMessageActionInp) (*sysin.ImMessageCommandModel, error) { + return publishMessageCommand(ctx, in, "group", consts.ImAdminCommandMessageHide) +} + +func (s *sSysImGroupMessage) Delete(ctx context.Context, in *sysin.ImMessageActionInp) (*sysin.ImMessageCommandModel, error) { + return publishMessageCommand(ctx, in, "group", consts.ImAdminCommandMessageDelete) +} + +func publishMessageCommand(ctx context.Context, in *sysin.ImMessageActionInp, conversationType string, eventType string) (*sysin.ImMessageCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + bizId := in.Topic + ":" + strconv.FormatInt(in.SeqId, 10) + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, TenantCode: tenant.Code, EventType: eventType, BizType: "message", BizID: bizId, + Actor: &imkafka.Actor{Type: "admin", ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), DisplayName: actorName}, + Payload: map[string]any{"conversation_type": conversationType, "topic": in.Topic, "seq_id": in.SeqId, "reason": in.Reason}, + CreatedBy: contexts.GetUserId(ctx), + }) + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, tenant.Id, eventType, "message", in.SeqId, result, map[string]any{"conversation_type": conversationType, "topic": in.Topic, "reason": in.Reason}) + if err != nil { + return nil, err + } + return &sysin.ImMessageCommandModel{CommandId: command.CommandID, EventId: command.EventID, Status: command.Status}, nil +} + +func (s *sSysImPrivateMessage) Export(ctx context.Context, in *sysin.ImPrivateMessageListInp) error { + list, total, err := s.List(ctx, in) + if err != nil { + return err + } + return exportImMessages(ctx, list, total, in.Page, in.PerPage, "单聊消息") +} + +func (s *sSysImGroupMessage) Export(ctx context.Context, in *sysin.ImGroupMessageListInp) error { + list, total, err := s.List(ctx, in) + if err != nil { + return err + } + return exportImMessages(ctx, list, total, in.Page, in.PerPage, "群聊消息") +} + +func exportImMessages(ctx context.Context, list []*sysin.ImMessageListModel, total int, page int, perPage int, label string) error { + tags, err := convert.GetEntityDescTags(sysin.ImMessageExportModel{}) + if err != nil { + return err + } + var exports []sysin.ImMessageExportModel + if err = gconv.Scan(list, &exports); err != nil { + return err + } + fileName := "导出" + label + "-" + gctx.CtxId(ctx) + sheetName := fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", total, form.CalPageCount(total, perPage), page, len(list)) + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} + +func setQuery(values url.Values, key string, value string) { + if value != "" { + values.Set(key, value) + } +} diff --git a/server/internal/logic/sys/im_notification.go b/server/internal/logic/sys/im_notification.go new file mode 100644 index 0000000..a8b36ac --- /dev/null +++ b/server/internal/logic/sys/im_notification.go @@ -0,0 +1,186 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "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" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +type sSysImNotification struct{} + +func NewSysImNotification() *sSysImNotification { + return &sSysImNotification{} +} + +func init() { + service.RegisterSysImNotification(NewSysImNotification()) +} + +func (s *sSysImNotification) TemplateList(ctx context.Context, in *sysin.ImNotificationTemplateListInp) (list []*sysin.ImNotificationTemplateListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScopeWithAll(ctx, in.TenantId, true) + if err != nil { + return + } + tenants, err := s.listTenants(ctx, scope.TenantId, in.TenantId) + if err != nil { + return + } + list = []*sysin.ImNotificationTemplateListModel{} + for _, tenant := range tenants { + if err = s.ensureTemplates(ctx, tenant.Id); err != nil { + return + } + var tenantList []*sysin.ImNotificationTemplateListModel + mod := imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx). + Where("tenant_id", tenant.Id) + if in.Scene != "" { + mod = mod.Where("scene", in.Scene) + } + if in.Status != nil { + mod = mod.Where("status", *in.Status) + } + if err = mod.OrderAsc("id").Scan(&tenantList); err != nil { + return nil, 0, gerror.Wrap(err, "获取IM通知模板失败,请稍后重试!") + } + for _, item := range tenantList { + item.TenantName = tenant.Name + if parsed, parseErr := gjson.DecodeToJson(item.PayloadJson); parseErr == nil && parsed != nil { + item.PayloadJson = parsed.MustToJsonString() + } + } + list = append(list, tenantList...) + } + totalCount = len(list) + start, end := pageBounds(in.Page, in.PerPage, totalCount) + if start >= totalCount { + return []*sysin.ImNotificationTemplateListModel{}, totalCount, nil + } + return list[start:end], totalCount, nil +} + +func (s *sSysImNotification) TemplateUpdate(ctx context.Context, in *sysin.ImNotificationTemplateUpdateInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureTemplates(ctx, scope.TenantId); err != nil { + return + } + payload := "{}" + if in.PayloadJson != nil { + payload = in.PayloadJson.MustToJsonString() + } + if _, err = imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx). + Where("tenant_id", scope.TenantId). + Where("scene", in.Scene). + Data(g.Map{ + "in_app_enabled": in.InAppEnabled, + "push_enabled": in.PushEnabled, + "sms_enabled": in.SmsEnabled, + "status": in.Status, + "title_template": in.TitleTemplate, + "content_template": in.ContentTemplate, + "payload_json": payload, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return gerror.Wrap(err, "更新IM通知模板失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, scope.TenantId, "im_notification_template_update", "im_notification_template", 0, "success", g.Map{"scene": in.Scene}) + return nil +} + +func (s *sSysImNotification) listTenants(ctx context.Context, scopeTenantId, requestedTenantId int64) (tenants []*entity.ImTenant, err error) { + mod := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx) + if scopeTenantId > 0 { + mod = mod.WherePri(scopeTenantId) + } else if requestedTenantId > 0 { + mod = mod.WherePri(requestedTenantId) + } + if err = mod.OrderAsc(dao.ImTenant.Columns().Id).Scan(&tenants); err != nil { + return nil, gerror.Wrap(err, "获取IM租户失败,请稍后重试!") + } + return tenants, nil +} + +func (s *sSysImNotification) ensureTemplates(ctx context.Context, tenantId int64) error { + for _, item := range defaultNotificationTemplates() { + count, err := imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx). + Where("tenant_id", tenantId). + Where("scene", item.Scene). + Count() + if err != nil { + return gerror.Wrap(err, "检查IM通知模板失败,请稍后重试!") + } + if count > 0 { + continue + } + if _, err = imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx).Data(g.Map{ + "tenant_id": tenantId, + "scene": item.Scene, + "name": item.Name, + "in_app_enabled": 1, + "push_enabled": item.PushEnabled, + "sms_enabled": 0, + "status": consts.ImNotificationTemplateEnabled, + "title_template": item.Title, + "content_template": item.Content, + "payload_json": gdb.Raw("CAST('{}' AS JSON)"), + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert(); err != nil { + return gerror.Wrap(err, "初始化IM通知模板失败,请稍后重试!") + } + } + return nil +} + +type notificationTemplateSeed struct { + Scene string + Name string + Title string + Content string + PushEnabled int +} + +func defaultNotificationTemplates() []*notificationTemplateSeed { + return []*notificationTemplateSeed{ + {Scene: consts.ImNotificationSceneAccountSecurity, Name: "账号安全", Title: "账号安全提醒", Content: "你的账号发生安全事件:{{event}}", PushEnabled: 1}, + {Scene: consts.ImNotificationSceneFriendRequest, Name: "好友申请", Title: "新的好友申请", Content: "{{user}} 请求添加你为好友", PushEnabled: 1}, + {Scene: consts.ImNotificationSceneGroupEvent, Name: "群事件", Title: "群通知", Content: "{{group}} 有新的群事件:{{event}}", PushEnabled: 1}, + {Scene: consts.ImNotificationSceneFileEvent, Name: "文件事件", Title: "文件通知", Content: "{{file}} 状态已更新", PushEnabled: 0}, + {Scene: consts.ImNotificationSceneMeetingReminder, Name: "会议提醒", Title: "会议提醒", Content: "{{meeting}} 将于 {{time}} 开始", PushEnabled: 1}, + {Scene: consts.ImNotificationSceneReportReminder, Name: "日报月报提醒", Title: "填报提醒", Content: "请及时提交 {{report_type}}", PushEnabled: 1}, + {Scene: consts.ImNotificationSceneWorkOrder, Name: "工单处理", Title: "工单状态更新", Content: "你的工单 {{ticket}} 已更新", PushEnabled: 1}, + } +} + +func pageBounds(page, perPage, total int) (start, end int) { + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 10 + } + start = (page - 1) * perPage + end = start + perPage + if end > total { + end = total + } + return +} diff --git a/server/internal/logic/sys/im_org.go b/server/internal/logic/sys/im_org.go new file mode 100644 index 0000000..c625f5e --- /dev/null +++ b/server/internal/logic/sys/im_org.go @@ -0,0 +1,399 @@ +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" + "net/url" + "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" +) + +type sSysImOrg struct{} + +func NewSysImOrg() *sSysImOrg { + return &sSysImOrg{} +} + +func init() { + service.RegisterSysImOrg(NewSysImOrg()) +} + +func (s *sSysImOrg) DepartmentList(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (list []*sysin.ImOrgDepartmentModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + if in.DepartmentId != "" { + values.Set("department_id", strings.TrimSpace(in.DepartmentId)) + } + if in.ParentId != "" { + values.Set("parent_id", strings.TrimSpace(in.ParentId)) + } + if in.Name != "" { + values.Set("name", strings.TrimSpace(in.Name)) + } + if in.State != "" { + values.Set("state", strings.TrimSpace(in.State)) + } + return imAdminQueryList[sysin.ImOrgDepartmentModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/org/departments", + values, + nil, + ) +} + +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 + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if in.ParentId != "root" { + if exists, err := s.departmentExists(ctx, db, in.ParentId); err != nil { + return err + } else if !exists { + return gerror.New("上级部门不存在") + } + } + if in.Id > 0 { + current, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("id", in.Id).One() + if err != nil { + return gerror.Wrap(err, "获取IM组织部门失败,请稍后重试!") + } + if current.IsEmpty() { + return gerror.New("部门不存在") + } + departmentId := current["department_id"].String() + if in.ParentId == departmentId { + return gerror.New("上级部门不能选择自身") + } + if err = s.ensureNoDepartmentCycle(ctx, db, departmentId, in.ParentId); err != nil { + return err + } + if _, err = db.Model("im_org_departments").Safe().Ctx(ctx). + Where("id", in.Id). + Data(g.Map{ + "name": in.Name, + "parent_id": in.ParentId, + "sort_order": in.SortOrder, + "state": in.State, + "version": gdb.Raw("version+1"), + "updated_at": gtime.Now(), + }).Update(); err != nil { + return gerror.Wrap(err, "更新IM组织部门失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_update", "im_org_department", in.Id, "success", g.Map{"department_id": departmentId}) + return nil + } + departmentId := in.DepartmentId + if departmentId == "" { + departmentId = "dep_" + strings.ToLower(guid.S()) + } + if exists, err := s.departmentExists(ctx, db, departmentId); err != nil { + return err + } else if exists { + return gerror.New("部门ID已存在") + } + if _, err = db.Model("im_org_departments").Safe().Ctx(ctx).Data(g.Map{ + "department_id": departmentId, + "name": in.Name, + "parent_id": in.ParentId, + "sort_order": in.SortOrder, + "state": in.State, + "version": 1, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert(); err != nil { + return gerror.Wrap(err, "创建IM组织部门失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_create", "im_org_department", 0, "success", g.Map{"department_id": departmentId}) + return nil +} + +func (s *sSysImOrg) DepartmentDelete(ctx context.Context, in *sysin.ImOrgDepartmentDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if in.DepartmentId == "root" { + return gerror.New("根部门不能删除") + } + if exists, err := s.departmentExists(ctx, db, in.DepartmentId); err != nil { + return err + } else if !exists { + return gerror.New("部门不存在") + } + childCount, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("parent_id", in.DepartmentId).Count() + if err != nil { + return gerror.Wrap(err, "检查子部门失败,请稍后重试!") + } + if childCount > 0 { + return gerror.New("存在子部门,请先处理后再删除") + } + memberCount, err := db.Model("im_org_members").Safe().Ctx(ctx).Where("department_id", in.DepartmentId).Count() + if err != nil { + return gerror.Wrap(err, "检查部门成员失败,请稍后重试!") + } + if memberCount > 0 { + return gerror.New("存在部门成员,请先移除后再删除") + } + if _, err = db.Model("im_org_departments").Safe().Ctx(ctx).Where("department_id", in.DepartmentId).Delete(); err != nil { + return gerror.Wrap(err, "删除IM组织部门失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_delete", "im_org_department", 0, "success", g.Map{"department_id": in.DepartmentId}) + return nil +} + +func (s *sSysImOrg) MemberList(ctx context.Context, in *sysin.ImOrgMemberListInp) (list []*sysin.ImOrgMemberModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return + } + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + if in.DepartmentId != "" { + values.Set("department_id", strings.TrimSpace(in.DepartmentId)) + } + if in.UserId > 0 { + values.Set("user_id", int64ToString(in.UserId)) + } + if in.EmploymentState != "" { + values.Set("employment_state", strings.TrimSpace(in.EmploymentState)) + } + if in.Query != "" { + values.Set("q", strings.TrimSpace(in.Query)) + } + return imAdminQueryList[sysin.ImOrgMemberModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/org/members", + values, + nil, + ) +} + +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 + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if exists, err := s.departmentExists(ctx, db, in.DepartmentId); err != nil { + return err + } else if !exists { + return gerror.New("部门不存在") + } + userCount, err := db.Model("im_users").Safe().Ctx(ctx).Where("id", in.UserId).Count() + if err != nil { + return gerror.Wrap(err, "检查IM用户失败,请稍后重试!") + } + if userCount == 0 { + return gerror.New("IM用户不存在") + } + data := g.Map{ + "department_id": in.DepartmentId, + "user_id": in.UserId, + "display_name": in.DisplayName, + "position": in.Position, + "avatar_file_id": in.AvatarFileId, + "mobile": in.Mobile, + "email": in.Email, + "employment_state": in.EmploymentState, + "contactable": in.Contactable, + "sort_order": in.SortOrder, + "updated_at": gtime.Now(), + } + if in.Id > 0 { + data["version"] = gdb.Raw("version+1") + if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM组织成员失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_update", "im_org_member", in.Id, "success", g.Map{"department_id": in.DepartmentId, "user_id": in.UserId}) + return nil + } + data["version"] = 1 + data["created_at"] = gtime.Now() + if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "添加IM组织成员失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_create", "im_org_member", 0, "success", g.Map{"department_id": in.DepartmentId, "user_id": in.UserId}) + return nil +} + +func (s *sSysImOrg) MemberDelete(ctx context.Context, in *sysin.ImOrgMemberDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + current, err := db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).One() + if err != nil { + return gerror.Wrap(err, "获取IM组织成员失败,请稍后重试!") + } + if current.IsEmpty() { + return gerror.New("组织成员不存在") + } + if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).Delete(); err != nil { + return gerror.Wrap(err, "删除IM组织成员失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_delete", "im_org_member", in.Id, "success", g.Map{"department_id": current["department_id"].String(), "user_id": current["user_id"].Int64()}) + return nil +} + +func (s *sSysImOrg) Search(ctx context.Context, in *sysin.ImOrgSearchInp) (res *sysin.ImOrgSearchModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + departments, _, err := s.DepartmentList(ctx, &sysin.ImOrgDepartmentListInp{ + PageReq: form.PageReq{Page: 1, PerPage: 20, Pagination: true}, + TenantId: in.TenantId, + Name: in.Query, + State: consts.ImOrgDepartmentStateActive, + }) + if err != nil { + return nil, err + } + members, _, err := s.MemberList(ctx, &sysin.ImOrgMemberListInp{ + PageReq: form.PageReq{Page: 1, PerPage: 20, Pagination: true}, + TenantId: in.TenantId, + Query: in.Query, + EmploymentState: consts.ImOrgEmploymentStateActive, + }) + if err != nil { + return nil, err + } + res = &sysin.ImOrgSearchModel{Departments: departments, Members: members} + return +} + +func (s *sSysImOrg) departmentModel(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_org_departments d").Safe().Ctx(ctx).Fields( + "d.id", + "d.department_id", + "d.name", + "d.parent_id", + "d.sort_order", + "d.version", + "d.state", + "d.created_at", + "d.updated_at", + "(SELECT COUNT(*) FROM im_org_departments child WHERE child.parent_id=d.department_id AND child.state='active') child_count", + "(SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id) member_count", + ) +} + +func (s *sSysImOrg) memberModel(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_org_members m").Safe().Ctx(ctx). + LeftJoin("im_org_departments d", "d.department_id=m.department_id"). + Fields( + "m.id", + "m.department_id", + "COALESCE(d.name,'') department_name", + "m.user_id", + "m.display_name", + "m.position", + "m.avatar_file_id", + "m.mobile", + "m.email", + "m.employment_state", + "m.contactable", + "m.sort_order", + "m.version", + "m.created_at", + "m.updated_at", + ) +} + +func (s *sSysImOrg) departmentExists(ctx context.Context, db gdb.DB, departmentId string) (bool, error) { + count, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("department_id", strings.TrimSpace(departmentId)).Count() + if err != nil { + return false, gerror.Wrap(err, "检查IM组织部门失败,请稍后重试!") + } + return count > 0, nil +} + +func (s *sSysImOrg) ensureNoDepartmentCycle(ctx context.Context, db gdb.DB, departmentId, parentId string) error { + next := strings.TrimSpace(parentId) + for next != "" && next != "root" { + if next == departmentId { + return gerror.New("上级部门不能选择自身或下级部门") + } + row, err := db.Model("im_org_departments").Safe().Ctx(ctx).Fields("parent_id").Where("department_id", next).One() + if err != nil { + return gerror.Wrap(err, "检查部门层级失败,请稍后重试!") + } + if row.IsEmpty() { + return gerror.New("上级部门不存在") + } + next = row["parent_id"].String() + } + return nil +} diff --git a/server/internal/logic/sys/im_outbox.go b/server/internal/logic/sys/im_outbox.go new file mode 100644 index 0000000..3bfc066 --- /dev/null +++ b/server/internal/logic/sys/im_outbox.go @@ -0,0 +1,190 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "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 sSysImOutbox struct{} + +func NewSysImOutbox() *sSysImOutbox { + return &sSysImOutbox{} +} + +func init() { + service.RegisterSysImOutbox(NewSysImOutbox()) +} + +func (s *sSysImOutbox) List(ctx context.Context, in *sysin.ImOutboxListInp) (list []*sysin.ImOutboxListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := s.tenantDB(ctx, in.TenantId) + if err != nil { + return + } + + mod := s.model(ctx, db, in.Source) + mod = s.applyWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM Outbox列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImOutbox) View(ctx context.Context, in *sysin.ImOutboxViewInp) (res *sysin.ImOutboxViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := s.tenantDB(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = s.model(ctx, db, in.Source).Where("id", in.Id).Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM Outbox详情失败,请稍后重试!") + } + if res == nil { + return nil, nil + } + res.TenantId = tenant.Id + res.TenantName = tenant.Name + return +} + +func (s *sSysImOutbox) Retry(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.updateStatus(ctx, in, sysin.ImOutboxStatusPending) +} + +func (s *sSysImOutbox) Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + return s.updateStatus(ctx, in, sysin.ImOutboxStatusDead) +} + +func (s *sSysImOutbox) tenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) { + scope, err := resolveImAdminScope(ctx, selectedTenantId) + if err != nil { + return nil, nil, err + } + return imtenant.DB(ctx, scope.TenantId) +} + +func (s *sSysImOutbox) model(ctx context.Context, db gdb.DB, source string) *gdb.Model { + if source == sysin.ImOutboxSourceGroup { + return db.Model("im_group_events_outbox").Safe().Ctx(ctx).Fields( + "'group' source", + "id", + "event_id", + "'topic' target_type", + "topic target", + "actor_user_id", + "event_type", + "CAST(payload AS CHAR) payload", + "status", + "attempts", + "next_retry_at", + "delivered_at", + "created_at", + "updated_at", + ) + } + return db.Model("im_business_events_outbox").Safe().Ctx(ctx).Fields( + "'business' source", + "id", + "event_id", + "target_type", + "target", + "actor_user_id", + "event_type", + "CAST(payload AS CHAR) payload", + "status", + "attempts", + "next_retry_at", + "delivered_at", + "created_at", + "updated_at", + ) +} + +func (s *sSysImOutbox) applyWhere(mod *gdb.Model, in *sysin.ImOutboxListInp) *gdb.Model { + if in.EventId != "" { + mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + } + if in.EventType != "" { + mod = mod.Where("event_type", in.EventType) + } + if in.TargetType != "" && in.Source == sysin.ImOutboxSourceBusiness { + mod = mod.Where("target_type", in.TargetType) + } + if in.Target != "" { + if in.Source == sysin.ImOutboxSourceGroup { + mod = mod.WhereLike("topic", "%"+in.Target+"%") + } else { + mod = mod.WhereLike("target", "%"+in.Target+"%") + } + } + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) + } + return mod +} + +func (s *sSysImOutbox) updateStatus(ctx context.Context, in *sysin.ImOutboxActionInp, status string) (err error) { + db, _, err := s.tenantDB(ctx, in.TenantId) + if err != nil { + return err + } + table := "im_business_events_outbox" + if in.Source == sysin.ImOutboxSourceGroup { + table = "im_group_events_outbox" + } + current, err := db.Model(table).Safe().Ctx(ctx).Fields("status").Where("id", in.Id).Value() + if err != nil { + return gerror.Wrap(err, "获取IM Outbox事件失败,请稍后重试!") + } + if current.IsNil() { + return gerror.New("IM Outbox事件不存在") + } + if current.String() == sysin.ImOutboxStatusDelivered { + return gerror.New("已投递事件不能重新处置") + } + + data := g.Map{ + "status": status, + "updated_at": gtime.Now(), + } + if status == sysin.ImOutboxStatusPending { + data["next_retry_at"] = nil + data["delivered_at"] = nil + } + if _, err = db.Model(table).Safe().Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM Outbox事件失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/im_policy_support.go b/server/internal/logic/sys/im_policy_support.go new file mode 100644 index 0000000..ab6c025 --- /dev/null +++ b/server/internal/logic/sys/im_policy_support.go @@ -0,0 +1,60 @@ +package sys + +import ( + "context" + "fmt" + "strconv" + + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" +) + +func resolveImPolicyTenant(ctx context.Context, selectedTenantID int64) (tenantID int64, tenantCode string, err error) { + operatorID := contexts.GetUserId(ctx) + if service.AdminMember().VerifySuperId(ctx, operatorID) && selectedTenantID == 0 { + return 0, "*", nil + } + scope, err := resolveImAdminScope(ctx, selectedTenantID) + if err != nil { + return 0, "", err + } + var tenant *entity.ImTenant + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + Where(dao.ImTenant.Columns().Id, scope.TenantId).Scan(&tenant); err != nil { + return 0, "", gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil { + return 0, "", gerror.New("IM租户不存在") + } + return tenant.Id, tenant.Code, nil +} + +func policyJSON(value *gjson.Json, fallback string) string { + if value == nil || value.String() == "" || value.String() == "null" { + return fallback + } + return value.String() +} + +func publishImPolicyEvent(ctx context.Context, tenantID int64, tenantCode, eventType, group string, version uint) error { + return imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ + TenantID: tenantID, + TenantCode: tenantCode, + EventType: eventType, + Group: group, + Keys: []string{"version", "payload"}, + BizID: fmt.Sprintf("%s:%d:%d", group, tenantID, version), + Actor: &imkafka.Actor{ + Type: "admin", + ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), + }, + CreatedBy: contexts.GetUserId(ctx), + }) +} diff --git a/server/internal/logic/sys/im_realname.go b/server/internal/logic/sys/im_realname.go index a718926..56bb639 100644 --- a/server/internal/logic/sys/im_realname.go +++ b/server/internal/logic/sys/im_realname.go @@ -8,7 +8,6 @@ package sys import ( "context" "hotgo/internal/consts" - "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -33,7 +32,7 @@ func (s *sSysImRealname) List(ctx context.Context, in *sysin.ImRealnameListInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } @@ -91,7 +90,7 @@ func (s *sSysImRealname) View(ctx context.Context, in *sysin.ImRealnameViewInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return nil, err } @@ -129,7 +128,7 @@ func (s *sSysImRealname) Review(ctx context.Context, in *sysin.ImRealnameReviewI if err = in.Filter(ctx); err != nil { return } - db, _, err := imtenant.DB(ctx, in.TenantId) + db, _, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } diff --git a/server/internal/logic/sys/im_security_policy.go b/server/internal/logic/sys/im_security_policy.go new file mode 100644 index 0000000..eec3393 --- /dev/null +++ b/server/internal/logic/sys/im_security_policy.go @@ -0,0 +1,331 @@ +package sys + +import ( + "context" + "net" + + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imtenant" + "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" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +type sSysImSecurityPolicy struct{} + +func NewSysImSecurityPolicy() *sSysImSecurityPolicy { return &sSysImSecurityPolicy{} } +func init() { service.RegisterSysImSecurityPolicy(NewSysImSecurityPolicy()) } + +func securityPolicyFields(alias string) []any { + c := dao.ImSecurityPolicy.Columns() + p := alias + if p != "" { + p += "." + } + return []any{p + c.Id, p + c.TenantId, p + c.PolicyVersion, p + c.PolicyName, p + c.PublishStatus, "CAST(" + p + c.PasswordPolicy + " AS CHAR) password_policy", "CAST(" + p + c.LoginRateLimit + " AS CHAR) login_rate_limit", "CAST(" + p + c.SensitiveWordPolicy + " AS CHAR) sensitive_word_policy", "CAST(" + p + c.FileReviewPolicy + " AS CHAR) file_review_policy", "CAST(" + p + c.RealnamePolicy + " AS CHAR) realname_policy", "CAST(" + p + c.RiskChallengePolicy + " AS CHAR) risk_challenge_policy", p + c.GlobalFriendEnabled, "CAST(" + p + c.FriendDisabledPolicy + " AS CHAR) friend_disabled_policy", p + c.MessageCurfewEnabled, p + c.Remark, p + c.PublishedAt, p + c.PublishedBy, p + c.CreatedAt, p + c.UpdatedAt} +} + +func (s *sSysImSecurityPolicy) List(ctx context.Context, in *sysin.ImSecurityPolicyListInp) (list []*sysin.ImSecurityPolicyModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + c := dao.ImSecurityPolicy.Columns() + model := imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Fields(securityPolicyFields("")...).Where(c.TenantId, tenantID).WhereNull(c.DeletedAt) + if in.PublishStatus != "" { + model = model.Where(c.PublishStatus, in.PublishStatus) + } + err = model.Page(in.Page, in.PerPage).OrderDesc(c.PolicyVersion).ScanAndCount(&list, &total, false) + return +} + +func (s *sSysImSecurityPolicy) View(ctx context.Context, in *sysin.ImSecurityPolicyViewInp) (res *sysin.ImSecurityPolicyModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + c := dao.ImSecurityPolicy.Columns() + baseModel := imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Fields(securityPolicyFields("")...).WhereNull(c.DeletedAt) + if in.Id > 0 { + err = baseModel.Where(c.Id, in.Id).Scan(&res) + } else { + tenantID, _, resolveErr := resolveImPolicyTenant(ctx, in.TenantId) + if resolveErr != nil { + return nil, resolveErr + } + 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 { + return nil, gerror.New("安全策略不存在") + } + if _, _, err = resolveImPolicyTenant(ctx, res.TenantId); err != nil { + return nil, err + } + res.CurfewRules, err = s.curfewRules(ctx, res.Id) + return +} + +func (s *sSysImSecurityPolicy) curfewRules(ctx context.Context, policyID int64) (list []*sysin.ImMessageCurfewRuleModel, err error) { + c := dao.ImMessageCurfewRules.Columns() + err = imtenant.RegistryDB().Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Fields(c.Id, c.RuleCode, c.RuleName, c.DateType, "CAST("+c.CustomDates+" AS CHAR) custom_dates", "CAST("+c.Weekdays+" AS CHAR) weekdays", c.Timezone, "TIME_FORMAT("+c.StartTime+",'%H:%i') start_time", "TIME_FORMAT("+c.EndTime+",'%H:%i') end_time", c.Scope, "CAST("+c.WhitelistUserIds+" AS CHAR) whitelist_user_ids", "CAST("+c.WhitelistGroupIds+" AS CHAR) whitelist_group_ids", c.Message, c.Status, c.Sort).Where(c.PolicyId, policyID).WhereNull(c.DeletedAt).OrderAsc(c.Sort).OrderAsc(c.Id).Scan(&list) + return +} + +func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPolicySaveInp) (id int64, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return 0, err + } + db := imtenant.RegistryDB() + c := dao.ImSecurityPolicy.Columns() + rc := dao.ImMessageCurfewRules.Columns() + operator := contexts.GetUserId(ctx) + now := gtime.Now() + 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 + } + data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: policyJSON(in.SensitiveWordPolicy, "{}"), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator} + 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 { + 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 { + return insertErr + } + } + if _, deleteErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Where(rc.PolicyId, id).Delete(); deleteErr != nil { + return deleteErr + } + for _, rule := range in.CurfewRules { + _, insertErr := tx.Model(dao.ImMessageCurfewRules.Table()).Safe().Ctx(ctx).Data(g.Map{rc.TenantId: tenantID, rc.PolicyId: id, 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}).Insert() + if insertErr != nil { + return insertErr + } + } + return nil + }) + if err != nil { + return 0, gerror.Wrap(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 +} + +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.ImTenantConfig.Columns() + if _, updateErr := tx.Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx).Where(tc.TenantId, 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 + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + 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) + if err != nil { + return 0, gerror.Wrap(err, "读取全局默认安全策略失败") + } + if source == 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}) + if err == nil { + _ = writeImAdminAudit(ctx, tenantID, "im_security_policy_restore_default", "im_security_policy", id, "success", g.Map{"reason": in.Reason}) + } + return id, err +} + +func (s *sSysImSecurityPolicy) IPRuleList(ctx context.Context, in *sysin.ImSecurityIPRuleListInp) (list []*sysin.ImSecurityIPRuleModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + c := dao.ImSecurityIpRules.Columns() + model := imtenant.RegistryDB().Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Fields(c.Id, c.TenantId, c.RuleType, c.IpCidr, c.Action, "CAST("+c.SceneJson+" AS CHAR) scene_json", c.RiskLevel, c.Status, c.ExpiresAt, c.Remark, c.CreatedAt, c.UpdatedAt).Where(c.TenantId, tenantID).WhereNull(c.DeletedAt) + if in.RuleType != "" { + model = model.Where(c.RuleType, in.RuleType) + } + if in.IpCidr != "" { + model = model.WhereLike(c.IpCidr, "%"+in.IpCidr+"%") + } + if in.Status != 0 { + model = model.Where(c.Status, in.Status) + } + err = model.Page(in.Page, in.PerPage).OrderDesc(c.Id).ScanAndCount(&list, &total, false) + return +} + +func (s *sSysImSecurityPolicy) IPRuleEdit(ctx context.Context, in *sysin.ImSecurityIPRuleEditInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if net.ParseIP(in.IpCidr) == nil { + if _, _, err := net.ParseCIDR(in.IpCidr); err != nil { + return gerror.New("IP或CIDR格式不合法") + } + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + c := dao.ImSecurityIpRules.Columns() + db := imtenant.RegistryDB() + now := gtime.Now() + data := g.Map{c.TenantId: tenantID, c.RuleType: in.RuleType, c.IpCidr: in.IpCidr, c.Action: in.Action, c.SceneJson: policyJSON(in.SceneJson, "[]"), c.RiskLevel: in.RiskLevel, c.Status: in.Status, c.ExpiresAt: in.ExpiresAt, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: contexts.GetUserId(ctx)} + targetID := in.Id + if in.Id > 0 { + var row gdb.Record + if row, err = db.Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() || row[c.TenantId].Int64() != tenantID { + return gerror.New("IP规则不存在") + } + _, err = db.Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update() + } else { + data[c.CreatedAt] = now + data[c.CreatedBy] = contexts.GetUserId(ctx) + result, insertErr := db.Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Data(data).Insert() + err = insertErr + if err == nil { + targetID, err = result.LastInsertId() + } + } + if err != nil { + return gerror.Wrap(err, "保存IP规则失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_security_ip_rule_edit", "im_security_ip_rule", targetID, "success", g.Map{"ip_cidr": in.IpCidr, "rule_type": in.RuleType}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", uint(targetID)) +} + +func (s *sSysImSecurityPolicy) IPRuleDelete(ctx context.Context, in *sysin.ImSecurityIPRuleDeleteInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + c := dao.ImSecurityIpRules.Columns() + db := imtenant.RegistryDB() + var row gdb.Record + var err error + if row, err = db.Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() { + return gerror.New("IP规则不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64()) + if err != nil { + return err + } + _, err = db.Model(dao.ImSecurityIpRules.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(g.Map{c.Status: consts.StatusDisable, c.DeletedAt: gtime.Now(), c.DeletedBy: contexts.GetUserId(ctx), c.UpdatedAt: gtime.Now(), c.UpdatedBy: contexts.GetUserId(ctx)}).Update() + if err != nil { + return gerror.Wrap(err, "删除IP规则失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_security_ip_rule_delete", "im_security_ip_rule", in.Id, "success", nil) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "security.policy.updated", "security_policy", uint(in.Id)) +} diff --git a/server/internal/logic/sys/im_sensitive_word.go b/server/internal/logic/sys/im_sensitive_word.go new file mode 100644 index 0000000..12780f0 --- /dev/null +++ b/server/internal/logic/sys/im_sensitive_word.go @@ -0,0 +1,266 @@ +package sys + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imtenant" + "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 sSysImSensitiveWord struct{} + +func NewSysImSensitiveWord() *sSysImSensitiveWord { return &sSysImSensitiveWord{} } +func init() { service.RegisterSysImSensitiveWord(NewSysImSensitiveWord()) } + +func normalizedSensitiveWord(value string) (string, string) { + normalized := strings.ToLower(strings.TrimSpace(value)) + sum := sha256.Sum256([]byte(normalized)) + return normalized, hex.EncodeToString(sum[:]) +} + +func (s *sSysImSensitiveWord) List(ctx context.Context, in *sysin.ImSensitiveWordListInp) (list []*sysin.ImSensitiveWordListModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + c := dao.ImSensitiveWords.Columns() + gc := dao.ImSensitiveWordGroups.Columns() + model := imtenant.RegistryDB().Model(dao.ImSensitiveWords.Table()+" w").Safe().Ctx(ctx).LeftJoin(dao.ImSensitiveWordGroups.Table()+" g", "g."+gc.Id+"=w."+c.GroupId).Fields("w."+c.Id, "w."+c.TenantId, "w."+c.GroupId, "g."+gc.GroupName, "w."+c.Word, "w."+c.MatchType, "CAST(w."+c.SceneJson+" AS CHAR) scene_json", "w."+c.Action, "w."+c.Replacement, "w."+c.RiskLevel, "w."+c.Priority, "w."+c.Status, "w."+c.Remark, "w."+c.CreatedAt, "w."+c.UpdatedAt).Where("w."+c.TenantId, tenantID).WhereNull("w." + c.DeletedAt) + if in.GroupId > 0 { + model = model.Where("w."+c.GroupId, in.GroupId) + } + if in.Word != "" { + model = model.WhereLike("w."+c.Word, "%"+in.Word+"%") + } + if in.MatchType != "" { + model = model.Where("w."+c.MatchType, in.MatchType) + } + if in.Action != "" { + model = model.Where("w."+c.Action, in.Action) + } + if in.RiskLevel != "" { + model = model.Where("w."+c.RiskLevel, in.RiskLevel) + } + if in.Status != 0 { + model = model.Where("w."+c.Status, in.Status) + } + err = model.Page(in.Page, in.PerPage).OrderDesc("w."+c.Priority).OrderDesc("w."+c.Id).ScanAndCount(&list, &total, false) + if err != nil { + err = gerror.Wrap(err, "获取敏感词列表失败") + } + return +} + +func (s *sSysImSensitiveWord) Edit(ctx context.Context, in *sysin.ImSensitiveWordEditInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + if err = validateSensitiveWordGroup(ctx, tenantID, in.GroupId); err != nil { + return err + } + _, hash := normalizedSensitiveWord(in.Word) + c := dao.ImSensitiveWords.Columns() + db := imtenant.RegistryDB() + now := gtime.Now() + data := g.Map{c.TenantId: tenantID, c.GroupId: in.GroupId, c.Word: in.Word, c.WordHash: hash, c.MatchType: in.MatchType, c.SceneJson: policyJSON(in.SceneJson, "[]"), c.Action: in.Action, c.Replacement: in.Replacement, c.RiskLevel: in.RiskLevel, c.Priority: in.Priority, c.Status: in.Status, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: contexts.GetUserId(ctx)} + targetID := in.Id + if in.Id > 0 { + var row gdb.Record + if row, err = db.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() || row[c.TenantId].Int64() != tenantID { + return gerror.New("敏感词不存在") + } + _, err = db.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update() + } else { + data[c.CreatedAt] = now + data[c.CreatedBy] = contexts.GetUserId(ctx) + result, insertErr := db.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Data(data).Insert() + err = insertErr + if err == nil { + targetID, err = result.LastInsertId() + } + } + if err != nil { + return gerror.Wrap(err, "保存敏感词失败,请检查是否重复") + } + _ = writeImAdminAudit(ctx, tenantID, "im_sensitive_word_edit", "im_sensitive_word", targetID, "success", g.Map{"group_id": in.GroupId, "action": in.Action}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "sensitive_words.updated", "sensitive_words", uint(targetID)) +} + +func (s *sSysImSensitiveWord) Delete(ctx context.Context, in *sysin.ImSensitiveWordDeleteInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + return s.updateWordState(ctx, in.Id, consts.StatusDisable, true, "im_sensitive_word_delete") +} +func (s *sSysImSensitiveWord) Status(ctx context.Context, in *sysin.ImSensitiveWordStatusInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + return s.updateWordState(ctx, in.Id, in.Status, false, "im_sensitive_word_status") +} +func (s *sSysImSensitiveWord) updateWordState(ctx context.Context, id int64, status int, deleted bool, action string) error { + c := dao.ImSensitiveWords.Columns() + db := imtenant.RegistryDB() + var row gdb.Record + var err error + if row, err = db.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Where(c.Id, id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() { + return gerror.New("敏感词不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64()) + if err != nil { + return err + } + data := g.Map{c.Status: status, c.UpdatedAt: gtime.Now(), c.UpdatedBy: contexts.GetUserId(ctx)} + if deleted { + data[c.DeletedAt] = gtime.Now() + data[c.DeletedBy] = contexts.GetUserId(ctx) + } + if _, err = db.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Where(c.Id, id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新敏感词失败") + } + _ = writeImAdminAudit(ctx, tenantID, action, "im_sensitive_word", id, "success", g.Map{"status": status}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "sensitive_words.updated", "sensitive_words", uint(id)) +} + +func (s *sSysImSensitiveWord) BatchImport(ctx context.Context, in *sysin.ImSensitiveWordBatchImportInp) (res *sysin.ImSensitiveWordBatchImportModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + if err = validateSensitiveWordGroup(ctx, tenantID, in.GroupId); err != nil { + return nil, err + } + res = &sysin.ImSensitiveWordBatchImportModel{} + db := imtenant.RegistryDB() + c := dao.ImSensitiveWords.Columns() + seen := map[string]struct{}{} + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, raw := range in.Words { + word, hash := normalizedSensitiveWord(raw) + if word == "" { + res.Skipped++ + continue + } + if _, ok := seen[hash]; ok { + res.Skipped++ + continue + } + seen[hash] = struct{}{} + count, countErr := tx.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Where(c.TenantId, tenantID).Where(c.GroupId, in.GroupId).Where(c.WordHash, hash).WhereNull(c.DeletedAt).Count() + if countErr != nil { + return countErr + } + if count > 0 { + res.Skipped++ + continue + } + now := gtime.Now() + _, insertErr := tx.Model(dao.ImSensitiveWords.Table()).Safe().Ctx(ctx).Data(g.Map{c.TenantId: tenantID, c.GroupId: in.GroupId, c.Word: strings.TrimSpace(raw), c.WordHash: hash, c.MatchType: in.MatchType, c.SceneJson: policyJSON(in.SceneJson, "[]"), c.Action: in.Action, c.Replacement: in.Replacement, c.RiskLevel: in.RiskLevel, c.Status: consts.StatusEnabled, c.CreatedAt: now, c.CreatedBy: contexts.GetUserId(ctx), c.UpdatedAt: now, c.UpdatedBy: contexts.GetUserId(ctx)}).Insert() + if insertErr != nil { + return insertErr + } + res.Inserted++ + } + return nil + }) + if err != nil { + return nil, gerror.Wrap(err, "批量导入敏感词失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_sensitive_word_batch_import", "im_sensitive_word", 0, "success", g.Map{"inserted": res.Inserted, "skipped": res.Skipped}) + err = publishImPolicyEvent(ctx, tenantID, tenantCode, "sensitive_words.updated", "sensitive_words", uint(res.Inserted)) + return +} + +func (s *sSysImSensitiveWord) GroupList(ctx context.Context, in *sysin.ImSensitiveWordGroupListInp) (list []*sysin.ImSensitiveWordGroupModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + c := dao.ImSensitiveWordGroups.Columns() + err = imtenant.RegistryDB().Model(dao.ImSensitiveWordGroups.Table()).Safe().Ctx(ctx).Fields(c.Id, c.TenantId, c.GroupCode, c.GroupName, c.Priority, c.Status, c.Remark, c.CreatedAt).Where(c.TenantId, tenantID).WhereNull(c.DeletedAt).OrderDesc(c.Priority).OrderAsc(c.GroupName).Scan(&list) + return +} + +func (s *sSysImSensitiveWord) GroupEdit(ctx context.Context, in *sysin.ImSensitiveWordGroupEditInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + c := dao.ImSensitiveWordGroups.Columns() + db := imtenant.RegistryDB() + now := gtime.Now() + data := g.Map{c.TenantId: tenantID, c.GroupCode: in.GroupCode, c.GroupName: in.GroupName, c.Priority: in.Priority, c.Status: in.Status, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: contexts.GetUserId(ctx)} + targetID := in.Id + if in.Id > 0 { + var row gdb.Record + if row, err = db.Model(dao.ImSensitiveWordGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() || row[c.TenantId].Int64() != tenantID { + return gerror.New("敏感词分组不存在") + } + if row[c.GroupCode].String() != in.GroupCode { + return gerror.New("分组编码创建后不能修改") + } + _, err = db.Model(dao.ImSensitiveWordGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update() + } else { + data[c.CreatedAt] = now + data[c.CreatedBy] = contexts.GetUserId(ctx) + result, insertErr := db.Model(dao.ImSensitiveWordGroups.Table()).Safe().Ctx(ctx).Data(data).Insert() + err = insertErr + if err == nil { + targetID, err = result.LastInsertId() + } + } + if err != nil { + return gerror.Wrap(err, "保存敏感词分组失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_sensitive_word_group_edit", "im_sensitive_word_group", targetID, "success", nil) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "sensitive_words.updated", "sensitive_words", uint(targetID)) +} + +func validateSensitiveWordGroup(ctx context.Context, tenantID, groupID int64) error { + c := dao.ImSensitiveWordGroups.Columns() + count, err := imtenant.RegistryDB().Model(dao.ImSensitiveWordGroups.Table()).Safe().Ctx(ctx).Where(c.Id, groupID).Where(c.TenantId, tenantID).WhereNull(c.DeletedAt).Count() + if err != nil { + return gerror.Wrap(err, "检查敏感词分组失败") + } + if count == 0 { + return gerror.New("敏感词分组不存在") + } + return nil +} diff --git a/server/internal/logic/sys/im_task_dashboard.go b/server/internal/logic/sys/im_task_dashboard.go new file mode 100644 index 0000000..b7fc1ef --- /dev/null +++ b/server/internal/logic/sys/im_task_dashboard.go @@ -0,0 +1,117 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "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/os/gtime" +) + +type sSysImTaskDashboard struct{} + +func NewSysImTaskDashboard() *sSysImTaskDashboard { return &sSysImTaskDashboard{} } + +func init() { + service.RegisterSysImTaskDashboard(NewSysImTaskDashboard()) +} + +func (s *sSysImTaskDashboard) List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScopeWithAll(ctx, in.TenantId, true) + if err != nil { + return + } + mod := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx) + if scope.TenantId > 0 { + mod = mod.WherePri(scope.TenantId) + } + if in.TenantId > 0 { + mod = mod.WherePri(in.TenantId) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc(dao.ImTenant.Columns().Id) + + var tenants []*entity.ImTenant + if err = mod.ScanAndCount(&tenants, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM任务看板租户失败,请稍后重试!") + return + } + list = make([]*sysin.ImTaskDashboardListModel, 0, len(tenants)) + for _, tenant := range tenants { + item := s.buildTenantSummary(ctx, tenant) + if in.Status != "" && item.Status != in.Status { + continue + } + list = append(list, item) + } + if in.Status != "" { + totalCount = len(list) + } + return +} + +func (s *sSysImTaskDashboard) buildTenantSummary(ctx context.Context, tenant *entity.ImTenant) *sysin.ImTaskDashboardListModel { + item := &sysin.ImTaskDashboardListModel{ + TenantId: tenant.Id, + TenantName: tenant.Name, + TenantCode: tenant.Code, + TenantState: tenant.State, + DbStatus: "ok", + Status: consts.ImTaskDashboardStatusHealthy, + RecommendedNextAction: "无需处理", + } + db, _, err := imtenant.DB(ctx, tenant.Id) + if err != nil { + item.DbStatus = "error" + item.Status = consts.ImTaskDashboardStatusError + item.RecommendedNextAction = "检查租户数据库连接" + return item + } + item.OutboxPendingCount, _ = s.count(ctx, db, "im_business_events_outbox", "status='pending'") + item.OutboxFailedCount, _ = s.count(ctx, db, "im_business_events_outbox", "status='failed'") + item.GroupOutboxPending, _ = s.count(ctx, db, "im_group_events_outbox", "status='pending'") + item.GroupOutboxFailed, _ = s.count(ctx, db, "im_group_events_outbox", "status='failed'") + item.OrphanFileCount, _ = s.countOrphanFiles(ctx, db) + item.DeletedFileCount, _ = s.count(ctx, db, "im_fileuploads", "status=3") + item.LastOutboxCreatedAt, _ = s.maxTime(ctx, db, "im_business_events_outbox", "created_at") + item.LastGroupEventAt, _ = s.maxTime(ctx, db, "im_group_events_outbox", "created_at") + item.LastFileUpdatedAt, _ = s.maxTime(ctx, db, "im_fileuploads", "updatedat") + if item.OutboxFailedCount > 0 || item.GroupOutboxFailed > 0 { + item.Status = consts.ImTaskDashboardStatusError + item.RecommendedNextAction = "处理失败Outbox事件" + } else if item.OutboxPendingCount > 0 || item.GroupOutboxPending > 0 || item.OrphanFileCount > 0 || item.DeletedFileCount > 0 { + item.Status = consts.ImTaskDashboardStatusWarning + item.RecommendedNextAction = "检查待处理任务和文件清理" + } + return item +} + +func (s *sSysImTaskDashboard) count(ctx context.Context, db gdb.DB, table string, where string) (int, error) { + return db.Model(table).Safe().Ctx(ctx).Where(where).Count() +} + +func (s *sSysImTaskDashboard) countOrphanFiles(ctx context.Context, db gdb.DB) (int, error) { + return db.Model("im_fileuploads fu").Safe().Ctx(ctx). + LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id"). + Where("fml.id IS NULL"). + WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}). + Count() +} + +func (s *sSysImTaskDashboard) maxTime(ctx context.Context, db gdb.DB, table string, field string) (res *gtime.Time, err error) { + var row struct { + Value *gtime.Time `orm:"value"` + } + if err = db.Model(table).Safe().Ctx(ctx).Fields("MAX(" + field + ") value").Scan(&row); err != nil { + return nil, err + } + return row.Value, nil +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index 2b34bff..17c479e 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -7,12 +7,12 @@ package sys import ( "context" + "fmt" "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" "hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/hook" - "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" @@ -21,7 +21,6 @@ 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/util/gconv" ) type sSysImTenant struct{} @@ -36,11 +35,11 @@ func init() { // Model IM租户ORM模型 func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gdb.Model { - return handler.Model(imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) + return handler.Model(imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) } func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model { - return imtenant.RegistryDB(ctx).Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx) + return imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx) } // List 获取IM租户列表 @@ -145,7 +144,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err return } - err = imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + return imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil { return } @@ -170,12 +169,6 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err } return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) }) - if err != nil { - return err - } - s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantUpdated, "tenant") - s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantLimitsUpdated, "tenant_limits") - return nil } func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) { @@ -278,33 +271,139 @@ func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp) err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!") return } + return +} + +func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (res *sysin.ImTenantUsageModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + var tenant *entity.ImTenant - if scanErr := s.Model(ctx).Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().Code).WherePri(in.Id).Scan(&tenant); scanErr == nil && tenant != nil { - s.publishTenantConfigEvent(ctx, tenant.Code, tenant.Id, consts.ImConfigEventTenantStatusChanged, "tenant_status") + if err = s.Model(ctx).WherePri(in.Id).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户信息失败,请稍后重试!") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + + res = &sysin.ImTenantUsageModel{ + Id: tenant.Id, + Code: tenant.Code, + Name: tenant.Name, + DbName: tenant.DbName, + DbDsnConfigured: tenant.DbDsn != "", + Warnings: []string{}, + } + + db, _, dbErr := imtenant.DB(ctx, tenant.Id) + if dbErr != nil { + res.DbReachable = false + res.DbCheckError = dbErr.Error() + return res, nil + } + if _, pingErr := db.GetValue(ctx, "SELECT 1"); pingErr != nil { + res.DbReachable = false + res.DbCheckError = pingErr.Error() + return res, nil + } + res.DbReachable = true + + res.SchemaVersion = s.queryScalarString(ctx, db, "kvmeta.version", "SELECT value FROM kvmeta WHERE key_='version' LIMIT 1", &res.Warnings) + res.UserCount = s.queryScalarInt64(ctx, db, "im_users.total", "SELECT COUNT(*) FROM im_users", &res.Warnings) + res.ActiveUserCount = s.queryScalarInt64(ctx, db, "im_users.active", "SELECT COUNT(*) FROM im_users WHERE state=0", &res.Warnings) + res.GroupCount = s.queryScalarInt64(ctx, db, "im_group_settings", "SELECT COUNT(*) FROM im_group_settings WHERE group_status<>'dissolved'", &res.Warnings) + res.DeviceCount = s.queryScalarInt64(ctx, db, "im_devices", "SELECT COUNT(*) FROM im_devices", &res.Warnings) + res.PushTokenCount = s.queryScalarInt64(ctx, db, "im_device_push_tokens", "SELECT COUNT(*) FROM im_device_push_tokens", &res.Warnings) + res.InviteUseCount = s.queryScalarInt64(ctx, db, "im_registration_invite_uses", "SELECT COUNT(*) FROM im_registration_invite_uses", &res.Warnings) + res.OutboxPendingCount = s.queryScalarInt64(ctx, db, "im_business_events_outbox", "SELECT COUNT(*) FROM im_business_events_outbox WHERE status='pending'", &res.Warnings) + return +} + +func (s *sSysImTenant) queryScalarInt64(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string) int64 { + value, err := db.GetValue(ctx, sql) + if err != nil { + *warnings = append(*warnings, label+": "+err.Error()) + return 0 + } + return value.Int64() +} + +func (s *sSysImTenant) queryScalarString(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string) string { + value, err := db.GetValue(ctx, sql) + if err != nil { + *warnings = append(*warnings, label+": "+err.Error()) + return "" + } + return value.String() +} + +func (s *sSysImTenant) DeletionCheck(ctx context.Context, in *sysin.ImTenantDeletionCheckInp) (res *sysin.ImTenantDeletionCheckModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + usage, err := s.Usage(ctx, &sysin.ImTenantUsageInp{Id: in.Id}) + if err != nil { + return nil, err + } + res = &sysin.ImTenantDeletionCheckModel{ + Id: usage.Id, + Code: usage.Code, + Name: usage.Name, + Blockers: []string{}, + Warnings: usage.Warnings, + UserCount: usage.UserCount, + GroupCount: usage.GroupCount, + PendingOutbox: usage.OutboxPendingCount, + } + if !usage.DbReachable { + res.Blockers = append(res.Blockers, "租户业务库不可达:"+usage.DbCheckError) + } + if usage.UserCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 个用户", usage.UserCount)) + } + if usage.GroupCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 个群", usage.GroupCount)) + } + if usage.OutboxPendingCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 条待处理 Outbox", usage.OutboxPendingCount)) + } + res.CanEnterDeleting = len(res.Blockers) == 0 + return +} + +func (s *sSysImTenant) EnterDeleting(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + check, err := s.DeletionCheck(ctx, &sysin.ImTenantDeletionCheckInp{Id: in.Id}) + if err != nil { + return err + } + if !check.CanEnterDeleting { + return gerror.New("租户未通过删除前检查,不能进入删除流程") + } + if _, err = s.Model(ctx).WherePri(in.Id).Data(g.Map{ + dao.ImTenant.Columns().State: consts.ImTenantStateDeleting, + dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx), + }).Update(); err != nil { + return gerror.Wrap(err, "设置IM租户删除流程失败,请稍后重试!") } return } -func (s *sSysImTenant) publishTenantConfigEvent(ctx context.Context, tenantCode string, tenantId int64, eventType string, bizId string) { - user := contexts.GetUser(ctx) - actorName := "" - if user != nil { - actorName = user.RealName +func (s *sSysImTenant) Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) { + if err = in.Filter(ctx); err != nil { + return } - if err := imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ - TenantID: tenantId, - TenantCode: tenantCode, - EventType: eventType, - BizID: bizId, - Group: "tenant", - Keys: []string{"tenant", "limits", "features"}, - Actor: &imkafka.Actor{ - Type: "admin", - ID: gconv.String(contexts.GetUserId(ctx)), - DisplayName: actorName, - }, - CreatedBy: contexts.GetUserId(ctx), - }); err != nil { - g.Log().Warningf(ctx, "发送IM租户配置Kafka事件失败 tenant:%s type:%s err:%+v", tenantCode, eventType, err) + if _, err = s.Model(ctx). + WherePri(in.Id). + WhereIn(dao.ImTenant.Columns().State, []int{consts.ImTenantStateSuspended, consts.ImTenantStateDeleting}). + Data(g.Map{ + dao.ImTenant.Columns().State: consts.ImTenantStateActive, + dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx), + }).Update(); err != nil { + return gerror.Wrap(err, "恢复IM租户失败,请稍后重试!") } + return } diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index 29020d9..084c038 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -7,17 +7,35 @@ package sys import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" "hotgo/internal/consts" + "hotgo/internal/dao" "hotgo/internal/library/contexts" - "hotgo/internal/library/imkafka" + "hotgo/internal/library/imadmin" "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "hotgo/utility/excel" + "io" + "net" + "net/url" "strconv" "strings" + "time" "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/guid" + "github.com/xuri/excelize/v2" + "golang.org/x/crypto/bcrypt" ) type sSysImUser struct{} @@ -30,37 +48,299 @@ func init() { service.RegisterSysImUser(NewSysImUser()) } +func (s *sSysImUser) Create(ctx context.Context, in *sysin.ImUserCreateInp) (res *sysin.ImUserCreateModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + if in.GroupId > 0 { + if err = validateImUserPolicyGroup(ctx, tenant.Id, in.GroupId); err != nil { + return nil, err + } + } + payload := map[string]any{ + "username": in.Username, + "nickname": in.Nickname, + "phone": in.Phone, + "password": in.Password, + } + var wire struct { + DisplayNo int `json:"display_no"` + UserId int64 `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Phone string `json:"phone"` + } + path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users" + if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, &wire); err != nil { + return nil, err + } + res = &sysin.ImUserCreateModel{DisplayNo: wire.DisplayNo, UserId: wire.UserId, Username: wire.Username, Nickname: wire.Nickname, Phone: wire.Phone} + if in.GroupId > 0 { + if err = service.SysImUserPolicyGroup().MemberAdd(ctx, &sysin.ImUserPolicyGroupMemberAddInp{GroupId: in.GroupId, UserId: res.UserId}); err != nil { + return nil, gerror.Wrap(err, "用户已创建,但加入用户分组失败") + } + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_user_create", "user", res.UserId, "success", map[string]any{"username": res.Username, "group_id": in.GroupId}) + return res, nil +} + +func (s *sSysImUser) BatchCreate(ctx context.Context, in *sysin.ImUserBatchCreateInp) (res *sysin.ImUserBatchCreateModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + rows := make([]*sysin.ImUserCreateInp, 0, in.Count) + for i := 1; i <= in.Count; i++ { + nickname := in.NicknamePrefix + if nickname == "" { + nickname = "用户" + } + rows = append(rows, &sysin.ImUserCreateInp{TenantId: in.TenantId, Nickname: fmt.Sprintf("%s%d", nickname, i), Password: in.Password, GroupId: in.GroupId}) + } + return s.Import(ctx, &sysin.ImUserImportInp{TenantId: in.TenantId, GroupId: in.GroupId, Rows: rows}) +} + +func (s *sSysImUser) Import(ctx context.Context, in *sysin.ImUserImportInp) (res *sysin.ImUserBatchCreateModel, err error) { + if len(in.Rows) == 0 { + return nil, gerror.New("没有可导入的用户数据") + } + if len(in.Rows) > 500 { + return nil, gerror.New("单次最多导入500个用户") + } + res = &sysin.ImUserBatchCreateModel{Items: make([]*sysin.ImUserBatchItemModel, 0, len(in.Rows))} + for index, row := range in.Rows { + row.TenantId = in.TenantId + if row.GroupId == 0 { + row.GroupId = in.GroupId + } + item := &sysin.ImUserBatchItemModel{Row: index + 2, Username: row.Username} + created, createErr := s.Create(ctx, row) + if createErr != nil { + item.Reason = createErr.Error() + res.FailedCount++ + } else { + item.Success = true + item.DisplayNo = created.DisplayNo + item.UserId = created.UserId + item.Username = created.Username + res.SuccessCount++ + } + res.Items = append(res.Items, item) + } + return res, nil +} + +func (s *sSysImUser) ImportExcel(ctx context.Context, tenantId, groupId int64, reader io.Reader) (res *sysin.ImUserBatchCreateModel, err error) { + workbook, err := excelize.OpenReader(reader) + if err != nil { + return nil, gerror.New("Excel文件格式不正确") + } + defer workbook.Close() + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return nil, gerror.New("Excel文件没有工作表") + } + rows, err := workbook.GetRows(sheets[0]) + if err != nil { + return nil, gerror.Wrap(err, "读取Excel文件失败") + } + input := &sysin.ImUserImportInp{TenantId: tenantId, GroupId: groupId} + for index, row := range rows { + if index == 0 || len(row) == 0 { + continue + } + cell := func(i int) string { + if i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) + } + rowGroupId := groupId + if raw := cell(4); raw != "" { + if _, scanErr := fmt.Sscan(raw, &rowGroupId); scanErr != nil { + rowGroupId = groupId + } + } + input.Rows = append(input.Rows, &sysin.ImUserCreateInp{ + TenantId: tenantId, Username: cell(0), Nickname: cell(1), Phone: cell(2), Password: cell(3), GroupId: rowGroupId, + }) + } + return s.Import(ctx, input) +} + +func (s *sSysImUser) DownloadTemplate(ctx context.Context) error { + type templateRow struct { + Username string + Nickname string + Phone string + Password string + GroupId string + } + return excel.ExportByStructs(ctx, + []string{"用户名(可空,自动生成)", "昵称(必填)", "手机号", "密码(必填)", "用户分组ID"}, + []templateRow{{Username: "", Nickname: "示例用户", Phone: "", Password: "123456", GroupId: ""}}, + "用户导入模板", "用户") +} + // List 获取IM用户列表 func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) { if err = in.Filter(ctx); err != nil { return } - - db, tenant, err := imtenant.DB(ctx, in.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return } - - mod := s.listModel(ctx, db) - mod = s.applyListWhere(mod, in) - mod = mod.Page(in.Page, in.PerPage).OrderDesc("u.id") - - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM用户列表失败,请稍后重试!") - return + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + if in.GroupId > 0 { + userIds, groupErr := imUserPolicyGroupUserIds(ctx, tenant.Id, in.GroupId) + if groupErr != nil { + return nil, 0, groupErr + } + if len(userIds) == 0 { + return []*sysin.ImUserListModel{}, 0, nil + } + values.Set("user_ids", strings.Join(userIds, ",")) } + if in.UserId != "" { + values.Set("account_no", in.UserId) + } + if in.Nickname != "" { + values.Set("nickname", in.Nickname) + } + if in.AuthName != "" { + values.Set("auth_name", in.AuthName) + } + if in.Keyword != "" { + values.Set("keyword", in.Keyword) + } + if in.State != nil { + values.Set("state", intToString(*in.State)) + } + addTimeRangeQuery(values, "created_at", in.CreatedAt) + addTimeRangeQuery(values, "last_seen", in.LastSeen) + list, totalCount, err = imAdminQueryList[sysin.ImUserListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users", + values, + map[string]string{ + "public": "profileJson", + "trusted": "trustedJson", + "access": "accessJson", + }, + ) + if err != nil { + return nil, 0, err + } + if err = fillImUserPolicyGroups(ctx, tenant.Id, list); err != nil { + return nil, 0, err + } + if err = fillImUserRegisterTypes(ctx, tenant.Id, list); err != nil { + return nil, 0, err + } + return list, totalCount, nil +} + +func fillImUserRegisterTypes(ctx context.Context, tenantId int64, list []*sysin.ImUserListModel) error { if len(list) == 0 { - return + return nil } - + ids := make([]int64, 0, len(list)) + byID := make(map[int64]*sysin.ImUserListModel, len(list)) for _, item := range list { - item.TenantId = tenant.Id - item.TenantName = tenant.Name + id, err := strconv.ParseInt(item.UserId, 10, 64) + if err == nil { + ids = append(ids, id) + byID[id] = item + } + if item.RegisterType == "" { + item.RegisterType = consts.ImUserRegisterTypeAdmin + } } - if err = s.fillAuthAndCredentials(ctx, db, list); err != nil { - return + if len(ids) == 0 { + return nil } - return + rows, err := imtenant.RegistryDB().Model(imAdminOperationAuditTable).Safe().Ctx(ctx). + Fields("target_id").Where("tenant_id", tenantId).Where("target_type", "user"). + Where("action", "im_user_create").Where("result", "success").WhereIn("target_id", ids).All() + if err != nil { + return gerror.Wrap(err, "获取用户注册方式失败") + } + for _, row := range rows { + if item := byID[row["target_id"].Int64()]; item != nil { + item.RegisterType = consts.ImUserRegisterTypeAdmin + } + } + return nil +} + +func validateImUserPolicyGroup(ctx context.Context, tenantId, groupId int64) error { + c := dao.ImUserPolicyGroups.Columns() + count, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx). + Where(c.Id, groupId).Where(c.TenantId, tenantId).Where(c.Status, consts.StatusEnabled).WhereNull(c.DeletedAt).Count() + if err != nil { + return gerror.Wrap(err, "检查用户分组失败") + } + if count == 0 { + return gerror.New("用户分组不存在或已停用") + } + return nil +} + +func imUserPolicyGroupUserIds(ctx context.Context, tenantId, groupId int64) ([]string, error) { + if err := validateImUserPolicyGroup(ctx, tenantId, groupId); err != nil { + return nil, err + } + c := dao.ImUserPolicyGroupMembers.Columns() + rows, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx). + Fields(c.UserId).Where(c.TenantId, tenantId).Where(c.GroupId, groupId).Where(c.Status, consts.StatusEnabled).WhereNull(c.DeletedAt).All() + if err != nil { + return nil, gerror.Wrap(err, "获取用户分组成员失败") + } + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, strconv.FormatInt(row[c.UserId].Int64(), 10)) + } + return ids, nil +} + +func fillImUserPolicyGroups(ctx context.Context, tenantId int64, list []*sysin.ImUserListModel) error { + if len(list) == 0 { + return nil + } + ids := make([]int64, 0, len(list)) + byId := make(map[int64]*sysin.ImUserListModel, len(list)) + for _, item := range list { + id, err := strconv.ParseInt(item.UserId, 10, 64) + if err == nil { + ids = append(ids, id) + byId[id] = item + } + } + if len(ids) == 0 { + return nil + } + mc := dao.ImUserPolicyGroupMembers.Columns() + gc := dao.ImUserPolicyGroups.Columns() + rows, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroupMembers.Table()+" m").Safe().Ctx(ctx). + LeftJoin(dao.ImUserPolicyGroups.Table()+" g", "g."+gc.Id+"=m."+mc.GroupId). + Fields("m."+mc.UserId, "g."+gc.Id+" policy_group_id", "g."+gc.GroupName+" policy_group_name"). + Where("m."+mc.TenantId, tenantId).WhereIn("m."+mc.UserId, ids). + Where("m."+mc.Status, consts.StatusEnabled).Where("g."+gc.Status, consts.StatusEnabled). + WhereNull("m." + mc.DeletedAt).WhereNull("g." + gc.DeletedAt).OrderDesc("g." + gc.Priority).All() + if err != nil { + return gerror.Wrap(err, "获取用户分组信息失败") + } + for _, row := range rows { + item := byId[row[mc.UserId].Int64()] + if item != nil && item.PolicyGroupId == 0 { + item.PolicyGroupId = row["policy_group_id"].Int64() + item.PolicyGroupName = row["policy_group_name"].String() + } + } + return nil } func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model { @@ -68,7 +348,9 @@ func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model { LeftJoin("im_user_profiles up", "up.user_id = u.id"). LeftJoin("im_user_security us", "us.user_id = u.id"). Fields( - "u.id user_id", + "CAST(u.id AS CHAR) user_id", + "u.username", + "COALESCE(u.phone, '') phone", "u.state", "u.lastseen last_seen", "u.useragent user_agent", @@ -84,14 +366,14 @@ func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model { } func (s *sSysImUser) applyListWhere(mod *gdb.Model, in *sysin.ImUserListInp) *gdb.Model { - if in.UserId > 0 { - mod = mod.Where("u.id", in.UserId) + if userId, _ := sysin.ParseOptionalInt64(in.UserId); userId > 0 { + mod = mod.Where("u.id", userId) } if in.Nickname != "" { mod = mod.WhereLike("up.nickname", "%"+in.Nickname+"%") } if in.AuthName != "" { - mod = mod.Where("EXISTS (SELECT 1 FROM im_auth a WHERE a.userid = u.id AND a.uname LIKE ?)", "%"+in.AuthName+"%") + mod = mod.Where("u.username = ? OR u.phone = ? OR up.email = ?", in.AuthName, in.AuthName, in.AuthName) } if in.State != nil { mod = mod.Where("u.state", *in.State) @@ -109,24 +391,18 @@ func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list ids := make([]int64, 0, len(list)) userMap := make(map[int64]*sysin.ImUserListModel, len(list)) for _, item := range list { - ids = append(ids, item.UserId) - userMap[item.UserId] = item + userId, parseErr := sysin.ParseOptionalInt64(item.UserId) + if parseErr != nil { + return parseErr + } + if userId <= 0 { + continue + } + ids = append(ids, userId) + userMap[userId] = item } - - authRows, err := db.Model("im_auth").Safe().Ctx(ctx). - Fields("userid", "scheme", "uname"). - WhereIn("userid", ids). - OrderAsc("scheme").OrderAsc("uname"). - All() - if err != nil { - return gerror.Wrap(err, "获取IM用户认证标识失败,请稍后重试!") - } - authMap := make(map[int64][]string) - for _, row := range authRows { - userId := row["userid"].Int64() - scheme := row["scheme"].String() - uname := row["uname"].String() - authMap[userId] = append(authMap[userId], scheme+":"+uname) + if len(ids) == 0 { + return nil } credentialRows, err := db.Model("im_credentials").Safe().Ctx(ctx). @@ -151,7 +427,10 @@ func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list } for userId, item := range userMap { - item.AuthNames = strings.Join(authMap[userId], ", ") + item.AuthNames = item.Username + if item.Phone != "" && item.Phone != item.Username { + item.AuthNames += ", " + item.Phone + } item.Credentials = strings.Join(credentialMap[userId], ", ") } return @@ -162,135 +441,25 @@ func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sy if err = in.Filter(ctx); err != nil { return } - - db, tenant, err := imtenant.DB(ctx, in.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { - return - } - - fields := append(s.viewFields(), "u.access access_json", "u.public profile_json", "u.trusted trusted_json", "u.tags tags_json") - err = db.Model("im_users u").Safe().Ctx(ctx). - LeftJoin("im_user_profiles up", "up.user_id = u.id"). - LeftJoin("im_user_security us", "us.user_id = u.id"). - Fields(fields...). - Where("u.id", in.UserId). - Scan(&res) - if err != nil { - return nil, gerror.Wrap(err, "获取IM用户信息失败,请稍后重试!") - } - if res == nil { return nil, nil } - - res.TenantId = tenant.Id - res.TenantName = tenant.Name - list := []*sysin.ImUserListModel{&res.ImUserListModel} - if err = s.fillAuthAndCredentials(ctx, db, list); err != nil { - return nil, err - } - return -} - -// Ban 通过Kafka封禁IM用户 -func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) -} - -// Unban 通过Kafka解封IM用户 -func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) -} - -// Freeze 通过Kafka冻结IM用户 -func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) -} - -// Unfreeze 通过Kafka解冻IM用户 -func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) -} - -// DeviceKick 通过Kafka踢出IM用户设备 -func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - if err = in.FilterDevice(ctx); err != nil { - return nil, err - } - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) -} - -// LogoutAll 通过Kafka踢出IM用户全部在线会话 -func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) -} - -// AccountCancel 通过Kafka注销IM用户账号 -func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) -} - -// AccountDelete 通过Kafka删除IM用户账号 -func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - in.Hard = true - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) -} - -func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { - if err := in.Filter(ctx); err != nil { - return nil, err - } - _, tenant, err := imtenant.DB(ctx, in.TenantId) - if err != nil { - return nil, err - } - user := contexts.GetUser(ctx) - actorName := "" - if user != nil { - actorName = user.RealName - } - payload := map[string]any{ - "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), - "user_id": strconv.FormatInt(in.UserId, 10), - "target_user_id": strconv.FormatInt(in.UserId, 10), - "reason": in.Reason, - } - if in.DeviceId != "" { - payload["device_id"] = in.DeviceId - } - if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete { - payload["hard"] = in.Hard - } - command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ - TenantID: tenant.Id, - TenantCode: tenant.Code, - EventType: eventType, - BizType: "user", - BizID: strconv.FormatInt(in.UserId, 10), - Actor: &imkafka.Actor{ - Type: "admin", - ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), - DisplayName: actorName, + return imAdminQueryOne[sysin.ImUserViewModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId), + map[string]string{ + "public": "profileJson", + "trusted": "trustedJson", + "access": "accessJson", }, - Payload: payload, - CreatedBy: contexts.GetUserId(ctx), - }) - if err != nil { - return nil, err - } - return &sysin.ImUserCommandModel{ - CommandID: command.CommandID, - EventID: command.EventID, - Status: command.Status, - TenantID: command.TenantID, - TenantCode: command.TenantCode, - EventType: command.EventType, - IdempotencyKey: command.IdempotencyKey, - }, nil + ) } func (s *sSysImUser) viewFields() []interface{} { return []interface{}{ - "u.id user_id", + "CAST(u.id AS CHAR) user_id", + "u.username", + "COALESCE(u.phone, '') phone", "u.state", "u.lastseen last_seen", "u.useragent user_agent", @@ -308,3 +477,487 @@ func (s *sSysImUser) viewFields() []interface{} { "us.password_updated_at", } } + +func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (res *sysin.ImUserCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + userId, err := sysin.ParseOptionalInt64(in.UserId) + if err != nil { + return nil, err + } + res, err = s.syncPasswordReset(ctx, in, userId) + result := "success" + if err != nil { + result = "failed" + } + tenantId := in.TenantId + if res != nil && res.TenantID > 0 { + tenantId = res.TenantID + } + _ = writeImAdminAudit(ctx, tenantId, "user_password_reset", "user", userId, result, nil) + return res, err +} + +func (s *sSysImUser) RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return err + } + userId, err := sysin.ParseOptionalInt64(in.UserId) + if err != nil { + return err + } + db, _, err := imtenant.DB(ctx, scope.TenantId) + if err != nil { + return err + } + err = revokeIMUserSessions(ctx, db, scope, userId, "security.sessions_revoked") + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "user_sessions_revoke", "user", userId, result, nil) + return err +} + +func (s *sSysImUser) Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if in.State != consts.ImUserStateOk && in.State != consts.ImUserStateSuspended { + return gerror.New("账号状态只能设置为正常或已停用") + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return err + } + userId, err := sysin.ParseOptionalInt64(in.UserId) + if err != nil { + return err + } + db, _, err := imtenant.DB(ctx, scope.TenantId) + if err != nil { + return err + } + err = updateIMUserStatus(ctx, db, scope, userId, in.State) + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "user_status_update", "user", userId, result, map[string]any{"state": in.State}) + return err +} + +func (s *sSysImUser) AdjustGroup(ctx context.Context, in *sysin.ImUserAdjustGroupInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return err + } + if err = validateImUserPolicyGroup(ctx, scope.TenantId, in.GroupId); err != nil { + return err + } + db := imtenant.RegistryDB() + c := dao.ImUserPolicyGroupMembers.Columns() + now := gtime.Now() + operatorID := contexts.GetUserId(ctx) + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, updateErr := tx.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx). + Where(c.TenantId, scope.TenantId).Where(c.UserId, in.UserId).WhereNull(c.DeletedAt). + Data(g.Map{c.Status: consts.StatusDisable, c.DeletedAt: now, c.DeletedBy: operatorID, c.UpdatedAt: now, c.UpdatedBy: operatorID}).Update() + if updateErr != nil { + return updateErr + } + _, insertErr := tx.Exec(`INSERT INTO `+dao.ImUserPolicyGroupMembers.Table()+` + (tenant_id,group_id,user_id,status,valid_from,remark,created_at,created_by,updated_at,updated_by,deleted_at,deleted_by) + VALUES(?,?,?,1,?,'后台调整分组',?,?,?, ?,NULL,NULL) + ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),status=1,valid_from=VALUES(valid_from),valid_until=NULL, + remark=VALUES(remark),updated_at=VALUES(updated_at),updated_by=VALUES(updated_by),deleted_at=NULL,deleted_by=NULL`, + scope.TenantId, in.GroupId, in.UserId, now, now, operatorID, now, operatorID) + return insertErr + }) + if err != nil { + return gerror.Wrap(err, "调整用户分组失败") + } + _ = writeImAdminAudit(ctx, scope.TenantId, "im_user_adjust_group", "user", in.UserId, "success", g.Map{"group_id": in.GroupId}) + return nil +} + +func (s *sSysImUser) IPWhitelist(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (*sysin.ImUserIPWhitelistModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return nil, err + } + db, _, err := imtenant.DB(ctx, scope.TenantId) + if err != nil { + return nil, err + } + rows, err := db.Model("im_user_ip_whitelists").Safe().Ctx(ctx). + Fields("ip_cidr").Where("user_id", in.UserId). + Where("status", consts.StatusEnabled).WhereNull("deleted_at").OrderAsc("id").All() + if err != nil { + return nil, gerror.Wrap(err, "获取用户IP白名单失败") + } + result := &sysin.ImUserIPWhitelistModel{IPList: make([]string, 0, len(rows))} + for _, row := range rows { + result.IPList = append(result.IPList, row["ip_cidr"].String()) + } + return result, nil +} + +func (s *sSysImUser) IPWhitelistSave(ctx context.Context, in *sysin.ImUserIPWhitelistInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return err + } + unique := make([]string, 0, len(in.IPList)) + seen := make(map[string]struct{}, len(in.IPList)) + for _, raw := range in.IPList { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if net.ParseIP(value) == nil { + if _, _, parseErr := net.ParseCIDR(value); parseErr != nil { + return gerror.Newf("IP或CIDR格式不合法:%s", value) + } + } + if _, ok := seen[value]; !ok { + seen[value] = struct{}{} + unique = append(unique, value) + } + } + db, _, err := imtenant.DB(ctx, scope.TenantId) + if err != nil { + return err + } + now := gtime.Now() + operatorID := contexts.GetUserId(ctx) + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, updateErr := tx.Model("im_user_ip_whitelists").Safe().Ctx(ctx). + Where("user_id", in.UserId).WhereNull("deleted_at"). + Data(g.Map{"status": consts.StatusDisable, "deleted_at": now, "deleted_by": operatorID, "updated_at": now, "updated_by": operatorID}).Update(); updateErr != nil { + return updateErr + } + for _, value := range unique { + if _, insertErr := tx.Exec(`INSERT INTO im_user_ip_whitelists + (user_id,ip_cidr,status,created_at,created_by,updated_at,updated_by,deleted_at,deleted_by) + VALUES(?,?,1,?,?,?,?,NULL,NULL) + ON DUPLICATE KEY UPDATE status=1,updated_at=VALUES(updated_at),updated_by=VALUES(updated_by),deleted_at=NULL,deleted_by=NULL`, + in.UserId, value, now, operatorID, now, operatorID); insertErr != nil { + return insertErr + } + } + return nil + }) + if err != nil { + return gerror.Wrap(err, "保存用户IP白名单失败") + } + _ = writeImAdminAudit(ctx, scope.TenantId, "im_user_ip_whitelist_save", "user", in.UserId, "success", g.Map{"ip_list": unique}) + return nil +} + +func (s *sSysImUser) Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return + } + return imAdminQueryList[sysin.ImUserDeviceListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId)+"/devices", + addPageQuery(url.Values{}, in.Page, in.PerPage), + nil, + ) +} + +func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) +} + +func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) +} + +func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) +} + +func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) +} + +func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + if err = in.FilterDevice(ctx); err != nil { + return nil, err + } + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) +} + +func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) +} + +func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) +} + +func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + in.Hard = true + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) +} + +func (s *sSysImUser) syncGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + payload := map[string]any{ + "event_type": eventType, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "reason": in.Reason, + } + if in.DeviceId != "" { + payload["device_id"] = in.DeviceId + } + if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete { + payload["hard"] = in.Hard + } + res, err := s.syncUserAction(ctx, tenant, in.UserId, eventType, payload) + if err != nil { + return nil, err + } + return res, nil +} + +func (s *sSysImUser) syncPasswordReset(ctx context.Context, in *sysin.ImUserPasswordResetInp, userId int64) (*sysin.ImUserCommandModel, error) { + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) + if err != nil { + return nil, gerror.Wrap(err, "生成密码摘要失败") + } + payload := map[string]any{ + "event_type": consts.ImAdminCommandUserPasswordReset, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "password_hash": base64.StdEncoding.EncodeToString(passwordHash), + } + return s.syncUserAction(ctx, tenant, userId, consts.ImAdminCommandUserPasswordReset, payload) +} + +func (s *sSysImUser) syncUserAction(ctx context.Context, tenant *entity.ImTenant, userId int64, eventType string, payload map[string]any) (*sysin.ImUserCommandModel, error) { + var out struct { + UserId int64 `json:"user_id"` + EventType string `json:"event_type"` + Status string `json:"status"` + Result map[string]any `json:"result"` + } + path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatInt(userId, 10) + "/action" + err := imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, &out) + if err != nil { + return nil, err + } + status := out.Status + if status == "" { + status = consts.ImAdminCommandStatusSucceeded + } + return &sysin.ImUserCommandModel{ + Status: status, + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, + }, nil +} + +func updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, state int) error { + securityId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全记录ID失败") + } + eventId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全事件ID失败") + } + now := time.Now().UTC().Round(time.Millisecond) + requestId := scope.RequestId + if requestId == "" { + requestId = gctx.CtxId(ctx) + } + if requestId == "" { + requestId = guid.S() + } + eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("user-status:%d:%d:%d:%s", scope.TenantId, userId, state, requestId))) + eventKey := hex.EncodeToString(eventKeyBytes[:]) + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + current, txErr := tx.GetValue("SELECT state FROM im_users WHERE id=? LIMIT 1", userId) + if txErr != nil { + return txErr + } + if current.IsNil() { + return gerror.New("IM用户不存在") + } + if current.Int() == consts.ImUserStateDeleted { + return gerror.New("已删除账号不能变更状态") + } + if current.Int() == state { + return nil + } + if _, txErr = tx.Exec("UPDATE im_users SET state=?, updatedat=? WHERE id=?", state, now, userId); txErr != nil { + return txErr + } + if _, txErr = tx.Exec(`INSERT INTO im_user_security + (id,user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?,?) + ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`, + securityId, userId, 2, now, now); txErr != nil { + return txErr + } + version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId) + if txErr != nil { + return txErr + } + payload, _ := json.Marshal(map[string]any{ + "user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "state": state, "auth_version": version.Int(), + }) + _, txErr = tx.Exec(`INSERT INTO im_business_events_outbox + (id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?) + ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user", + fmt.Sprintf("%d", userId), 0, "security.user_status_changed", string(payload), now, now) + return txErr + }) +} + +func revokeIMUserSessions(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, eventType string) error { + securityId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全记录ID失败") + } + eventId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全事件ID失败") + } + now := time.Now().UTC().Round(time.Millisecond) + requestId := scope.RequestId + if requestId == "" { + requestId = gctx.CtxId(ctx) + } + if requestId == "" { + requestId = guid.S() + } + eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d:%s", eventType, scope.TenantId, userId, requestId))) + eventKey := hex.EncodeToString(eventKeyBytes[:]) + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE id=? LIMIT 1)", userId) + if txErr != nil { + return txErr + } + if !exists.Bool() { + return gerror.New("IM用户不存在") + } + if _, txErr = tx.Exec(`INSERT INTO im_user_security + (id,user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?,?) + ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`, + securityId, userId, 2, now, now); txErr != nil { + return txErr + } + version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId) + if txErr != nil { + return txErr + } + payload, _ := json.Marshal(map[string]any{ + "user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "auth_version": version.Int(), + }) + _, txErr = tx.Exec(`INSERT INTO im_business_events_outbox + (id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?) + ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user", + fmt.Sprintf("%d", userId), 0, eventType, string(payload), now, now) + return txErr + }) +} + +func resetIMUserPassword(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, newPassword string) error { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return gerror.Wrap(err, "生成密码摘要失败") + } + securityId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全记录ID失败") + } + eventId, err := imtenant.NextSnowflakeID(ctx) + if err != nil { + return gerror.Wrap(err, "生成安全事件ID失败") + } + now := time.Now().UTC().Round(time.Millisecond) + requestId := scope.RequestId + if requestId == "" { + requestId = gctx.CtxId(ctx) + } + if requestId == "" { + requestId = guid.S() + } + eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("password-reset:%d:%d:%s", scope.TenantId, userId, requestId))) + eventKey := hex.EncodeToString(eventKeyBytes[:]) + payload, _ := json.Marshal(map[string]any{ + "user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, + }) + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE id=? LIMIT 1)", userId) + if txErr != nil { + return txErr + } + if !exists.Bool() { + return gerror.New("IM用户不存在") + } + result, txErr := tx.Exec("UPDATE im_auth SET secret=? WHERE userid=?", passwordHash, userId) + if txErr != nil { + return txErr + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("用户认证记录不存在") + } + if _, txErr = tx.Exec(`INSERT INTO im_user_security + (id,user_id,auth_version,password_updated_at,created_at,updated_at) + VALUES(?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE auth_version=auth_version+1, + password_updated_at=VALUES(password_updated_at),updated_at=VALUES(updated_at)`, + securityId, userId, 2, now, now, now); txErr != nil { + return txErr + } + version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId) + if txErr != nil { + return txErr + } + var eventPayload map[string]any + _ = json.Unmarshal(payload, &eventPayload) + eventPayload["auth_version"] = version.Int() + payload, _ = json.Marshal(eventPayload) + _, txErr = tx.Exec(`INSERT INTO im_business_events_outbox + (id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?) + ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user", + fmt.Sprintf("%d", userId), 0, "security.password_reset", string(payload), now, now) + return txErr + }) +} diff --git a/server/internal/logic/sys/im_user_policy_group.go b/server/internal/logic/sys/im_user_policy_group.go new file mode 100644 index 0000000..7647a27 --- /dev/null +++ b/server/internal/logic/sys/im_user_policy_group.go @@ -0,0 +1,374 @@ +package sys + +import ( + "context" + + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imtenant" + "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 sSysImUserPolicyGroup struct{} + +const imTenantLoginIPWhitelistTable = "im_tenant_login_ip_whitelists" + +func NewSysImUserPolicyGroup() *sSysImUserPolicyGroup { return &sSysImUserPolicyGroup{} } +func init() { service.RegisterSysImUserPolicyGroup(NewSysImUserPolicyGroup()) } + +func (s *sSysImUserPolicyGroup) List(ctx context.Context, in *sysin.ImUserPolicyGroupListInp) (list []*sysin.ImUserPolicyGroupListModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, 0, err + } + c := dao.ImUserPolicyGroups.Columns() + mc := dao.ImUserPolicyGroupMembers.Columns() + model := imtenant.RegistryDB().Model(dao.ImUserPolicyGroups.Table()+" p").Safe().Ctx(ctx). + Fields("p."+c.Id, "p."+c.TenantId, "p."+c.GroupCode, "p."+c.GroupName, "p."+c.Priority, "p."+c.Status, + "CAST(p."+c.PolicyJson+" AS CHAR) policy_json", "CAST(p."+c.IpWhitelist+" AS CHAR) ip_whitelist", + "CAST(p."+c.IpBlacklist+" AS CHAR) ip_blacklist", "p."+c.Version, "p."+c.Remark, "p."+c.CreatedAt, "p."+c.UpdatedAt, + "(SELECT COUNT(*) FROM "+dao.ImUserPolicyGroupMembers.Table()+" m WHERE m."+mc.GroupId+"=p."+c.Id+" AND m."+mc.Status+"=1 AND m."+mc.DeletedAt+" IS NULL) member_count"). + Where("p."+c.TenantId, tenantID).WhereNull("p." + c.DeletedAt) + if in.Keyword != "" { + keyword := "%" + in.Keyword + "%" + model = model.Where("(p."+c.GroupCode+" LIKE ? OR p."+c.GroupName+" LIKE ?)", keyword, keyword) + } + if in.Status != 0 { + model = model.Where("p."+c.Status, in.Status) + } + err = model.Page(in.Page, in.PerPage).OrderDesc("p."+c.Priority).OrderDesc("p."+c.Id).ScanAndCount(&list, &total, false) + if err != nil { + err = gerror.Wrap(err, "获取用户策略分组失败") + } + return +} + +func (s *sSysImUserPolicyGroup) View(ctx context.Context, in *sysin.ImUserPolicyGroupViewInp) (res *sysin.ImUserPolicyGroupViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + c := dao.ImUserPolicyGroups.Columns() + var row *sysin.ImUserPolicyGroupListModel + err = imtenant.RegistryDB().Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx). + Fields(c.Id, c.TenantId, c.GroupCode, c.GroupName, c.Priority, c.Status, "CAST("+c.PolicyJson+" AS CHAR) policy_json", "CAST("+c.IpWhitelist+" AS CHAR) ip_whitelist", "CAST("+c.IpBlacklist+" AS CHAR) ip_blacklist", c.Version, c.Remark, c.CreatedAt, c.UpdatedAt). + Where(c.Id, in.Id).WhereNull(c.DeletedAt).Scan(&row) + if err != nil { + return nil, gerror.Wrap(err, "获取用户策略分组详情失败") + } + if row == nil { + return nil, gerror.New("用户策略分组不存在") + } + if _, _, err = resolveImPolicyTenant(ctx, row.TenantId); err != nil { + return nil, err + } + return &sysin.ImUserPolicyGroupViewModel{ImUserPolicyGroupListModel: *row}, nil +} + +func (s *sSysImUserPolicyGroup) Edit(ctx context.Context, in *sysin.ImUserPolicyGroupEditInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + c := dao.ImUserPolicyGroups.Columns() + db := imtenant.RegistryDB() + now := gtime.Now() + operator := contexts.GetUserId(ctx) + data := g.Map{c.GroupName: in.GroupName, c.Priority: in.Priority, c.Status: in.Status, c.PolicyJson: policyJSON(in.PolicyJson, "{}"), c.IpWhitelist: policyJSON(in.IpWhitelist, "[]"), c.IpBlacklist: policyJSON(in.IpBlacklist, "[]"), c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator} + var targetID int64 + if in.Id > 0 { + var current gdb.Record + if current, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return gerror.Wrap(err, "读取用户策略分组失败") + } + if current.IsEmpty() || current[c.TenantId].Int64() != tenantID { + return gerror.New("用户策略分组不存在") + } + if current[c.GroupCode].String() != in.GroupCode { + return gerror.New("分组编码创建后不能修改") + } + data[c.Version] = gdb.Raw("version+1") + if _, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新用户策略分组失败") + } + targetID = in.Id + } else { + data[c.TenantId] = tenantID + data[c.GroupCode] = in.GroupCode + data[c.Version] = 1 + data[c.CreatedAt] = now + data[c.CreatedBy] = operator + result, insertErr := db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Data(data).Insert() + if insertErr != nil { + return gerror.Wrap(insertErr, "新增用户策略分组失败") + } + targetID, err = result.LastInsertId() + if err != nil { + return gerror.Wrap(err, "获取用户策略分组ID失败") + } + } + _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_edit", "im_user_policy_group", targetID, "success", g.Map{"group_code": in.GroupCode}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.updated", "user_policy_groups", uint(targetID)) +} + +func (s *sSysImUserPolicyGroup) Delete(ctx context.Context, in *sysin.ImUserPolicyGroupDeleteInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + c := dao.ImUserPolicyGroups.Columns() + mc := dao.ImUserPolicyGroupMembers.Columns() + db := imtenant.RegistryDB() + var row gdb.Record + var err error + if row, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return gerror.Wrap(err, "读取用户策略分组失败") + } + if row.IsEmpty() { + return gerror.New("用户策略分组不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64()) + if err != nil { + return err + } + count, err := db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Where(mc.GroupId, in.Id).Where(mc.Status, consts.StatusEnabled).WhereNull(mc.DeletedAt).Count() + if err != nil { + return err + } + if count > 0 { + return gerror.New("请先移除分组内成员") + } + now := gtime.Now() + _, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(g.Map{c.DeletedAt: now, c.DeletedBy: contexts.GetUserId(ctx), c.Status: consts.StatusDisable}).Update() + if err != nil { + return gerror.Wrap(err, "删除用户策略分组失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_delete", "im_user_policy_group", in.Id, "success", nil) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.deleted", "user_policy_groups", uint(in.Id)) +} + +func (s *sSysImUserPolicyGroup) Status(ctx context.Context, in *sysin.ImUserPolicyGroupStatusInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + c := dao.ImUserPolicyGroups.Columns() + db := imtenant.RegistryDB() + var row gdb.Record + var err error + if row, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).WhereNull(c.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() { + return gerror.New("用户策略分组不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[c.TenantId].Int64()) + if err != nil { + return err + } + _, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(g.Map{c.Status: in.Status, c.Version: gdb.Raw("version+1"), c.UpdatedBy: contexts.GetUserId(ctx), c.UpdatedAt: gtime.Now()}).Update() + if err != nil { + return gerror.Wrap(err, "更新用户策略分组状态失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_status", "im_user_policy_group", in.Id, "success", g.Map{"status": in.Status}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.updated", "user_policy_groups", uint(in.Id)) +} + +func (s *sSysImUserPolicyGroup) MemberList(ctx context.Context, in *sysin.ImUserPolicyGroupMemberListInp) (list []*sysin.ImUserPolicyGroupMemberModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + gc := dao.ImUserPolicyGroups.Columns() + mc := dao.ImUserPolicyGroupMembers.Columns() + var group gdb.Record + if group, err = imtenant.RegistryDB().Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(gc.Id, in.GroupId).WhereNull(gc.DeletedAt).One(); err != nil { + return nil, 0, err + } + if group.IsEmpty() { + return nil, 0, gerror.New("用户策略分组不存在") + } + if _, _, err = resolveImPolicyTenant(ctx, group[gc.TenantId].Int64()); err != nil { + return nil, 0, err + } + model := imtenant.RegistryDB().Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Fields(mc.Id, mc.GroupId, mc.TenantId, mc.UserId, mc.Status, mc.ValidFrom, mc.ValidUntil, mc.Remark, mc.CreatedAt).Where(mc.GroupId, in.GroupId).WhereNull(mc.DeletedAt) + if in.UserId > 0 { + model = model.Where(mc.UserId, in.UserId) + } + err = model.Page(in.Page, in.PerPage).OrderDesc(mc.Id).ScanAndCount(&list, &total, false) + return +} + +func (s *sSysImUserPolicyGroup) MemberAdd(ctx context.Context, in *sysin.ImUserPolicyGroupMemberAddInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + gc := dao.ImUserPolicyGroups.Columns() + mc := dao.ImUserPolicyGroupMembers.Columns() + db := imtenant.RegistryDB() + var group gdb.Record + var err error + if group, err = db.Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).Where(gc.Id, in.GroupId).WhereNull(gc.DeletedAt).One(); err != nil { + return err + } + if group.IsEmpty() { + return gerror.New("用户策略分组不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, group[gc.TenantId].Int64()) + if err != nil { + return err + } + if tenantID == 0 { + return gerror.New("全局默认分组不能添加成员,请先选择具体租户") + } + tenantDB, _, err := imtenant.DB(ctx, tenantID) + if err != nil { + return err + } + userCount, err := tenantDB.Model("im_users").Safe().Ctx(ctx).Where("id", in.UserId).Count() + if err != nil { + return gerror.Wrap(err, "检查租户用户失败") + } + if userCount == 0 { + return gerror.New("用户不存在或不属于当前租户") + } + var current gdb.Record + current, err = db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Where(mc.GroupId, in.GroupId).Where(mc.UserId, in.UserId).One() + if err != nil { + return err + } + now := gtime.Now() + data := g.Map{mc.TenantId: tenantID, mc.Status: consts.StatusEnabled, mc.ValidFrom: in.ValidFrom, mc.ValidUntil: in.ValidUntil, mc.Remark: in.Remark, mc.DeletedAt: nil, mc.DeletedBy: nil, mc.UpdatedAt: now, mc.UpdatedBy: contexts.GetUserId(ctx)} + if current.IsEmpty() { + data[mc.GroupId] = in.GroupId + data[mc.UserId] = in.UserId + data[mc.CreatedAt] = now + data[mc.CreatedBy] = contexts.GetUserId(ctx) + _, err = db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Data(data).Insert() + } else { + _, err = db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Where(mc.Id, current[mc.Id]).Data(data).Update() + } + if err != nil { + return gerror.Wrap(err, "添加策略分组成员失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_member_add", "im_user_policy_group", in.GroupId, "success", g.Map{"user_id": in.UserId}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.updated", "user_policy_groups", uint(in.GroupId)) +} + +func (s *sSysImUserPolicyGroup) MemberRemove(ctx context.Context, in *sysin.ImUserPolicyGroupMemberRemoveInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + mc := dao.ImUserPolicyGroupMembers.Columns() + db := imtenant.RegistryDB() + var row gdb.Record + var err error + if row, err = db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Where(mc.Id, in.Id).WhereNull(mc.DeletedAt).One(); err != nil { + return err + } + if row.IsEmpty() { + return gerror.New("策略分组成员不存在") + } + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, row[mc.TenantId].Int64()) + if err != nil { + return err + } + _, err = db.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).Where(mc.Id, in.Id).Data(g.Map{mc.Status: consts.StatusDisable, mc.DeletedAt: gtime.Now(), mc.DeletedBy: contexts.GetUserId(ctx), mc.UpdatedAt: gtime.Now(), mc.UpdatedBy: contexts.GetUserId(ctx)}).Update() + if err != nil { + return gerror.Wrap(err, "移除策略分组成员失败") + } + groupID := row[mc.GroupId].Int64() + _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_member_remove", "im_user_policy_group", groupID, "success", g.Map{"user_id": row[mc.UserId].Int64()}) + return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.updated", "user_policy_groups", uint(groupID)) +} + +func (s *sSysImUserPolicyGroup) LoginIPWhitelist(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistInp) (res *sysin.ImTenantLoginIPWhitelistModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + return s.LoginIPWhitelistByTenant(ctx, tenantID) +} + +func (s *sSysImUserPolicyGroup) LoginIPWhitelistByTenant(ctx context.Context, tenantID int64) (res *sysin.ImTenantLoginIPWhitelistModel, err error) { + if tenantID <= 0 { + return nil, gerror.New("租户ID不合法") + } + rows, err := imtenant.RegistryDB().Model(imTenantLoginIPWhitelistTable).Safe().Ctx(ctx). + Fields("channel", "ip_cidr"). + Where("tenant_id", tenantID). + Where("status", consts.StatusEnabled). + WhereNull("deleted_at"). + OrderAsc("channel").OrderAsc("id").All() + if err != nil { + return nil, gerror.Wrap(err, "获取租户登录IP白名单失败") + } + res = &sysin.ImTenantLoginIPWhitelistModel{ + TenantId: tenantID, + AppList: make([]string, 0), + WebList: make([]string, 0), + } + for _, row := range rows { + switch row["channel"].String() { + case consts.ImLoginChannelApp: + res.AppList = append(res.AppList, row["ip_cidr"].String()) + case consts.ImLoginChannelWeb: + res.WebList = append(res.WebList, row["ip_cidr"].String()) + } + } + return res, nil +} + +func (s *sSysImUserPolicyGroup) LoginIPWhitelistSave(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistSaveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return err + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + now := gtime.Now() + operatorID := contexts.GetUserId(ctx) + db := imtenant.RegistryDB() + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, txErr := tx.Model(imTenantLoginIPWhitelistTable).Safe().Ctx(ctx). + Where("tenant_id", tenantID).WhereNull("deleted_at"). + Data(g.Map{"status": consts.StatusDisable, "deleted_at": now, "deleted_by": operatorID, "updated_at": now, "updated_by": operatorID}).Update(); txErr != nil { + return txErr + } + for channel, values := range map[string][]string{ + consts.ImLoginChannelApp: in.AppList, + consts.ImLoginChannelWeb: in.WebList, + } { + for _, value := range values { + if _, txErr := tx.Exec(`INSERT INTO im_tenant_login_ip_whitelists + (tenant_id,channel,ip_cidr,status,created_by,updated_by) + VALUES(?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE status=VALUES(status),deleted_at=NULL,deleted_by=NULL,updated_at=CURRENT_TIMESTAMP(3),updated_by=VALUES(updated_by)`, + tenantID, channel, value, consts.StatusEnabled, operatorID, operatorID); txErr != nil { + return txErr + } + } + } + return nil + }) + if err != nil { + return gerror.Wrap(err, "保存租户登录IP白名单失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_tenant_login_ip_whitelist_save", "im_tenant", tenantID, "success", g.Map{ + "app_count": len(in.AppList), "web_count": len(in.WebList), + }) + return nil +} diff --git a/server/internal/logic/sys/im_work_order.go b/server/internal/logic/sys/im_work_order.go index 0e9b6fc..35794cc 100644 --- a/server/internal/logic/sys/im_work_order.go +++ b/server/internal/logic/sys/im_work_order.go @@ -7,7 +7,6 @@ package sys import ( "context" - "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -32,7 +31,7 @@ func (s *sSysImWorkOrder) List(ctx context.Context, in *sysin.ImWorkOrderListInp if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } @@ -112,7 +111,7 @@ func (s *sSysImWorkOrder) View(ctx context.Context, in *sysin.ImWorkOrderViewInp if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return nil, err } @@ -190,7 +189,7 @@ func (s *sSysImWorkOrder) Status(ctx context.Context, in *sysin.ImWorkOrderStatu if err = in.Filter(ctx); err != nil { return } - db, _, err := imtenant.DB(ctx, in.TenantId) + db, _, err := resolveImTenantDB(ctx, in.TenantId) if err != nil { return } diff --git a/server/internal/logic/sys/im_workbench.go b/server/internal/logic/sys/im_workbench.go new file mode 100644 index 0000000..551c173 --- /dev/null +++ b/server/internal/logic/sys/im_workbench.go @@ -0,0 +1,194 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "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" +) + +const imWorkbenchAppsTable = "im_workbench_apps" + +type sSysImWorkbench struct{} + +func NewSysImWorkbench() *sSysImWorkbench { return &sSysImWorkbench{} } + +func init() { + service.RegisterSysImWorkbench(NewSysImWorkbench()) +} + +func (s *sSysImWorkbench) List(ctx context.Context, in *sysin.ImWorkbenchAppListInp) (list []*sysin.ImWorkbenchAppModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaultApps(ctx, db); err != nil { + return + } + mod := s.model(ctx, db) + if in.Code != "" { + mod = mod.WhereLike("code", "%"+in.Code+"%") + } + if in.Name != "" { + mod = mod.WhereLike("name", "%"+in.Name+"%") + } + if in.Enabled != nil { + mod = mod.Where("enabled", *in.Enabled) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("sort_order").OrderAsc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM工作台模块失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImWorkbench) Edit(ctx context.Context, in *sysin.ImWorkbenchAppEditInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaultApps(ctx, db); err != nil { + return + } + data := g.Map{ + "code": in.Code, + "name": in.Name, + "icon_file_id": in.IconFileId, + "route": in.Route, + "sort_order": in.SortOrder, + "unread_key": in.UnreadKey, + "enabled": in.Enabled, + "permission": in.Permission, + "updated_at": gtime.Now(), + } + action := "im_workbench_app_create" + targetId := in.Id + if in.Id > 0 { + if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).WherePri(in.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "更新IM工作台模块失败,请稍后重试!") + } + action = "im_workbench_app_update" + } else { + data["created_at"] = gtime.Now() + if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "新增IM工作台模块失败,请稍后重试!") + } + idValue, err := db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Fields("id").Where("code", in.Code).Value() + if err != nil { + return gerror.Wrap(err, "获取IM工作台模块ID失败,请稍后重试!") + } + targetId = idValue.Int64() + } + _ = writeImAdminAudit(ctx, tenant.Id, action, "im_workbench_app", targetId, "success", g.Map{ + "code": in.Code, "route": in.Route, "enabled": in.Enabled, "permission": in.Permission, + }) + return nil +} + +func (s *sSysImWorkbench) Delete(ctx context.Context, in *sysin.ImWorkbenchAppDeleteInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaultApps(ctx, db); err != nil { + return + } + mod := db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx) + if in.Id > 0 { + mod = mod.WherePri(in.Id) + } else { + mod = mod.Where("code", in.Code) + } + if _, err = mod.Data(g.Map{"enabled": consts.ImWorkbenchAppDisabled, "updated_at": gtime.Now()}).Update(); err != nil { + return gerror.Wrap(err, "禁用IM工作台模块失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_workbench_app_disable", "im_workbench_app", in.Id, "success", g.Map{"code": in.Code}) + return nil +} + +func (s *sSysImWorkbench) Reorder(ctx context.Context, in *sysin.ImWorkbenchAppReorderInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := resolveImTenantDB(ctx, in.TenantId) + if err != nil { + return + } + if err = s.ensureDefaultApps(ctx, db); err != nil { + return + } + if err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, item := range in.Items { + if _, e := tx.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Where("code", item.Code). + Data(g.Map{"sort_order": item.SortOrder, "updated_at": gtime.Now()}).Update(); e != nil { + return e + } + } + return nil + }); err != nil { + return gerror.Wrap(err, "调整IM工作台模块排序失败,请稍后重试!") + } + _ = writeImAdminAudit(ctx, tenant.Id, "im_workbench_app_reorder", "im_workbench_app", 0, "success", g.Map{"count": len(in.Items)}) + return nil +} + +func (s *sSysImWorkbench) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Fields( + "id", + "code", + "name", + "icon_file_id", + "route", + "sort_order", + "unread_key", + "enabled", + "permission", + "created_at", + "updated_at", + ) +} + +func (s *sSysImWorkbench) ensureDefaultApps(ctx context.Context, db gdb.DB) (err error) { + defaults := []g.Map{ + {"code": "organization", "name": "组织架构", "route": consts.ImWorkbenchRouteOrganization, "sort_order": 10, "unread_key": "organization", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled}, + {"code": "meetings", "name": "会议通知", "route": consts.ImWorkbenchRouteMeetings, "sort_order": 20, "unread_key": "meetings", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled}, + {"code": "daily_reports", "name": "日报", "route": consts.ImWorkbenchRouteDailyReports, "sort_order": 30, "unread_key": "daily_reports", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled}, + {"code": "monthly_reports", "name": "月报", "route": consts.ImWorkbenchRouteMonthlyReports, "sort_order": 40, "unread_key": "monthly_reports", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled}, + {"code": "executive_mailbox", "name": "总经理信箱", "route": consts.ImWorkbenchRouteExecutiveMailbox, "sort_order": 50, "unread_key": "executive_mailbox", "enabled": consts.ImWorkbenchAppEnabled, "permission": consts.ImWorkbenchPermissionEnabled}, + } + now := gtime.Now() + for _, item := range defaults { + var count int + if count, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Where("code", item["code"]).Count(); err != nil { + return gerror.Wrap(err, "检查IM工作台默认模块失败,请稍后重试!") + } + if count > 0 { + continue + } + item["created_at"] = now + item["updated_at"] = now + if _, err = db.Model(imWorkbenchAppsTable).Safe().Ctx(ctx).Data(item).Insert(); err != nil { + return gerror.Wrap(err, "初始化IM工作台默认模块失败,请稍后重试!") + } + } + return nil +} diff --git a/server/internal/model/do/im_message_curfew_rules.go b/server/internal/model/do/im_message_curfew_rules.go new file mode 100644 index 0000000..1467e68 --- /dev/null +++ b/server/internal/model/do/im_message_curfew_rules.go @@ -0,0 +1,39 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImMessageCurfewRules is the golang structure of table im_message_curfew_rules for DAO operations like Where/Data. +type ImMessageCurfewRules struct { + g.Meta `orm:"table:im_message_curfew_rules, do:true"` + Id any // + TenantId any // + PolicyId any // + RuleCode any // + RuleName any // + DateType any // daily, weekday, weekend, custom + CustomDates *gjson.Json // YYYY-MM-DD values for custom date type + Weekdays *gjson.Json // ISO weekdays 1 through 7 + Timezone any // + StartTime *gtime.Time // + EndTime *gtime.Time // Less than start_time means crossing midnight + Scope any // private_message or group_message + WhitelistUserIds *gjson.Json // + WhitelistGroupIds *gjson.Json // + Message any // + Status any // 1 enabled, 2 disabled + Sort any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_security_ip_rules.go b/server/internal/model/do/im_security_ip_rules.go new file mode 100644 index 0000000..99d8c0c --- /dev/null +++ b/server/internal/model/do/im_security_ip_rules.go @@ -0,0 +1,32 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSecurityIpRules is the golang structure of table im_security_ip_rules for DAO operations like Where/Data. +type ImSecurityIpRules struct { + g.Meta `orm:"table:im_security_ip_rules, do:true"` + Id any // + TenantId any // + RuleType any // whitelist or blacklist + IpCidr any // IPv4, IPv6 or CIDR + Action any // allow, challenge, block, audit_only + SceneJson *gjson.Json // login, register, message, upload and other scenes + RiskLevel any // + Status any // 1 enabled, 2 disabled + ExpiresAt *gtime.Time // + Remark any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_security_policy.go b/server/internal/model/do/im_security_policy.go new file mode 100644 index 0000000..e5a9ad2 --- /dev/null +++ b/server/internal/model/do/im_security_policy.go @@ -0,0 +1,39 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSecurityPolicy is the golang structure of table im_security_policy for DAO operations like Where/Data. +type ImSecurityPolicy struct { + g.Meta `orm:"table:im_security_policy, do:true"` + Id any // + TenantId any // 0 means global fallback policy + PolicyVersion any // + PolicyName any // + PublishStatus any // + PasswordPolicy *gjson.Json // + LoginRateLimit *gjson.Json // + SensitiveWordPolicy *gjson.Json // + FileReviewPolicy *gjson.Json // + RealnamePolicy *gjson.Json // + RiskChallengePolicy *gjson.Json // + GlobalFriendEnabled any // Backend master switch for every add-friend entry + FriendDisabledPolicy *gjson.Json // Profile masking, QR and group-member restrictions + MessageCurfewEnabled any // Backend master switch for message curfew + Remark any // + PublishedAt *gtime.Time // + PublishedBy any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_sensitive_word_groups.go b/server/internal/model/do/im_sensitive_word_groups.go new file mode 100644 index 0000000..3a4f270 --- /dev/null +++ b/server/internal/model/do/im_sensitive_word_groups.go @@ -0,0 +1,28 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSensitiveWordGroups is the golang structure of table im_sensitive_word_groups for DAO operations like Where/Data. +type ImSensitiveWordGroups struct { + g.Meta `orm:"table:im_sensitive_word_groups, do:true"` + Id any // + TenantId any // 0 means global dictionary + GroupCode any // + GroupName any // + Priority any // + Status any // 1 enabled, 2 disabled + Remark any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_sensitive_words.go b/server/internal/model/do/im_sensitive_words.go new file mode 100644 index 0000000..1a58efd --- /dev/null +++ b/server/internal/model/do/im_sensitive_words.go @@ -0,0 +1,35 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSensitiveWords is the golang structure of table im_sensitive_words for DAO operations like Where/Data. +type ImSensitiveWords struct { + g.Meta `orm:"table:im_sensitive_words, do:true"` + Id any // + TenantId any // 0 means global dictionary + GroupId any // + Word any // + WordHash any // SHA-256 of normalized word + MatchType any // + SceneJson *gjson.Json // Enabled scenes + Action any // + Replacement any // + RiskLevel any // + Priority any // + Status any // 1 enabled, 2 disabled + Remark any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_tenant_config.go b/server/internal/model/do/im_tenant_config.go index 35ac912..63edd98 100644 --- a/server/internal/model/do/im_tenant_config.go +++ b/server/internal/model/do/im_tenant_config.go @@ -11,14 +11,18 @@ import ( // ImTenantConfig is the golang structure of table im_tenant_config for DAO operations like Where/Data. type ImTenantConfig struct { - g.Meta `orm:"table:im_tenant_config, do:true"` - Id any // - TenantId any // - MaxUsers any // 0 means unlimited - MaxGroups any // 0 means unlimited - MaxGroupMembers any // 0 means unlimited - CreatedAt *gtime.Time // - CreatedBy any // - UpdatedAt *gtime.Time // - UpdatedBy any // + g.Meta `orm:"table:im_tenant_config, do:true"` + Id any // + TenantId any // + MaxUsers any // 0 means unlimited + MaxGroups any // 0 means unlimited + MaxGroupMembers any // 0 means unlimited + DefaultPolicyGroupId any // Default user policy group + SecurityPolicyVersion any // Published security policy version + GlobalFriendEnabled any // Published global friend switch + MessageCurfewEnabled any // Published message curfew switch + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // } diff --git a/server/internal/model/do/im_user_policy_group_members.go b/server/internal/model/do/im_user_policy_group_members.go new file mode 100644 index 0000000..9e00af6 --- /dev/null +++ b/server/internal/model/do/im_user_policy_group_members.go @@ -0,0 +1,29 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImUserPolicyGroupMembers is the golang structure of table im_user_policy_group_members for DAO operations like Where/Data. +type ImUserPolicyGroupMembers struct { + g.Meta `orm:"table:im_user_policy_group_members, do:true"` + Id any // + TenantId any // + GroupId any // + UserId any // User ID in the tenant database + Status any // 1 enabled, 2 disabled + ValidFrom *gtime.Time // + ValidUntil *gtime.Time // + Remark any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/do/im_user_policy_groups.go b/server/internal/model/do/im_user_policy_groups.go new file mode 100644 index 0000000..ee4638a --- /dev/null +++ b/server/internal/model/do/im_user_policy_groups.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImUserPolicyGroups is the golang structure of table im_user_policy_groups for DAO operations like Where/Data. +type ImUserPolicyGroups struct { + g.Meta `orm:"table:im_user_policy_groups, do:true"` + Id any // + TenantId any // 0 means global default + GroupCode any // Stable policy group code + GroupName any // Policy group name + Priority any // Higher value has higher priority + Status any // 1 enabled, 2 disabled + PolicyJson *gjson.Json // Login, friend, message, group and file policies + IpWhitelist *gjson.Json // IPv4/IPv6 or CIDR list + IpBlacklist *gjson.Json // IPv4/IPv6 or CIDR list + Version any // Optimistic-lock/config version + Remark any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // + DeletedAt *gtime.Time // + DeletedBy any // +} diff --git a/server/internal/model/entity/im_message_curfew_rules.go b/server/internal/model/entity/im_message_curfew_rules.go new file mode 100644 index 0000000..9dae62e --- /dev/null +++ b/server/internal/model/entity/im_message_curfew_rules.go @@ -0,0 +1,37 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImMessageCurfewRules is the golang structure for table im_message_curfew_rules. +type ImMessageCurfewRules struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` + PolicyId int64 `json:"policyId" orm:"policy_id" description:""` + RuleCode string `json:"ruleCode" orm:"rule_code" description:""` + RuleName string `json:"ruleName" orm:"rule_name" description:""` + DateType string `json:"dateType" orm:"date_type" description:"daily, weekday, weekend, custom"` + CustomDates *gjson.Json `json:"customDates" orm:"custom_dates" description:"YYYY-MM-DD values for custom date type"` + Weekdays *gjson.Json `json:"weekdays" orm:"weekdays" description:"ISO weekdays 1 through 7"` + Timezone string `json:"timezone" orm:"timezone" description:""` + StartTime *gtime.Time `json:"startTime" orm:"start_time" description:""` + EndTime *gtime.Time `json:"endTime" orm:"end_time" description:"Less than start_time means crossing midnight"` + Scope string `json:"scope" orm:"scope" description:"private_message or group_message"` + WhitelistUserIds *gjson.Json `json:"whitelistUserIds" orm:"whitelist_user_ids" description:""` + WhitelistGroupIds *gjson.Json `json:"whitelistGroupIds" orm:"whitelist_group_ids" description:""` + Message string `json:"message" orm:"message" description:""` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + Sort int `json:"sort" orm:"sort" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_security_ip_rules.go b/server/internal/model/entity/im_security_ip_rules.go new file mode 100644 index 0000000..bc28762 --- /dev/null +++ b/server/internal/model/entity/im_security_ip_rules.go @@ -0,0 +1,30 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSecurityIpRules is the golang structure for table im_security_ip_rules. +type ImSecurityIpRules struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` + RuleType string `json:"ruleType" orm:"rule_type" description:"whitelist or blacklist"` + IpCidr string `json:"ipCidr" orm:"ip_cidr" description:"IPv4, IPv6 or CIDR"` + Action string `json:"action" orm:"action" description:"allow, challenge, block, audit_only"` + SceneJson *gjson.Json `json:"sceneJson" orm:"scene_json" description:"login, register, message, upload and other scenes"` + RiskLevel string `json:"riskLevel" orm:"risk_level" description:""` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:""` + Remark string `json:"remark" orm:"remark" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_security_policy.go b/server/internal/model/entity/im_security_policy.go new file mode 100644 index 0000000..ccd0566 --- /dev/null +++ b/server/internal/model/entity/im_security_policy.go @@ -0,0 +1,37 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSecurityPolicy is the golang structure for table im_security_policy. +type ImSecurityPolicy struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:"0 means global fallback policy"` + PolicyVersion uint `json:"policyVersion" orm:"policy_version" description:""` + PolicyName string `json:"policyName" orm:"policy_name" description:""` + PublishStatus string `json:"publishStatus" orm:"publish_status" description:""` + PasswordPolicy *gjson.Json `json:"passwordPolicy" orm:"password_policy" description:""` + LoginRateLimit *gjson.Json `json:"loginRateLimit" orm:"login_rate_limit" description:""` + SensitiveWordPolicy *gjson.Json `json:"sensitiveWordPolicy" orm:"sensitive_word_policy" description:""` + FileReviewPolicy *gjson.Json `json:"fileReviewPolicy" orm:"file_review_policy" description:""` + RealnamePolicy *gjson.Json `json:"realnamePolicy" orm:"realname_policy" description:""` + RiskChallengePolicy *gjson.Json `json:"riskChallengePolicy" orm:"risk_challenge_policy" description:""` + GlobalFriendEnabled int `json:"globalFriendEnabled" orm:"global_friend_enabled" description:"Backend master switch for every add-friend entry"` + FriendDisabledPolicy *gjson.Json `json:"friendDisabledPolicy" orm:"friend_disabled_policy" description:"Profile masking, QR and group-member restrictions"` + MessageCurfewEnabled int `json:"messageCurfewEnabled" orm:"message_curfew_enabled" description:"Backend master switch for message curfew"` + Remark string `json:"remark" orm:"remark" description:""` + PublishedAt *gtime.Time `json:"publishedAt" orm:"published_at" description:""` + PublishedBy int64 `json:"publishedBy" orm:"published_by" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_sensitive_word_groups.go b/server/internal/model/entity/im_sensitive_word_groups.go new file mode 100644 index 0000000..882ba77 --- /dev/null +++ b/server/internal/model/entity/im_sensitive_word_groups.go @@ -0,0 +1,26 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSensitiveWordGroups is the golang structure for table im_sensitive_word_groups. +type ImSensitiveWordGroups struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:"0 means global dictionary"` + GroupCode string `json:"groupCode" orm:"group_code" description:""` + GroupName string `json:"groupName" orm:"group_name" description:""` + Priority int `json:"priority" orm:"priority" description:""` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + Remark string `json:"remark" orm:"remark" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_sensitive_words.go b/server/internal/model/entity/im_sensitive_words.go new file mode 100644 index 0000000..ba097ca --- /dev/null +++ b/server/internal/model/entity/im_sensitive_words.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImSensitiveWords is the golang structure for table im_sensitive_words. +type ImSensitiveWords struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:"0 means global dictionary"` + GroupId int64 `json:"groupId" orm:"group_id" description:""` + Word string `json:"word" orm:"word" description:""` + WordHash string `json:"wordHash" orm:"word_hash" description:"SHA-256 of normalized word"` + MatchType string `json:"matchType" orm:"match_type" description:""` + SceneJson *gjson.Json `json:"sceneJson" orm:"scene_json" description:"Enabled scenes"` + Action string `json:"action" orm:"action" description:""` + Replacement string `json:"replacement" orm:"replacement" description:""` + RiskLevel string `json:"riskLevel" orm:"risk_level" description:""` + Priority int `json:"priority" orm:"priority" description:""` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + Remark string `json:"remark" orm:"remark" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_tenant_config.go b/server/internal/model/entity/im_tenant_config.go index a28e460..e8caeb9 100644 --- a/server/internal/model/entity/im_tenant_config.go +++ b/server/internal/model/entity/im_tenant_config.go @@ -10,13 +10,17 @@ import ( // ImTenantConfig is the golang structure for table im_tenant_config. type ImTenantConfig struct { - Id int64 `json:"id" orm:"id" description:""` - TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` - MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0 means unlimited"` - MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0 means unlimited"` - MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0 means unlimited"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` - CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` - UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` - UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` + MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0 means unlimited"` + MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0 means unlimited"` + MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0 means unlimited"` + DefaultPolicyGroupId int64 `json:"defaultPolicyGroupId" orm:"default_policy_group_id" description:"Default user policy group"` + SecurityPolicyVersion uint `json:"securityPolicyVersion" orm:"security_policy_version" description:"Published security policy version"` + GlobalFriendEnabled int `json:"globalFriendEnabled" orm:"global_friend_enabled" description:"Published global friend switch"` + MessageCurfewEnabled int `json:"messageCurfewEnabled" orm:"message_curfew_enabled" description:"Published message curfew switch"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` } diff --git a/server/internal/model/entity/im_user_policy_group_members.go b/server/internal/model/entity/im_user_policy_group_members.go new file mode 100644 index 0000000..af440ba --- /dev/null +++ b/server/internal/model/entity/im_user_policy_group_members.go @@ -0,0 +1,27 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ImUserPolicyGroupMembers is the golang structure for table im_user_policy_group_members. +type ImUserPolicyGroupMembers struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` + GroupId int64 `json:"groupId" orm:"group_id" description:""` + UserId int64 `json:"userId" orm:"user_id" description:"User ID in the tenant database"` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + ValidFrom *gtime.Time `json:"validFrom" orm:"valid_from" description:""` + ValidUntil *gtime.Time `json:"validUntil" orm:"valid_until" description:""` + Remark string `json:"remark" orm:"remark" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/entity/im_user_policy_groups.go b/server/internal/model/entity/im_user_policy_groups.go new file mode 100644 index 0000000..1485510 --- /dev/null +++ b/server/internal/model/entity/im_user_policy_groups.go @@ -0,0 +1,31 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImUserPolicyGroups is the golang structure for table im_user_policy_groups. +type ImUserPolicyGroups struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:"0 means global default"` + GroupCode string `json:"groupCode" orm:"group_code" description:"Stable policy group code"` + GroupName string `json:"groupName" orm:"group_name" description:"Policy group name"` + Priority int `json:"priority" orm:"priority" description:"Higher value has higher priority"` + Status int `json:"status" orm:"status" description:"1 enabled, 2 disabled"` + PolicyJson *gjson.Json `json:"policyJson" orm:"policy_json" description:"Login, friend, message, group and file policies"` + IpWhitelist *gjson.Json `json:"ipWhitelist" orm:"ip_whitelist" description:"IPv4/IPv6 or CIDR list"` + IpBlacklist *gjson.Json `json:"ipBlacklist" orm:"ip_blacklist" description:"IPv4/IPv6 or CIDR list"` + Version uint `json:"version" orm:"version" description:"Optimistic-lock/config version"` + Remark string `json:"remark" orm:"remark" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:""` +} diff --git a/server/internal/model/input/sysin/im_abuse_reason.go b/server/internal/model/input/sysin/im_abuse_reason.go new file mode 100644 index 0000000..5fdd042 --- /dev/null +++ b/server/internal/model/input/sysin/im_abuse_reason.go @@ -0,0 +1,96 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImAbuseReasonListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + TargetType string `json:"targetType" dc:"对象类型"` + ReasonCode string `json:"reasonCode" dc:"原因编码"` + Enabled *int `json:"enabled" dc:"启用状态"` +} + +func (in *ImAbuseReasonListInp) Filter(ctx context.Context) (err error) { + in.TargetType = strings.TrimSpace(in.TargetType) + in.ReasonCode = strings.TrimSpace(in.ReasonCode) + if in.TargetType != "" && !dict.HasOptionKey(consts.ImAbuseTargetTypeOptions, in.TargetType) { + return gerror.New("举报对象类型不合法") + } + if in.Enabled != nil && !dict.HasOptionKey(consts.ImAbuseReasonEnabledOptions, *in.Enabled) { + return gerror.New("启用状态不合法") + } + return nil +} + +type ImAbuseReasonModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + TargetType string `json:"targetType" dc:"对象类型"` + ReasonCode string `json:"reasonCode" dc:"原因编码"` + Label string `json:"label" dc:"展示文案"` + RequiresDescription int `json:"requiresDescription" dc:"要求描述"` + AllowsEvidence int `json:"allowsEvidence" dc:"允许证据"` + SortOrder int `json:"sortOrder" dc:"排序"` + Enabled int `json:"enabled" dc:"启用状态"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImAbuseReasonEditInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" dc:"ID"` + TargetType string `json:"targetType" v:"required#对象类型不能为空" dc:"对象类型"` + ReasonCode string `json:"reasonCode" v:"required#原因编码不能为空" dc:"原因编码"` + Label string `json:"label" v:"required#展示文案不能为空" dc:"展示文案"` + RequiresDescription int `json:"requiresDescription" dc:"要求描述"` + AllowsEvidence int `json:"allowsEvidence" dc:"允许证据"` + SortOrder int `json:"sortOrder" dc:"排序"` + Enabled int `json:"enabled" dc:"启用状态"` +} + +func (in *ImAbuseReasonEditInp) Filter(ctx context.Context) (err error) { + in.TargetType = strings.TrimSpace(in.TargetType) + in.ReasonCode = strings.TrimSpace(in.ReasonCode) + in.Label = strings.TrimSpace(in.Label) + if !dict.HasOptionKey(consts.ImAbuseTargetTypeOptions, in.TargetType) { + return gerror.New("举报对象类型不合法") + } + if in.ReasonCode == "" || strings.ContainsAny(in.ReasonCode, " \t\r\n/") || len([]rune(in.ReasonCode)) > 64 { + return gerror.New("原因编码不能为空,且不能包含空格或斜杠") + } + if in.Label == "" || len([]rune(in.Label)) > 128 { + return gerror.New("展示文案不能为空且不能超过128个字符") + } + if !dict.HasOptionKey(consts.ImAbuseReasonEnabledOptions, in.RequiresDescription) { + return gerror.New("要求描述开关不合法") + } + if !dict.HasOptionKey(consts.ImAbuseReasonEnabledOptions, in.AllowsEvidence) { + return gerror.New("允许证据开关不合法") + } + if !dict.HasOptionKey(consts.ImAbuseReasonEnabledOptions, in.Enabled) { + return gerror.New("启用状态不合法") + } + return nil +} + +type ImAbuseReasonDeleteInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" v:"required|min:1#请选择要禁用的举报原因|请选择要禁用的举报原因" dc:"ID"` +} + +func (in *ImAbuseReasonDeleteInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("请选择要禁用的举报原因") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_app_release.go b/server/internal/model/input/sysin/im_app_release.go new file mode 100644 index 0000000..9147a8f --- /dev/null +++ b/server/internal/model/input/sysin/im_app_release.go @@ -0,0 +1,153 @@ +package sysin + +import ( + "context" + "net/url" + "strconv" + "strings" + + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImAppReleaseListInp struct { + form.PageReq + Platform string `json:"platform" dc:"平台"` + Channel string `json:"channel" dc:"渠道"` +} + +func (in *ImAppReleaseListInp) Filter(ctx context.Context) (err error) { + in.Platform = strings.TrimSpace(in.Platform) + in.Channel = strings.TrimSpace(in.Channel) + if in.Platform != "" && !dict.HasOptionKey(consts.ImAppPlatformOptions, in.Platform) { + return gerror.New("客户端平台不合法") + } + return nil +} + +type ImAppReleaseModel struct { + Id int64 `json:"id" dc:"ID"` + Platform string `json:"platform" dc:"平台"` + Channel string `json:"channel" dc:"渠道"` + LatestVersion string `json:"latestVersion" dc:"最新版本"` + MinSupportedVersion string `json:"minSupportedVersion" dc:"最低支持版本"` + UpdateType string `json:"updateType" dc:"更新类型"` + ReleaseNotes string `json:"releaseNotes" dc:"更新说明"` + StoreUrl string `json:"storeUrl" dc:"商店地址"` + PackageUrl string `json:"packageUrl" dc:"安装包地址"` + Checksum string `json:"checksum" dc:"校验值"` + RemindAfterSeconds int `json:"remindAfterSeconds" dc:"再次提醒秒数"` + PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImAppReleaseEditInp struct { + Id int64 `json:"id" dc:"ID"` + Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"` + Channel string `json:"channel" v:"required#渠道不能为空" dc:"渠道"` + LatestVersion string `json:"latestVersion" v:"required#最新版本不能为空" dc:"最新版本"` + MinSupportedVersion string `json:"minSupportedVersion" v:"required#最低支持版本不能为空" dc:"最低支持版本"` + UpdateType string `json:"updateType" v:"required#更新类型不能为空" dc:"更新类型"` + ReleaseNotes string `json:"releaseNotes" dc:"更新说明"` + StoreUrl string `json:"storeUrl" dc:"商店地址"` + PackageUrl string `json:"packageUrl" dc:"安装包地址"` + Checksum string `json:"checksum" dc:"校验值"` + RemindAfterSeconds int `json:"remindAfterSeconds" dc:"再次提醒秒数"` +} + +func (in *ImAppReleaseEditInp) Filter(ctx context.Context) (err error) { + in.Platform = strings.TrimSpace(in.Platform) + in.Channel = strings.TrimSpace(in.Channel) + in.LatestVersion = strings.TrimSpace(in.LatestVersion) + in.MinSupportedVersion = strings.TrimSpace(in.MinSupportedVersion) + in.UpdateType = strings.TrimSpace(in.UpdateType) + in.StoreUrl = strings.TrimSpace(in.StoreUrl) + in.PackageUrl = strings.TrimSpace(in.PackageUrl) + in.Checksum = strings.TrimSpace(in.Checksum) + if !dict.HasOptionKey(consts.ImAppPlatformOptions, in.Platform) { + return gerror.New("客户端平台不合法") + } + if in.Channel == "" || strings.ContainsAny(in.Channel, " \t\r\n/") || len([]rune(in.Channel)) > 32 { + return gerror.New("渠道不能为空,且不能包含空格或斜杠") + } + if in.LatestVersion == "" || len([]rune(in.LatestVersion)) > 64 { + return gerror.New("最新版本不能为空且不能超过64个字符") + } + if in.MinSupportedVersion == "" || len([]rune(in.MinSupportedVersion)) > 64 { + return gerror.New("最低支持版本不能为空且不能超过64个字符") + } + latest, err := parseAppVersion(in.LatestVersion) + if err != nil { + return gerror.New("最新版本号格式不正确,请使用数字点分版本号") + } + minimum, err := parseAppVersion(in.MinSupportedVersion) + if err != nil { + return gerror.New("最低支持版本号格式不正确,请使用数字点分版本号") + } + if compareAppVersion(latest, minimum) < 0 { + return gerror.New("最新版本不能低于最低支持版本") + } + if !dict.HasOptionKey(consts.ImAppUpdateTypeOptions, in.UpdateType) { + return gerror.New("更新类型不合法") + } + if in.RemindAfterSeconds < 0 { + return gerror.New("再次提醒时间不能小于0") + } + if in.Platform == consts.ImAppPlatformIos && in.UpdateType != consts.ImAppUpdateTypeNone { + storeURL, parseErr := url.Parse(in.StoreUrl) + if parseErr != nil || storeURL.Scheme != "https" || !strings.EqualFold(storeURL.Hostname(), "apps.apple.com") { + return gerror.New("iOS更新策略需要配置 apps.apple.com 的 HTTPS 商店地址") + } + in.PackageUrl = "" + in.Checksum = "" + } + return nil +} + +func parseAppVersion(raw string) ([]uint64, error) { + raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v") + parts := strings.Split(raw, ".") + if len(parts) == 0 || len(parts) > 8 { + return nil, gerror.New("版本号格式不正确") + } + result := make([]uint64, len(parts)) + for i, part := range parts { + if part == "" { + return nil, gerror.New("版本号格式不正确") + } + value, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, err + } + result[i] = value + } + return result, nil +} + +func compareAppVersion(left, right []uint64) int { + length := len(left) + if len(right) > length { + length = len(right) + } + for i := 0; i < length; i++ { + var l, r uint64 + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + if l < r { + return -1 + } + if l > r { + return 1 + } + } + return 0 +} diff --git a/server/internal/model/input/sysin/im_app_release_test.go b/server/internal/model/input/sysin/im_app_release_test.go new file mode 100644 index 0000000..99c5807 --- /dev/null +++ b/server/internal/model/input/sysin/im_app_release_test.go @@ -0,0 +1,39 @@ +package sysin + +import "testing" + +func TestImAppReleaseEditFilter(t *testing.T) { + tests := []struct { + name string + input ImAppReleaseEditInp + wantErr bool + }{ + { + name: "valid ios release", + input: ImAppReleaseEditInp{Platform: "ios", Channel: "stable", LatestVersion: "1.10.0", MinSupportedVersion: "1.9.9", UpdateType: "recommended", StoreUrl: "https://apps.apple.com/app/id123"}, + }, + { + name: "minimum newer than latest", + input: ImAppReleaseEditInp{Platform: "android", Channel: "stable", LatestVersion: "1.2.0", MinSupportedVersion: "2.0.0", UpdateType: "force"}, + wantErr: true, + }, + { + name: "invalid version", + input: ImAppReleaseEditInp{Platform: "android", Channel: "stable", LatestVersion: "1.2-beta", MinSupportedVersion: "1.0.0", UpdateType: "recommended"}, + wantErr: true, + }, + { + name: "ios store url required", + input: ImAppReleaseEditInp{Platform: "ios", Channel: "stable", LatestVersion: "2.0.0", MinSupportedVersion: "1.0.0", UpdateType: "force", StoreUrl: "https://example.com/app"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Filter(nil) + if (err != nil) != tt.wantErr { + t.Fatalf("Filter() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/internal/model/input/sysin/im_audit_log.go b/server/internal/model/input/sysin/im_audit_log.go index 3589a35..717c826 100644 --- a/server/internal/model/input/sysin/im_audit_log.go +++ b/server/internal/model/input/sysin/im_audit_log.go @@ -29,9 +29,6 @@ type ImAuditLogListInp struct { } func (in *ImAuditLogListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } return } @@ -61,9 +58,6 @@ type ImAuditLogViewInp struct { } func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Id <= 0 { return gerror.New("ID不能为空") } @@ -73,3 +67,18 @@ func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) { type ImAuditLogViewModel struct { ImAuditLogListModel } + +type ImAuditLogExportModel struct { + Id int64 `json:"id" dc:"ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + ActorUid int64 `json:"actorUid" dc:"操作人UID"` + ActorRole string `json:"actorRole" dc:"操作角色"` + Action string `json:"action" dc:"操作类型"` + TargetType string `json:"targetType" dc:"目标类型"` + TargetId string `json:"targetId" dc:"目标ID"` + Reason string `json:"reason" dc:"操作原因"` + RequestId string `json:"requestId" dc:"请求ID"` + Ip string `json:"ip" dc:"IP"` + UserAgent string `json:"userAgent" dc:"客户端"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} diff --git a/server/internal/model/input/sysin/im_dashboard.go b/server/internal/model/input/sysin/im_dashboard.go new file mode 100644 index 0000000..9eeedee --- /dev/null +++ b/server/internal/model/input/sysin/im_dashboard.go @@ -0,0 +1,44 @@ +package sysin + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImDashboardOverviewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` +} + +type ImDashboardOverviewModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + TotalUsers int64 `json:"totalUsers" dc:"总用户数"` + ActiveUsers int64 `json:"activeUsers" dc:"活跃用户数"` + TotalGroups int64 `json:"totalGroups" dc:"总群组数"` + TotalConversations int64 `json:"totalConversations" dc:"总会话数"` + TotalMessages int64 `json:"totalMessages" dc:"总消息数"` + TodayMessages int64 `json:"todayMessages" dc:"今日消息数"` +} + +type ImDashboardTrendsInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Days int `json:"days" dc:"统计天数"` +} + +func (in *ImDashboardTrendsInp) Filter(ctx context.Context) error { + if in.Days == 0 { + in.Days = 7 + } + if in.Days < 1 || in.Days > 31 { + return gerror.New("统计天数必须在1到31天之间") + } + return nil +} + +type ImDashboardTrendModel struct { + Date *gtime.Time `json:"date" dc:"日期"` + UserCount int64 `json:"userCount" dc:"新增用户数"` + MessageCount int64 `json:"messageCount" dc:"新增消息数"` +} diff --git a/server/internal/model/input/sysin/im_external_config.go b/server/internal/model/input/sysin/im_external_config.go new file mode 100644 index 0000000..e926fd5 --- /dev/null +++ b/server/internal/model/input/sysin/im_external_config.go @@ -0,0 +1,85 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImExternalConfigListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Service string `json:"service" dc:"服务"` + Provider string `json:"provider" dc:"服务商"` + Enabled *int `json:"enabled" dc:"是否启用"` +} + +func (in *ImExternalConfigListInp) Filter(ctx context.Context) (err error) { return } + +type ImExternalConfigListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Service string `json:"service" dc:"服务"` + Provider string `json:"provider" dc:"服务商"` + Endpoint string `json:"endpoint" dc:"端点"` + Enabled int `json:"enabled" dc:"是否启用"` + Status string `json:"status" dc:"配置状态"` + ConfigJson string `json:"configJson" dc:"普通配置"` + HasSecret bool `json:"hasSecret" dc:"是否有敏感配置"` + CheckedAt *gtime.Time `json:"checkedAt" dc:"检查时间"` + CheckResult string `json:"checkResult" dc:"检查结果"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImExternalConfigViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Service string `json:"service" v:"required#服务不能为空" dc:"服务"` +} + +func (in *ImExternalConfigViewInp) Filter(ctx context.Context) (err error) { + if in.Service == "" { + return gerror.New("服务不能为空") + } + return +} + +type ImExternalConfigViewModel struct { + ImExternalConfigListModel +} + +type ImExternalConfigUpdateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Service string `json:"service" v:"required#服务不能为空" dc:"服务"` + Provider string `json:"provider" dc:"服务商"` + Endpoint string `json:"endpoint" dc:"端点"` + Enabled int `json:"enabled" dc:"是否启用"` + ConfigJson *gjson.Json `json:"configJson" dc:"普通配置"` + SecretJson *gjson.Json `json:"secretJson" dc:"敏感配置,空则保持原值"` +} + +func (in *ImExternalConfigUpdateInp) Filter(ctx context.Context) (err error) { + if in.Service == "" { + return gerror.New("服务不能为空") + } + if in.Enabled != 0 && in.Enabled != 1 { + return gerror.New("启用状态不正确") + } + return +} + +type ImExternalConfigCheckInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Service string `json:"service" v:"required#服务不能为空" dc:"服务"` +} + +func (in *ImExternalConfigCheckInp) Filter(ctx context.Context) (err error) { + if in.Service == "" { + return gerror.New("服务不能为空") + } + return +} diff --git a/server/internal/model/input/sysin/im_feedback_config.go b/server/internal/model/input/sysin/im_feedback_config.go new file mode 100644 index 0000000..a0e8d1d --- /dev/null +++ b/server/internal/model/input/sysin/im_feedback_config.go @@ -0,0 +1,48 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImFeedbackConfigGetInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` +} + +func (in *ImFeedbackConfigGetInp) Filter(ctx context.Context) (err error) { return } + +type ImFeedbackConfigModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Categories string `json:"categories" dc:"分类JSON"` + MaxLength int `json:"maxLength" dc:"最大字数"` + AttachmentPolicy string `json:"attachmentPolicy" dc:"附件策略JSON"` + AllowContact int `json:"allowContact" dc:"允许联系方式"` + DiagnosticsNotice string `json:"diagnosticsNotice" dc:"诊断信息提示"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImFeedbackConfigUpdateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Categories *gjson.Json `json:"categories" dc:"分类JSON"` + MaxLength int `json:"maxLength" dc:"最大字数"` + AttachmentPolicy *gjson.Json `json:"attachmentPolicy" dc:"附件策略JSON"` + AllowContact int `json:"allowContact" dc:"允许联系方式"` + DiagnosticsNotice string `json:"diagnosticsNotice" v:"required#诊断提示不能为空" dc:"诊断信息提示"` +} + +func (in *ImFeedbackConfigUpdateInp) Filter(ctx context.Context) (err error) { + if in.MaxLength <= 0 { + return gerror.New("最大字数必须大于0") + } + if !dict.HasOptionKey(consts.ImFilePolicySwitchOptions, in.AllowContact) { + return gerror.New("联系方式开关不合法") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_file.go b/server/internal/model/input/sysin/im_file.go new file mode 100644 index 0000000..18e82b2 --- /dev/null +++ b/server/internal/model/input/sysin/im_file.go @@ -0,0 +1,116 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImFileListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + FileId int64 `json:"fileId" dc:"文件ID"` + UserId int64 `json:"userId" dc:"上传用户ID"` + Status *int `json:"status" dc:"状态"` + Purpose string `json:"purpose" dc:"用途"` + MimeType string `json:"mimeType" dc:"MIME类型"` + Topic string `json:"topic" dc:"关联Topic"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt []*gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +func (in *ImFileListInp) Filter(ctx context.Context) (err error) { + return +} + +type ImFileListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"文件ID"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` + UserId int64 `json:"userId" dc:"上传用户ID"` + Status int `json:"status" dc:"状态"` + MimeType string `json:"mimeType" dc:"MIME类型"` + Size int64 `json:"size" dc:"大小"` + Etag string `json:"etag" dc:"ETag"` + Location string `json:"location" dc:"文件位置"` + Purpose string `json:"purpose" dc:"用途"` + LinkCount int `json:"linkCount" dc:"引用数"` + FirstTopic string `json:"firstTopic" dc:"首个关联Topic"` + FirstMsgId int64 `json:"firstMsgId" dc:"首个关联消息ID"` + FirstLinkAt *gtime.Time `json:"firstLinkAt" dc:"首个关联时间"` +} + +type ImFileViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" v:"required#文件ID不能为空" dc:"文件ID"` +} + +func (in *ImFileViewInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("文件ID不能为空") + } + return +} + +type ImFileViewModel struct { + ImFileListModel + Links []*ImFileLinkModel `json:"links" dc:"引用列表"` +} + +type ImFileLinkModel struct { + Id int64 `json:"id" dc:"ID"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + FileId int64 `json:"fileId" dc:"文件ID"` + MsgId int64 `json:"msgId" dc:"消息ID"` + Topic string `json:"topic" dc:"Topic"` + UserId int64 `json:"userId" dc:"用户ID"` +} + +type ImFileActionInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" v:"required#文件ID不能为空" dc:"文件ID"` + Reason string `json:"reason" dc:"原因"` +} + +func (in *ImFileActionInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("文件ID不能为空") + } + if len([]rune(in.Reason)) > 200 { + return gerror.New("原因不能超过200个字符") + } + return +} + +type ImFileCleanupOrphanInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Days int `json:"days" dc:"保留天数"` + Limit int `json:"limit" dc:"处理上限"` +} + +func (in *ImFileCleanupOrphanInp) Filter(ctx context.Context) (err error) { + if in.Days <= 0 { + in.Days = 7 + } + if in.Limit <= 0 { + in.Limit = 100 + } + if in.Limit > 1000 { + return gerror.New("单次清理上限不能超过1000") + } + return +} + +type ImFileCleanupOrphanModel struct { + Count int `json:"count" dc:"标记数量"` + Locations []string `json:"locations" dc:"待物理清理位置"` +} 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_group.go b/server/internal/model/input/sysin/im_group.go index a37dba7..1d370fe 100644 --- a/server/internal/model/input/sysin/im_group.go +++ b/server/internal/model/input/sysin/im_group.go @@ -26,28 +26,27 @@ type ImGroupListInp struct { } func (in *ImGroupListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } return } type ImGroupListModel struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` - TenantName string `json:"tenantName" dc:"租户名称"` - Topic string `json:"topic" dc:"Topic"` - State int `json:"state" dc:"状态"` - OwnerId int64 `json:"ownerId" dc:"群主ID"` - SeqId int `json:"seqId" dc:"最新消息序号"` - DelId int `json:"delId" dc:"删除序号"` - SubCnt int `json:"subCnt" dc:"成员数"` - TouchedAt *gtime.Time `json:"touchedAt" dc:"活跃时间"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` - PublicJson string `json:"publicJson" dc:"公开资料"` - TrustedJson string `json:"trustedJson" dc:"可信资料"` - TagsJson string `json:"tagsJson" dc:"标签"` - AuxJson string `json:"auxJson" dc:"辅助资料"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Topic string `json:"topic" dc:"Topic"` + State int `json:"state" dc:"状态"` + OwnerId int64 `json:"ownerId,string" dc:"群主ID"` + OwnerNickname string `json:"ownerNickname" dc:"群主昵称"` + OwnerUsername string `json:"ownerUsername" dc:"群主用户名"` + SeqId int `json:"seqId" dc:"最新消息序号"` + DelId int `json:"delId" dc:"删除序号"` + SubCnt int `json:"subCnt" dc:"成员数"` + TouchedAt *gtime.Time `json:"touchedAt" dc:"活跃时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + PublicJson string `json:"publicJson" dc:"公开资料"` + TrustedJson string `json:"trustedJson" dc:"可信资料"` + TagsJson string `json:"tagsJson" dc:"标签"` + AuxJson string `json:"auxJson" dc:"辅助资料"` GroupName string `json:"groupName" dc:"群名称"` AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` @@ -72,9 +71,6 @@ type ImGroupViewInp struct { } func (in *ImGroupViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Topic == "" { return gerror.New("Topic不能为空") } @@ -98,9 +94,6 @@ type ImGroupMemberListInp struct { } func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Topic == "" { return gerror.New("Topic不能为空") } @@ -108,7 +101,7 @@ func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) { } type ImGroupMemberListModel struct { - UserId int64 `json:"userId" dc:"用户ID"` + UserId int64 `json:"userId,string" dc:"用户ID"` Nickname string `json:"nickname" dc:"昵称"` State int `json:"state" dc:"用户状态"` ModeWant string `json:"modeWant" dc:"期望权限"` @@ -126,10 +119,10 @@ type ImGroupMemberListModel struct { Pinned int `json:"pinned" dc:"置顶"` MuteNotifications int `json:"muteNotifications" dc:"免打扰"` JoinSource string `json:"joinSource" dc:"加入来源"` - JoinedBy int64 `json:"joinedBy" dc:"邀请/处理人"` + JoinedBy int64 `json:"joinedBy,string" dc:"邀请/处理人"` JoinedAt *gtime.Time `json:"joinedAt" dc:"业务加入时间"` LeftAt *gtime.Time `json:"leftAt" dc:"离开时间"` - RemovedBy int64 `json:"removedBy" dc:"移除人"` + RemovedBy int64 `json:"removedBy,string" dc:"移除人"` Version int `json:"version" dc:"版本"` } @@ -140,9 +133,6 @@ type ImGroupTopicInp struct { } func (in *ImGroupTopicInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Topic == "" { return gerror.New("Topic不能为空") } @@ -153,7 +143,7 @@ type ImGroupGovernanceModel struct { TenantId int64 `json:"tenantId" dc:"租户ID"` TenantName string `json:"tenantName" dc:"租户名称"` Topic string `json:"topic" dc:"Topic"` - OwnerUserId int64 `json:"ownerUserId" dc:"群主ID"` + OwnerUserId int64 `json:"ownerUserId,string" dc:"群主ID"` Name string `json:"name" dc:"群名称"` AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` Description string `json:"description" dc:"简介"` @@ -167,13 +157,176 @@ type ImGroupGovernanceModel struct { MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"` MemberCount int `json:"memberCount" dc:"成员数"` Version int `json:"version" dc:"版本"` - CreatedBy int64 `json:"createdBy" dc:"创建人"` - DissolvedBy int64 `json:"dissolvedBy" dc:"解散人"` + CreatedBy int64 `json:"createdBy,string" dc:"创建人"` + DissolvedBy int64 `json:"dissolvedBy,string" dc:"解散人"` DissolvedAt *gtime.Time `json:"dissolvedAt" dc:"解散时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } +type ImGroupGovernanceUpdateInp struct { + ImGroupTopicInp + JoinApproval int `json:"joinApproval" dc:"入群审批"` + MuteAll int `json:"muteAll" dc:"全员禁言"` + MemberUploadFiles int `json:"memberUploadFiles" dc:"成员上传文件"` + MemberPrivateChat int `json:"memberPrivateChat" dc:"成员私聊"` + MemberInvite int `json:"memberInvite" dc:"成员邀请"` + MaxMemberCount int `json:"maxMemberCount" dc:"最大成员数"` + MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"` +} + +func (in *ImGroupGovernanceUpdateInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.JoinApproval != 0 && in.JoinApproval != 1 { + return gerror.New("入群审批值不正确") + } + if in.MuteAll != 0 && in.MuteAll != 1 { + return gerror.New("全员禁言值不正确") + } + if in.MemberUploadFiles != 0 && in.MemberUploadFiles != 1 { + return gerror.New("成员上传文件值不正确") + } + if in.MemberPrivateChat != 0 && in.MemberPrivateChat != 1 { + return gerror.New("成员私聊值不正确") + } + if in.MemberInvite != 0 && in.MemberInvite != 1 { + return gerror.New("成员邀请值不正确") + } + if in.MaxMemberCount < 0 || in.MaxAdminCount < 0 { + return gerror.New("人数上限不能小于0") + } + return +} + +type ImGroupProfileUpdateInp struct { + ImGroupTopicInp + Name string `json:"name" dc:"群名称"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Description string `json:"description" dc:"简介"` + Version int64 `json:"version" dc:"版本"` +} + +func (in *ImGroupProfileUpdateInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.Name == "" && in.AvatarFileId == "" && in.Description == "" { + return gerror.New("请填写需要修改的群资料") + } + if len([]rune(in.Name)) > 80 { + return gerror.New("群名称不能超过80个字符") + } + if len([]rune(in.Description)) > 500 { + return gerror.New("群简介不能超过500个字符") + } + return +} + +type ImGroupDissolveInp struct { + ImGroupTopicInp + Reason string `json:"reason" dc:"解散原因"` +} + +func (in *ImGroupDissolveInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if len([]rune(in.Reason)) > 200 { + return gerror.New("解散原因不能超过200个字符") + } + return +} + +type ImGroupMuteInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` + Reason string `json:"reason" dc:"禁言原因"` +} + +func (in *ImGroupMuteInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if len([]rune(in.Reason)) > 200 { + return gerror.New("禁言原因不能超过200个字符") + } + return +} + +type ImGroupUnmuteInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImGroupUnmuteInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + return +} + +type ImGroupJoinRequestResolveInp struct { + ImGroupTopicInp + RequestId string `json:"requestId" v:"required#申请ID不能为空" dc:"申请ID"` + RejectReason string `json:"rejectReason" dc:"拒绝原因"` +} + +func (in *ImGroupJoinRequestResolveInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.RequestId == "" { + return gerror.New("申请ID不能为空") + } + if len([]rune(in.RejectReason)) > 200 { + return gerror.New("拒绝原因不能超过200个字符") + } + return +} + +type ImGroupRoleUpdateInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Role string `json:"role" v:"required#角色不能为空" dc:"角色"` +} + +func (in *ImGroupRoleUpdateInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if in.Role == "" { + return gerror.New("角色不能为空") + } + return +} + +type ImGroupOwnerTransferInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#新群主ID不能为空" dc:"新群主ID"` +} + +func (in *ImGroupOwnerTransferInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("新群主ID不能为空") + } + return +} + type ImGroupJoinRequestListInp struct { form.PageReq ImGroupTopicInp @@ -185,13 +338,13 @@ type ImGroupJoinRequestListModel struct { Id int64 `json:"id" dc:"ID"` RequestId string `json:"requestId" dc:"申请ID"` Topic string `json:"topic" dc:"Topic"` - ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"` - InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` + ApplicantUserId int64 `json:"applicantUserId,string" dc:"申请人ID"` + InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"` Source string `json:"source" dc:"来源"` Message string `json:"message" dc:"申请消息"` Status string `json:"status" dc:"状态"` RejectReason string `json:"rejectReason" dc:"拒绝原因"` - HandledBy int64 `json:"handledBy" dc:"处理人"` + HandledBy int64 `json:"handledBy,string" dc:"处理人"` HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"` ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` Version int `json:"version" dc:"版本"` @@ -199,6 +352,49 @@ type ImGroupJoinRequestListModel struct { UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } +type ImGroupFileListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` + FileType string `json:"fileType" dc:"文件类型"` + FileName string `json:"fileName" dc:"文件名"` + UploaderId int64 `json:"uploaderId" dc:"上传人ID"` +} + +type ImGroupFileListModel struct { + Id int64 `json:"id" dc:"ID"` + GroupFileId string `json:"groupFileId" dc:"群文件ID"` + Topic string `json:"topic" dc:"Topic"` + FileId string `json:"fileId" dc:"文件ID"` + SourceTopic string `json:"sourceTopic" dc:"来源Topic"` + SourceSeq int `json:"sourceSeq" dc:"来源消息序号"` + UploaderId int64 `json:"uploaderId,string" dc:"上传人ID"` + FileName string `json:"fileName" dc:"文件名"` + MimeType string `json:"mimeType" dc:"MIME类型"` + FileType string `json:"fileType" dc:"文件类型"` + FileSize int64 `json:"fileSize" dc:"文件大小"` + Status string `json:"status" dc:"状态"` + DeletedBy int64 `json:"deletedBy,string" dc:"删除人"` + DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupFileDeleteInp struct { + ImGroupTopicInp + GroupFileId string `json:"groupFileId" v:"required#群文件ID不能为空" dc:"群文件ID"` +} + +func (in *ImGroupFileDeleteInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.GroupFileId == "" { + return gerror.New("群文件ID不能为空") + } + return +} + type ImGroupInvitationListInp struct { form.PageReq ImGroupTopicInp @@ -211,8 +407,8 @@ type ImGroupInvitationListModel struct { Id int64 `json:"id" dc:"ID"` InvitationId string `json:"invitationId" dc:"邀请ID"` Topic string `json:"topic" dc:"Topic"` - InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` - TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"` + InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"` + TargetUserId int64 `json:"targetUserId,string" dc:"目标用户ID"` Message string `json:"message" dc:"邀请消息"` Status string `json:"status" dc:"状态"` Result string `json:"result" dc:"结果"` @@ -233,12 +429,12 @@ type ImGroupAnnouncementListModel struct { Id int64 `json:"id" dc:"ID"` AnnouncementId string `json:"announcementId" dc:"公告ID"` Topic string `json:"topic" dc:"Topic"` - AuthorUserId int64 `json:"authorUserId" dc:"发布人ID"` + AuthorUserId int64 `json:"authorUserId,string" dc:"发布人ID"` Content string `json:"content" dc:"内容"` Status string `json:"status" dc:"状态"` Version int `json:"version" dc:"版本"` PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"` - DeletedBy int64 `json:"deletedBy" dc:"删除人"` + DeletedBy int64 `json:"deletedBy,string" dc:"删除人"` DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` @@ -253,13 +449,13 @@ type ImGroupMuteListInp struct { type ImGroupMuteListModel struct { Topic string `json:"topic" dc:"Topic"` - UserId int64 `json:"userId" dc:"用户ID"` - MutedBy int64 `json:"mutedBy" dc:"禁言人"` + UserId int64 `json:"userId,string" dc:"用户ID"` + MutedBy int64 `json:"mutedBy,string" dc:"禁言人"` MutedAt *gtime.Time `json:"mutedAt" dc:"禁言时间"` ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` Reason string `json:"reason" dc:"原因"` Status string `json:"status" dc:"状态"` - RevokedBy int64 `json:"revokedBy" dc:"解除人"` + RevokedBy int64 `json:"revokedBy,string" dc:"解除人"` RevokedAt *gtime.Time `json:"revokedAt" dc:"解除时间"` Version int `json:"version" dc:"版本"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` @@ -277,8 +473,8 @@ type ImGroupAdminLogListModel struct { Id int64 `json:"id" dc:"ID"` LogId string `json:"logId" dc:"日志ID"` Topic string `json:"topic" dc:"Topic"` - OperatorId int64 `json:"operatorId" dc:"操作人"` - TargetUserId int64 `json:"targetUserId" dc:"目标用户"` + OperatorId int64 `json:"operatorId,string" dc:"操作人"` + TargetUserId int64 `json:"targetUserId,string" dc:"目标用户"` Action string `json:"action" dc:"动作"` ObjectType string `json:"objectType" dc:"对象类型"` ObjectId string `json:"objectId" dc:"对象ID"` @@ -289,6 +485,25 @@ type ImGroupAdminLogListModel struct { CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } +type ImGroupMemberRemoveInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Reason string `json:"reason" dc:"原因"` +} + +func (in *ImGroupMemberRemoveInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if len([]rune(in.Reason)) > 200 { + return gerror.New("原因不能超过200个字符") + } + return +} + type ImGroupCommandModel struct { CommandID string `json:"commandId" dc:"命令ID"` EventID string `json:"eventId" dc:"Kafka事件ID"` @@ -298,62 +513,3 @@ type ImGroupCommandModel struct { EventType string `json:"eventType" dc:"事件类型"` IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` } - -type ImGroupProfileUpdateInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - Name string `json:"name" dc:"群名称"` - AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` - Description string `json:"description" dc:"简介"` - Version int `json:"version" dc:"版本"` -} - -type ImGroupGovernanceUpdateInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - JoinApproval *bool `json:"joinApproval" dc:"入群审批"` - MuteAll *bool `json:"muteAll" dc:"全员禁言"` - MemberUploadFiles *bool `json:"memberUploadFiles" dc:"成员上传文件"` - MemberPrivateChat *bool `json:"memberPrivateChat" dc:"成员私聊"` - MemberInvite *bool `json:"memberInvite" dc:"成员邀请"` - Version int `json:"version" dc:"版本"` -} - -type ImGroupAdminSetInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - UserIds []int64 `json:"userIds" v:"required#用户ID不能为空" dc:"用户ID列表"` -} - -type ImGroupAdminRemoveInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` -} - -type ImGroupMemberMuteInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` - Reason string `json:"reason" dc:"原因"` - ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` -} - -type ImGroupMemberUnmuteInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` -} - -type ImGroupMemberRemoveInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` -} - -type ImGroupOwnerTransferInp struct { - ImGroupTopicInp - ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` - TargetUserId int64 `json:"targetUserId" v:"required#新群主ID不能为空" dc:"新群主ID"` - Version int `json:"version" dc:"版本"` -} diff --git a/server/internal/model/input/sysin/im_invite.go b/server/internal/model/input/sysin/im_invite.go index 713e6dc..b7cb29f 100644 --- a/server/internal/model/input/sysin/im_invite.go +++ b/server/internal/model/input/sysin/im_invite.go @@ -4,6 +4,7 @@ import ( "context" "hotgo/internal/model/input/form" "strconv" + "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" @@ -25,6 +26,9 @@ type ImInviteListInp struct { } func (in *ImInviteListInp) Filter(ctx context.Context) (err error) { + if in.Page <= 0 { + in.Page = 1 + } if in.PerPage <= 0 { in.PerPage = 20 } @@ -53,12 +57,16 @@ type ImInviteCreateInp struct { } func (in *ImInviteCreateInp) Filter(ctx context.Context) (err error) { + in.IdempotencyKey = strings.TrimSpace(in.IdempotencyKey) if in.ExpiresAt == nil || in.ExpiresAt.Before(gtime.Now()) { return gerror.New("到期时间必须晚于当前时间") } if len(in.Remark) > 255 { return gerror.New("备注不能超过255个字符") } + if len(in.IdempotencyKey) > 128 { + return gerror.New("幂等键不能超过128个字符") + } return } @@ -71,6 +79,7 @@ type ImInviteBatchCreateInp struct { } func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) { + in.IdempotencyKey = strings.TrimSpace(in.IdempotencyKey) if in.Quantity < 1 || in.Quantity > 1000 { return gerror.New("批量生成数量必须在1到1000之间") } @@ -80,6 +89,9 @@ func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) { if len(in.Remark) > 255 { return gerror.New("备注不能超过255个字符") } + if len(in.IdempotencyKey) == 0 || len(in.IdempotencyKey) > 128 { + return gerror.New("批量生成必须提供不超过128个字符的幂等键") + } return } @@ -93,30 +105,51 @@ type ImInviteRevokeInp struct { Id string `json:"id" v:"required#邀请码ID不能为空" dc:"邀请码ID"` } +type ImInviteBindUserInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + Id string `json:"id" v:"required#邀请码ID不能为空" dc:"邀请码ID"` + UserId int64 `json:"userId" v:"required|min:1#绑定用户不能为空|绑定用户不合法" dc:"绑定C端用户ID"` +} + +func (in *ImInviteBindUserInp) Filter(ctx context.Context) (err error) { + if _, err = ParseOptionalInt64(in.Id); err != nil { + return gerror.New("邀请码ID不能为空") + } + if in.UserId <= 0 { + return gerror.New("绑定用户不能为空") + } + return nil +} + type ImInviteBatchRevokeInp struct { TenantId int64 `json:"tenantId" dc:"IM租户ID"` BatchId string `json:"batchId" v:"required#批次ID不能为空" dc:"批次ID"` } type ImInviteCodeModel struct { - Id string `json:"id" dc:"ID"` - BatchId string `json:"batchId" dc:"批次ID"` - Code string `json:"code" dc:"邀请码"` - Status string `json:"status" dc:"状态"` - ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"` - UsedBy string `json:"usedBy" dc:"使用用户"` - UsedAt *gtime.Time `json:"usedAt" dc:"使用时间"` - RequestId string `json:"requestId" dc:"请求ID"` - CreatedBy string `json:"createdBy" dc:"创建人"` - Remark string `json:"remark" dc:"备注"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + Id string `json:"id" dc:"ID"` + TenantId string `json:"tenantId" dc:"IM租户ID"` + TenantName string `json:"tenantName" dc:"IM租户名称"` + BatchId string `json:"batchId" dc:"批次ID"` + Code string `json:"code" dc:"邀请码"` + Status string `json:"status" dc:"状态"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"` + MaxUses int `json:"maxUses" dc:"最大可用次数,0表示不限制"` + UsageCount int `json:"usageCount" dc:"累计使用次数"` + BoundUserId string `json:"boundUserId" dc:"绑定用户ID"` + BoundUserLabel string `json:"boundUserLabel" dc:"绑定用户展示名"` + LastUsedAt *gtime.Time `json:"lastUsedAt" dc:"最后使用时间"` + CreatedBy string `json:"createdBy" dc:"创建人"` + Remark string `json:"remark" dc:"备注"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } type ImInviteBatchModel struct { BatchId string `json:"batchId" dc:"批次ID"` + TenantId string `json:"tenantId" dc:"IM租户ID"` Quantity int `json:"quantity" dc:"数量"` ActiveCount int `json:"activeCount" dc:"有效数量"` - UsedCount int `json:"usedCount" dc:"已使用数量"` + UsageCount int `json:"usageCount" dc:"累计使用次数"` RevokedCount int `json:"revokedCount" dc:"已撤销数量"` ExpiredCount int `json:"expiredCount" dc:"已过期数量"` ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"` diff --git a/server/internal/model/input/sysin/im_login_audit.go b/server/internal/model/input/sysin/im_login_audit.go new file mode 100644 index 0000000..c94b0b5 --- /dev/null +++ b/server/internal/model/input/sysin/im_login_audit.go @@ -0,0 +1,161 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImUserLoginHistoryListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" dc:"用户ID"` + Account string `json:"account" dc:"脱敏账号"` + LoginType string `json:"loginType" dc:"登录方式"` + EventType string `json:"eventType" dc:"事件类型"` + Platform string `json:"platform" dc:"客户端平台"` + Ip string `json:"ip" dc:"IP地址"` + LoginAt []*gtime.Time `json:"loginAt" dc:"登录时间"` +} + +func (in *ImUserLoginHistoryListInp) Filter(ctx context.Context) error { + in.Account = strings.TrimSpace(in.Account) + in.Ip = strings.TrimSpace(in.Ip) + return filterImAuditPage(&in.PageReq) +} + +type ImUserLoginHistoryModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + UserId string `json:"userId" dc:"用户ID"` + Username string `json:"username" dc:"用户名"` + AccountType string `json:"accountType" dc:"账号类型"` + AccountMask string `json:"accountMask" dc:"脱敏账号"` + LoginType string `json:"loginType" dc:"登录方式"` + EventType string `json:"eventType" dc:"事件类型"` + SessionId string `json:"sessionId" dc:"会话ID"` + DeviceId string `json:"deviceId" dc:"设备ID"` + Platform string `json:"platform" dc:"客户端平台"` + AppVersion string `json:"appVersion" dc:"客户端版本"` + Ip string `json:"ip" dc:"IP地址"` + Region string `json:"region" dc:"地区"` + UserAgent string `json:"userAgent" dc:"客户端标识"` + RiskLevel string `json:"riskLevel" dc:"风险等级"` + RequestId string `json:"requestId" dc:"请求ID"` + LoginAt *gtime.Time `json:"loginAt" dc:"登录时间"` + LogoutAt *gtime.Time `json:"logoutAt" dc:"登出时间"` + Metadata string `json:"metadata" dc:"元数据"` +} + +type ImLoginFailureLogListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" dc:"用户ID"` + Account string `json:"account" dc:"脱敏账号"` + FailureType string `json:"failureType" dc:"失败类型"` + Platform string `json:"platform" dc:"客户端平台"` + Ip string `json:"ip" dc:"IP地址"` + AttemptAt []*gtime.Time `json:"attemptAt" dc:"尝试时间"` +} + +func (in *ImLoginFailureLogListInp) Filter(ctx context.Context) error { + in.Account = strings.TrimSpace(in.Account) + in.Ip = strings.TrimSpace(in.Ip) + return filterImAuditPage(&in.PageReq) +} + +func filterImAuditPage(page *form.PageReq) error { + if page.Page <= 0 { + page.Page = 1 + } + if page.PerPage <= 0 { + page.PerPage = 20 + } + if page.PerPage > 200 { + return gerror.New("每页数量不能超过200") + } + return nil +} + +type ImLoginFailureLogModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + UserId string `json:"userId" dc:"用户ID"` + AccountType string `json:"accountType" dc:"账号类型"` + AccountMask string `json:"accountMask" dc:"脱敏账号"` + FailureType string `json:"failureType" dc:"失败类型"` + Reason string `json:"reason" dc:"原因码"` + Detail string `json:"detail" dc:"失败详情"` + Ip string `json:"ip" dc:"IP地址"` + Region string `json:"region" dc:"地区"` + DeviceId string `json:"deviceId" dc:"设备ID"` + Platform string `json:"platform" dc:"客户端平台"` + AppVersion string `json:"appVersion" dc:"客户端版本"` + UserAgent string `json:"userAgent" dc:"客户端标识"` + RiskLevel string `json:"riskLevel" dc:"风险等级"` + RiskAction string `json:"riskAction" dc:"风控动作"` + RequestId string `json:"requestId" dc:"请求ID"` + AttemptAt *gtime.Time `json:"attemptAt" dc:"尝试时间"` + Metadata string `json:"metadata" dc:"元数据"` +} + +type ImLoginFailureViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" dc:"日志ID"` +} + +func (in *ImLoginFailureViewInp) Filter(ctx context.Context) error { + if in.Id <= 0 { + return gerror.New("日志ID不能为空") + } + return nil +} + +type ImLoginFailureAddBlacklistInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Ip string `json:"ip" dc:"IP地址"` + Remark string `json:"remark" dc:"备注"` +} + +func (in *ImLoginFailureAddBlacklistInp) Filter(ctx context.Context) error { + in.Ip = strings.TrimSpace(in.Ip) + if in.Ip == "" { + return gerror.New("IP地址不能为空") + } + return nil +} + +type ImLoginFailureBanUserInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" dc:"用户ID"` + Reason string `json:"reason" dc:"封禁原因"` +} + +func (in *ImLoginFailureBanUserInp) Filter(ctx context.Context) error { + in.Reason = strings.TrimSpace(in.Reason) + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if in.Reason == "" { + return gerror.New("封禁原因不能为空") + } + return nil +} + +type ImLoginAuditExportModel struct { + Id int64 `json:"id" dc:"ID"` + UserId string `json:"userId" dc:"用户ID"` + AccountMask string `json:"accountMask" dc:"脱敏账号"` + EventType string `json:"eventType" dc:"事件/失败类型"` + Platform string `json:"platform" dc:"客户端平台"` + Ip string `json:"ip" dc:"IP地址"` + Region string `json:"region" dc:"地区"` + RiskLevel string `json:"riskLevel" dc:"风险等级"` + Reason string `json:"reason" dc:"原因"` + OccurredAt *gtime.Time `json:"occurredAt" dc:"发生时间"` +} diff --git a/server/internal/model/input/sysin/im_mailbox_config.go b/server/internal/model/input/sysin/im_mailbox_config.go new file mode 100644 index 0000000..328915c --- /dev/null +++ b/server/internal/model/input/sysin/im_mailbox_config.go @@ -0,0 +1,59 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImMailboxConfigGetInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` +} + +func (in *ImMailboxConfigGetInp) Filter(ctx context.Context) (err error) { return } + +type ImMailboxConfigModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Enabled int `json:"enabled" dc:"启用状态"` + AllowAnonymous int `json:"allowAnonymous" dc:"允许匿名"` + RequiredFields string `json:"requiredFields" dc:"必填字段JSON"` + MaxLength int `json:"maxLength" dc:"最大字数"` + AttachmentPolicy string `json:"attachmentPolicy" dc:"附件策略JSON"` + NoticeTitle string `json:"noticeTitle" dc:"告知标题"` + NoticeContent string `json:"noticeContent" dc:"告知内容"` + NoticeUpdatedAt *gtime.Time `json:"noticeUpdatedAt" dc:"告知更新时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImMailboxConfigUpdateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Enabled int `json:"enabled" dc:"启用状态"` + AllowAnonymous int `json:"allowAnonymous" dc:"允许匿名"` + RequiredFields *gjson.Json `json:"requiredFields" dc:"必填字段JSON"` + MaxLength int `json:"maxLength" dc:"最大字数"` + AttachmentPolicy *gjson.Json `json:"attachmentPolicy" dc:"附件策略JSON"` + NoticeTitle string `json:"noticeTitle" v:"required#告知标题不能为空" dc:"告知标题"` + NoticeContent string `json:"noticeContent" v:"required#告知内容不能为空" dc:"告知内容"` +} + +func (in *ImMailboxConfigUpdateInp) Filter(ctx context.Context) (err error) { + if !dict.HasOptionKey(consts.ImFilePolicySwitchOptions, in.Enabled) { + return gerror.New("启用状态不合法") + } + if !dict.HasOptionKey(consts.ImFilePolicySwitchOptions, in.AllowAnonymous) { + return gerror.New("匿名开关不合法") + } + if in.MaxLength <= 0 { + return gerror.New("最大字数必须大于0") + } + if len([]rune(in.NoticeTitle)) > 128 { + return gerror.New("告知标题不能超过128个字符") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_message_admin.go b/server/internal/model/input/sysin/im_message_admin.go new file mode 100644 index 0000000..dfbab99 --- /dev/null +++ b/server/internal/model/input/sysin/im_message_admin.go @@ -0,0 +1,154 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImPrivateMessageListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Topic string `json:"topic" dc:"会话Topic"` + FromUserId string `json:"fromUserId" dc:"发送用户ID"` + ToUserId string `json:"toUserId" dc:"接收用户ID"` + ContentType string `json:"contentType" dc:"内容类型"` + Status string `json:"status" dc:"状态"` + Keyword string `json:"keyword" dc:"内容关键词"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"发送时间"` +} + +func (in *ImPrivateMessageListInp) Filter(ctx context.Context) error { + return filterImMessageList(&in.PageReq, &in.Topic, &in.Keyword, in.ContentType, in.Status) +} + +type ImGroupMessageListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Topic string `json:"topic" dc:"群Topic"` + SeqId int64 `json:"seqId" dc:"消息序号"` + SenderUserId string `json:"senderUserId" dc:"发送用户ID"` + SenderUsername string `json:"senderUsername" dc:"发送人用户名"` + SenderNickname string `json:"senderNickname" dc:"发送人昵称"` + GroupName string `json:"groupName" dc:"群名称"` + ContentType string `json:"contentType" dc:"内容类型"` + Status string `json:"status" dc:"状态"` + Keyword string `json:"keyword" dc:"内容关键词"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"发送时间"` +} + +func (in *ImGroupMessageListInp) Filter(ctx context.Context) error { + in.SenderUsername = strings.TrimSpace(in.SenderUsername) + in.SenderNickname = strings.TrimSpace(in.SenderNickname) + in.GroupName = strings.TrimSpace(in.GroupName) + return filterImMessageList(&in.PageReq, &in.Topic, &in.Keyword, in.ContentType, in.Status) +} + +func filterImMessageList(page *form.PageReq, topic *string, keyword *string, contentType string, status string) error { + *topic = strings.TrimSpace(*topic) + *keyword = strings.TrimSpace(*keyword) + if page.Page <= 0 { + page.Page = 1 + } + if page.PerPage <= 0 { + page.PerPage = 20 + } + if page.PerPage > 200 { + return gerror.New("每页数量不能超过200") + } + if contentType != "" && !dict.HasOptionKey(consts.ImMessageContentTypeOptions, contentType) { + return gerror.New("消息类型不合法") + } + if status != "" && !dict.HasOptionKey(consts.ImMessageStatusOptions, status) { + return gerror.New("消息状态不合法") + } + return nil +} + +type ImMessageListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Topic string `json:"topic" dc:"会话Topic"` + SeqId int64 `json:"seqId" dc:"消息序号"` + FromUserId string `json:"fromUserId" dc:"发送用户ID"` + SenderUsername string `json:"senderUsername" dc:"发送人用户名"` + SenderNickname string `json:"senderNickname" dc:"发送人昵称"` + ToUserId string `json:"toUserId" dc:"接收用户ID"` + ReceiverUsername string `json:"receiverUsername" dc:"接收人用户名"` + ReceiverNickname string `json:"receiverNickname" dc:"接收人昵称"` + GroupName string `json:"groupName" dc:"群名称"` + GroupNo string `json:"groupNo" dc:"群号"` + ContentType string `json:"contentType" dc:"消息类型"` + ContentExcerpt string `json:"contentExcerpt" dc:"内容摘要"` + Status string `json:"status" dc:"状态"` + ReplyToSeq int64 `json:"replyToSeq" dc:"回复消息序号"` + AtUsers string `json:"atUsers" dc:"@用户"` + CreatedAt *gtime.Time `json:"createdAt" dc:"发送时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImMessageViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Topic string `json:"topic" dc:"会话Topic"` + SeqId int64 `json:"seqId" dc:"消息序号"` +} + +func (in *ImMessageViewInp) Filter(ctx context.Context) error { + in.Topic = strings.TrimSpace(in.Topic) + if in.Topic == "" || in.SeqId <= 0 { + return gerror.New("消息Topic和序号不能为空") + } + return nil +} + +type ImMessageViewModel struct { + ImMessageListModel + Head string `json:"head" dc:"消息头"` + Content string `json:"content" dc:"完整消息内容"` + Metadata string `json:"metadata" dc:"扩展数据"` +} + +type ImMessageActionInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Topic string `json:"topic" dc:"会话Topic"` + SeqId int64 `json:"seqId" dc:"消息序号"` + Reason string `json:"reason" dc:"处置原因"` +} + +func (in *ImMessageActionInp) Filter(ctx context.Context) error { + in.Topic = strings.TrimSpace(in.Topic) + in.Reason = strings.TrimSpace(in.Reason) + if in.Topic == "" || in.SeqId <= 0 { + return gerror.New("消息Topic和序号不能为空") + } + if in.Reason == "" { + return gerror.New("处置原因不能为空") + } + if len([]rune(in.Reason)) > 500 { + return gerror.New("处置原因不能超过500个字符") + } + return nil +} + +type ImMessageCommandModel struct { + CommandId string `json:"commandId" dc:"命令ID"` + EventId string `json:"eventId" dc:"事件ID"` + Status string `json:"status" dc:"命令状态"` +} + +type ImMessageExportModel struct { + Topic string `json:"topic" dc:"Topic"` + SeqId int64 `json:"seqId" dc:"消息序号"` + FromUserId string `json:"fromUserId" dc:"发送用户ID"` + ToUserId string `json:"toUserId" dc:"接收用户ID"` + GroupName string `json:"groupName" dc:"群名称"` + ContentType string `json:"contentType" dc:"消息类型"` + ContentExcerpt string `json:"contentExcerpt" dc:"内容摘要"` + Status string `json:"status" dc:"状态"` + CreatedAt *gtime.Time `json:"createdAt" dc:"发送时间"` +} diff --git a/server/internal/model/input/sysin/im_message_admin_test.go b/server/internal/model/input/sysin/im_message_admin_test.go new file mode 100644 index 0000000..081a5ef --- /dev/null +++ b/server/internal/model/input/sysin/im_message_admin_test.go @@ -0,0 +1,22 @@ +package sysin + +import ( + "context" + "testing" +) + +func TestImGroupMessageListFilterTrimsTextFilters(t *testing.T) { + in := &ImGroupMessageListInp{ + SenderUsername: " alice ", + SenderNickname: " 小明 ", + GroupName: " 测试群 ", + Keyword: " 关键字 ", + Topic: " grpTest ", + } + if err := in.Filter(context.Background()); err != nil { + t.Fatal(err) + } + if in.SenderUsername != "alice" || in.SenderNickname != "小明" || in.GroupName != "测试群" || in.Keyword != "关键字" || in.Topic != "grpTest" { + t.Fatalf("message filters were not normalized: %#v", in) + } +} diff --git a/server/internal/model/input/sysin/im_notification.go b/server/internal/model/input/sysin/im_notification.go new file mode 100644 index 0000000..3536c60 --- /dev/null +++ b/server/internal/model/input/sysin/im_notification.go @@ -0,0 +1,69 @@ +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 ImNotificationTemplateListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Scene string `json:"scene" dc:"通知场景"` + Status *int `json:"status" dc:"状态"` +} + +func (in *ImNotificationTemplateListInp) Filter(ctx context.Context) (err error) { return } + +type ImNotificationTemplateListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Scene string `json:"scene" dc:"通知场景"` + Name string `json:"name" dc:"场景名称"` + InAppEnabled int `json:"inAppEnabled" dc:"站内通知"` + PushEnabled int `json:"pushEnabled" dc:"Push通知"` + SmsEnabled int `json:"smsEnabled" dc:"短信通知"` + Status int `json:"status" dc:"状态"` + TitleTemplate string `json:"titleTemplate" dc:"标题模板"` + ContentTemplate string `json:"contentTemplate" dc:"内容模板"` + PayloadJson string `json:"payloadJson" dc:"扩展JSON"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImNotificationTemplateUpdateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Scene string `json:"scene" v:"required#通知场景不能为空" dc:"通知场景"` + InAppEnabled int `json:"inAppEnabled" dc:"站内通知"` + PushEnabled int `json:"pushEnabled" dc:"Push通知"` + SmsEnabled int `json:"smsEnabled" dc:"短信通知"` + Status int `json:"status" dc:"状态"` + TitleTemplate string `json:"titleTemplate" dc:"标题模板"` + ContentTemplate string `json:"contentTemplate" dc:"内容模板"` + PayloadJson *gjson.Json `json:"payloadJson" dc:"扩展JSON"` +} + +func (in *ImNotificationTemplateUpdateInp) Filter(ctx context.Context) (err error) { + in.Scene = strings.TrimSpace(in.Scene) + in.TitleTemplate = strings.TrimSpace(in.TitleTemplate) + in.ContentTemplate = strings.TrimSpace(in.ContentTemplate) + if !dict.HasOptionKey(consts.ImNotificationSceneOptions, in.Scene) { + return gerror.New("通知场景不合法") + } + if !dict.HasOptionKey(consts.ImNotificationTemplateStatusOptions, in.Status) { + return gerror.New("通知模板状态不合法") + } + if in.TitleTemplate == "" { + return gerror.New("标题模板不能为空") + } + if in.ContentTemplate == "" { + return gerror.New("内容模板不能为空") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_org.go b/server/internal/model/input/sysin/im_org.go new file mode 100644 index 0000000..1621750 --- /dev/null +++ b/server/internal/model/input/sysin/im_org.go @@ -0,0 +1,215 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImOrgDepartmentListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + ParentId string `json:"parentId" dc:"上级部门ID"` + Name string `json:"name" dc:"部门名称"` + State string `json:"state" dc:"状态"` +} + +func (in *ImOrgDepartmentListInp) Filter(ctx context.Context) (err error) { return } + +type ImOrgDepartmentModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + 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 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"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + Name string `json:"name" dc:"部门名称"` + ParentId string `json:"parentId" dc:"上级部门ID"` + SortOrder int `json:"sortOrder" dc:"排序"` + State string `json:"state" dc:"状态"` +} + +func (in *ImOrgDepartmentEditInp) Filter(ctx context.Context) (err error) { + in.Name = strings.TrimSpace(in.Name) + in.DepartmentId = strings.TrimSpace(in.DepartmentId) + in.ParentId = strings.TrimSpace(in.ParentId) + in.State = strings.TrimSpace(in.State) + if in.Name == "" { + return gerror.New("部门名称不能为空") + } + if in.ParentId == "" { + in.ParentId = "root" + } + if in.State == "" { + in.State = consts.ImOrgDepartmentStateActive + } + if !dict.HasOptionKey(consts.ImOrgDepartmentStateOptions, in.State) { + return gerror.New("部门状态不合法") + } + return +} + +type ImOrgDepartmentDeleteInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + DepartmentId string `json:"departmentId" v:"required#部门ID不能为空" dc:"部门ID"` +} + +func (in *ImOrgDepartmentDeleteInp) Filter(ctx context.Context) (err error) { + in.DepartmentId = strings.TrimSpace(in.DepartmentId) + if in.DepartmentId == "" { + return gerror.New("部门ID不能为空") + } + return +} + +type ImOrgMemberListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + UserId int64 `json:"userId" dc:"用户ID"` + Query string `json:"query" dc:"关键词"` + EmploymentState string `json:"employmentState" dc:"在职状态"` +} + +func (in *ImOrgMemberListInp) Filter(ctx context.Context) (err error) { return } + +type ImOrgMemberModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + 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:"岗位"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + 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 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"` + DepartmentId string `json:"departmentId" dc:"部门ID"` + UserId int64 `json:"userId" dc:"用户ID"` + DisplayName string `json:"displayName" dc:"展示名称"` + Position string `json:"position" dc:"岗位"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + 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:"排序"` +} + +func (in *ImOrgMemberEditInp) Filter(ctx context.Context) (err error) { + in.DepartmentId = strings.TrimSpace(in.DepartmentId) + in.DisplayName = strings.TrimSpace(in.DisplayName) + in.Position = strings.TrimSpace(in.Position) + in.AvatarFileId = strings.TrimSpace(in.AvatarFileId) + in.Mobile = strings.TrimSpace(in.Mobile) + in.Email = strings.TrimSpace(in.Email) + in.EmploymentState = strings.TrimSpace(in.EmploymentState) + if in.DepartmentId == "" { + return gerror.New("部门ID不能为空") + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if in.EmploymentState == "" { + in.EmploymentState = consts.ImOrgEmploymentStateActive + } + if !dict.HasOptionKey(consts.ImOrgEmploymentStateOptions, in.EmploymentState) { + return gerror.New("在职状态不合法") + } + if !dict.HasOptionKey(consts.ImOrgContactableOptions, in.Contactable) { + return gerror.New("可联系状态不合法") + } + return +} + +type ImOrgMemberDeleteInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" v:"required#成员关系ID不能为空" dc:"ID"` +} + +func (in *ImOrgMemberDeleteInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("成员关系ID不能为空") + } + return +} + +type ImOrgSearchInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Query string `json:"query" v:"required#关键词不能为空" dc:"关键词"` +} + +func (in *ImOrgSearchInp) Filter(ctx context.Context) (err error) { + in.Query = strings.TrimSpace(in.Query) + if in.Query == "" { + return gerror.New("关键词不能为空") + } + return +} + +type ImOrgSearchModel struct { + Departments []*ImOrgDepartmentModel `json:"departments" dc:"部门"` + Members []*ImOrgMemberModel `json:"members" dc:"成员"` +} diff --git a/server/internal/model/input/sysin/im_outbox.go b/server/internal/model/input/sysin/im_outbox.go new file mode 100644 index 0000000..8bb5ed0 --- /dev/null +++ b/server/internal/model/input/sysin/im_outbox.go @@ -0,0 +1,101 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +const ( + ImOutboxSourceBusiness = "business" + ImOutboxSourceGroup = "group" + + ImOutboxStatusPending = "pending" + ImOutboxStatusDelivered = "delivered" + ImOutboxStatusFailed = "failed" + ImOutboxStatusDead = "dead" +) + +type ImOutboxListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Source string `json:"source" dc:"事件来源"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + TargetType string `json:"targetType" dc:"目标类型"` + Target string `json:"target" dc:"目标"` + Status string `json:"status" dc:"状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImOutboxListInp) Filter(ctx context.Context) (err error) { + if in.Source == "" { + in.Source = ImOutboxSourceBusiness + } + if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup { + return gerror.New("Outbox来源不正确") + } + return +} + +type ImOutboxListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Source string `json:"source" dc:"事件来源"` + Id int64 `json:"id" dc:"ID"` + EventId string `json:"eventId" dc:"事件ID"` + TargetType string `json:"targetType" dc:"目标类型"` + Target string `json:"target" dc:"目标"` + ActorUserId int64 `json:"actorUserId" dc:"操作用户ID"` + EventType string `json:"eventType" dc:"事件类型"` + Payload string `json:"payload" dc:"事件负载"` + Status string `json:"status" dc:"状态"` + Attempts int `json:"attempts" dc:"尝试次数"` + NextRetryAt *gtime.Time `json:"nextRetryAt" dc:"下次重试时间"` + DeliveredAt *gtime.Time `json:"deliveredAt" dc:"投递时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImOutboxViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Source string `json:"source" v:"required#事件来源不能为空" dc:"事件来源"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImOutboxViewInp) Filter(ctx context.Context) (err error) { + if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup { + return gerror.New("Outbox来源不正确") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImOutboxViewModel struct { + ImOutboxListModel +} + +type ImOutboxActionInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Source string `json:"source" v:"required#事件来源不能为空" dc:"事件来源"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImOutboxActionInp) Filter(ctx context.Context) (err error) { + if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup { + return gerror.New("Outbox来源不正确") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} diff --git a/server/internal/model/input/sysin/im_policy_test.go b/server/internal/model/input/sysin/im_policy_test.go new file mode 100644 index 0000000..1cbbd13 --- /dev/null +++ b/server/internal/model/input/sysin/im_policy_test.go @@ -0,0 +1,163 @@ +package sysin + +import ( + "context" + "testing" + + "hotgo/internal/consts" + + "github.com/gogf/gf/v2/encoding/gjson" +) + +func TestImMessageCurfewRuleFilter(t *testing.T) { + ctx := context.Background() + valid := &ImMessageCurfewRuleInp{ + RuleCode: "after_work", + RuleName: "下班时段", + DateType: consts.ImCurfewDateWeekday, + StartTime: "18:00", + EndTime: "09:00", + Scope: consts.ImCurfewScopePrivate, + } + if err := valid.Filter(ctx); err != nil { + t.Fatalf("跨午夜宵禁规则应合法: %v", err) + } + if valid.Timezone != "Asia/Shanghai" || valid.Status != consts.StatusEnabled { + t.Fatalf("默认值未补齐: timezone=%s status=%d", valid.Timezone, valid.Status) + } + + invalidTime := *valid + invalidTime.StartTime = "25:00" + if err := invalidTime.Filter(ctx); err == nil { + t.Fatal("非法宵禁时间应被拒绝") + } +} + +func TestImSecurityPolicySaveFilterRequiresCurfewRule(t *testing.T) { + in := &ImSecurityPolicySaveInp{ + PolicyName: "默认策略", + GlobalFriendEnabled: 1, + MessageCurfewEnabled: 1, + } + if err := in.Filter(context.Background()); err == nil { + t.Fatal("启用宵禁但未配置规则应被拒绝") + } + in.CurfewRules = []*ImMessageCurfewRuleInp{{ + RuleCode: "group_only", + RuleName: "仅群聊", + DateType: consts.ImCurfewDateDaily, + StartTime: "18:00", + EndTime: "09:00", + Scope: consts.ImCurfewScopeGroup, + }} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("宵禁仅配置群聊规则时应被拒绝") + } +} + +func TestImSecurityPolicySaveFilterForcesEveryFriendEntryOff(t *testing.T) { + in := &ImSecurityPolicySaveInp{ + PolicyName: "下班策略", + GlobalFriendEnabled: 0, + MessageCurfewEnabled: 0, + FriendDisabledPolicy: gjson.New(map[string]interface{}{ + "hide_profile": false, + "blocked_entries": []string{"search"}, + }), + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("关闭全局好友功能的策略应合法: %v", err) + } + policy := in.FriendDisabledPolicy.Map() + if hide, ok := policy["hide_profile"].(bool); !ok || !hide { + t.Fatal("关闭全局好友功能时必须强制隐藏个人资料") + } + entries := in.FriendDisabledPolicy.Get("blocked_entries").Array() + if len(entries) != 6 { + t.Fatalf("关闭全局好友功能时必须禁用全部6个入口: %#v", policy["blocked_entries"]) + } +} + +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{ + RuleType: "blacklist", + IpCidr: "192.0.2.0/24", + Action: consts.ImSecurityActionBlock, + SceneJson: gjson.New([]string{"login"}), + RiskLevel: consts.ImRiskLevelHigh, + } + if err := valid.Filter(ctx); err != nil { + t.Fatalf("合法IP阻断规则不应报错: %v", err) + } + + invalidAction := *valid + invalidAction.Action = consts.ImPolicyActionDeny + if err := invalidAction.Filter(ctx); err == nil { + t.Fatal("数据库枚举不支持的deny动作应被拒绝") + } +} + +func TestImSensitiveWordEditFilter(t *testing.T) { + in := &ImSensitiveWordEditInp{ + GroupId: 1, + Word: "测试词", + MatchType: consts.ImSensitiveMatchContains, + SceneJson: gjson.New([]string{"private_message"}), + Action: consts.ImSensitiveActionReplace, + RiskLevel: consts.ImRiskLevelMedium, + } + if err := in.Filter(context.Background()); err == nil { + t.Fatal("替换动作未填写替换文本应被拒绝") + } +} + +func TestImSensitiveWordGroupEditFilter(t *testing.T) { + in := &ImSensitiveWordGroupEditInp{GroupCode: "default", GroupName: " "} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("空分组名称应被拒绝") + } +} + +func TestImUserPolicyGroupInputFilter(t *testing.T) { + ctx := context.Background() + group := &ImUserPolicyGroupEditInp{ + GroupCode: "staff", + GroupName: " ", + PolicyJson: gjson.New(map[string]interface{}{}), + } + if err := group.Filter(ctx); err == nil { + t.Fatal("空用户策略分组名称应被拒绝") + } + if err := (&ImUserPolicyGroupMemberAddInp{GroupId: 1, UserId: 0}).Filter(ctx); err == nil { + t.Fatal("无效用户ID应被拒绝") + } +} + +func TestImTenantLoginIPWhitelistSaveFilter(t *testing.T) { + in := &ImTenantLoginIPWhitelistSaveInp{ + TenantId: 1, + AppList: []string{" 203.0.113.10 ", "2001:db8::/32", "203.0.113.10"}, + WebList: []string{"198.51.100.7/24"}, + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("合法租户登录白名单不应报错: %v", err) + } + if len(in.AppList) != 2 || in.AppList[0] != "203.0.113.10" || in.WebList[0] != "198.51.100.0/24" { + t.Fatalf("IP白名单未正确规范化: app=%v web=%v", in.AppList, in.WebList) + } + in.AppList = []string{"not-an-ip"} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("非法IP地址应被拒绝") + } +} diff --git a/server/internal/model/input/sysin/im_realname.go b/server/internal/model/input/sysin/im_realname.go index d22d1cb..c799b67 100644 --- a/server/internal/model/input/sysin/im_realname.go +++ b/server/internal/model/input/sysin/im_realname.go @@ -26,9 +26,6 @@ type ImRealnameListInp struct { } func (in *ImRealnameListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } return } @@ -55,9 +52,6 @@ type ImRealnameViewInp struct { } func (in *ImRealnameViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Id <= 0 { return gerror.New("ID不能为空") } @@ -78,9 +72,6 @@ type ImRealnameReviewInp struct { } func (in *ImRealnameReviewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Id <= 0 { return gerror.New("ID不能为空") } diff --git a/server/internal/model/input/sysin/im_security_policy.go b/server/internal/model/input/sysin/im_security_policy.go new file mode 100644 index 0000000..020a456 --- /dev/null +++ b/server/internal/model/input/sysin/im_security_policy.go @@ -0,0 +1,324 @@ +package sysin + +import ( + "context" + "strings" + "time" + + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImSecurityPolicyListInp struct { + form.PageReq + TenantId int64 `json:"tenantId"` + PublishStatus string `json:"publishStatus"` +} + +func (in *ImSecurityPolicyListInp) Filter(ctx context.Context) error { + if in.PublishStatus != "" && !dict.HasOptionKey(consts.ImSecurityPublishStatusOptions, in.PublishStatus) { + return gerror.New("发布状态不合法") + } + return nil +} + +type ImMessageCurfewRuleModel struct { + Id int64 `json:"id"` + RuleCode string `json:"ruleCode"` + RuleName string `json:"ruleName"` + DateType string `json:"dateType"` + CustomDates string `json:"customDates"` + Weekdays string `json:"weekdays"` + Timezone string `json:"timezone"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Scope string `json:"scope"` + WhitelistUserIds string `json:"whitelistUserIds"` + WhitelistGroupIds string `json:"whitelistGroupIds"` + Message string `json:"message"` + Status int `json:"status"` + Sort int `json:"sort"` +} + +type ImSecurityPolicyModel struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + PolicyVersion uint `json:"policyVersion"` + PolicyName string `json:"policyName"` + PublishStatus string `json:"publishStatus"` + PasswordPolicy string `json:"passwordPolicy"` + LoginRateLimit string `json:"loginRateLimit"` + SensitiveWordPolicy string `json:"sensitiveWordPolicy"` + FileReviewPolicy string `json:"fileReviewPolicy"` + RealnamePolicy string `json:"realnamePolicy"` + RiskChallengePolicy string `json:"riskChallengePolicy"` + GlobalFriendEnabled int `json:"globalFriendEnabled"` + FriendDisabledPolicy string `json:"friendDisabledPolicy"` + MessageCurfewEnabled int `json:"messageCurfewEnabled"` + Remark string `json:"remark"` + PublishedAt *gtime.Time `json:"publishedAt"` + PublishedBy int64 `json:"publishedBy"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` + CurfewRules []*ImMessageCurfewRuleModel `json:"curfewRules"` +} + +type ImSecurityPolicyViewInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` +} + +func (in *ImSecurityPolicyViewInp) Filter(ctx context.Context) error { + if in.Id <= 0 && in.TenantId < 0 { + return gerror.New("策略ID或租户ID不合法") + } + return nil +} + +type ImMessageCurfewRuleInp struct { + RuleCode string `json:"ruleCode"` + RuleName string `json:"ruleName"` + DateType string `json:"dateType"` + CustomDates *gjson.Json `json:"customDates"` + Weekdays *gjson.Json `json:"weekdays"` + Timezone string `json:"timezone"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Scope string `json:"scope"` + WhitelistUserIds *gjson.Json `json:"whitelistUserIds"` + WhitelistGroupIds *gjson.Json `json:"whitelistGroupIds"` + Message string `json:"message"` + Status int `json:"status"` + Sort int `json:"sort"` +} + +func (in *ImMessageCurfewRuleInp) Filter(ctx context.Context) error { + in.RuleCode = strings.TrimSpace(in.RuleCode) + in.RuleName = strings.TrimSpace(in.RuleName) + in.Timezone = strings.TrimSpace(in.Timezone) + in.Message = strings.TrimSpace(in.Message) + if !imPolicyGroupCodePattern.MatchString(in.RuleCode) { + return gerror.New("宵禁规则编码格式不合法") + } + if in.RuleName == "" { + return gerror.New("宵禁规则名称不能为空") + } + if !dict.HasOptionKey(consts.ImCurfewDateTypeOptions, in.DateType) { + return gerror.New("宵禁日期类型不合法") + } + if !dict.HasOptionKey(consts.ImCurfewScopeOptions, in.Scope) { + return gerror.New("宵禁范围不合法") + } + if len(in.StartTime) != 5 || len(in.EndTime) != 5 { + return gerror.New("宵禁时间必须使用HH:mm格式") + } + if _, err := time.Parse("15:04", in.StartTime); err != nil { + return gerror.New("宵禁开始时间不合法") + } + if _, err := time.Parse("15:04", in.EndTime); err != nil { + return gerror.New("宵禁结束时间不合法") + } + if in.Timezone == "" { + in.Timezone = "Asia/Shanghai" + } + if in.Message == "" { + in.Message = "当前时段暂不能发送消息" + } + if in.Status == 0 { + in.Status = consts.StatusEnabled + } + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("宵禁规则状态不合法") + } + return nil +} + +type ImSecurityPolicySaveInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + PolicyName string `json:"policyName" v:"required#策略名称不能为空"` + PasswordPolicy *gjson.Json `json:"passwordPolicy"` + LoginRateLimit *gjson.Json `json:"loginRateLimit"` + SensitiveWordPolicy *gjson.Json `json:"sensitiveWordPolicy"` + FileReviewPolicy *gjson.Json `json:"fileReviewPolicy"` + RealnamePolicy *gjson.Json `json:"realnamePolicy"` + RiskChallengePolicy *gjson.Json `json:"riskChallengePolicy"` + GlobalFriendEnabled int `json:"globalFriendEnabled"` + FriendDisabledPolicy *gjson.Json `json:"friendDisabledPolicy"` + MessageCurfewEnabled int `json:"messageCurfewEnabled"` + Remark string `json:"remark"` + CurfewRules []*ImMessageCurfewRuleInp `json:"curfewRules"` +} + +func (in *ImSecurityPolicySaveInp) Filter(ctx context.Context) error { + in.PolicyName = strings.TrimSpace(in.PolicyName) + in.Remark = strings.TrimSpace(in.Remark) + if in.PolicyName == "" || len([]rune(in.PolicyName)) > 128 { + return gerror.New("策略名称长度必须为1至128个字符") + } + if in.GlobalFriendEnabled != 0 && in.GlobalFriendEnabled != 1 { + return gerror.New("全局好友开关不合法") + } + if in.MessageCurfewEnabled != 0 && in.MessageCurfewEnabled != 1 { + return gerror.New("宵禁开关不合法") + } + if in.GlobalFriendEnabled == 0 { + policy := make(map[string]interface{}) + if in.FriendDisabledPolicy != nil { + policy = in.FriendDisabledPolicy.Map() + } + policy["hide_profile"] = true + policy["block_qrcode"] = true + policy["block_group_member_entry"] = true + policy["blocked_entries"] = []string{ + "search", "profile_card", "group_member", "qrcode", "invite_auto_add", "recommendation", + } + message, ok := policy["message"].(string) + if !ok || strings.TrimSpace(message) == "" { + policy["message"] = "当前时段暂不允许添加好友" + } + in.FriendDisabledPolicy = gjson.New(policy) + } + if in.MessageCurfewEnabled == 1 && len(in.CurfewRules) == 0 { + return gerror.New("启用宵禁时至少配置一条宵禁规则") + } + seen := make(map[string]struct{}, len(in.CurfewRules)) + hasPrivateMessageRule := false + for _, rule := range in.CurfewRules { + if rule == nil { + return gerror.New("宵禁规则不能为空") + } + if err := rule.Filter(ctx); err != nil { + return err + } + if _, ok := seen[rule.RuleCode]; ok { + return gerror.New("宵禁规则编码不能重复") + } + seen[rule.RuleCode] = struct{}{} + if rule.Scope == consts.ImCurfewScopePrivate && rule.Status == consts.StatusEnabled { + hasPrivateMessageRule = true + } + } + if in.MessageCurfewEnabled == 1 && !hasPrivateMessageRule { + return gerror.New("启用宵禁时至少配置一条启用的单聊/私聊规则") + } + return 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#恢复原因不能为空"` +} + +func (in *ImSecurityPolicyRestoreInp) Filter(ctx context.Context) error { + in.Reason = strings.TrimSpace(in.Reason) + if in.TenantId < 0 { + return gerror.New("租户ID不合法") + } + if in.Reason == "" { + return gerror.New("恢复原因不能为空") + } + if len([]rune(in.Reason)) > 500 { + return gerror.New("恢复原因不能超过500个字符") + } + return nil +} + +type ImSecurityIPRuleListInp struct { + form.PageReq + TenantId int64 `json:"tenantId"` + RuleType string `json:"ruleType"` + IpCidr string `json:"ipCidr"` + Status int `json:"status"` +} + +func (in *ImSecurityIPRuleListInp) Filter(ctx context.Context) error { + in.IpCidr = strings.TrimSpace(in.IpCidr) + if in.RuleType != "" && in.RuleType != "whitelist" && in.RuleType != "blacklist" { + return gerror.New("IP规则类型不合法") + } + return nil +} + +type ImSecurityIPRuleModel struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + RuleType string `json:"ruleType"` + IpCidr string `json:"ipCidr"` + Action string `json:"action"` + SceneJson string `json:"sceneJson"` + RiskLevel string `json:"riskLevel"` + Status int `json:"status"` + ExpiresAt *gtime.Time `json:"expiresAt"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` +} +type ImSecurityIPRuleEditInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + RuleType string `json:"ruleType"` + IpCidr string `json:"ipCidr"` + Action string `json:"action"` + SceneJson *gjson.Json `json:"sceneJson"` + RiskLevel string `json:"riskLevel"` + Status int `json:"status"` + ExpiresAt *gtime.Time `json:"expiresAt"` + Remark string `json:"remark"` +} + +func (in *ImSecurityIPRuleEditInp) Filter(ctx context.Context) error { + in.IpCidr = strings.TrimSpace(in.IpCidr) + if in.RuleType != "whitelist" && in.RuleType != "blacklist" { + return gerror.New("IP规则类型不合法") + } + if in.IpCidr == "" { + return gerror.New("IP或CIDR不能为空") + } + if !dict.HasOptionKey(consts.ImSecurityActionOptions, in.Action) { + return gerror.New("规则动作不合法") + } + if !dict.HasOptionKey(consts.ImRiskLevelOptions, in.RiskLevel) { + return gerror.New("风险等级不合法") + } + if in.Status == 0 { + in.Status = consts.StatusEnabled + } + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + if in.SceneJson == nil || in.SceneJson.String() == "" || in.SceneJson.String() == "null" { + return gerror.New("生效场景不能为空") + } + return nil +} + +type ImSecurityIPRuleDeleteInp struct { + Id int64 `json:"id" v:"required|min:1#IP规则ID不能为空|IP规则ID不合法"` +} + +func (in *ImSecurityIPRuleDeleteInp) Filter(ctx context.Context) error { return nil } diff --git a/server/internal/model/input/sysin/im_sensitive_word.go b/server/internal/model/input/sysin/im_sensitive_word.go new file mode 100644 index 0000000..f13908d --- /dev/null +++ b/server/internal/model/input/sysin/im_sensitive_word.go @@ -0,0 +1,190 @@ +package sysin + +import ( + "context" + "strings" + + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImSensitiveWordListInp struct { + form.PageReq + TenantId int64 `json:"tenantId"` + GroupId int64 `json:"groupId"` + Word string `json:"word"` + MatchType string `json:"matchType"` + Action string `json:"action"` + RiskLevel string `json:"riskLevel"` + Status int `json:"status"` +} + +func (in *ImSensitiveWordListInp) Filter(ctx context.Context) error { + in.Word = strings.TrimSpace(in.Word) + if in.MatchType != "" && !dict.HasOptionKey(consts.ImSensitiveMatchTypeOptions, in.MatchType) { + return gerror.New("匹配类型不合法") + } + if in.Action != "" && !dict.HasOptionKey(consts.ImSensitiveActionOptions, in.Action) { + return gerror.New("命中动作不合法") + } + if in.RiskLevel != "" && !dict.HasOptionKey(consts.ImRiskLevelOptions, in.RiskLevel) { + return gerror.New("风险等级不合法") + } + return nil +} + +type ImSensitiveWordListModel struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + GroupId int64 `json:"groupId"` + GroupName string `json:"groupName"` + Word string `json:"word"` + MatchType string `json:"matchType"` + SceneJson string `json:"sceneJson"` + Action string `json:"action"` + Replacement string `json:"replacement"` + RiskLevel string `json:"riskLevel"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` +} + +type ImSensitiveWordEditInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + GroupId int64 `json:"groupId" v:"required|min:1#敏感词分组不能为空|敏感词分组不合法"` + Word string `json:"word" v:"required#敏感词不能为空"` + MatchType string `json:"matchType"` + SceneJson *gjson.Json `json:"sceneJson" v:"required#生效场景不能为空"` + Action string `json:"action"` + Replacement string `json:"replacement"` + RiskLevel string `json:"riskLevel"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +func (in *ImSensitiveWordEditInp) Filter(ctx context.Context) error { + in.Word = strings.TrimSpace(in.Word) + in.Replacement = strings.TrimSpace(in.Replacement) + in.Remark = strings.TrimSpace(in.Remark) + if in.Word == "" || len([]rune(in.Word)) > 255 { + return gerror.New("敏感词长度必须为1至255个字符") + } + if !dict.HasOptionKey(consts.ImSensitiveMatchTypeOptions, in.MatchType) { + return gerror.New("匹配类型不合法") + } + if !dict.HasOptionKey(consts.ImSensitiveActionOptions, in.Action) { + return gerror.New("命中动作不合法") + } + if !dict.HasOptionKey(consts.ImRiskLevelOptions, in.RiskLevel) { + return gerror.New("风险等级不合法") + } + if in.Status == 0 { + in.Status = consts.StatusEnabled + } + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + if in.SceneJson == nil || in.SceneJson.String() == "" || in.SceneJson.String() == "null" { + return gerror.New("生效场景不能为空") + } + if in.Action == consts.ImSensitiveActionReplace && in.Replacement == "" { + return gerror.New("替换动作必须填写替换文本") + } + return nil +} + +type ImSensitiveWordDeleteInp struct { + Id int64 `json:"id" v:"required|min:1#敏感词ID不能为空|敏感词ID不合法"` +} + +func (in *ImSensitiveWordDeleteInp) Filter(ctx context.Context) error { return nil } + +type ImSensitiveWordStatusInp struct { + Id int64 `json:"id" v:"required|min:1#敏感词ID不能为空|敏感词ID不合法"` + Status int `json:"status" v:"required#状态不能为空"` +} + +func (in *ImSensitiveWordStatusInp) Filter(ctx context.Context) error { + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + return nil +} + +type ImSensitiveWordBatchImportInp struct { + TenantId int64 `json:"tenantId"` + GroupId int64 `json:"groupId" v:"required|min:1#敏感词分组不能为空|敏感词分组不合法"` + Words []string `json:"words" v:"required#敏感词列表不能为空"` + MatchType string `json:"matchType"` + SceneJson *gjson.Json `json:"sceneJson"` + Action string `json:"action"` + Replacement string `json:"replacement"` + RiskLevel string `json:"riskLevel"` +} + +func (in *ImSensitiveWordBatchImportInp) Filter(ctx context.Context) error { + if len(in.Words) == 0 || len(in.Words) > 5000 { + return gerror.New("每次导入1至5000个敏感词") + } + base := ImSensitiveWordEditInp{GroupId: in.GroupId, Word: "validate", MatchType: in.MatchType, SceneJson: in.SceneJson, Action: in.Action, Replacement: in.Replacement, RiskLevel: in.RiskLevel, Status: consts.StatusEnabled} + return base.Filter(ctx) +} + +type ImSensitiveWordBatchImportModel struct { + Inserted int `json:"inserted"` + Skipped int `json:"skipped"` +} + +type ImSensitiveWordGroupListInp struct { + TenantId int64 `json:"tenantId"` +} + +func (in *ImSensitiveWordGroupListInp) Filter(ctx context.Context) error { return nil } + +type ImSensitiveWordGroupModel struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + GroupCode string `json:"groupCode"` + GroupName string `json:"groupName"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"createdAt"` +} +type ImSensitiveWordGroupEditInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + GroupCode string `json:"groupCode" v:"required#分组编码不能为空"` + GroupName string `json:"groupName" v:"required#分组名称不能为空"` + Priority int `json:"priority"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +func (in *ImSensitiveWordGroupEditInp) Filter(ctx context.Context) error { + in.GroupCode = strings.TrimSpace(in.GroupCode) + in.GroupName = strings.TrimSpace(in.GroupName) + in.Remark = strings.TrimSpace(in.Remark) + if !imPolicyGroupCodePattern.MatchString(in.GroupCode) { + return gerror.New("分组编码格式不合法") + } + if in.GroupName == "" || len([]rune(in.GroupName)) > 128 { + return gerror.New("分组名称长度必须为1至128个字符") + } + if in.Status == 0 { + in.Status = consts.StatusEnabled + } + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_task_dashboard.go b/server/internal/model/input/sysin/im_task_dashboard.go new file mode 100644 index 0000000..ac8f1b7 --- /dev/null +++ b/server/internal/model/input/sysin/im_task_dashboard.go @@ -0,0 +1,35 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/os/gtime" +) + +type ImTaskDashboardListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Status string `json:"status" dc:"状态"` +} + +func (in *ImTaskDashboardListInp) Filter(ctx context.Context) (err error) { return } + +type ImTaskDashboardListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + TenantState int `json:"tenantState" dc:"租户状态"` + DbStatus string `json:"dbStatus" dc:"数据库状态"` + Status string `json:"status" dc:"看板状态"` + OutboxPendingCount int `json:"outboxPendingCount" dc:"业务Outbox待处理"` + OutboxFailedCount int `json:"outboxFailedCount" dc:"业务Outbox失败"` + GroupOutboxPending int `json:"groupOutboxPending" dc:"群Outbox待处理"` + GroupOutboxFailed int `json:"groupOutboxFailed" dc:"群Outbox失败"` + OrphanFileCount int `json:"orphanFileCount" dc:"孤儿文件数"` + DeletedFileCount int `json:"deletedFileCount" dc:"待物理清理文件数"` + LastOutboxCreatedAt *gtime.Time `json:"lastOutboxCreatedAt" dc:"最新Outbox时间"` + LastGroupEventAt *gtime.Time `json:"lastGroupEventAt" dc:"最新群事件时间"` + LastFileUpdatedAt *gtime.Time `json:"lastFileUpdatedAt" dc:"最新文件更新时间"` + RecommendedNextAction string `json:"recommendedNextAction" dc:"建议动作"` +} diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index 3b887f7..7bdd32d 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -155,3 +155,67 @@ func (in *ImTenantStatusInp) Filter(ctx context.Context) (err error) { } type ImTenantStatusModel struct{} + +type ImTenantUsageInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantUsageInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImTenantUsageModel struct { + Id int64 `json:"id" dc:"租户ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + DbName string `json:"dbName" dc:"业务库名"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + DbReachable bool `json:"dbReachable" dc:"业务库是否可达"` + DbCheckError string `json:"dbCheckError" dc:"业务库检查错误"` + SchemaVersion string `json:"schemaVersion" dc:"Schema版本"` + UserCount int64 `json:"userCount" dc:"用户数"` + ActiveUserCount int64 `json:"activeUserCount" dc:"正常用户数"` + GroupCount int64 `json:"groupCount" dc:"群数"` + DeviceCount int64 `json:"deviceCount" dc:"设备数"` + PushTokenCount int64 `json:"pushTokenCount" dc:"Push Token数"` + InviteUseCount int64 `json:"inviteUseCount" dc:"邀请码使用次数"` + OutboxPendingCount int64 `json:"outboxPendingCount" dc:"待处理Outbox数"` + Warnings []string `json:"warnings" dc:"警告信息"` +} + +type ImTenantDeletionCheckInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantDeletionCheckInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImTenantDeletionCheckModel struct { + Id int64 `json:"id" dc:"租户ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + CanEnterDeleting bool `json:"canEnterDeleting" dc:"是否可进入删除流程"` + Blockers []string `json:"blockers" dc:"阻断原因"` + Warnings []string `json:"warnings" dc:"警告信息"` + UserCount int64 `json:"userCount" dc:"用户数"` + GroupCount int64 `json:"groupCount" dc:"群数"` + PendingOutbox int64 `json:"pendingOutbox" dc:"待处理Outbox"` +} + +type ImTenantLifecycleInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantLifecycleInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index 463d25b..00319f8 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -8,6 +8,7 @@ package sysin import ( "context" "hotgo/internal/model/input/form" + "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" @@ -17,7 +18,9 @@ import ( type ImUserListInp struct { form.PageReq TenantId int64 `json:"tenantId" dc:"租户ID"` - UserId int64 `json:"userId" dc:"用户ID"` + UserId string `json:"userId" dc:"后台展示编号"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` + Keyword string `json:"keyword" dc:"用户名、昵称、手机号或编号"` Nickname string `json:"nickname" dc:"昵称"` AuthName string `json:"authName" dc:"登录标识"` State *int `json:"state" dc:"账号状态"` @@ -26,8 +29,11 @@ type ImUserListInp struct { } func (in *ImUserListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") + in.Keyword = strings.TrimSpace(in.Keyword) + if in.UserId != "" { + if _, err = ParseOptionalInt64(in.UserId); err != nil { + return err + } } return } @@ -35,7 +41,10 @@ func (in *ImUserListInp) Filter(ctx context.Context) (err error) { type ImUserListModel struct { TenantId int64 `json:"tenantId" dc:"租户ID"` TenantName string `json:"tenantName" dc:"租户名称"` - UserId int64 `json:"userId" dc:"用户ID"` + DisplayNo int `json:"displayNo" dc:"后台展示编号"` + UserId string `json:"userId" dc:"用户ID"` + Username string `json:"username" dc:"登录账号"` + Phone string `json:"phone" dc:"手机号"` Nickname string `json:"nickname" dc:"昵称"` PublicAccount string `json:"publicAccount" dc:"公开账号"` Gender string `json:"gender" dc:"性别"` @@ -49,24 +58,140 @@ type ImUserListModel struct { UserAgent string `json:"userAgent" dc:"客户端"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Avatar string `json:"avatar" dc:"头像地址"` + PolicyGroupId int64 `json:"policyGroupId" dc:"用户策略分组ID"` + PolicyGroupName string `json:"policyGroupName" dc:"用户策略分组名称"` + RegisterType string `json:"registerType" dc:"注册方式"` +} + +type ImUserCreateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Username string `json:"username" dc:"用户名"` + Nickname string `json:"nickname" v:"required#昵称不能为空" dc:"昵称"` + Phone string `json:"phone" dc:"手机号"` + Password string `json:"password" v:"required#密码不能为空" dc:"密码"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` +} + +func (in *ImUserCreateInp) Filter(ctx context.Context) error { + in.Username = strings.ToLower(strings.TrimSpace(in.Username)) + in.Nickname = strings.TrimSpace(in.Nickname) + in.Phone = strings.TrimSpace(in.Phone) + if in.Nickname == "" { + return gerror.New("昵称不能为空") + } + if len([]rune(in.Password)) < 3 || len([]byte(in.Password)) > 72 { + return gerror.New("密码长度需为3至72字节") + } + return nil +} + +type ImUserCreateModel struct { + DisplayNo int `json:"displayNo" dc:"后台展示编号"` + UserId int64 `json:"userId" dc:"用户ID"` + Username string `json:"username" dc:"用户名"` + Nickname string `json:"nickname" dc:"昵称"` + Phone string `json:"phone" dc:"手机号"` +} + +type ImUserBatchCreateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Count int `json:"count" v:"required|min:1|max:100#创建数量不能为空|创建数量最少为1|单次最多创建100个用户" dc:"创建数量"` + Password string `json:"password" v:"required#默认密码不能为空" dc:"默认密码"` + NicknamePrefix string `json:"nicknamePrefix" dc:"昵称前缀"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` +} + +func (in *ImUserBatchCreateInp) Filter(ctx context.Context) error { + in.NicknamePrefix = strings.TrimSpace(in.NicknamePrefix) + if in.Count < 1 || in.Count > 100 { + return gerror.New("单次创建数量需为1至100") + } + if len([]rune(in.Password)) < 3 || len([]byte(in.Password)) > 72 { + return gerror.New("密码长度需为3至72字节") + } + return nil +} + +type ImUserBatchItemModel struct { + Row int `json:"row" dc:"行号"` + DisplayNo int `json:"displayNo" dc:"后台展示编号"` + Username string `json:"username" dc:"用户名"` + UserId int64 `json:"userId" dc:"用户ID"` + Success bool `json:"success" dc:"是否成功"` + Reason string `json:"reason" dc:"失败原因"` +} + +type ImUserBatchCreateModel struct { + SuccessCount int `json:"successCount" dc:"成功数量"` + FailedCount int `json:"failedCount" dc:"失败数量"` + Items []*ImUserBatchItemModel `json:"items" dc:"处理明细"` +} + +type ImUserImportInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + GroupId int64 `json:"groupId" dc:"默认用户策略分组ID"` + Rows []*ImUserCreateInp `json:"rows" dc:"导入用户"` } // ImUserViewInp 获取指定IM用户信息 type ImUserViewInp struct { - TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` - UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` } func (in *ImUserViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } - if in.UserId <= 0 { + if _, err = ParseOptionalInt64(in.UserId); err != nil { return gerror.New("用户ID不能为空") } return } +type ImUserPasswordResetInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + NewPassword string `json:"newPassword" v:"required#新密码不能为空" dc:"新密码"` +} + +func (in *ImUserPasswordResetInp) Filter(ctx context.Context) error { + if _, err := ParseOptionalInt64(in.UserId); err != nil { + return err + } + if len([]rune(in.NewPassword)) < 3 { + return gerror.New("新密码长度不能少于3位") + } + if len([]byte(in.NewPassword)) > 72 { + return gerror.New("新密码长度不能超过72字节") + } + return nil +} + +type ImUserRevokeSessionsInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImUserRevokeSessionsInp) Filter(ctx context.Context) error { + if _, err := ParseOptionalInt64(in.UserId); err != nil { + return err + } + return nil +} + +type ImUserStatusInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + State int `json:"state" dc:"账号状态"` +} + +func (in *ImUserStatusInp) Filter(ctx context.Context) error { + if _, err := ParseOptionalInt64(in.UserId); err != nil { + return err + } + return nil +} + type ImUserViewModel struct { ImUserListModel Signature string `json:"signature" dc:"个性签名"` @@ -74,19 +199,36 @@ type ImUserViewModel struct { BirthDate string `json:"birthDate" dc:"出生日期"` ProfileJson string `json:"profileJson" dc:"Tinode Public资料"` TrustedJson string `json:"trustedJson" dc:"Tinode Trusted资料"` - TagsJson string `json:"tagsJson" dc:"Tinode标签"` AccessJson string `json:"accessJson" dc:"访问权限"` ProfileVersion int `json:"profileVersion" dc:"资料版本"` } -type ImUserCommandModel struct { - CommandID string `json:"commandId" dc:"命令ID"` - EventID string `json:"eventId" dc:"事件ID"` - Status string `json:"status" dc:"状态"` - TenantID int64 `json:"tenantId" dc:"租户ID"` - TenantCode string `json:"tenantCode" dc:"租户编码"` - EventType string `json:"eventType" dc:"事件类型"` - IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +type ImUserDeviceListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImUserDeviceListInp) Filter(ctx context.Context) (err error) { + if _, err = ParseOptionalInt64(in.UserId); err != nil { + return gerror.New("用户ID不能为空") + } + return +} + +type ImUserDeviceListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + UserId int64 `json:"userId" dc:"用户ID"` + DeviceId string `json:"deviceId" dc:"设备ID"` + Platform string `json:"platform" dc:"平台"` + Lang string `json:"lang" dc:"语言"` + LastSeen *gtime.Time `json:"lastSeen" dc:"最后活跃时间"` + PushProvider string `json:"pushProvider" dc:"Push服务商"` + PushPlatform string `json:"pushPlatform" dc:"Push平台"` + PushAppVersion string `json:"pushAppVersion" dc:"App版本"` + PushTokenConfigured bool `json:"pushTokenConfigured" dc:"是否配置Push Token"` + PushUpdatedAt *gtime.Time `json:"pushUpdatedAt" dc:"Push更新时间"` } type ImUserGovernanceInp struct { @@ -105,12 +247,39 @@ func (in *ImUserGovernanceInp) Filter(ctx context.Context) (err error) { if in.UserId <= 0 { return gerror.New("用户ID不能为空") } - if in.ActorUserId <= 0 { - return gerror.New("操作IM用户ID不能为空") + return nil +} + +type ImUserAdjustGroupInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" v:"required|min:1#用户ID不能为空|用户ID不合法" dc:"用户ID"` + GroupId int64 `json:"groupId" v:"required|min:1#用户分组不能为空|用户分组不合法" dc:"用户策略分组ID"` +} + +func (in *ImUserAdjustGroupInp) Filter(ctx context.Context) error { + if in.UserId <= 0 || in.GroupId <= 0 { + return gerror.New("用户和用户分组不能为空") } return nil } +type ImUserIPWhitelistInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" v:"required|min:1#用户ID不能为空|用户ID不合法" dc:"用户ID"` + IPList []string `json:"ipList" dc:"IP或CIDR列表"` +} + +func (in *ImUserIPWhitelistInp) Filter(ctx context.Context) error { + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + return nil +} + +type ImUserIPWhitelistModel struct { + IPList []string `json:"ipList" dc:"IP或CIDR列表"` +} + func (in *ImUserGovernanceInp) FilterDevice(ctx context.Context) (err error) { if err = in.Filter(ctx); err != nil { return err @@ -120,3 +289,13 @@ func (in *ImUserGovernanceInp) FilterDevice(ctx context.Context) (err error) { } return nil } + +type ImUserCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"Kafka事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} diff --git a/server/internal/model/input/sysin/im_user_policy_group.go b/server/internal/model/input/sysin/im_user_policy_group.go new file mode 100644 index 0000000..a2ec573 --- /dev/null +++ b/server/internal/model/input/sysin/im_user_policy_group.go @@ -0,0 +1,221 @@ +package sysin + +import ( + "context" + "net/netip" + "regexp" + "strings" + + "hotgo/internal/consts" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +var imPolicyGroupCodePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{1,63}$`) + +type ImUserPolicyGroupListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID,0为全局默认"` + Keyword string `json:"keyword" dc:"编码或名称"` + Status int `json:"status" dc:"状态"` +} + +func (in *ImUserPolicyGroupListInp) Filter(ctx context.Context) error { + in.Keyword = strings.TrimSpace(in.Keyword) + if in.Status != 0 && in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + return nil +} + +type ImUserPolicyGroupListModel struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId"` + GroupCode string `json:"groupCode"` + GroupName string `json:"groupName"` + Priority int `json:"priority"` + Status int `json:"status"` + PolicyJson string `json:"policyJson"` + IpWhitelist string `json:"ipWhitelist"` + IpBlacklist string `json:"ipBlacklist"` + Version uint `json:"version"` + Remark string `json:"remark"` + MemberCount int `json:"memberCount"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` +} + +type ImUserPolicyGroupViewInp struct { + Id int64 `json:"id" v:"required|min:1#策略分组ID不能为空|策略分组ID不合法"` +} + +func (in *ImUserPolicyGroupViewInp) Filter(ctx context.Context) error { return nil } + +type ImUserPolicyGroupViewModel struct{ ImUserPolicyGroupListModel } + +type ImUserPolicyGroupEditInp struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenantId" dc:"租户ID,0为全局默认"` + GroupCode string `json:"groupCode" v:"required#分组编码不能为空"` + GroupName string `json:"groupName" v:"required#分组名称不能为空"` + Priority int `json:"priority"` + Status int `json:"status"` + PolicyJson *gjson.Json `json:"policyJson" v:"required#策略配置不能为空"` + IpWhitelist *gjson.Json `json:"ipWhitelist"` + IpBlacklist *gjson.Json `json:"ipBlacklist"` + Remark string `json:"remark"` +} + +func (in *ImUserPolicyGroupEditInp) Filter(ctx context.Context) error { + in.GroupCode = strings.TrimSpace(in.GroupCode) + in.GroupName = strings.TrimSpace(in.GroupName) + in.Remark = strings.TrimSpace(in.Remark) + if !imPolicyGroupCodePattern.MatchString(in.GroupCode) { + return gerror.New("分组编码必须以字母开头,只能包含字母、数字、下划线和中划线") + } + if in.GroupName == "" || len([]rune(in.GroupName)) > 128 { + return gerror.New("分组名称长度必须为1至128个字符") + } + if in.Status == 0 { + in.Status = consts.StatusEnabled + } + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + if in.PolicyJson == nil || in.PolicyJson.String() == "null" { + return gerror.New("策略配置不能为空") + } + return nil +} + +type ImUserPolicyGroupDeleteInp struct { + Id int64 `json:"id" v:"required|min:1#策略分组ID不能为空|策略分组ID不合法"` +} + +func (in *ImUserPolicyGroupDeleteInp) Filter(ctx context.Context) error { return nil } + +type ImUserPolicyGroupStatusInp struct { + Id int64 `json:"id" v:"required|min:1#策略分组ID不能为空|策略分组ID不合法"` + Status int `json:"status" v:"required#状态不能为空"` +} + +func (in *ImUserPolicyGroupStatusInp) Filter(ctx context.Context) error { + if in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable { + return gerror.New("状态不合法") + } + return nil +} + +type ImUserPolicyGroupMemberListInp struct { + form.PageReq + GroupId int64 `json:"groupId" v:"required|min:1#策略分组ID不能为空|策略分组ID不合法"` + UserId int64 `json:"userId"` +} + +func (in *ImUserPolicyGroupMemberListInp) Filter(ctx context.Context) error { return nil } + +type ImUserPolicyGroupMemberModel struct { + Id int64 `json:"id"` + GroupId int64 `json:"groupId"` + TenantId int64 `json:"tenantId"` + UserId int64 `json:"userId"` + Status int `json:"status"` + ValidFrom *gtime.Time `json:"validFrom"` + ValidUntil *gtime.Time `json:"validUntil"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"createdAt"` +} + +type ImUserPolicyGroupMemberAddInp struct { + GroupId int64 `json:"groupId" v:"required|min:1#策略分组ID不能为空|策略分组ID不合法"` + UserId int64 `json:"userId" v:"required|min:1#用户ID不能为空|用户ID不合法"` + ValidFrom *gtime.Time `json:"validFrom"` + ValidUntil *gtime.Time `json:"validUntil"` + Remark string `json:"remark"` +} + +func (in *ImUserPolicyGroupMemberAddInp) Filter(ctx context.Context) error { + in.Remark = strings.TrimSpace(in.Remark) + if in.GroupId <= 0 { + return gerror.New("策略分组ID不合法") + } + if in.UserId <= 0 { + return gerror.New("用户ID不合法") + } + if in.ValidFrom != nil && in.ValidUntil != nil && in.ValidUntil.Before(in.ValidFrom) { + return gerror.New("有效结束时间不能早于开始时间") + } + return nil +} + +type ImUserPolicyGroupMemberRemoveInp struct { + Id int64 `json:"id" v:"required|min:1#成员关系ID不能为空|成员关系ID不合法"` +} + +func (in *ImUserPolicyGroupMemberRemoveInp) Filter(ctx context.Context) error { return nil } + +type ImTenantLoginIPWhitelistInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` +} + +func (in *ImTenantLoginIPWhitelistInp) Filter(ctx context.Context) error { + if in.TenantId < 0 { + return gerror.New("租户ID不合法") + } + return nil +} + +type ImTenantLoginIPWhitelistSaveInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + AppList []string `json:"appList" dc:"APP登录IP白名单"` + WebList []string `json:"webList" dc:"Web登录IP白名单"` +} + +func (in *ImTenantLoginIPWhitelistSaveInp) Filter(ctx context.Context) (err error) { + if in.TenantId < 0 { + return gerror.New("租户ID不合法") + } + if in.AppList, err = normalizeTenantLoginIPList(in.AppList); err != nil { + return gerror.Wrap(err, "APP登录白名单不合法") + } + if in.WebList, err = normalizeTenantLoginIPList(in.WebList); err != nil { + return gerror.Wrap(err, "Web登录白名单不合法") + } + return nil +} + +type ImTenantLoginIPWhitelistModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + AppList []string `json:"appList" dc:"APP登录IP白名单"` + WebList []string `json:"webList" dc:"Web登录IP白名单"` +} + +func normalizeTenantLoginIPList(values []string) ([]string, error) { + if len(values) > 500 { + return nil, gerror.New("单个渠道最多配置500条IP或CIDR") + } + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if prefix, err := netip.ParsePrefix(value); err == nil { + value = prefix.Masked().String() + } else if address, addressErr := netip.ParseAddr(value); addressErr == nil { + value = address.String() + } else { + return nil, gerror.Newf("%s 不是有效的IPv4、IPv6或CIDR", value) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} diff --git a/server/internal/model/input/sysin/im_user_test.go b/server/internal/model/input/sysin/im_user_test.go new file mode 100644 index 0000000..a5ec00b --- /dev/null +++ b/server/internal/model/input/sysin/im_user_test.go @@ -0,0 +1,27 @@ +package sysin + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gctx" +) + +func TestImUserGovernanceAllowsAdminActor(t *testing.T) { + in := &ImUserGovernanceInp{TenantId: 1, UserId: 2} + if err := in.Filter(gctx.New()); err != nil { + t.Fatalf("admin governance should not require an IM actor: %v", err) + } +} + +func TestImUserActionInputs(t *testing.T) { + ctx := gctx.New() + if err := (&ImUserAdjustGroupInp{UserId: 2, GroupId: 3}).Filter(ctx); err != nil { + t.Fatalf("valid group adjustment rejected: %v", err) + } + if err := (&ImUserAdjustGroupInp{UserId: 2}).Filter(ctx); err == nil { + t.Fatal("missing group must be rejected") + } + if err := (&ImUserIPWhitelistInp{UserId: 2}).Filter(ctx); err != nil { + t.Fatalf("valid whitelist input rejected: %v", err) + } +} diff --git a/server/internal/model/input/sysin/im_work_order.go b/server/internal/model/input/sysin/im_work_order.go index c6d1fe9..f990793 100644 --- a/server/internal/model/input/sysin/im_work_order.go +++ b/server/internal/model/input/sysin/im_work_order.go @@ -32,9 +32,6 @@ type ImWorkOrderListInp struct { } func (in *ImWorkOrderListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Type == "" { return gerror.New("请选择工单类型") } @@ -68,9 +65,6 @@ type ImWorkOrderViewInp struct { } func (in *ImWorkOrderViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback { return gerror.New("工单类型不正确") } @@ -100,9 +94,6 @@ type ImWorkOrderStatusInp struct { } func (in *ImWorkOrderStatusInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback { return gerror.New("工单类型不正确") } diff --git a/server/internal/model/input/sysin/im_workbench.go b/server/internal/model/input/sysin/im_workbench.go new file mode 100644 index 0000000..f33c903 --- /dev/null +++ b/server/internal/model/input/sysin/im_workbench.go @@ -0,0 +1,141 @@ +package sysin + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImWorkbenchAppListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Code string `json:"code" dc:"模块编码"` + Name string `json:"name" dc:"模块名称"` + Enabled *int `json:"enabled" dc:"启用状态"` +} + +func (in *ImWorkbenchAppListInp) Filter(ctx context.Context) (err error) { + in.Code = strings.TrimSpace(in.Code) + in.Name = strings.TrimSpace(in.Name) + if in.Enabled != nil && !dict.HasOptionKey(consts.ImWorkbenchAppEnabledOptions, *in.Enabled) { + return gerror.New("启用状态不合法") + } + return nil +} + +type ImWorkbenchAppModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"模块编码"` + Name string `json:"name" dc:"模块名称"` + IconFileId string `json:"iconFileId" dc:"图标文件ID"` + Route string `json:"route" dc:"客户端路由"` + SortOrder int `json:"sortOrder" dc:"排序"` + UnreadKey string `json:"unreadKey" dc:"未读角标Key"` + Enabled int `json:"enabled" dc:"启用状态"` + Permission string `json:"permission" dc:"权限策略"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImWorkbenchAppEditInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" v:"required#模块编码不能为空" dc:"模块编码"` + Name string `json:"name" v:"required#模块名称不能为空" dc:"模块名称"` + IconFileId string `json:"iconFileId" dc:"图标文件ID"` + Route string `json:"route" v:"required#客户端路由不能为空" dc:"客户端路由"` + SortOrder int `json:"sortOrder" dc:"排序"` + UnreadKey string `json:"unreadKey" dc:"未读角标Key"` + Enabled int `json:"enabled" dc:"启用状态"` + Permission string `json:"permission" dc:"权限策略"` +} + +func (in *ImWorkbenchAppEditInp) Filter(ctx context.Context) (err error) { + in.Code = strings.TrimSpace(in.Code) + in.Name = strings.TrimSpace(in.Name) + in.IconFileId = strings.TrimSpace(in.IconFileId) + in.Route = strings.TrimSpace(in.Route) + in.UnreadKey = strings.TrimSpace(in.UnreadKey) + in.Permission = strings.TrimSpace(in.Permission) + if in.Code == "" { + return gerror.New("模块编码不能为空") + } + if strings.ContainsAny(in.Code, " \t\r\n/") { + return gerror.New("模块编码不能包含空格或斜杠") + } + if len([]rune(in.Code)) > 64 { + return gerror.New("模块编码不能超过64个字符") + } + if in.Name == "" { + return gerror.New("模块名称不能为空") + } + if len([]rune(in.Name)) > 128 { + return gerror.New("模块名称不能超过128个字符") + } + if !dict.HasOptionKey(consts.ImWorkbenchRouteOptions, in.Route) { + return gerror.New("客户端路由不在当前IM服务端白名单内") + } + if !dict.HasOptionKey(consts.ImWorkbenchAppEnabledOptions, in.Enabled) { + return gerror.New("启用状态不合法") + } + if in.Permission == "" { + in.Permission = consts.ImWorkbenchPermissionEnabled + } + if !dict.HasOptionKey(consts.ImWorkbenchPermissionOptions, in.Permission) { + return gerror.New("权限策略不合法") + } + if len([]rune(in.IconFileId)) > 64 { + return gerror.New("图标文件ID不能超过64个字符") + } + if len([]rune(in.UnreadKey)) > 64 { + return gerror.New("未读角标Key不能超过64个字符") + } + return nil +} + +type ImWorkbenchAppDeleteInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"模块编码"` +} + +func (in *ImWorkbenchAppDeleteInp) Filter(ctx context.Context) (err error) { + in.Code = strings.TrimSpace(in.Code) + if in.Id <= 0 && in.Code == "" { + return gerror.New("请选择要禁用的工作台模块") + } + return nil +} + +type ImWorkbenchAppReorderItem struct { + Code string `json:"code" dc:"模块编码"` + SortOrder int `json:"sortOrder" dc:"排序"` +} + +type ImWorkbenchAppReorderInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Items []*ImWorkbenchAppReorderItem `json:"items" dc:"排序列表"` +} + +func (in *ImWorkbenchAppReorderInp) Filter(ctx context.Context) (err error) { + if len(in.Items) == 0 { + return gerror.New("排序列表不能为空") + } + for _, item := range in.Items { + if item == nil { + return gerror.New("排序项不能为空") + } + item.Code = strings.TrimSpace(item.Code) + if item.Code == "" { + return gerror.New("模块编码不能为空") + } + } + return nil +} diff --git a/server/internal/queues/im_admin_command_result.go b/server/internal/queues/im_admin_command_result.go index ed58590..f7b7b40 100644 --- a/server/internal/queues/im_admin_command_result.go +++ b/server/internal/queues/im_admin_command_result.go @@ -26,27 +26,24 @@ func (q *qImAdminCommandResult) GetTopic() string { } func (q *qImAdminCommandResult) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { - var envelope imkafka.Envelope - if err = json.Unmarshal(mqMsg.Body, &envelope); err == nil && envelope.EventID != "" && len(envelope.Payload) > 0 { - var result imkafka.CommandResult - if err = json.Unmarshal(envelope.Payload, &result); err != nil { - return err - } - if result.CommandEventID == "" { - result.CommandEventID = envelope.ParentEventID - } - if result.TenantCode == "" { - result.TenantCode = envelope.TenantCode - } - if result.EventID == "" { - result.EventID = envelope.EventID - } - return imkafka.ApplyCommandResult(ctx, result) - } - var result imkafka.CommandResult if err = json.Unmarshal(mqMsg.Body, &result); err != nil { return err } + if result.CommandID == "" && result.CommandEventID == "" { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + if len(envelope.Payload) > 0 { + if err = json.Unmarshal(envelope.Payload, &result); err != nil { + return err + } + result.EventID = envelope.EventID + result.TraceID = envelope.TraceID + result.TenantCode = envelope.TenantCode + result.OccurredAt = envelope.OccurredAt + } + } return imkafka.ApplyCommandResult(ctx, result) } diff --git a/server/internal/queues/im_audit_event.go b/server/internal/queues/im_audit_event.go index 8d122ff..7b2726a 100644 --- a/server/internal/queues/im_audit_event.go +++ b/server/internal/queues/im_audit_event.go @@ -5,9 +5,9 @@ import ( "encoding/json" "hotgo/internal/consts" "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" "hotgo/internal/library/queue" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -32,23 +32,12 @@ func (q *qImAuditEvent) Handle(ctx context.Context, mqMsg queue.MqMsg) (err erro if len(envelope.Payload) > 0 { _ = json.Unmarshal(envelope.Payload, &payload) } - _, err = g.DB().Model("im_audit_events").Safe().Ctx(ctx).Data(g.Map{ - "event_id": envelope.EventID, - "tenant_code": envelope.TenantCode, - "event_type": envelope.EventType, - "biz_type": envelope.BizType, - "biz_id": envelope.BizID, - "actor_type": firstString(payload["actor_type"]), - "actor_id": firstString(payload["actor_id"]), - "action": firstString(payload["action"]), - "resource": firstString(payload["resource"]), - "resource_id": firstString(payload["resource_id"]), - "result": firstString(payload["result"]), - "reason": firstString(payload["reason"]), - "payload": string(envelope.Payload), - "envelope": string(mqMsg.Body), - "occurred_at": envelope.OccurredAt, - "received_at": gtime.Now(), - }).InsertIgnore() + _, err = imtenant.RegistryDB().Exec(ctx, `INSERT IGNORE INTO im_audit_events + (event_id,tenant_code,event_type,biz_type,biz_id,actor_type,actor_id,action,resource,resource_id,result,reason,payload,envelope,occurred_at,received_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,CAST(NULLIF(?, '') AS JSON),CAST(? AS JSON),?,?)`, + envelope.EventID, envelope.TenantCode, envelope.EventType, envelope.BizType, envelope.BizID, + firstString(payload["actor_type"]), firstString(payload["actor_id"]), firstString(payload["action"]), + firstString(payload["resource"]), firstString(payload["resource_id"]), firstString(payload["result"]), + firstString(payload["reason"]), string(envelope.Payload), string(mqMsg.Body), envelope.OccurredAt, gtime.Now()) return err } diff --git a/server/internal/queues/im_dead_letter.go b/server/internal/queues/im_dead_letter.go index 4651d69..616cf23 100644 --- a/server/internal/queues/im_dead_letter.go +++ b/server/internal/queues/im_dead_letter.go @@ -5,9 +5,9 @@ import ( "encoding/json" "hotgo/internal/consts" "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" "hotgo/internal/library/queue" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -32,22 +32,13 @@ func (q *qImDeadLetter) Handle(ctx context.Context, mqMsg queue.MqMsg) (err erro if len(envelope.Payload) > 0 { _ = json.Unmarshal(envelope.Payload, &payload) } - _, err = g.DB().Model("im_kafka_dead_letter_events").Safe().Ctx(ctx).Data(g.Map{ - "event_id": envelope.EventID, - "parent_event_id": envelope.ParentEventID, - "tenant_code": envelope.TenantCode, - "event_type": envelope.EventType, - "biz_type": envelope.BizType, - "biz_id": envelope.BizID, - "source_service": envelope.SourceService, - "target_service": envelope.TargetService, - "dead_id": firstString(payload["dead_id"], payload["deadId"], payload["DeadID"]), - "reason": firstString(payload["reason"], payload["Reason"], payload["last_reason"], payload["lastReason"], payload["LastReason"]), - "message": firstString(payload["message"], payload["Message"], payload["last_error"], payload["lastError"], payload["LastError"]), - "payload": string(envelope.Payload), - "envelope": string(mqMsg.Body), - "received_at": gtime.Now(), - }).InsertIgnore() + _, err = imtenant.RegistryDB().Exec(ctx, `INSERT IGNORE INTO im_kafka_dead_letter_events + (event_id,parent_event_id,tenant_code,event_type,biz_type,biz_id,source_service,target_service,dead_id,reason,message,payload,envelope,received_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,CAST(NULLIF(?, '') AS JSON),CAST(? AS JSON),?)`, + envelope.EventID, envelope.ParentEventID, envelope.TenantCode, envelope.EventType, envelope.BizType, envelope.BizID, + envelope.SourceService, envelope.TargetService, stringValue(payload["dead_id"]), + firstString(payload["reason"], payload["last_reason"]), firstString(payload["message"], payload["last_error"]), + string(envelope.Payload), string(mqMsg.Body), gtime.Now()) return err } diff --git a/server/internal/router/genrouter/im_abuse_reason.go b/server/internal/router/genrouter/im_abuse_reason.go new file mode 100644 index 0000000..989a817 --- /dev/null +++ b/server/internal/router/genrouter/im_abuse_reason.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImAbuseReason) // IM举报原因 +} diff --git a/server/internal/router/genrouter/im_app_release.go b/server/internal/router/genrouter/im_app_release.go new file mode 100644 index 0000000..3e9e344 --- /dev/null +++ b/server/internal/router/genrouter/im_app_release.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImAppRelease) // IM版本更新 +} diff --git a/server/internal/router/genrouter/im_dashboard.go b/server/internal/router/genrouter/im_dashboard.go new file mode 100644 index 0000000..d5aa19a --- /dev/null +++ b/server/internal/router/genrouter/im_dashboard.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImDashboard) } diff --git a/server/internal/router/genrouter/im_external_config.go b/server/internal/router/genrouter/im_external_config.go new file mode 100644 index 0000000..61c8da0 --- /dev/null +++ b/server/internal/router/genrouter/im_external_config.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImExternalConfig) // IM外部服务配置 +} diff --git a/server/internal/router/genrouter/im_feedback_config.go b/server/internal/router/genrouter/im_feedback_config.go new file mode 100644 index 0000000..908f11c --- /dev/null +++ b/server/internal/router/genrouter/im_feedback_config.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImFeedbackConfig) // IM意见反馈配置 +} diff --git a/server/internal/router/genrouter/im_file.go b/server/internal/router/genrouter/im_file.go new file mode 100644 index 0000000..87a1d29 --- /dev/null +++ b/server/internal/router/genrouter/im_file.go @@ -0,0 +1,12 @@ +// Package genrouter +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImFile) // IM文件治理 +} 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/router/genrouter/im_group_message.go b/server/internal/router/genrouter/im_group_message.go new file mode 100644 index 0000000..956c32a --- /dev/null +++ b/server/internal/router/genrouter/im_group_message.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImGroupMessage) } diff --git a/server/internal/router/genrouter/im_login_failure_log.go b/server/internal/router/genrouter/im_login_failure_log.go new file mode 100644 index 0000000..69c8d31 --- /dev/null +++ b/server/internal/router/genrouter/im_login_failure_log.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImLoginFailureLog) } diff --git a/server/internal/router/genrouter/im_mailbox_config.go b/server/internal/router/genrouter/im_mailbox_config.go new file mode 100644 index 0000000..920556b --- /dev/null +++ b/server/internal/router/genrouter/im_mailbox_config.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImMailboxConfig) // IM总经理信箱配置 +} diff --git a/server/internal/router/genrouter/im_notification.go b/server/internal/router/genrouter/im_notification.go new file mode 100644 index 0000000..a4b1cbb --- /dev/null +++ b/server/internal/router/genrouter/im_notification.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImNotification) // IM通知模板 +} diff --git a/server/internal/router/genrouter/im_org.go b/server/internal/router/genrouter/im_org.go new file mode 100644 index 0000000..69850d4 --- /dev/null +++ b/server/internal/router/genrouter/im_org.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImOrg) // IM组织架构 +} diff --git a/server/internal/router/genrouter/im_outbox.go b/server/internal/router/genrouter/im_outbox.go new file mode 100644 index 0000000..dffcf66 --- /dev/null +++ b/server/internal/router/genrouter/im_outbox.go @@ -0,0 +1,12 @@ +// Package genrouter +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImOutbox) // IM Outbox +} diff --git a/server/internal/router/genrouter/im_private_message.go b/server/internal/router/genrouter/im_private_message.go new file mode 100644 index 0000000..c3827ff --- /dev/null +++ b/server/internal/router/genrouter/im_private_message.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImPrivateMessage) } diff --git a/server/internal/router/genrouter/im_security_policy.go b/server/internal/router/genrouter/im_security_policy.go new file mode 100644 index 0000000..8dcb15d --- /dev/null +++ b/server/internal/router/genrouter/im_security_policy.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImSecurityPolicy) } diff --git a/server/internal/router/genrouter/im_sensitive_word.go b/server/internal/router/genrouter/im_sensitive_word.go new file mode 100644 index 0000000..d40be89 --- /dev/null +++ b/server/internal/router/genrouter/im_sensitive_word.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImSensitiveWord) } diff --git a/server/internal/router/genrouter/im_task_dashboard.go b/server/internal/router/genrouter/im_task_dashboard.go new file mode 100644 index 0000000..2b25c88 --- /dev/null +++ b/server/internal/router/genrouter/im_task_dashboard.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImTaskDashboard) // IM任务看板 +} diff --git a/server/internal/router/genrouter/im_user_login_history.go b/server/internal/router/genrouter/im_user_login_history.go new file mode 100644 index 0000000..6940376 --- /dev/null +++ b/server/internal/router/genrouter/im_user_login_history.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImUserLoginHistory) } diff --git a/server/internal/router/genrouter/im_user_policy_group.go b/server/internal/router/genrouter/im_user_policy_group.go new file mode 100644 index 0000000..1ba01b7 --- /dev/null +++ b/server/internal/router/genrouter/im_user_policy_group.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.ImUserPolicyGroup) } diff --git a/server/internal/router/genrouter/im_workbench.go b/server/internal/router/genrouter/im_workbench.go new file mode 100644 index 0000000..13f9e58 --- /dev/null +++ b/server/internal/router/genrouter/im_workbench.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImWorkbench) // IM工作台 +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 2a002ff..fb91818 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -12,6 +12,7 @@ import ( "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/utility/tree" + "io" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gredis" @@ -182,6 +183,14 @@ type ( View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error) // Status 更新IM租户状态 Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error) + // Usage 获取IM租户用量和健康状态 + Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (res *sysin.ImTenantUsageModel, err error) + // DeletionCheck IM租户删除前检查 + DeletionCheck(ctx context.Context, in *sysin.ImTenantDeletionCheckInp) (res *sysin.ImTenantDeletionCheckModel, err error) + // EnterDeleting IM租户进入删除流程 + EnterDeleting(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) + // Restore 恢复IM租户 + Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) } ISysImRegistration interface { // GetConfig 获取IM注册配置 @@ -200,16 +209,34 @@ type ( BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error) // Revoke 撤销邀请码 Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error) + // BindUser 绑定邀请码注册后自动添加的C端用户 + BindUser(ctx context.Context, in *sysin.ImInviteBindUserInp) (err error) // BatchRevoke 批量撤销邀请码 BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error) // Export 导出邀请码 Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) } ISysImUser interface { + Create(ctx context.Context, in *sysin.ImUserCreateInp) (res *sysin.ImUserCreateModel, err error) + BatchCreate(ctx context.Context, in *sysin.ImUserBatchCreateInp) (res *sysin.ImUserBatchCreateModel, err error) + Import(ctx context.Context, in *sysin.ImUserImportInp) (res *sysin.ImUserBatchCreateModel, err error) + ImportExcel(ctx context.Context, tenantId, groupId int64, reader io.Reader) (res *sysin.ImUserBatchCreateModel, err error) + DownloadTemplate(ctx context.Context) (err error) // List 获取IM用户列表 List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) // View 获取IM用户指定信息 View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) + // ResetPassword 管理员重置IM用户密码 + ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (res *sysin.ImUserCommandModel, err error) + // RevokeSessions 撤销IM用户会话 + RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) + // Status 更新IM用户状态 + Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) + AdjustGroup(ctx context.Context, in *sysin.ImUserAdjustGroupInp) (err error) + IPWhitelist(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (res *sysin.ImUserIPWhitelistModel, err error) + IPWhitelistSave(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (err error) + // Devices 获取IM用户设备列表 + Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, err error) // Ban 通过Kafka封禁IM用户 Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) // Unban 通过Kafka解封IM用户 @@ -236,8 +263,40 @@ type ( MemberList(ctx context.Context, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) // Governance 获取IM群治理配置 Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) + // GovernanceUpdate 更新IM群治理配置 + GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // ProfileUpdate 修改IM群资料 + ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // Dissolve 解散IM群 + Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error) + // Mute 禁言IM群成员 + Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberMute Kafka禁言IM群成员 + MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) + // Unmute 解除IM群成员禁言 + Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberUnmute Kafka解除IM群成员禁言 + MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberRemove 移出IM群成员 + MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) + // JoinRequestApprove 通过IM入群申请 + JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) + // JoinRequestReject 拒绝IM入群申请 + JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) + // RoleUpdate 更新IM群成员角色 + RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // AdminSet 设置IM群管理员 + AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // AdminRemove 删除IM群管理员 + AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // OwnerTransfer 转让IM群主 + OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) // JoinRequestList 获取IM入群申请列表 JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) + // FileList 获取IM群文件列表 + FileList(ctx context.Context, in *sysin.ImGroupFileListInp) (list []*sysin.ImGroupFileListModel, totalCount int, err error) + // FileDelete 删除IM群文件 + FileDelete(ctx context.Context, in *sysin.ImGroupFileDeleteInp) (err error) // InvitationList 获取IM群邀请列表 InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) // AnnouncementList 获取IM群公告列表 @@ -246,22 +305,6 @@ type ( MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) // AdminLogList 获取IM群管理日志列表 AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) - // ProfileUpdate 通过Kafka修改IM群资料 - ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) - // GovernanceUpdate 通过Kafka修改IM群治理配置 - GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) - // AdminSet 通过Kafka设置IM群管理员 - AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error) - // AdminRemove 通过Kafka删除IM群管理员 - AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error) - // MemberMute 通过Kafka禁言IM群成员 - MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error) - // MemberUnmute 通过Kafka解除IM群成员禁言 - MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error) - // MemberRemove 通过Kafka移出IM群成员 - MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) - // OwnerTransfer 通过Kafka转让IM群主 - OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) } ISysImRealname interface { // List 获取IM实名审核列表 @@ -279,9 +322,176 @@ type ( // Status 更新IM举报反馈工单状态 Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error) } + ISysImOutbox interface { + // List 获取IM Outbox列表 + List(ctx context.Context, in *sysin.ImOutboxListInp) (list []*sysin.ImOutboxListModel, totalCount int, err error) + // View 获取IM Outbox详情 + View(ctx context.Context, in *sysin.ImOutboxViewInp) (res *sysin.ImOutboxViewModel, err error) + // Retry 重试IM Outbox事件 + Retry(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) + // Dead 标记IM Outbox死信 + Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) + } + 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组织成员 + MemberDelete(ctx context.Context, in *sysin.ImOrgMemberDeleteInp) (err error) + // Search 搜索IM组织 + Search(ctx context.Context, in *sysin.ImOrgSearchInp) (res *sysin.ImOrgSearchModel, err error) + } + ISysImFile interface { + // List 获取IM文件列表 + List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, totalCount int, err error) + // View 获取IM文件详情 + View(ctx context.Context, in *sysin.ImFileViewInp) (res *sysin.ImFileViewModel, err error) + // Quarantine 隔离IM文件 + Quarantine(ctx context.Context, in *sysin.ImFileActionInp) (err error) + // Delete 删除IM文件 + Delete(ctx context.Context, in *sysin.ImFileActionInp) (err error) + // 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) + } + ISysImUserPolicyGroup interface { + List(ctx context.Context, in *sysin.ImUserPolicyGroupListInp) (list []*sysin.ImUserPolicyGroupListModel, totalCount int, err error) + View(ctx context.Context, in *sysin.ImUserPolicyGroupViewInp) (res *sysin.ImUserPolicyGroupViewModel, err error) + Edit(ctx context.Context, in *sysin.ImUserPolicyGroupEditInp) (err error) + Delete(ctx context.Context, in *sysin.ImUserPolicyGroupDeleteInp) (err error) + Status(ctx context.Context, in *sysin.ImUserPolicyGroupStatusInp) (err error) + MemberList(ctx context.Context, in *sysin.ImUserPolicyGroupMemberListInp) (list []*sysin.ImUserPolicyGroupMemberModel, totalCount int, err error) + MemberAdd(ctx context.Context, in *sysin.ImUserPolicyGroupMemberAddInp) (err error) + MemberRemove(ctx context.Context, in *sysin.ImUserPolicyGroupMemberRemoveInp) (err error) + LoginIPWhitelist(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistInp) (res *sysin.ImTenantLoginIPWhitelistModel, err error) + LoginIPWhitelistByTenant(ctx context.Context, tenantId int64) (res *sysin.ImTenantLoginIPWhitelistModel, err error) + LoginIPWhitelistSave(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistSaveInp) (err error) + } + ISysImSensitiveWord interface { + List(ctx context.Context, in *sysin.ImSensitiveWordListInp) (list []*sysin.ImSensitiveWordListModel, totalCount int, err error) + Edit(ctx context.Context, in *sysin.ImSensitiveWordEditInp) (err error) + Delete(ctx context.Context, in *sysin.ImSensitiveWordDeleteInp) (err error) + Status(ctx context.Context, in *sysin.ImSensitiveWordStatusInp) (err error) + BatchImport(ctx context.Context, in *sysin.ImSensitiveWordBatchImportInp) (res *sysin.ImSensitiveWordBatchImportModel, err error) + GroupList(ctx context.Context, in *sysin.ImSensitiveWordGroupListInp) (list []*sysin.ImSensitiveWordGroupModel, err error) + GroupEdit(ctx context.Context, in *sysin.ImSensitiveWordGroupEditInp) (err error) + } + ISysImSecurityPolicy interface { + 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) + IPRuleList(ctx context.Context, in *sysin.ImSecurityIPRuleListInp) (list []*sysin.ImSecurityIPRuleModel, totalCount int, err error) + IPRuleEdit(ctx context.Context, in *sysin.ImSecurityIPRuleEditInp) (err error) + IPRuleDelete(ctx context.Context, in *sysin.ImSecurityIPRuleDeleteInp) (err error) + } + ISysImDashboard interface { + Overview(ctx context.Context, in *sysin.ImDashboardOverviewInp) (res *sysin.ImDashboardOverviewModel, err error) + Trends(ctx context.Context, in *sysin.ImDashboardTrendsInp) (list []*sysin.ImDashboardTrendModel, err error) + } + ISysImPrivateMessage interface { + List(ctx context.Context, in *sysin.ImPrivateMessageListInp) (list []*sysin.ImMessageListModel, totalCount int, err error) + View(ctx context.Context, in *sysin.ImMessageViewInp) (res *sysin.ImMessageViewModel, err error) + Hide(ctx context.Context, in *sysin.ImMessageActionInp) (res *sysin.ImMessageCommandModel, err error) + Delete(ctx context.Context, in *sysin.ImMessageActionInp) (res *sysin.ImMessageCommandModel, err error) + Export(ctx context.Context, in *sysin.ImPrivateMessageListInp) (err error) + } + ISysImGroupMessage interface { + List(ctx context.Context, in *sysin.ImGroupMessageListInp) (list []*sysin.ImMessageListModel, totalCount int, err error) + View(ctx context.Context, in *sysin.ImMessageViewInp) (res *sysin.ImMessageViewModel, err error) + Hide(ctx context.Context, in *sysin.ImMessageActionInp) (res *sysin.ImMessageCommandModel, err error) + Delete(ctx context.Context, in *sysin.ImMessageActionInp) (res *sysin.ImMessageCommandModel, err error) + Export(ctx context.Context, in *sysin.ImGroupMessageListInp) (err error) + } + ISysImUserLoginHistory interface { + List(ctx context.Context, in *sysin.ImUserLoginHistoryListInp) (list []*sysin.ImUserLoginHistoryModel, totalCount int, err error) + Export(ctx context.Context, in *sysin.ImUserLoginHistoryListInp) (err error) + } + ISysImLoginFailureLog interface { + List(ctx context.Context, in *sysin.ImLoginFailureLogListInp) (list []*sysin.ImLoginFailureLogModel, totalCount int, err error) + View(ctx context.Context, in *sysin.ImLoginFailureViewInp) (res *sysin.ImLoginFailureLogModel, err error) + Export(ctx context.Context, in *sysin.ImLoginFailureLogListInp) (err error) + AddBlacklist(ctx context.Context, in *sysin.ImLoginFailureAddBlacklistInp) (err error) + BanUser(ctx context.Context, in *sysin.ImLoginFailureBanUserInp) (res *sysin.ImUserCommandModel, err error) + } + ISysImExternalConfig interface { + // List 获取IM外部服务配置列表 + List(ctx context.Context, in *sysin.ImExternalConfigListInp) (list []*sysin.ImExternalConfigListModel, totalCount int, err error) + // View 获取IM外部服务配置详情 + View(ctx context.Context, in *sysin.ImExternalConfigViewInp) (res *sysin.ImExternalConfigViewModel, err error) + // Update 更新IM外部服务配置 + Update(ctx context.Context, in *sysin.ImExternalConfigUpdateInp) (err error) + // Check 检查IM外部服务配置 + Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (err error) + } + ISysImNotification interface { + // TemplateList 获取IM通知模板列表 + TemplateList(ctx context.Context, in *sysin.ImNotificationTemplateListInp) (list []*sysin.ImNotificationTemplateListModel, totalCount int, err error) + // TemplateUpdate 更新IM通知模板 + TemplateUpdate(ctx context.Context, in *sysin.ImNotificationTemplateUpdateInp) (err error) + } + ISysImAppRelease interface { + // List 获取IM客户端版本策略列表 + List(ctx context.Context, in *sysin.ImAppReleaseListInp) (list []*sysin.ImAppReleaseModel, totalCount int, err error) + // Edit 新增/编辑IM客户端版本策略 + Edit(ctx context.Context, in *sysin.ImAppReleaseEditInp) (err error) + } + ISysImAbuseReason interface { + // List 获取IM举报原因列表 + List(ctx context.Context, in *sysin.ImAbuseReasonListInp) (list []*sysin.ImAbuseReasonModel, totalCount int, err error) + // Edit 新增/编辑IM举报原因 + Edit(ctx context.Context, in *sysin.ImAbuseReasonEditInp) (err error) + // Delete 禁用IM举报原因 + Delete(ctx context.Context, in *sysin.ImAbuseReasonDeleteInp) (err error) + } + ISysImMailboxConfig interface { + // Get 获取IM总经理信箱配置 + Get(ctx context.Context, in *sysin.ImMailboxConfigGetInp) (res *sysin.ImMailboxConfigModel, err error) + // Update 更新IM总经理信箱配置 + Update(ctx context.Context, in *sysin.ImMailboxConfigUpdateInp) (err error) + } + ISysImFeedbackConfig interface { + // Get 获取IM意见反馈配置 + Get(ctx context.Context, in *sysin.ImFeedbackConfigGetInp) (res *sysin.ImFeedbackConfigModel, err error) + // Update 更新IM意见反馈配置 + Update(ctx context.Context, in *sysin.ImFeedbackConfigUpdateInp) (err error) + } + ISysImWorkbench interface { + // List 获取IM工作台模块列表 + List(ctx context.Context, in *sysin.ImWorkbenchAppListInp) (list []*sysin.ImWorkbenchAppModel, totalCount int, err error) + // Edit 新增/编辑IM工作台模块 + Edit(ctx context.Context, in *sysin.ImWorkbenchAppEditInp) (err error) + // Delete 禁用IM工作台模块 + Delete(ctx context.Context, in *sysin.ImWorkbenchAppDeleteInp) (err error) + // Reorder 调整IM工作台模块排序 + Reorder(ctx context.Context, in *sysin.ImWorkbenchAppReorderInp) (err error) + } + ISysImTaskDashboard interface { + // List 获取IM任务看板列表 + List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error) + } ISysImAuditLog interface { // List 获取IM审计日志列表 List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error) + // Export 导出IM审计日志列表 + Export(ctx context.Context, in *sysin.ImAuditLogListInp) (err error) // View 获取IM审计日志指定信息 View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) } @@ -548,38 +758,58 @@ type ( ) var ( - localSysAddons ISysAddons - localSysAddonsConfig ISysAddonsConfig - localSysAttachment ISysAttachment - localSysBlacklist ISysBlacklist - localSysConfig ISysConfig - localSysCron ISysCron - localSysCronGroup ISysCronGroup - localSysCurdDemo ISysCurdDemo - localSysImTenant ISysImTenant - localSysImRegistration ISysImRegistration - localSysImInvite ISysImInvite - localSysImUser ISysImUser - localSysImGroup ISysImGroup - localSysImRealname ISysImRealname - localSysImWorkOrder ISysImWorkOrder - localSysImAuditLog ISysImAuditLog - localSysImKafkaAudit ISysImKafkaAudit - localSysImKafkaCommand ISysImKafkaCommand - localSysImDeadLetter ISysImDeadLetter - localSysDictData ISysDictData - localSysDictType ISysDictType - localSysEmsLog ISysEmsLog - localSysGenCodes ISysGenCodes - localSysLog ISysLog - localSysLoginLog ISysLoginLog - localSysNormalTreeDemo ISysNormalTreeDemo - localSysOptionTreeDemo ISysOptionTreeDemo - localSysProvinces ISysProvinces - localSysServeLicense ISysServeLicense - localSysServeLog ISysServeLog - localSysSmsLog ISysSmsLog - localSysTestCategory ISysTestCategory + localSysAddons ISysAddons + localSysAddonsConfig ISysAddonsConfig + localSysAttachment ISysAttachment + localSysBlacklist ISysBlacklist + localSysConfig ISysConfig + localSysCron ISysCron + localSysCronGroup ISysCronGroup + localSysCurdDemo ISysCurdDemo + localSysImTenant ISysImTenant + localSysImRegistration ISysImRegistration + localSysImInvite ISysImInvite + localSysImUser ISysImUser + localSysImGroup ISysImGroup + localSysImRealname ISysImRealname + localSysImWorkOrder ISysImWorkOrder + localSysImOutbox ISysImOutbox + localSysImOrg ISysImOrg + localSysImFile ISysImFile + localSysImFilePolicy ISysImFilePolicy + localSysImUserPolicyGroup ISysImUserPolicyGroup + localSysImSensitiveWord ISysImSensitiveWord + localSysImSecurityPolicy ISysImSecurityPolicy + localSysImDashboard ISysImDashboard + localSysImPrivateMessage ISysImPrivateMessage + localSysImGroupMessage ISysImGroupMessage + localSysImUserLoginHistory ISysImUserLoginHistory + localSysImLoginFailureLog ISysImLoginFailureLog + localSysImExternalConfig ISysImExternalConfig + localSysImNotification ISysImNotification + localSysImAppRelease ISysImAppRelease + localSysImAbuseReason ISysImAbuseReason + localSysImMailboxConfig ISysImMailboxConfig + localSysImFeedbackConfig ISysImFeedbackConfig + localSysImWorkbench ISysImWorkbench + localSysImTaskDashboard ISysImTaskDashboard + localSysImAuditLog ISysImAuditLog + localSysImKafkaAudit ISysImKafkaAudit + localSysImKafkaCommand ISysImKafkaCommand + localSysImDeadLetter ISysImDeadLetter + localSysDictData ISysDictData + localSysDictType ISysDictType + localSysEmsLog ISysEmsLog + localSysGenCodes ISysGenCodes + localSysLog ISysLog + localSysLoginLog ISysLoginLog + localSysNormalTreeDemo ISysNormalTreeDemo + localSysOptionTreeDemo ISysOptionTreeDemo + localSysProvinces ISysProvinces + localSysServeLicense ISysServeLicense + localSysServeLog ISysServeLog + localSysSmsLog ISysSmsLog + localSysTestCategory ISysTestCategory ) func SysAddons() ISysAddons { @@ -747,6 +977,210 @@ func RegisterSysImWorkOrder(i ISysImWorkOrder) { localSysImWorkOrder = i } +func SysImOutbox() ISysImOutbox { + if localSysImOutbox == nil { + panic("implement not found for interface ISysImOutbox, forgot register?") + } + return localSysImOutbox +} + +func RegisterSysImOutbox(i ISysImOutbox) { + localSysImOutbox = i +} + +func SysImOrg() ISysImOrg { + if localSysImOrg == nil { + panic("implement not found for interface ISysImOrg, forgot register?") + } + return localSysImOrg +} + +func RegisterSysImOrg(i ISysImOrg) { + localSysImOrg = i +} + +func SysImFile() ISysImFile { + if localSysImFile == nil { + panic("implement not found for interface ISysImFile, forgot register?") + } + return localSysImFile +} + +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 SysImUserPolicyGroup() ISysImUserPolicyGroup { + if localSysImUserPolicyGroup == nil { + panic("implement not found for interface ISysImUserPolicyGroup, forgot register?") + } + return localSysImUserPolicyGroup +} + +func RegisterSysImUserPolicyGroup(i ISysImUserPolicyGroup) { localSysImUserPolicyGroup = i } + +func SysImSensitiveWord() ISysImSensitiveWord { + if localSysImSensitiveWord == nil { + panic("implement not found for interface ISysImSensitiveWord, forgot register?") + } + return localSysImSensitiveWord +} + +func RegisterSysImSensitiveWord(i ISysImSensitiveWord) { localSysImSensitiveWord = i } + +func SysImSecurityPolicy() ISysImSecurityPolicy { + if localSysImSecurityPolicy == nil { + panic("implement not found for interface ISysImSecurityPolicy, forgot register?") + } + return localSysImSecurityPolicy +} + +func RegisterSysImSecurityPolicy(i ISysImSecurityPolicy) { localSysImSecurityPolicy = i } + +func SysImDashboard() ISysImDashboard { + if localSysImDashboard == nil { + panic("implement not found for interface ISysImDashboard, forgot register?") + } + return localSysImDashboard +} + +func RegisterSysImDashboard(i ISysImDashboard) { localSysImDashboard = i } + +func SysImPrivateMessage() ISysImPrivateMessage { + if localSysImPrivateMessage == nil { + panic("implement not found for interface ISysImPrivateMessage, forgot register?") + } + return localSysImPrivateMessage +} + +func RegisterSysImPrivateMessage(i ISysImPrivateMessage) { localSysImPrivateMessage = i } + +func SysImGroupMessage() ISysImGroupMessage { + if localSysImGroupMessage == nil { + panic("implement not found for interface ISysImGroupMessage, forgot register?") + } + return localSysImGroupMessage +} + +func RegisterSysImGroupMessage(i ISysImGroupMessage) { localSysImGroupMessage = i } + +func SysImUserLoginHistory() ISysImUserLoginHistory { + if localSysImUserLoginHistory == nil { + panic("implement not found for interface ISysImUserLoginHistory, forgot register?") + } + return localSysImUserLoginHistory +} + +func RegisterSysImUserLoginHistory(i ISysImUserLoginHistory) { localSysImUserLoginHistory = i } + +func SysImLoginFailureLog() ISysImLoginFailureLog { + if localSysImLoginFailureLog == nil { + panic("implement not found for interface ISysImLoginFailureLog, forgot register?") + } + return localSysImLoginFailureLog +} + +func RegisterSysImLoginFailureLog(i ISysImLoginFailureLog) { localSysImLoginFailureLog = i } + +func SysImExternalConfig() ISysImExternalConfig { + if localSysImExternalConfig == nil { + panic("implement not found for interface ISysImExternalConfig, forgot register?") + } + return localSysImExternalConfig +} + +func RegisterSysImExternalConfig(i ISysImExternalConfig) { + localSysImExternalConfig = i +} + +func SysImNotification() ISysImNotification { + if localSysImNotification == nil { + panic("implement not found for interface ISysImNotification, forgot register?") + } + return localSysImNotification +} + +func RegisterSysImNotification(i ISysImNotification) { + localSysImNotification = i +} + +func SysImAppRelease() ISysImAppRelease { + if localSysImAppRelease == nil { + panic("implement not found for interface ISysImAppRelease, forgot register?") + } + return localSysImAppRelease +} + +func RegisterSysImAppRelease(i ISysImAppRelease) { + localSysImAppRelease = i +} + +func SysImAbuseReason() ISysImAbuseReason { + if localSysImAbuseReason == nil { + panic("implement not found for interface ISysImAbuseReason, forgot register?") + } + return localSysImAbuseReason +} + +func RegisterSysImAbuseReason(i ISysImAbuseReason) { + localSysImAbuseReason = i +} + +func SysImMailboxConfig() ISysImMailboxConfig { + if localSysImMailboxConfig == nil { + panic("implement not found for interface ISysImMailboxConfig, forgot register?") + } + return localSysImMailboxConfig +} + +func RegisterSysImMailboxConfig(i ISysImMailboxConfig) { + localSysImMailboxConfig = i +} + +func SysImFeedbackConfig() ISysImFeedbackConfig { + if localSysImFeedbackConfig == nil { + panic("implement not found for interface ISysImFeedbackConfig, forgot register?") + } + return localSysImFeedbackConfig +} + +func RegisterSysImFeedbackConfig(i ISysImFeedbackConfig) { + localSysImFeedbackConfig = i +} + +func SysImWorkbench() ISysImWorkbench { + if localSysImWorkbench == nil { + panic("implement not found for interface ISysImWorkbench, forgot register?") + } + return localSysImWorkbench +} + +func RegisterSysImWorkbench(i ISysImWorkbench) { + localSysImWorkbench = i +} + +func SysImTaskDashboard() ISysImTaskDashboard { + if localSysImTaskDashboard == nil { + panic("implement not found for interface ISysImTaskDashboard, forgot register?") + } + return localSysImTaskDashboard +} + +func RegisterSysImTaskDashboard(i ISysImTaskDashboard) { + localSysImTaskDashboard = i +} + func SysImAuditLog() ISysImAuditLog { if localSysImAuditLog == nil { panic("implement not found for interface ISysImAuditLog, forgot register?") diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index 076b3f4..154f5ba 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -212,16 +212,13 @@ queue: retry: 0 # 重试次数 logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal kafka: - address: ["127.0.0.1:9092"] # kafka地址+端口,支持多个 - version: "2.0.0.0" # kafka专属配置,默认2.0.0.0 + address: ["127.0.0.1:9092"] # kafka地址+端口 + version: "2.4.0" # kafka专属配置,默认2.4.0 + username: "" # SASL用户名,未开启认证时留空 + password: "" # SASL密码,未开启认证时留空 + mechanism: "SCRAM-SHA-512" # SASL机制,可选:SCRAM-SHA-512|SCRAM-SHA-256 randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true multiConsumer: true # 是否支持创建多个消费者 - clientId: "hotgo-admin" # Kafka客户端ID - userName: "${KAFKA_USERNAME}" # SASL用户名,未启用SASL可留空 - password: "${KAFKA_PASSWORD}" # SASL密码,未启用SASL可留空 - saslEnabled: false # 是否开启SASL - saslMechanism: "SCRAM-SHA-512" # SASL机制,可选:PLAIN|SCRAM-SHA-256|SCRAM-SHA-512 - tlsEnabled: false # 是否开启TLS # Redis. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114217 @@ -318,7 +315,7 @@ imAdminApi: timeout: "5s" imInternalApi: - baseUrl: "http://127.0.0.1:6060/v1" + baseUrl: "http://127.0.0.1:6060" serviceName: "hotgo-admin" serviceSecret: "${IM_INTERNAL_API_SECRET}" diff --git a/server/manifest/config/config.template b/server/manifest/config/config.template new file mode 100644 index 0000000..7d6a9f6 --- /dev/null +++ b/server/manifest/config/config.template @@ -0,0 +1,322 @@ +# hotgo系统配置 +system: + # 应用名称 + appName: "hotgo" + # debug开关,开启后接口出现错误时会向前端输出堆栈信息,可选:false|true,默认为true + debug: true + # gf运行模式,可选:not-set|develop|testing|staging|product。product模式下后台【开发工具】菜单自动隐藏 + mode: "develop" + # IP归属地解析方法,可选:cz88|whois,默认为whois + ipMethod: "whois" + # 是否为演示系统,可选:false|true + isDemo: false + # 是否为集群部署,可选:false|true 默认为false。开启集群必须配置redis,通过redis发布订阅进行集群之间的数据同步 + isCluster: false + # 全局请求日志 + log: + switch: true # 日志开关,可选:false|true,默认为true + queue: true # 是否启用队列,启用时需要配置队列信息,可选:false|true,默认为true + module: [ "admin", "api", "default" ] # 需要记录的模块 + skipCode: [ ] # 不记录的状态码,如: ["0", "-1"] + # 系统日志,用于在后台统计异常日志 + serveLog: + switch: true # 日志开关,可选:false|true,默认为true + queue: true # 是否启用队列,启用时需要配置队列信息,可选:false|true,默认为true + levelFormat: [ "WARN", "ERRO", "FATA", "PANI" ] # 需要记录的等级 + # 国际化 + i18n: + switch: true # 国际化开关,可选:false|true,默认为true + defaultLanguage: "zh-CN" # 默认使用语言,可选:zh-CN|zh-TW|en,默认为zh-CN + # 插件资源目录,用于静态文件和模版文件的路径,默认为resource + addonsResourcePath: "resource" + + +# 统一默认日志配置 +defaultLogger: &defaultLogger + level: "all" + flags: 42 + file: "{Y-m-d}.log" # 日志文件格式。默认为"{Y-m-d}.log" + stdoutColorDisabled: false # 关闭终端的颜色打印。可选:false|true,默认false + writerColorEnable: false # 日志文件是否带上颜色。可选:false|true,默认false,表示不带颜色 + rotateExpire: "7d" # 日志保留天数 + rotateBackupLimit: 2 # 最大备份数量 + rotateBackupCompress: 2 # 日志文件压缩级别,0-9,9最高 + + +# gf配置,配置参考:https://goframe.org/pages/viewpage.action?pageId=44449486 +server: + address: ":8000" # 本地监听地址。默认":8000",多个地址以","号分隔。例如:"192.168.2.3:8000,10.0.3.10:8001" + openapiPath: "/api.json" # OpenAPI接口文档地址 + swaggerPath: "/swagger" # 内置SwaggerUI展示地址 + serverRoot: "resource/public" # 静态文件服务的目录根路径,配置时自动开启静态文件服务。 + DumpRouterMap: false # 是否在Server启动时打印所有的路由列表。 + logPath: "logs/server" # 服务日志保存路径 + ErrorStack: true # 当Server捕获到异常时是否记录堆栈信息到日志中。默认为true + ErrorLogEnabled: true # 是否记录异常日志信息到日志中。默认为true + errorLogPattern: "error/{Y-m-d}.log" # 异常错误日志文件格式。默认为"error-{Ymd}.log" + accessLogEnabled: true # 是否记录访问日志。默认为false + accessLogPattern: "access/{Y-m-d}.log" # 访问日志文件格式。默认为"access-{Ymd}.log" + maxHeaderBytes: "100KB" # 请求头大小限制,请求头包括客户端提交的Cookie数据,默认设置为100KB + clientMaxBodySize: "200MB" # 客户端提交的Body大小限制,同时也影响文件上传大小,默认设置为200MB + serverAgent: "HG HTTP Server" # 服务端Agent信息。默认为"HG HTTP Server" + # PProf配置 + pprofEnabled: true # 是否开启PProf性能调试特性。默认为false + pprofPattern: "/pprof" # 开启PProf时有效,表示PProf特性的页面访问路径,对当前Server绑定的所有域名有效。 + # 服务日志配置 + logger: + path: "logs/server" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + + +# tcp服务 +tcp: + # 服务器 + server: + address: ":8099" + # 客户端 + client: + # 定时任务 + cron: + group: "cron" # 分组名称 + name: "cron1" # 客户端名称 + address: "127.0.0.1:8099" # 服务器地址 + appId: "1002" # 应用名称 + secretKey: "hotgo" # 密钥 + # 系统授权 + auth: + group: "auth" # 分组名称 + name: "auth1" # 客户端名称 + address: "127.0.0.1:8099" # 服务器地址 + appId: "mengshuai" # 应用名称 + secretKey: "123456" # 密钥 + + +# 日志配置 +logger: + # 全局日志:g.Log() + path: "logs/logger" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + # 定时任务:g.Log("cron") + cron: + path: "logs/cron" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + # 消息队列:g.Log("cron") + queue: + path: "logs/queue" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + # tcp服务器:g.Log("tcpServer") + tcpServer: + path: "logs/tcpServer" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + # tcp客户端:g.Log("tcpClient") + tcpClient: + path: "logs/tcpClient" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + + +# Viewer. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114335 +viewer: + paths: "resource/template" + defaultFile: "index.html" + delimiters: [ "@{", "}" ] + homeLayout: "home/index.html" + + +# 内容设置 +setting: + title: "HotGo" + keywords: "中后台解决方案,gf框架,vue3" + description: "hotgo 是一个基于 goframe2,vue3,vite2,TypeScript,uniapp 的中后台解决方案,它可以帮助你快速搭建企业级中后台项目,相信不管是从新技术使用还是其他方面,都能帮助到你,持续更新中。" + + +# 路由配置 +router: + # 后台 + admin: + # 前缀 + prefix: "/admin" + # 不需要验证登录的路由地址 + exceptLogin: [ + "/sms/send", # 短信验证码 + "/wechat/authorizeCall", # 微信用户授权回调 + ] + # 不需要验证权限的路由地址 + exceptAuth: [ + "/member/info", # 登录用户信息 + "/role/dynamic", # 获取动态路由 + "/notice/pullMessages", # 拉取我的消息 + "/notice/readAll", # 设置全部已读公告 + "/notice/upRead", # 已读指定公告 + "/dictData/option", # 单个数据字典 + "/dictData/options", # 多个数据字典 + "/provinces/select", # 动态省市区选项 + "/provinces/cityLabel", # 动态解析省市区名称 + "/member/option", # 用户选项 + ] + # 接口 + api: + # 前缀 + prefix: "/api" + # 不需要验证登录的路由地址 + exceptLogin: [ ] + # websocket + websocket: + # 前缀 + prefix: "/socket" + # 不需要验证登录的路由地址 + exceptLogin: [ ] + # 前台页面 + home: + # 前缀 + prefix: "/home" + # 不需要验证登录的路由地址 + exceptLogin: [ ] + + +# 缓存驱动 +cache: + adapter: "redis" # 缓存驱动方式,支持:memory|redis|file,不填默认memory + fileDir: "./storage/cache" # 文件缓存路径,adapter=file时必填 + + +# 登录令牌 +token: + secretKey: "$TOKEN_SECRET_KEY" # 令牌加密秘钥,考虑安全问题生产环境中请修改默认值 + expires: 604800 # 令牌有效期,单位:秒。默认7天 + autoRefresh: true # 是否开启自动刷新过期时间, false|true 默认为true + refreshInterval: 86400 # 刷新间隔,单位:秒。必须小于expires,否则无法触发。默认1天内只允许刷新一次 + maxRefreshTimes: 30 # 最大允许刷新次数,-1不限制。默认30次 + multiLogin: true # 是否允许多端登录, false|true 默认为true + + +# 消息队列 +queue: + switch: true # 队列开关,可选:true|false,默认为true + driver: "$QUEUE_DRIVER" # 队列驱动,可选:disk|redis|rocketmq|kafka,默认为disk + groupName: "hotgo" # mq群组名称 + # 磁盘队列 + disk: + path: "./storage/diskqueue" # 数据存放路径 + batchSize: 100 # 每100条消息同步一次,batchSize和batchTime满足其一就会同步一次 + batchTime: 1 # 每1秒消息同步一次 + segmentSize: 10485760 # 每个topic分片数据文件最大字节,默认10M + segmentLimit: 3000 # 每个topic最大分片数据文件数量,超出部分将会丢弃 + # redis,默认使用全局redis运行队列 + redis: + timeout: 0 # 队列超时时间以秒为单位,0表示永不超时。如果队列在设定的超时时间内没有被消费,则会被销毁 + rocketmq: + nameSrvAdders: ["127.0.0.1:9876"] # nameSrvAdder+端口,支持多个 + accessKey: "" # 选填。如果开启了acl控制就必填 + secretKey: "" # 选填。如果开启了acl控制就必填 + brokerAddr: "127.0.0.1:10911" # brokerAddr+端口,选填。用于消费者订阅主题前会检查主题是否存在,不存在会自动创建。你也可以在rocketmq控制台手动创建主题 + retry: 0 # 重试次数 + logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal + kafka: + address: ["$KAFKA_ADDRESS"] # kafka地址+端口 + version: "$KAFKA_VERSION" # kafka专属配置,默认2.4.0.0 + username: "$KAFKA_USERNAME" # SASL用户名,未开启认证时留空 + password: "$KAFKA_PASSWORD" # SASL密码,未开启认证时留空 + mechanism: "$KAFKA_SASL_MECHANISM" # SASL机制,可选:SCRAM-SHA-512|SCRAM-SHA-256 + randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true + multiConsumer: true # 是否支持创建多个消费者 + + +# Redis. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114217 +redis: + default: + address: "$REDIS_ADDR" + db: "$REDIS_DB" + pass: "$REDIS_PASSWORD" + idleTimeout: "20" + + +# Database. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114245 +database: + logger: + path: "logs/database" # 日志文件路径。默认为空,表示关闭,仅输出到终端 + <<: *defaultLogger + stdout: true + default: + link: "$MYSQL_DSN" + debug: true + Prefix: "hg_" + + +# 链路追踪 +jaeger: + switch: false # 链路追踪开关,可选:true|false,默认为false + endpoint: "127.0.0.1:6831" # jaeger-agent上报地址 + + +# 生成代码 +hggen: + allowedIPs: [ "127.0.0.1", "*" ] # 白名单,*代表所有,只有允许的IP后台才能使用生成代码功能 + selectDbs: [ "default" ] # 可选生成表的数据库配置名称,支持多库 + disableTables: [ "hg_sys_gen_codes","hg_admin_role_casbin" ] # 禁用的表,禁用以后将不会在选择表中看到 + delimiters: [ "@{", "}" ] # 模板引擎变量分隔符号 + # 生成应用模型,所有生成模板允许自定义,可以参考default模板进行改造 + application: + # CRUD和关系树列表模板 + crud: + templates: + # 默认的主包模板 + - group: "default" # 分组名称 + isAddon: false # 是否为插件模板 false|true + masterPackage: "sys" # 主包名称,需和controllerPath、logicPath、inputPath保持关联 + templatePath: "./resource/generate/default/curd" # 模板路径 + apiPath: "./api/admin" # goApi生成路径 + controllerPath: "./internal/controller/admin/sys" # 控制器生成路径 + logicPath: "./internal/logic/sys" # 主要业务生成路径 + inputPath: "./internal/model/input/sysin" # 表单过滤器生成路径 + routerPath: "./internal/router/genrouter" # 生成路由表路径 + sqlPath: "./storage/data/generate" # 生成sql语句路径 + webApiPath: "../web/src/api" # webApi生成路径 + webViewsPath: "../web/src/views" # web页面生成路径 + + # 默认的插件包模板,{$name}会自动替换成实际的插件名称 + - group: "addon" # 分组名称 + isAddon: true # 是否为插件模板 false|true + masterPackage: "sys" # 主包名称,需和controllerPath、logicPath、inputPath保持关联 + templatePath: "./resource/generate/default/curd" # 模板路径 + apiPath: "./addons/{$name}/api/admin" # goApi生成路径 + controllerPath: "./addons/{$name}/controller/admin/sys" # 控制器生成路径 + logicPath: "./addons/{$name}/logic/sys" # 主要业务生成路径 + inputPath: "./addons/{$name}/model/input/sysin" # 表单过滤器生成路径 + routerPath: "./addons/{$name}/router/genrouter" # 生成路由表路径 + sqlPath: "./storage/data/generate/addons" # 生成sql语句路径 + webApiPath: "../web/src/api/addons/{$name}" # webApi生成路径 + webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径 + + # 消息队列模板 + queue: + templates: + - group: "default" + templatePath: "./resource/generate/default/queue" + + # 定时任务模板 + cron: + templates: + - group: "default" + templatePath: "./resource/generate/default/cron" + + # 生成插件模块,通过后台创建新插件时使用的模板,允许自定义,可以参考default模板进行改造 + addon: + srcPath: "./resource/generate/default/addon" # 生成模板路径 + webApiPath: "../web/src/api/addons/{$name}" # webApi生成路径 + webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径 + +imAdminApi: + baseUrl: "$IM_ADMIN_API_BASE_URL" + serviceId: "$IM_ADMIN_API_SERVICE_ID" + serviceSecret: "$IM_ADMIN_API_SECRET" + timeout: "5s" + +imInternalApi: + baseUrl: "$IM_INTERNAL_API_BASE_URL" + serviceName: "$IM_INTERNAL_API_SERVICE_NAME" + serviceSecret: "$IM_INTERNAL_API_SECRET" + +imConfigApi: + serviceName: "$IM_CONFIG_API_SERVICE_NAME" + serviceSecret: "$IM_CONFIG_API_SECRET" + timeWindow: "300s" diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index f36b107..4ce87d8 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -5,6 +5,12 @@ FROM loads/alpine:3.8 ############################################################################### ENV WORKDIR /app +ENV QUEUE_DRIVER kafka +ENV KAFKA_ADDRESS 64.118.137.182:9094 +ENV KAFKA_VERSION 2.4.0 +ENV KAFKA_USERNAME kafka-external +ENV KAFKA_PASSWORD gr58tNnlZOVGW3AmMbL2 +ENV KAFKA_SASL_MECHANISM SCRAM-SHA-512 ADD hack $WORKDIR/hack/ ADD manifest/config $WORKDIR/manifest/config/ ADD resource $WORKDIR/resource/ @@ -20,4 +26,3 @@ RUN chmod +x $WORKDIR/entrypoint.sh WORKDIR $WORKDIR CMD ./entrypoint.sh - diff --git a/server/manifest/docker/entrypoint.sh b/server/manifest/docker/entrypoint.sh index e8f6e9b..72a473a 100644 --- a/server/manifest/docker/entrypoint.sh +++ b/server/manifest/docker/entrypoint.sh @@ -1,5 +1,16 @@ -#!/bin/bash +#!/bin/sh +set -e -cd /app && ./hotgo & -echo "hotgo start all server.." -tail -f /dev/null \ No newline at end of file +cd /app + +# Render the real config from the template using only the specific env vars we +# expect — avoids accidentally substituting anything else that might contain a +# literal '$' in the YAML. +envsubst '$MYSQL_DSN $REDIS_ADDR $REDIS_DB $REDIS_PASSWORD $TOKEN_SECRET_KEY $QUEUE_DRIVER $KAFKA_ADDRESS $KAFKA_VERSION $KAFKA_USERNAME $KAFKA_PASSWORD $KAFKA_SASL_MECHANISM $IM_ADMIN_API_BASE_URL $IM_ADMIN_API_SERVICE_ID $IM_ADMIN_API_SECRET $IM_INTERNAL_API_BASE_URL $IM_INTERNAL_API_SERVICE_NAME $IM_INTERNAL_API_SECRET $IM_CONFIG_API_SERVICE_NAME $IM_CONFIG_API_SECRET' \ + < manifest/config/config.template > manifest/config/config.yaml + +echo "queue.driver=${QUEUE_DRIVER:-}" +echo "queue.kafka.address=${KAFKA_ADDRESS:-}" +echo "imAdminApi.baseUrl=${IM_ADMIN_API_BASE_URL:-}" + +exec ./hotgo diff --git a/server/storage/data/generate/im_abuse_reason_menu.sql b/server/storage/data/generate/im_abuse_reason_menu.sql new file mode 100644 index 0000000..baa637c --- /dev/null +++ b/server/storage/data/generate/im_abuse_reason_menu.sql @@ -0,0 +1,16 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +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, '举报原因', 'imAbuseReasonIndex', 'abuse-reason/index', '', '2', '', '/imAbuseReason/list', '', '/imAbuseReason/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '82', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAbuseReasonIndex'); + +SET @imAbuseReasonId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imAbuseReasonIndex' LIMIT 1); + +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, @imAbuseReasonId, '原因编辑', 'imAbuseReasonEdit', '', '', '3', '', '/imAbuseReason/edit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imAbuseReasonId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAbuseReasonEdit'); + +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, @imAbuseReasonId, '原因禁用', 'imAbuseReasonDelete', '', '', '3', '', '/imAbuseReason/delete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imAbuseReasonId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAbuseReasonDelete'); diff --git a/server/storage/data/generate/im_adfahi_batch2_menu.sql b/server/storage/data/generate/im_adfahi_batch2_menu.sql new file mode 100644 index 0000000..c7fd58b --- /dev/null +++ b/server/storage/data/generate/im_adfahi_batch2_menu.sql @@ -0,0 +1,43 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +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,p.title,p.name,p.path,'','2','',p.permissions,'',p.component,'1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),p.sort,'','1',@now,@now +FROM ( + SELECT '数据概览' title,'imDashboardIndex' name,'dashboard/index' path,'/imDashboard/overview' permissions,'/imDashboard/index' component,5 sort UNION ALL + SELECT '单聊消息','imPrivateMessageIndex','private-message/index','/imPrivateMessage/list','/imPrivateMessage/index',40 UNION ALL + SELECT '群聊消息','imGroupMessageIndex','group-message/index','/imGroupMessage/list','/imGroupMessage/index',45 UNION ALL + SELECT '用户登录历史','imUserLoginHistoryIndex','user-login-history/index','/imUserLoginHistory/list','/imUserLoginHistory/index',50 UNION ALL + SELECT '登录失败日志','imLoginFailureLogIndex','login-failure-log/index','/imLoginFailureLog/list','/imLoginFailureLog/index',55 +) p WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=p.name); + +UPDATE `hg_admin_menu` +SET `pid`=@imRootId, `icon`='DashboardOutlined', `type`=2, `level`=2, + `tree`=CONCAT('tr_',@imRootId,' '), `sort`=5, `updated_at`=@now +WHERE `name`='imDashboardIndex'; + +SET @dashboardId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imDashboardIndex' LIMIT 1); +SET @privateMessageId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imPrivateMessageIndex' LIMIT 1); +SET @groupMessageId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imGroupMessageIndex' LIMIT 1); +SET @userLoginId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imUserLoginHistoryIndex' LIMIT 1); +SET @loginFailureId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imLoginFailureLogIndex' LIMIT 1); + +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,x.pid,x.title,x.name,'','','3','',x.permissions,'','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',x.pid,' '),x.sort,'','1',@now,@now +FROM ( + SELECT @dashboardId pid,'查看概览' title,'imDashboardOverview' name,'/imDashboard/overview' permissions,10 sort UNION ALL + SELECT @dashboardId,'查看趋势','imDashboardTrends','/imDashboard/trends',20 UNION ALL + SELECT @privateMessageId,'消息详情','imPrivateMessageView','/imPrivateMessage/view',10 UNION ALL + SELECT @privateMessageId,'隐藏消息','imPrivateMessageHide','/imPrivateMessage/hide',20 UNION ALL + SELECT @privateMessageId,'删除消息','imPrivateMessageDelete','/imPrivateMessage/delete',30 UNION ALL + SELECT @privateMessageId,'导出消息','imPrivateMessageExport','/imPrivateMessage/export',40 UNION ALL + SELECT @groupMessageId,'消息详情','imGroupMessageView','/imGroupMessage/view',10 UNION ALL + SELECT @groupMessageId,'隐藏消息','imGroupMessageHide','/imGroupMessage/hide',20 UNION ALL + SELECT @groupMessageId,'删除消息','imGroupMessageDelete','/imGroupMessage/delete',30 UNION ALL + SELECT @groupMessageId,'导出消息','imGroupMessageExport','/imGroupMessage/export',40 UNION ALL + SELECT @userLoginId,'导出登录历史','imUserLoginHistoryExport','/imUserLoginHistory/export',10 UNION ALL + SELECT @loginFailureId,'失败详情','imLoginFailureLogView','/imLoginFailureLog/view',10 UNION ALL + SELECT @loginFailureId,'导出失败日志','imLoginFailureLogExport','/imLoginFailureLog/export',20 UNION ALL + SELECT @loginFailureId,'加入IP黑名单','imLoginFailureLogAddBlacklist','/imLoginFailureLog/addBlacklist',30 UNION ALL + SELECT @loginFailureId,'封禁关联用户','imLoginFailureLogBanUser','/imLoginFailureLog/banUser',40 +) x WHERE x.pid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=x.name); diff --git a/server/storage/data/generate/im_adfahi_policy_menu.sql b/server/storage/data/generate/im_adfahi_policy_menu.sql new file mode 100644 index 0000000..5c6dbde --- /dev/null +++ b/server/storage/data/generate/im_adfahi_policy_menu.sql @@ -0,0 +1,52 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +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,'用户分组','imUserPolicyGroupIndex','user-policy-group/index','','2','','/imUserPolicyGroup/list','','/imUserPolicyGroup/index','1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),'25','','1',@now,@now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='imUserPolicyGroupIndex'); +SET @policyGroupId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imUserPolicyGroupIndex' LIMIT 1); + +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,@policyGroupId,p.title,p.name,'','','3','',p.permissions,'','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@policyGroupId,' '),p.sort,'','1',@now,@now +FROM ( + SELECT '分组详情' title,'imUserPolicyGroupView' name,'/imUserPolicyGroup/view' permissions,10 sort UNION ALL + SELECT '分组编辑','imUserPolicyGroupEdit','/imUserPolicyGroup/edit',20 UNION ALL + SELECT '分组删除','imUserPolicyGroupDelete','/imUserPolicyGroup/delete',30 UNION ALL + SELECT '状态更新','imUserPolicyGroupStatus','/imUserPolicyGroup/status',40 UNION ALL + SELECT '成员列表','imUserPolicyGroupMemberList','/imUserPolicyGroup/memberList',50 UNION ALL + SELECT '添加成员','imUserPolicyGroupMemberAdd','/imUserPolicyGroup/memberAdd',60 UNION ALL + SELECT '移除成员','imUserPolicyGroupMemberRemove','/imUserPolicyGroup/memberRemove',70 +) 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,'敏感词库','imSensitiveWordIndex','sensitive-word/index','','2','','/imSensitiveWord/list','','/imSensitiveWord/index','1','imManager','0','0','','0','0','0','2',CONCAT('tr_',@imRootId,' '),'90','','1',@now,@now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='imSensitiveWordIndex'); +SET @sensitiveWordId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='imSensitiveWordIndex' LIMIT 1); + +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,@sensitiveWordId,p.title,p.name,'','','3','',p.permissions,'','','1','','0','0','','0','0','0','3',CONCAT('tr_',@imRootId,' tr_',@sensitiveWordId,' '),p.sort,'','1',@now,@now +FROM ( + SELECT '敏感词编辑' title,'imSensitiveWordEdit' name,'/imSensitiveWord/edit' permissions,10 sort UNION ALL + SELECT '敏感词删除','imSensitiveWordDelete','/imSensitiveWord/delete',20 UNION ALL + SELECT '状态更新','imSensitiveWordStatus','/imSensitiveWord/status',30 UNION ALL + SELECT '批量导入','imSensitiveWordBatchImport','/imSensitiveWord/batchImport',40 UNION ALL + SELECT '分组列表','imSensitiveWordGroupList','/imSensitiveWord/groupList',50 UNION ALL + SELECT '分组编辑','imSensitiveWordGroupEdit','/imSensitiveWord/groupEdit',60 +) 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 +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); + +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 +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 '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); diff --git a/server/storage/data/generate/im_app_release_menu.sql b/server/storage/data/generate/im_app_release_menu.sql new file mode 100644 index 0000000..361d4e5 --- /dev/null +++ b/server/storage/data/generate/im_app_release_menu.sql @@ -0,0 +1,12 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +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, '版本更新', 'imAppReleaseIndex', 'app-release/index', '', '2', '', '/imAppRelease/list', '', '/imAppRelease/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '81', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAppReleaseIndex'); + +SET @imAppReleaseId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imAppReleaseIndex' LIMIT 1); + +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, @imAppReleaseId, '版本编辑', 'imAppReleaseEdit', '', '', '3', '', '/imAppRelease/edit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imAppReleaseId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAppReleaseEdit'); diff --git a/server/storage/data/generate/im_app_releases_global.sql b/server/storage/data/generate/im_app_releases_global.sql new file mode 100644 index 0000000..bab3f58 --- /dev/null +++ b/server/storage/data/generate/im_app_releases_global.sql @@ -0,0 +1,26 @@ +-- Global MeetingIM client release policy. This table belongs to hotgo and is not tenant-scoped. +CREATE TABLE IF NOT EXISTS `im_app_releases` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `platform` VARCHAR(32) NOT NULL, + `channel` VARCHAR(32) NOT NULL DEFAULT 'stable', + `latest_version` VARCHAR(64) NOT NULL, + `min_supported_version` VARCHAR(64) NOT NULL, + `update_type` VARCHAR(16) NOT NULL DEFAULT 'none', + `release_notes` TEXT NOT NULL, + `store_url` VARCHAR(512) NOT NULL DEFAULT '', + `package_url` VARCHAR(512) NOT NULL DEFAULT '', + `checksum` VARCHAR(128) NOT NULL DEFAULT '', + `remind_after_seconds` INT NOT NULL DEFAULT 0, + `published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_app_releases_platform_channel` (`platform`, `channel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MeetingIM全局客户端版本策略'; + +INSERT INTO `im_app_releases` + (`platform`, `channel`, `latest_version`, `min_supported_version`, `update_type`, `release_notes`) +VALUES + ('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。'), + ('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。') +ON DUPLICATE KEY UPDATE `platform` = VALUES(`platform`); diff --git a/server/storage/data/generate/im_audit_log_menu.sql b/server/storage/data/generate/im_audit_log_menu.sql index fcc6e8b..39db76f 100644 --- a/server/storage/data/generate/im_audit_log_menu.sql +++ b/server/storage/data/generate/im_audit_log_menu.sql @@ -29,3 +29,7 @@ SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex 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, @auditListId, '审计详情', 'imAuditLogView', '', '', '3', '', '/imAuditLog/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' tr_', @auditListId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAuditLogView'); + +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, @auditListId, '导出审计日志', 'imAuditLogExport', '', '', '3', '', '/imAuditLog/export', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' tr_', @auditListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAuditLogExport'); diff --git a/server/storage/data/generate/im_external_config_menu.sql b/server/storage/data/generate/im_external_config_menu.sql new file mode 100644 index 0000000..1840792 --- /dev/null +++ b/server/storage/data/generate/im_external_config_menu.sql @@ -0,0 +1,50 @@ +SET @now = NOW(); + +CREATE TABLE IF NOT EXISTS `im_external_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL, + `service` varchar(32) NOT NULL, + `provider` varchar(64) NOT NULL DEFAULT '', + `endpoint` varchar(255) NOT NULL DEFAULT '', + `enabled` tinyint NOT NULL DEFAULT 0, + `status` varchar(32) NOT NULL DEFAULT 'unconfigured', + `config_json` json DEFAULT NULL, + `secret_json` json DEFAULT NULL, + `checked_at` datetime DEFAULT NULL, + `check_result` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_external_config_tenant_service` (`tenant_id`,`service`), + KEY `idx_im_external_config_service_status` (`service`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @configId := `id` FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigIndex' LIMIT 1; + +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, '外部服务配置', 'imExternalConfigIndex', 'external-config/index', '', '2', '', '/imExternalConfig/list', '', '/imExternalConfig/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '80', '', '1', @now, @now +WHERE @configId IS NULL; + +SELECT @configId := `id` FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigIndex' LIMIT 1; + +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, @configId, '配置详情', 'imExternalConfigView', '', '', '3', '', '/imExternalConfig/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigView'); + +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, @configId, '更新配置', 'imExternalConfigUpdate', '', '', '3', '', '/imExternalConfig/update', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigUpdate'); + +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, @configId, '检查配置', 'imExternalConfigCheck', '', '', '3', '', '/imExternalConfig/check', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigCheck'); diff --git a/server/storage/data/generate/im_feedback_config_menu.sql b/server/storage/data/generate/im_feedback_config_menu.sql new file mode 100644 index 0000000..bbfef77 --- /dev/null +++ b/server/storage/data/generate/im_feedback_config_menu.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; +SET @now := NOW(); + +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 0, '工作台管理', 'workbenchManager', '/workbench', 'AppstoreOutlined', 1, '/workbench/config/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 126, '附加功能独立管理入口', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'workbenchManager'); + +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'workbenchManager' LIMIT 1); + +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 @workbenchRootId, '意见反馈配置', 'imFeedbackConfigIndex', 'feedback-config/index', '', 2, '', '/imFeedbackConfig/get', '', '/imFeedbackConfig/index', 1, 'workbenchManager', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @workbenchRootId, ' '), 40, '', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigIndex'); + +SET @imFeedbackConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid`=@workbenchRootId, `path`='feedback-config/index', `active_menu`='workbenchManager', + `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=40, + `status`=1, `hidden`=0, `updated_at`=@now +WHERE `id`=@imFeedbackConfigId; + +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 @imFeedbackConfigId, '反馈配置更新', 'imFeedbackConfigUpdate', '', '', 3, '', '/imFeedbackConfig/update', '', '', 1, '', 0, 0, '', 0, 1, 0, 3, CONCAT('tr_', @workbenchRootId, ' tr_', @imFeedbackConfigId, ' '), 10, '', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigUpdate'); + +UPDATE `hg_admin_menu` +SET `level`=3, `tree`=CONCAT('tr_', @workbenchRootId, ' tr_', @imFeedbackConfigId, ' '), `updated_at`=@now +WHERE `pid`=@imFeedbackConfigId AND `type`=3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT `role_id`, @workbenchRootId FROM `hg_admin_role_menu` WHERE `menu_id`=@imFeedbackConfigId; diff --git a/server/storage/data/generate/im_file_menu.sql b/server/storage/data/generate/im_file_menu.sql new file mode 100644 index 0000000..4296a3b --- /dev/null +++ b/server/storage/data/generate/im_file_menu.sql @@ -0,0 +1,33 @@ +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @fileId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFileIndex' LIMIT 1; + +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, '文件治理', 'imFileIndex', 'file/index', '', '2', '', '/imFile/list', '', '/imFile/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '70', '', '1', @now, @now +WHERE @fileId IS NULL; + +SELECT @fileId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFileIndex' LIMIT 1; + +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, @fileId, '文件详情', 'imFileView', '', '', '3', '', '/imFile/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileView'); + +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, @fileId, '隔离文件', 'imFileQuarantine', '', '', '3', '', '/imFile/quarantine', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileQuarantine'); + +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, @fileId, '删除文件', 'imFileDelete', '', '', '3', '', '/imFile/delete', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileDelete'); + +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, @fileId, '清理孤儿文件', 'imFileCleanupOrphan', '', '', '3', '', '/imFile/cleanupOrphan', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileCleanupOrphan'); 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..2503e92 --- /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`, `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, '文件策略', '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`, `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, @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_group_menu.sql b/server/storage/data/generate/im_group_menu.sql index 5424a19..c1112e6 100644 --- a/server/storage/data/generate/im_group_menu.sql +++ b/server/storage/data/generate/im_group_menu.sql @@ -89,3 +89,13 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberRem 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, @groupListId, '转让群主', 'imGroupOwnerTransfer', '', '', '3', '', '/imGroup/ownerTransfer', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '160', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer'); + +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, @groupListId, '解散群', 'imGroupDissolve', '', '', '3', '', '/imGroup/dissolve', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '170', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupDissolve'); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT source_role.`role_id`, dissolve_menu.`id` +FROM `hg_admin_role_menu` source_role +JOIN `hg_admin_menu` governance_update ON governance_update.`id` = source_role.`menu_id` AND governance_update.`name` = 'imGroupGovernanceUpdate' +JOIN `hg_admin_menu` dissolve_menu ON dissolve_menu.`name` = 'imGroupDissolve'; diff --git a/server/storage/data/generate/im_mailbox_config_menu.sql b/server/storage/data/generate/im_mailbox_config_menu.sql new file mode 100644 index 0000000..e13044b --- /dev/null +++ b/server/storage/data/generate/im_mailbox_config_menu.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; +SET @now := NOW(); + +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 0, '工作台管理', 'workbenchManager', '/workbench', 'AppstoreOutlined', 1, '/workbench/config/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 126, '附加功能独立管理入口', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'workbenchManager'); + +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'workbenchManager' LIMIT 1); + +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 @workbenchRootId, '总经理信箱配置', 'imMailboxConfigIndex', 'mailbox-config/index', '', 2, '', '/imMailboxConfig/get', '', '/imMailboxConfig/index', 1, 'workbenchManager', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @workbenchRootId, ' '), 30, '', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigIndex'); + +SET @imMailboxConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid`=@workbenchRootId, `path`='mailbox-config/index', `active_menu`='workbenchManager', + `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=30, + `status`=1, `hidden`=0, `updated_at`=@now +WHERE `id`=@imMailboxConfigId; + +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 @imMailboxConfigId, '信箱配置更新', 'imMailboxConfigUpdate', '', '', 3, '', '/imMailboxConfig/update', '', '', 1, '', 0, 0, '', 0, 1, 0, 3, CONCAT('tr_', @workbenchRootId, ' tr_', @imMailboxConfigId, ' '), 10, '', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigUpdate'); + +UPDATE `hg_admin_menu` +SET `level`=3, `tree`=CONCAT('tr_', @workbenchRootId, ' tr_', @imMailboxConfigId, ' '), `updated_at`=@now +WHERE `pid`=@imMailboxConfigId AND `type`=3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT `role_id`, @workbenchRootId FROM `hg_admin_role_menu` WHERE `menu_id`=@imMailboxConfigId; diff --git a/server/storage/data/generate/im_menu_hierarchy.sql b/server/storage/data/generate/im_menu_hierarchy.sql new file mode 100644 index 0000000..287aa3f --- /dev/null +++ b/server/storage/data/generate/im_menu_hierarchy.sql @@ -0,0 +1,112 @@ +-- Organize all visible IM admin pages under the GedaDa menu hierarchy. +-- This script is idempotent and preserves page/button menu IDs and permissions. + +SET NAMES utf8mb4; +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); + +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 @imRootId, '用户与群组', 'imUserAndGroup', 'people', 'TeamOutlined', 1, '/im/people/user/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 10, '', 1, @now, @now +WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAndGroup'); + +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 @imRootId, '内容审核', 'imContentAudit', 'content', 'FileSearchOutlined', 1, '/im/content/private-message/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 20, '', 1, @now, @now +WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imContentAudit'); + +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 @imRootId, '日志审计', 'imLogAudit', 'audit', 'AuditOutlined', 1, '/im/audit/operation/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 30, '', 1, @now, @now +WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imLogAudit'); + +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 @imRootId, '系统设置', 'imSystemSettings', 'settings', 'SettingOutlined', 1, '/im/settings/tenant/index', '', '', 'ParentLayout', 1, '', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), 40, '', 1, @now, @now +WHERE @imRootId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings'); + +SET @peopleId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imUserAndGroup' LIMIT 1); +SET @contentId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imContentAudit' LIMIT 1); +SET @auditId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imLogAudit' LIMIT 1); +SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1); + +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='用户与群组', `path`='people', `icon`='TeamOutlined', `type`=1, `redirect`='/im/people/user/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=10, `updated_at`=@now WHERE `id`=@peopleId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='内容审核', `path`='content', `icon`='FileSearchOutlined', `type`=1, `redirect`='/im/content/private-message/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=20, `updated_at`=@now WHERE `id`=@contentId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='日志审计', `path`='audit', `icon`='AuditOutlined', `type`=1, `redirect`='/im/audit/operation/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=30, `updated_at`=@now WHERE `id`=@auditId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `title`='系统设置', `path`='settings', `icon`='SettingOutlined', `type`=1, `redirect`='/im/settings/tenant/index', `component`='ParentLayout', `always_show`=1, `hidden`=0, `status`=1, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@settingsId; +UPDATE `hg_admin_menu` SET `pid`=@imRootId, `icon`='DashboardOutlined', `type`=2, `level`=2, `tree`=CONCAT('tr_', @imRootId, ' '), `sort`=5, `updated_at`=@now WHERE `name`='imDashboardIndex'; +UPDATE `hg_admin_menu` SET `redirect`='/im/dashboard/index', `updated_at`=@now WHERE `id`=@imRootId; + +-- User and group management. +UPDATE `hg_admin_menu` SET `pid`=@peopleId, `title`='用户管理', `path`='user/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @peopleId, ' '), `sort`=10, `updated_at`=@now WHERE `name`='imUserIndex'; +UPDATE `hg_admin_menu` SET `pid`=@peopleId, `path`='user-policy-group/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @peopleId, ' '), `sort`=20, `updated_at`=@now WHERE `name`='imUserPolicyGroupIndex'; +UPDATE `hg_admin_menu` SET `pid`=@peopleId, `title`='群组管理', `path`='group/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @peopleId, ' '), `sort`=30, `updated_at`=@now WHERE `name`='imGroupIndex'; +UPDATE `hg_admin_menu` SET `pid`=@peopleId, `title`='邀请码管理', `path`='registration-invite/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @peopleId, ' '), `sort`=50, `updated_at`=@now WHERE `name`='imRegistrationInviteIndex'; + +-- Content review. +UPDATE `hg_admin_menu` SET `pid`=@contentId, `path`='private-message/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=10, `updated_at`=@now WHERE `name`='imPrivateMessageIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `path`='group-message/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=20, `updated_at`=@now WHERE `name`='imGroupMessageIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `path`='sensitive-word/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=30, `updated_at`=@now WHERE `name`='imSensitiveWordIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `title`='文件管理', `path`='file/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=40, `updated_at`=@now WHERE `name`='imFileIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `title`='用户反馈', `path`='feedback/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=50, `updated_at`=@now WHERE `name`='imWorkOrderIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `title`='实名审核', `path`='realname/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=60, `updated_at`=@now WHERE `name`='imRealnameIndex'; +UPDATE `hg_admin_menu` SET `pid`=@contentId, `path`='abuse-reason/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @contentId, ' '), `sort`=70, `updated_at`=@now WHERE `name`='imAbuseReasonIndex'; + +-- Logs, audit and operations. +UPDATE `hg_admin_menu` SET `pid`=@auditId, `title`='操作日志', `path`='operation/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=10, `updated_at`=@now WHERE `name`='imAuditLogIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `title`='用户登录', `path`='user-login/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=20, `updated_at`=@now WHERE `name`='imUserLoginHistoryIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='login-failure/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=30, `updated_at`=@now WHERE `name`='imLoginFailureLogIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='kafka-audit/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=40, `updated_at`=@now WHERE `name`='imKafkaAuditIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `title`='事件队列', `path`='outbox/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=50, `updated_at`=@now WHERE `name`='imOutboxIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='kafka-command/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=60, `updated_at`=@now WHERE `name`='imKafkaCommandIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='dead-letter/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=70, `updated_at`=@now WHERE `name`='imDeadLetterIndex'; +UPDATE `hg_admin_menu` SET `pid`=@auditId, `path`='task-dashboard/index', `level`=3, `tree`=CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), `sort`=80, `updated_at`=@now WHERE `name`='imTaskDashboardIndex'; + +-- 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, `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'; + +-- 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'); + +-- Rebuild button ancestry beneath the moved pages. +UPDATE `hg_admin_menu` child +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) + AND child.`type` = 3; + +-- Give roles that already own pages in a section access to the new directory. +INSERT INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT DISTINCT rm.`role_id`, section.`id` +FROM `hg_admin_role_menu` rm +JOIN `hg_admin_menu` page ON page.`id` = rm.`menu_id` +JOIN `hg_admin_menu` section ON section.`id` = page.`pid` +LEFT JOIN `hg_admin_role_menu` existing ON existing.`role_id` = rm.`role_id` AND existing.`menu_id` = section.`id` +WHERE section.`id` IN (@peopleId, @contentId, @auditId, @settingsId) + AND existing.`menu_id` IS NULL; + +-- Optional features use their own top-level menu and can be replaced independently. +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 0, '工作台管理', 'workbenchManager', '/workbench', 'AppstoreOutlined', 1, '/workbench/config/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 126, '附加功能独立管理入口', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'workbenchManager'); + +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'workbenchManager' LIMIT 1); +SET @workbenchId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkbenchIndex' LIMIT 1); +SET @orgId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex' LIMIT 1); +SET @mailboxConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigIndex' LIMIT 1); +SET @feedbackConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigIndex' LIMIT 1); + +UPDATE `hg_admin_menu` SET `pid`=0, `title`='工作台管理', `path`='/workbench', `icon`='AppstoreOutlined', `type`=1, `redirect`='/workbench/config/index', `component`='LAYOUT', `always_show`=1, `active_menu`='', `is_root`=1, `hidden`=0, `level`=1, `tree`=CONCAT('tr_', `id`, ' '), `sort`=126, `status`=1, `updated_at`=@now WHERE `id`=@workbenchRootId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `title`='工作台配置', `path`='config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=10, `status`=1, `hidden`=0, `updated_at`=@now WHERE `id`=@workbenchId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `title`='组织架构', `path`='org/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=20, `status`=1, `hidden`=0, `updated_at`=@now WHERE `id`=@orgId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `path`='mailbox-config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=30, `status`=1, `hidden`=0, `updated_at`=@now WHERE `id`=@mailboxConfigId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `path`='feedback-config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=40, `status`=1, `hidden`=0, `updated_at`=@now WHERE `id`=@feedbackConfigId; +UPDATE `hg_admin_menu` SET `level`=3, `tree`=CONCAT('tr_', @workbenchRootId, ' tr_', @workbenchId, ' '), `updated_at`=@now WHERE `pid`=@workbenchId AND `type`=3; +UPDATE `hg_admin_menu` child JOIN `hg_admin_menu` page ON page.`id`=child.`pid` SET child.`level`=3, child.`tree`=CONCAT('tr_', @workbenchRootId, ' tr_', page.`id`, ' '), child.`updated_at`=@now WHERE page.`id` IN (@orgId, @mailboxConfigId, @feedbackConfigId) AND child.`type`=3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT `role_id`, @workbenchRootId FROM `hg_admin_role_menu` WHERE `menu_id` IN (@workbenchId, @orgId, @mailboxConfigId, @feedbackConfigId); diff --git a/server/storage/data/generate/im_menu_title_repair.sql b/server/storage/data/generate/im_menu_title_repair.sql new file mode 100644 index 0000000..b37e3e7 --- /dev/null +++ b/server/storage/data/generate/im_menu_title_repair.sql @@ -0,0 +1,119 @@ +SET NAMES utf8mb4; + +UPDATE `hg_admin_menu` SET `title` = 'IM管理' WHERE `name` = 'imManager'; +UPDATE `hg_admin_menu` SET `title` = '商户管理' WHERE `name` = 'imTenant'; +UPDATE `hg_admin_menu` SET `title` = '商户列表' WHERE `name` = 'imTenantIndex'; +UPDATE `hg_admin_menu` SET `title` = '商户详情' WHERE `name` = 'imTenantView'; +UPDATE `hg_admin_menu` SET `title` = '新增/编辑商户' WHERE `name` = 'imTenantEdit'; +UPDATE `hg_admin_menu` SET `title` = '启用/停用商户' WHERE `name` = 'imTenantStatus'; +UPDATE `hg_admin_menu` SET `title` = '商户用量统计' WHERE `name` = 'imTenantUsage'; +UPDATE `hg_admin_menu` SET `title` = '商户删除检查' WHERE `name` = 'imTenantDeletionCheck'; +UPDATE `hg_admin_menu` SET `title` = '进入删除保护期' WHERE `name` = 'imTenantEnterDeleting'; +UPDATE `hg_admin_menu` SET `title` = '恢复商户' WHERE `name` = 'imTenantRestore'; + +UPDATE `hg_admin_menu` SET `title` = '用户管理' WHERE `name` = 'imUser'; +UPDATE `hg_admin_menu` SET `title` = '用户列表' WHERE `name` = 'imUserIndex'; +UPDATE `hg_admin_menu` SET `title` = '用户详情' WHERE `name` = 'imUserView'; +UPDATE `hg_admin_menu` SET `title` = '重置密码' WHERE `name` = 'imUserResetPassword'; +UPDATE `hg_admin_menu` SET `title` = '封禁用户' WHERE `name` = 'imUserBan'; +UPDATE `hg_admin_menu` SET `title` = '解封用户' WHERE `name` = 'imUserUnban'; +UPDATE `hg_admin_menu` SET `title` = '冻结用户' WHERE `name` = 'imUserFreeze'; +UPDATE `hg_admin_menu` SET `title` = '解冻用户' WHERE `name` = 'imUserUnfreeze'; +UPDATE `hg_admin_menu` SET `title` = '踢出设备' WHERE `name` = 'imUserDeviceKick'; +UPDATE `hg_admin_menu` SET `title` = '退出所有设备' WHERE `name` = 'imUserLogoutAll'; +UPDATE `hg_admin_menu` SET `title` = '注销账号' WHERE `name` = 'imUserAccountCancel'; +UPDATE `hg_admin_menu` SET `title` = '删除账号' WHERE `name` = 'imUserAccountDelete'; + +UPDATE `hg_admin_menu` SET `title` = '群组管理' WHERE `name` = 'imGroup'; +UPDATE `hg_admin_menu` SET `title` = '群组列表' WHERE `name` = 'imGroupIndex'; +UPDATE `hg_admin_menu` SET `title` = '群组详情' WHERE `name` = 'imGroupView'; +UPDATE `hg_admin_menu` SET `title` = '群成员列表' WHERE `name` = 'imGroupMemberList'; +UPDATE `hg_admin_menu` SET `title` = '群治理配置' WHERE `name` = 'imGroupGovernance'; +UPDATE `hg_admin_menu` SET `title` = '入群审批列表' WHERE `name` = 'imGroupJoinRequestList'; +UPDATE `hg_admin_menu` SET `title` = '群邀请列表' WHERE `name` = 'imGroupInvitationList'; +UPDATE `hg_admin_menu` SET `title` = '群公告列表' WHERE `name` = 'imGroupAnnouncementList'; +UPDATE `hg_admin_menu` SET `title` = '群禁言列表' WHERE `name` = 'imGroupMuteList'; +UPDATE `hg_admin_menu` SET `title` = '群管理日志' WHERE `name` = 'imGroupAdminLogList'; +UPDATE `hg_admin_menu` SET `title` = '更新群资料' WHERE `name` = 'imGroupProfileUpdate'; +UPDATE `hg_admin_menu` SET `title` = '更新群治理配置' WHERE `name` = 'imGroupGovernanceUpdate'; +UPDATE `hg_admin_menu` SET `title` = '设置群管理员' WHERE `name` = 'imGroupAdminSet'; +UPDATE `hg_admin_menu` SET `title` = '移除群管理员' WHERE `name` = 'imGroupAdminRemove'; +UPDATE `hg_admin_menu` SET `title` = '禁言群成员' WHERE `name` = 'imGroupMemberMute'; +UPDATE `hg_admin_menu` SET `title` = '解除群成员禁言' WHERE `name` = 'imGroupMemberUnmute'; +UPDATE `hg_admin_menu` SET `title` = '移除群成员' WHERE `name` = 'imGroupMemberRemove'; +UPDATE `hg_admin_menu` SET `title` = '转让群主' WHERE `name` = 'imGroupOwnerTransfer'; + +UPDATE `hg_admin_menu` SET `title` = '注册邀请码' WHERE `name` = 'imRegistrationInviteIndex'; +UPDATE `hg_admin_menu` SET `title` = '创建邀请码' WHERE `name` = 'imInviteCreate'; +UPDATE `hg_admin_menu` SET `title` = '批量创建邀请码' WHERE `name` = 'imInviteBatchCreate'; +UPDATE `hg_admin_menu` SET `title` = '批次详情' WHERE `name` = 'imInviteBatchView'; +UPDATE `hg_admin_menu` SET `title` = '撤销邀请码' WHERE `name` = 'imInviteRevoke'; +UPDATE `hg_admin_menu` SET `title` = '绑定用户' WHERE `name` = 'imInviteBindUser'; +UPDATE `hg_admin_menu` SET `title` = '撤销批次邀请码' WHERE `name` = 'imInviteBatchRevoke'; +UPDATE `hg_admin_menu` SET `title` = '导出邀请码' WHERE `name` = 'imInviteExport'; + +UPDATE `hg_admin_menu` SET `title` = '审计日志' WHERE `name` = 'imAuditLog'; +UPDATE `hg_admin_menu` SET `title` = '审计列表' WHERE `name` = 'imAuditLogIndex'; +UPDATE `hg_admin_menu` SET `title` = '审计详情' WHERE `name` = 'imAuditLogView'; +UPDATE `hg_admin_menu` SET `title` = '导出审计日志' WHERE `name` = 'imAuditLogExport'; + +UPDATE `hg_admin_menu` SET `title` = '组织架构' WHERE `name` = 'imOrgIndex'; +UPDATE `hg_admin_menu` SET `title` = '部门编辑' WHERE `name` = 'imOrgDepartmentEdit'; +UPDATE `hg_admin_menu` SET `title` = '导出部门' WHERE `name` = 'imOrgDepartmentExport'; +UPDATE `hg_admin_menu` SET `title` = '删除部门' WHERE `name` = 'imOrgDepartmentDelete'; +UPDATE `hg_admin_menu` SET `title` = '成员列表' WHERE `name` = 'imOrgMemberList'; +UPDATE `hg_admin_menu` SET `title` = '成员编辑' WHERE `name` = 'imOrgMemberEdit'; +UPDATE `hg_admin_menu` SET `title` = '导出成员' WHERE `name` = 'imOrgMemberExport'; +UPDATE `hg_admin_menu` SET `title` = '移除成员' WHERE `name` = 'imOrgMemberDelete'; +UPDATE `hg_admin_menu` SET `title` = '组织搜索' WHERE `name` = 'imOrgSearch'; + +UPDATE `hg_admin_menu` SET `title` = '文件治理' WHERE `name` = 'imFileIndex'; +UPDATE `hg_admin_menu` SET `title` = '文件详情' WHERE `name` = 'imFileView'; +UPDATE `hg_admin_menu` SET `title` = '隔离文件' WHERE `name` = 'imFileQuarantine'; +UPDATE `hg_admin_menu` SET `title` = '删除文件' WHERE `name` = 'imFileDelete'; +UPDATE `hg_admin_menu` SET `title` = '清理孤儿文件' WHERE `name` = 'imFileCleanupOrphan'; +UPDATE `hg_admin_menu` SET `title` = '文件策略' WHERE `name` = 'imFilePolicyIndex'; +UPDATE `hg_admin_menu` SET `title` = '策略更新' WHERE `name` = 'imFilePolicyUpdate'; + +UPDATE `hg_admin_menu` SET `title` = '通知模板' WHERE `name` = 'imNotificationIndex'; +UPDATE `hg_admin_menu` SET `title` = '模板更新' WHERE `name` = 'imNotificationTemplateUpdate'; + +UPDATE `hg_admin_menu` SET `title` = '外部服务配置' WHERE `name` = 'imExternalConfigIndex'; +UPDATE `hg_admin_menu` SET `title` = '配置详情' WHERE `name` = 'imExternalConfigView'; +UPDATE `hg_admin_menu` SET `title` = '更新配置' WHERE `name` = 'imExternalConfigUpdate'; +UPDATE `hg_admin_menu` SET `title` = '检查配置' WHERE `name` = 'imExternalConfigCheck'; + +UPDATE `hg_admin_menu` SET `title` = '工作台配置' WHERE `name` = 'imWorkbenchIndex'; +UPDATE `hg_admin_menu` SET `title` = '工作台管理' WHERE `name` = 'workbenchManager'; +UPDATE `hg_admin_menu` SET `title` = '模块编辑' WHERE `name` = 'imWorkbenchEdit'; +UPDATE `hg_admin_menu` SET `title` = '模块删除' WHERE `name` = 'imWorkbenchDelete'; +UPDATE `hg_admin_menu` SET `title` = '模块排序' WHERE `name` = 'imWorkbenchReorder'; + +UPDATE `hg_admin_menu` SET `title` = '版本更新' WHERE `name` = 'imAppReleaseIndex'; +UPDATE `hg_admin_menu` SET `title` = '版本编辑' WHERE `name` = 'imAppReleaseEdit'; + +UPDATE `hg_admin_menu` SET `title` = '举报原因' WHERE `name` = 'imAbuseReasonIndex'; +UPDATE `hg_admin_menu` SET `title` = '原因编辑' WHERE `name` = 'imAbuseReasonEdit'; +UPDATE `hg_admin_menu` SET `title` = '原因删除' WHERE `name` = 'imAbuseReasonDelete'; + +UPDATE `hg_admin_menu` SET `title` = '总经理信箱配置' WHERE `name` = 'imMailboxConfigIndex'; +UPDATE `hg_admin_menu` SET `title` = '更新信箱配置' WHERE `name` = 'imMailboxConfigUpdate'; + +UPDATE `hg_admin_menu` SET `title` = '意见反馈配置' WHERE `name` = 'imFeedbackConfigIndex'; +UPDATE `hg_admin_menu` SET `title` = '更新反馈配置' WHERE `name` = 'imFeedbackConfigUpdate'; + +UPDATE `hg_admin_menu` SET `title` = '任务看板' WHERE `name` = 'imTaskDashboardIndex'; + +UPDATE `hg_admin_menu` SET `title` = 'Kafka命令' WHERE `name` = 'imKafkaCommandIndex'; +UPDATE `hg_admin_menu` SET `title` = '命令详情' WHERE `name` = 'imKafkaCommandView'; +UPDATE `hg_admin_menu` SET `title` = '发送缓存失效测试' WHERE `name` = 'imKafkaCommandCacheInvalidate'; +UPDATE `hg_admin_menu` SET `title` = '发送工作台测试事件' WHERE `name` = 'imKafkaCommandWorkbenchEvent'; + +UPDATE `hg_admin_menu` SET `title` = 'Kafka死信' WHERE `name` = 'imDeadLetterIndex'; +UPDATE `hg_admin_menu` SET `title` = '死信详情' WHERE `name` = 'imDeadLetterView'; +UPDATE `hg_admin_menu` SET `title` = '重放死信' WHERE `name` = 'imDeadLetterReplay'; +UPDATE `hg_admin_menu` SET `title` = '忽略死信' WHERE `name` = 'imDeadLetterIgnore'; +UPDATE `hg_admin_menu` SET `title` = '标记已处理' WHERE `name` = 'imDeadLetterResolve'; + +UPDATE `hg_admin_menu` SET `title` = 'Kafka审计事件' WHERE `name` = 'imKafkaAuditIndex'; +UPDATE `hg_admin_menu` SET `title` = '审计事件详情' WHERE `name` = 'imKafkaAuditView'; diff --git a/server/storage/data/generate/im_missing_menu_patch.sql b/server/storage/data/generate/im_missing_menu_patch.sql new file mode 100644 index 0000000..24584fa --- /dev/null +++ b/server/storage/data/generate/im_missing_menu_patch.sql @@ -0,0 +1,96 @@ +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, 0, 'IM管理', 'imManager', '/im', '', 1, '/im/tenant/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 200, '', 1, @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, title, name, path, '', 2, '', permissions, '', component, 1, 'imManager', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @imRootId, ' '), sort, '', 1, @now, @now +FROM ( + SELECT '组织架构' title, 'imOrgIndex' name, 'org/index' path, '/imOrg/departmentList' permissions, '/imOrg/index' component, 65 sort UNION ALL + SELECT '文件治理', 'imFileIndex', 'file/index', '/imFile/list', '/imFile/index', 70 UNION ALL + SELECT '文件策略', 'imFilePolicyIndex', 'file-policy/index', '/imFilePolicy/list', '/imFilePolicy/index', 73 UNION ALL + SELECT '通知模板', 'imNotificationIndex', 'notification/index', '/imNotification/templateList', '/imNotification/index', 75 UNION ALL + SELECT '外部服务配置', 'imExternalConfigIndex', 'external-config/index', '/imExternalConfig/list', '/imExternalConfig/index', 80 UNION ALL + SELECT '工作台配置', 'imWorkbenchIndex', 'workbench/index', '/imWorkbench/list', '/imWorkbench/index', 80 UNION ALL + SELECT '版本更新', 'imAppReleaseIndex', 'app-release/index', '/imAppRelease/list', '/imAppRelease/index', 81 UNION ALL + SELECT '举报原因', 'imAbuseReasonIndex', 'abuse-reason/index', '/imAbuseReason/list', '/imAbuseReason/index', 82 UNION ALL + SELECT '总经理信箱配置', 'imMailboxConfigIndex', 'mailbox-config/index', '/imMailboxConfig/get', '/imMailboxConfig/index', 83 UNION ALL + SELECT '意见反馈配置', 'imFeedbackConfigIndex', 'feedback-config/index', '/imFeedbackConfig/get', '/imFeedbackConfig/index', 84 UNION ALL + SELECT '任务看板', 'imTaskDashboardIndex', 'task-dashboard/index', '/imTaskDashboard/list', '/imTaskDashboard/index', 90 +) p +WHERE @imRootId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name` = p.name); + +SELECT @imOrgId := `id` FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex' LIMIT 1; +SELECT @fileId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFileIndex' LIMIT 1; +SELECT @filePolicyId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFilePolicyIndex' LIMIT 1; +SELECT @notificationId := `id` FROM `hg_admin_menu` WHERE `name` = 'imNotificationIndex' LIMIT 1; +SELECT @externalConfigId := `id` FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigIndex' LIMIT 1; +SELECT @workbenchId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkbenchIndex' LIMIT 1; +SELECT @appReleaseId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAppReleaseIndex' LIMIT 1; +SELECT @abuseReasonId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAbuseReasonIndex' LIMIT 1; +SELECT @mailboxConfigId := `id` FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigIndex' LIMIT 1; +SELECT @feedbackConfigId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigIndex' LIMIT 1; +SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' LIMIT 1; +SELECT @auditLogId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex' LIMIT 1; + +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, parent_id, title, name, '', '', 3, '', permissions, '', '', 1, '', 0, 0, '', 0, 1, 0, level, tree, sort, '', 1, @now, @now +FROM ( + SELECT @imOrgId parent_id, '部门编辑' title, 'imOrgDepartmentEdit' name, '/imOrg/departmentEdit' permissions, 3 level, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' ') tree, 10 sort UNION ALL + SELECT @imOrgId, '部门导出', 'imOrgDepartmentExport', '/imOrg/departmentExport', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 15 UNION ALL + SELECT @imOrgId, '部门删除', 'imOrgDepartmentDelete', '/imOrg/departmentDelete', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 20 UNION ALL + SELECT @imOrgId, '成员列表', 'imOrgMemberList', '/imOrg/memberList', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 30 UNION ALL + SELECT @imOrgId, '成员编辑', 'imOrgMemberEdit', '/imOrg/memberEdit', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 40 UNION ALL + SELECT @imOrgId, '成员导出', 'imOrgMemberExport', '/imOrg/memberExport', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 45 UNION ALL + SELECT @imOrgId, '成员移除', 'imOrgMemberDelete', '/imOrg/memberDelete', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 50 UNION ALL + SELECT @imOrgId, '组织搜索', 'imOrgSearch', '/imOrg/search', 3, CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), 60 UNION ALL + SELECT @fileId, '文件详情', 'imFileView', '/imFile/view', 3, CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), 10 UNION ALL + SELECT @fileId, '隔离文件', 'imFileQuarantine', '/imFile/quarantine', 3, CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), 20 UNION ALL + SELECT @fileId, '删除文件', 'imFileDelete', '/imFile/delete', 3, CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), 30 UNION ALL + SELECT @fileId, '清理孤儿文件', 'imFileCleanupOrphan', '/imFile/cleanupOrphan', 3, CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), 40 UNION ALL + SELECT @filePolicyId, '策略更新', 'imFilePolicyUpdate', '/imFilePolicy/update', 3, CONCAT('tr_', @imRootId, ' tr_', @filePolicyId, ' '), 10 UNION ALL + SELECT @notificationId, '模板更新', 'imNotificationTemplateUpdate', '/imNotification/templateUpdate', 3, CONCAT('tr_', @imRootId, ' tr_', @notificationId, ' '), 10 UNION ALL + SELECT @externalConfigId, '配置详情', 'imExternalConfigView', '/imExternalConfig/view', 3, CONCAT('tr_', @imRootId, ' tr_', @externalConfigId, ' '), 10 UNION ALL + SELECT @externalConfigId, '更新配置', 'imExternalConfigUpdate', '/imExternalConfig/update', 3, CONCAT('tr_', @imRootId, ' tr_', @externalConfigId, ' '), 20 UNION ALL + SELECT @externalConfigId, '检查配置', 'imExternalConfigCheck', '/imExternalConfig/check', 3, CONCAT('tr_', @imRootId, ' tr_', @externalConfigId, ' '), 30 UNION ALL + SELECT @workbenchId, '工作台编辑', 'imWorkbenchEdit', '/imWorkbench/edit', 3, CONCAT('tr_', @imRootId, ' tr_', @workbenchId, ' '), 10 UNION ALL + SELECT @workbenchId, '工作台禁用', 'imWorkbenchDelete', '/imWorkbench/delete', 3, CONCAT('tr_', @imRootId, ' tr_', @workbenchId, ' '), 20 UNION ALL + SELECT @workbenchId, '工作台排序', 'imWorkbenchReorder', '/imWorkbench/reorder', 3, CONCAT('tr_', @imRootId, ' tr_', @workbenchId, ' '), 30 UNION ALL + SELECT @appReleaseId, '版本编辑', 'imAppReleaseEdit', '/imAppRelease/edit', 3, CONCAT('tr_', @imRootId, ' tr_', @appReleaseId, ' '), 10 UNION ALL + SELECT @abuseReasonId, '原因编辑', 'imAbuseReasonEdit', '/imAbuseReason/edit', 3, CONCAT('tr_', @imRootId, ' tr_', @abuseReasonId, ' '), 10 UNION ALL + SELECT @abuseReasonId, '原因禁用', 'imAbuseReasonDelete', '/imAbuseReason/delete', 3, CONCAT('tr_', @imRootId, ' tr_', @abuseReasonId, ' '), 20 UNION ALL + SELECT @mailboxConfigId, '信箱配置更新', 'imMailboxConfigUpdate', '/imMailboxConfig/update', 3, CONCAT('tr_', @imRootId, ' tr_', @mailboxConfigId, ' '), 10 UNION ALL + SELECT @feedbackConfigId, '反馈配置更新', 'imFeedbackConfigUpdate', '/imFeedbackConfig/update', 3, CONCAT('tr_', @imRootId, ' tr_', @feedbackConfigId, ' '), 10 UNION ALL + SELECT @tenantListId, '商户用量与健康', 'imTenantUsage', '/imTenant/usage', 4, CONCAT('tr_', @imRootId, ' tr_', (SELECT `pid` FROM `hg_admin_menu` WHERE `id` = @tenantListId), ' tr_', @tenantListId, ' '), 40 UNION ALL + SELECT @tenantListId, '商户删除前检查', 'imTenantDeletionCheck', '/imTenant/deletionCheck', 4, CONCAT('tr_', @imRootId, ' tr_', (SELECT `pid` FROM `hg_admin_menu` WHERE `id` = @tenantListId), ' tr_', @tenantListId, ' '), 50 UNION ALL + SELECT @tenantListId, '商户进入删除流程', 'imTenantEnterDeleting', '/imTenant/enterDeleting', 4, CONCAT('tr_', @imRootId, ' tr_', (SELECT `pid` FROM `hg_admin_menu` WHERE `id` = @tenantListId), ' tr_', @tenantListId, ' '), 60 UNION ALL + SELECT @tenantListId, '恢复商户', 'imTenantRestore', '/imTenant/restore', 4, CONCAT('tr_', @imRootId, ' tr_', (SELECT `pid` FROM `hg_admin_menu` WHERE `id` = @tenantListId), ' tr_', @tenantListId, ' '), 70 UNION ALL + SELECT @auditLogId, '导出审计日志', 'imAuditLogExport', '/imAuditLog/export', 3, CONCAT('tr_', @imRootId, ' tr_', @auditLogId, ' '), 20 +) p +WHERE parent_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name` = p.name); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT 1, `id` FROM `hg_admin_menu` +WHERE `name` LIKE 'im%' OR `path` LIKE '/im%' OR `path` LIKE 'im/%'; + +-- Move replaceable workbench features out of the core IM menu. +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 0, '工作台管理', 'workbenchManager', '/workbench', 'AppstoreOutlined', 1, '/workbench/config/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 126, '附加功能独立管理入口', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='workbenchManager'); + +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name`='workbenchManager' LIMIT 1); +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `path`='config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=10, `updated_at`=@now WHERE `id`=@workbenchId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `title`='组织架构', `path`='org/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=20, `updated_at`=@now WHERE `id`=@imOrgId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `path`='mailbox-config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=30, `updated_at`=@now WHERE `id`=@mailboxConfigId; +UPDATE `hg_admin_menu` SET `pid`=@workbenchRootId, `path`='feedback-config/index', `active_menu`='workbenchManager', `level`=2, `tree`=CONCAT('tr_', @workbenchRootId, ' '), `sort`=40, `updated_at`=@now WHERE `id`=@feedbackConfigId; +UPDATE `hg_admin_menu` child JOIN `hg_admin_menu` page ON page.`id`=child.`pid` SET child.`level`=3, child.`tree`=CONCAT('tr_', @workbenchRootId, ' tr_', page.`id`, ' '), child.`updated_at`=@now WHERE page.`id` IN (@workbenchId, @imOrgId, @mailboxConfigId, @feedbackConfigId) AND child.`type`=3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT `role_id`, @workbenchRootId FROM `hg_admin_role_menu` WHERE `menu_id` IN (@workbenchId, @imOrgId, @mailboxConfigId, @feedbackConfigId); diff --git a/server/storage/data/generate/im_notification_menu.sql b/server/storage/data/generate/im_notification_menu.sql new file mode 100644 index 0000000..1049aef --- /dev/null +++ b/server/storage/data/generate/im_notification_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_notification_template` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `tenant_id` BIGINT NOT NULL, + `scene` VARCHAR(64) NOT NULL, + `name` VARCHAR(64) NOT NULL DEFAULT '', + `in_app_enabled` TINYINT NOT NULL DEFAULT 1, + `push_enabled` TINYINT NOT NULL DEFAULT 1, + `sms_enabled` TINYINT NOT NULL DEFAULT 0, + `status` TINYINT NOT NULL DEFAULT 1, + `title_template` VARCHAR(255) NOT NULL DEFAULT '', + `content_template` TEXT NOT NULL, + `payload_json` JSON DEFAULT NULL, + `created_at` DATETIME DEFAULT NULL, + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_notification_template_tenant_scene` (`tenant_id`, `scene`), + KEY `idx_im_notification_template_tenant_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM通知模板配置'; + +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, '通知模板', 'imNotificationIndex', 'notification/index', '', '2', '', '/imNotification/templateList', '', '/imNotification/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '75', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imNotificationIndex'); + +SET @imNotificationId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imNotificationIndex' LIMIT 1); + +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, @imNotificationId, '模板更新', 'imNotificationTemplateUpdate', '', '', '3', '', '/imNotification/templateUpdate', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imNotificationId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imNotificationTemplateUpdate'); diff --git a/server/storage/data/generate/im_org_menu.sql b/server/storage/data/generate/im_org_menu.sql new file mode 100644 index 0000000..34d59d1 --- /dev/null +++ b/server/storage/data/generate/im_org_menu.sql @@ -0,0 +1,60 @@ +SET @now := NOW(); +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'workbenchManager' LIMIT 1); + +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, COALESCE(@workbenchRootId, @imRootId), '组织架构', 'imOrgIndex', 'org/index', '', '2', '', '/imOrg/departmentList', '', '/imOrg/index', '1', IF(@workbenchRootId IS NULL, 'imManager', 'workbenchManager'), '0', '0', '', '0', '0', '0', IF(@workbenchRootId IS NULL, '2', '2'), CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex'); + +SET @imOrgId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = COALESCE(@workbenchRootId, @imRootId), + `title` = '组织架构', + `path` = 'org/index', + `active_menu` = IF(@workbenchRootId IS NULL, 'imManager', 'workbenchManager'), + `level` = 2, + `tree` = CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' '), + `sort` = 20, + `status` = 1, + `hidden` = 0, + `updated_at` = @now +WHERE `id` = @imOrgId; + +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, @imOrgId, '部门编辑', 'imOrgDepartmentEdit', '', '', '3', '', '/imOrg/departmentEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @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`, `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, @imOrgId, '部门导出', 'imOrgDepartmentExport', '', '', '3', '', '/imOrg/departmentExport', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @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`, `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, @imOrgId, '部门删除', 'imOrgDepartmentDelete', '', '', '3', '', '/imOrg/departmentDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' tr_', @imOrgId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentDelete'); + +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, @imOrgId, '成员列表', 'imOrgMemberList', '', '', '3', '', '/imOrg/memberList', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' tr_', @imOrgId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberList'); + +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, @imOrgId, '成员编辑', 'imOrgMemberEdit', '', '', '3', '', '/imOrg/memberEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @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`, `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, @imOrgId, '成员导出', 'imOrgMemberExport', '', '', '3', '', '/imOrg/memberExport', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @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`, `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, @imOrgId, '成员移除', 'imOrgMemberDelete', '', '', '3', '', '/imOrg/memberDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' tr_', @imOrgId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberDelete'); + +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, @imOrgId, '组织搜索', 'imOrgSearch', '', '', '3', '', '/imOrg/search', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' tr_', @imOrgId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgSearch'); + +UPDATE `hg_admin_menu` +SET `level` = 3, + `tree` = CONCAT('tr_', COALESCE(@workbenchRootId, @imRootId), ' tr_', @imOrgId, ' '), + `updated_at` = @now +WHERE `pid` = @imOrgId AND `type` = 3; diff --git a/server/storage/data/generate/im_outbox_menu.sql b/server/storage/data/generate/im_outbox_menu.sql new file mode 100644 index 0000000..b65ada8 --- /dev/null +++ b/server/storage/data/generate/im_outbox_menu.sql @@ -0,0 +1,16 @@ +-- Outbox运维菜单已废弃。 +-- 现架构 Kafka 事件直接落库,不再使用旧 `business_outbox` / `group_outbox` 运维入口。 + +SET @now = NOW(); + +UPDATE `hg_admin_menu` +SET `hidden` = 1, + `status` = 2, + `updated_at` = @now +WHERE `name` IN ( + 'imOutbox', + 'imOutboxIndex', + 'imOutboxView', + 'imOutboxRetry', + 'imOutboxDead' +); diff --git a/server/storage/data/generate/im_sensitive_word_seed.sql b/server/storage/data/generate/im_sensitive_word_seed.sql new file mode 100644 index 0000000..c895f1d --- /dev/null +++ b/server/storage/data/generate/im_sensitive_word_seed.sql @@ -0,0 +1,112 @@ +-- Seed a compact, high-confidence Chinese moderation lexicon for tenant 1. +-- Category references: +-- https://github.com/konsheng/Sensitive-lexicon +-- https://github.com/fwwdn/sensitive-stop-words +-- This script is idempotent and restores matching soft-deleted seed rows. + +SET NAMES utf8mb4; +SET @tenantId := 1; +SET @now := NOW(3); + +INSERT INTO `im_sensitive_word_groups` + (`tenant_id`, `group_code`, `group_name`, `priority`, `status`, `remark`, `created_at`, `created_by`, `updated_at`, `updated_by`, `deleted_at`, `deleted_by`) +VALUES + (@tenantId, 'fraud', '诈骗风险', 100, 1, '诈骗、黑产引流和资金风险测试词', @now, 0, @now, 0, NULL, NULL), + (@tenantId, 'gambling', '赌博风险', 95, 1, '博彩、下注和赌资交易测试词', @now, 0, @now, 0, NULL, NULL), + (@tenantId, 'drugs', '涉毒风险', 90, 1, '毒品交易和吸食引流测试词', @now, 0, @now, 0, NULL, NULL), + (@tenantId, 'adult_trade', '色情交易', 85, 1, '色情交易和成人服务测试词', @now, 0, @now, 0, NULL, NULL), + (@tenantId, 'abuse', '辱骂攻击', 70, 1, '常见辱骂和人身攻击测试词', @now, 0, @now, 0, NULL, NULL), + (@tenantId, 'spam', '广告导流', 50, 1, '站外导流和垃圾广告测试词', @now, 0, @now, 0, NULL, NULL) +ON DUPLICATE KEY UPDATE + `group_name` = VALUES(`group_name`), + `priority` = VALUES(`priority`), + `status` = VALUES(`status`), + `remark` = VALUES(`remark`), + `updated_at` = VALUES(`updated_at`), + `deleted_at` = NULL, + `deleted_by` = NULL; + +INSERT INTO `im_sensitive_words` + (`tenant_id`, `group_id`, `word`, `word_hash`, `match_type`, `scene_json`, `action`, `replacement`, `risk_level`, `priority`, `status`, `remark`, `created_at`, `created_by`, `updated_at`, `updated_by`, `deleted_at`, `deleted_by`) +SELECT + @tenantId, + g.`id`, + seed.`word`, + SHA2(LOWER(TRIM(seed.`word`)), 256), + 'contains', + CAST(seed.`scenes` AS JSON), + seed.`action`, + seed.`replacement`, + seed.`risk_level`, + seed.`priority`, + 1, + '公开词库筛选的内容审核测试词', + @now, + 0, + @now, + 0, + NULL, + NULL +FROM ( + SELECT 'fraud' group_code, '刷单返利' word, '["private_message","group_message","user_profile","feedback"]' scenes, 'manual_review' action, '' replacement, 'high' risk_level, 100 priority + UNION ALL SELECT 'fraud', '兼职刷单', '["private_message","group_message","user_profile"]', 'manual_review', '', 'high', 100 + UNION ALL SELECT 'fraud', '冒充客服退款', '["private_message","group_message"]', 'block', '', 'high', 100 + UNION ALL SELECT 'fraud', '转账保证金', '["private_message","group_message"]', 'manual_review', '', 'high', 95 + UNION ALL SELECT 'fraud', '征信修复', '["private_message","group_message","user_profile"]', 'manual_review', '', 'high', 95 + UNION ALL SELECT 'fraud', '内幕荐股', '["private_message","group_message","user_profile"]', 'manual_review', '', 'high', 95 + UNION ALL SELECT 'fraud', '杀猪盘', '["private_message","group_message","feedback","report"]', 'manual_review', '', 'high', 90 + UNION ALL SELECT 'fraud', '裸聊敲诈', '["private_message","group_message","feedback","report"]', 'block', '', 'high', 90 + + UNION ALL SELECT 'gambling', '网络赌场', '["private_message","group_message","user_profile","group_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'gambling', '博彩平台', '["private_message","group_message","user_profile","group_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'gambling', '私彩平台', '["private_message","group_message","user_profile"]', 'block', '', 'high', 95 + UNION ALL SELECT 'gambling', '在线下注', '["private_message","group_message","user_profile"]', 'block', '', 'high', 95 + UNION ALL SELECT 'gambling', '赌资代充', '["private_message","group_message"]', 'block', '', 'high', 95 + UNION ALL SELECT 'gambling', '真人百家乐', '["private_message","group_message","user_profile"]', 'block', '', 'high', 90 + UNION ALL SELECT 'gambling', '老虎机赢钱', '["private_message","group_message","user_profile"]', 'block', '', 'high', 90 + UNION ALL SELECT 'gambling', '跑分洗钱', '["private_message","group_message","feedback","report"]', 'block', '', 'high', 100 + + UNION ALL SELECT 'drugs', '冰毒出售', '["private_message","group_message","user_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'drugs', '海洛因出售', '["private_message","group_message","user_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'drugs', '麻古出售', '["private_message","group_message","user_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'drugs', '毒品交易', '["private_message","group_message","feedback","report"]', 'block', '', 'high', 100 + UNION ALL SELECT 'drugs', '上门送毒', '["private_message","group_message"]', 'block', '', 'high', 95 + + UNION ALL SELECT 'adult_trade', '招嫖', '["private_message","group_message","user_profile","report"]', 'block', '', 'high', 100 + UNION ALL SELECT 'adult_trade', '上门性服务', '["private_message","group_message","user_profile"]', 'block', '', 'high', 100 + UNION ALL SELECT 'adult_trade', '裸聊服务', '["private_message","group_message","user_profile"]', 'manual_review', '', 'high', 95 + UNION ALL SELECT 'adult_trade', '色情直播', '["private_message","group_message","user_profile","group_profile"]', 'manual_review', '', 'high', 95 + UNION ALL SELECT 'adult_trade', '成人视频', '["private_message","group_message","user_profile","group_profile"]', 'manual_review', '', 'high', 90 + UNION ALL SELECT 'adult_trade', '无码资源', '["private_message","group_message","user_profile"]', 'manual_review', '', 'high', 90 + + UNION ALL SELECT 'abuse', '操你妈', '["private_message","group_message","feedback"]', 'replace', '***', 'medium', 75 + UNION ALL SELECT 'abuse', '傻逼', '["private_message","group_message","feedback"]', 'replace', '***', 'medium', 75 + UNION ALL SELECT 'abuse', '狗娘养的', '["private_message","group_message","feedback"]', 'replace', '***', 'medium', 70 + UNION ALL SELECT 'abuse', '去死吧', '["private_message","group_message","feedback"]', 'manual_review', '', 'medium', 70 + UNION ALL SELECT 'abuse', '废物东西', '["private_message","group_message","feedback"]', 'replace', '***', 'medium', 65 + UNION ALL SELECT 'abuse', '脑残玩意', '["private_message","group_message","feedback"]', 'replace', '***', 'medium', 65 + + UNION ALL SELECT 'spam', '加微信返现', '["private_message","group_message","user_profile"]', 'audit_only', '', 'low', 50 + UNION ALL SELECT 'spam', '私聊加微信', '["private_message","group_message","user_profile"]', 'audit_only', '', 'low', 50 + UNION ALL SELECT 'spam', '扫码领红包', '["private_message","group_message","user_profile"]', 'audit_only', '', 'low', 45 + UNION ALL SELECT 'spam', '代理加盟', '["private_message","group_message","user_profile"]', 'audit_only', '', 'low', 45 + UNION ALL SELECT 'spam', '日赚千元', '["private_message","group_message","user_profile"]', 'manual_review', '', 'medium', 50 + UNION ALL SELECT 'spam', '低息套现', '["private_message","group_message","user_profile"]', 'manual_review', '', 'medium', 55 +) seed +JOIN `im_sensitive_word_groups` g + ON g.`tenant_id` = @tenantId + AND g.`group_code` = seed.`group_code` + AND g.`deleted_at` IS NULL +ON DUPLICATE KEY UPDATE + `word` = VALUES(`word`), + `match_type` = VALUES(`match_type`), + `scene_json` = VALUES(`scene_json`), + `action` = VALUES(`action`), + `replacement` = VALUES(`replacement`), + `risk_level` = VALUES(`risk_level`), + `priority` = VALUES(`priority`), + `status` = VALUES(`status`), + `remark` = VALUES(`remark`), + `updated_at` = VALUES(`updated_at`), + `deleted_at` = NULL, + `deleted_by` = NULL; diff --git a/server/storage/data/generate/im_task_dashboard_menu.sql b/server/storage/data/generate/im_task_dashboard_menu.sql new file mode 100644 index 0000000..76fb269 --- /dev/null +++ b/server/storage/data/generate/im_task_dashboard_menu.sql @@ -0,0 +1,13 @@ +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +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, '任务看板', 'imTaskDashboardIndex', 'task-dashboard/index', '', '2', '', '/imTaskDashboard/list', '', '/imTaskDashboard/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTaskDashboardIndex'); diff --git a/server/storage/data/generate/im_tenant_login_ip_whitelist.sql b/server/storage/data/generate/im_tenant_login_ip_whitelist.sql new file mode 100644 index 0000000..9ed2dbd --- /dev/null +++ b/server/storage/data/generate/im_tenant_login_ip_whitelist.sql @@ -0,0 +1,42 @@ +-- Tenant-scoped APP/Web login IP whitelist and admin permissions. +-- Empty whitelist for a channel means unrestricted access. +SET NAMES utf8mb4; +USE `hotgo`; + +CREATE TABLE IF NOT EXISTS `im_tenant_login_ip_whitelists` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT 'IM tenant ID', + `channel` varchar(16) NOT NULL COMMENT 'app or web', + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_tenant_login_ip` (`tenant_id`, `channel`, `ip_cidr`), + KEY `idx_im_tenant_login_ip_active` (`tenant_id`, `channel`, `status`, `deleted_at`), + CONSTRAINT `chk_im_tenant_login_ip_channel` CHECK (`channel` IN ('app', 'web')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Tenant APP/Web login IP whitelist'; + +SET @now = NOW(); +SELECT @groupMenuId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserPolicyGroupIndex' LIMIT 1; + +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, @groupMenuId, '查看商户登录IP白名单', 'imTenantLoginIPWhitelist', '', '', '3', '', '/imUserPolicyGroup/loginIPWhitelist', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT(`tree`, 'tr_', `id`, ' '), '9', '', '1', @now, @now +FROM `hg_admin_menu` WHERE `id` = @groupMenuId + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantLoginIPWhitelist'); + +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, @groupMenuId, '保存商户登录IP白名单', 'imTenantLoginIPWhitelistSave', '', '', '3', '', '/imUserPolicyGroup/loginIPWhitelistSave', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT(`tree`, 'tr_', `id`, ' '), '10', '', '1', @now, @now +FROM `hg_admin_menu` WHERE `id` = @groupMenuId + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantLoginIPWhitelistSave'); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT parent_role.`role_id`, child.`id` +FROM `hg_admin_role_menu` parent_role +JOIN `hg_admin_menu` parent ON parent.`id` = parent_role.`menu_id` AND parent.`name` = 'imUserPolicyGroupIndex' +JOIN `hg_admin_menu` child ON child.`pid` = parent.`id` +WHERE child.`name` IN ('imTenantLoginIPWhitelist', 'imTenantLoginIPWhitelistSave'); diff --git a/server/storage/data/generate/im_tenant_menu.sql b/server/storage/data/generate/im_tenant_menu.sql index d748a02..0d2d32e 100644 --- a/server/storage/data/generate/im_tenant_menu.sql +++ b/server/storage/data/generate/im_tenant_menu.sql @@ -13,7 +13,7 @@ SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1 SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT 1; 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, '租户管理', 'imTenant', 'tenant', '', '1', '/im/tenant/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '10', '', '1', @now, @now +SELECT NULL, @imRootId, '商户管理', 'imTenant', 'tenant', '', '1', '/im/tenant/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '10', '', '1', @now, @now WHERE @tenantDirId IS NULL; SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT 1; @@ -21,19 +21,35 @@ SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' LIMIT 1; 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, @tenantDirId, '租户列表', 'imTenantIndex', 'index', '', '2', '', '/imTenant/list', '', '/imTenant/index', '1', 'imTenant', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' '), '10', '', '1', @now, @now +SELECT NULL, @tenantDirId, '商户列表', 'imTenantIndex', 'index', '', '2', '', '/imTenant/list', '', '/imTenant/index', '1', 'imTenant', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' '), '10', '', '1', @now, @now WHERE @tenantListId IS NULL; SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' LIMIT 1; 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, @tenantListId, '租户详情', 'imTenantView', '', '', '3', '', '/imTenant/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '10', '', '1', @now, @now +SELECT NULL, @tenantListId, '商户详情', 'imTenantView', '', '', '3', '', '/imTenant/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantView'); 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, @tenantListId, '编辑/新增租户', 'imTenantEdit', '', '', '3', '', '/imTenant/edit', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '20', '', '1', @now, @now +SELECT NULL, @tenantListId, '编辑/新增商户', 'imTenantEdit', '', '', '3', '', '/imTenant/edit', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '20', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantEdit'); 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, @tenantListId, '修改租户状态', 'imTenantStatus', '', '', '3', '', '/imTenant/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '30', '', '1', @now, @now +SELECT NULL, @tenantListId, '修改商户状态', 'imTenantStatus', '', '', '3', '', '/imTenant/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '30', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantStatus'); + +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, @tenantListId, '商户用量与健康', 'imTenantUsage', '', '', '3', '', '/imTenant/usage', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantUsage'); + +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, @tenantListId, '商户删除前检查', 'imTenantDeletionCheck', '', '', '3', '', '/imTenant/deletionCheck', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantDeletionCheck'); + +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, @tenantListId, '商户进入删除流程', 'imTenantEnterDeleting', '', '', '3', '', '/imTenant/enterDeleting', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantEnterDeleting'); + +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, @tenantListId, '恢复商户', 'imTenantRestore', '', '', '3', '', '/imTenant/restore', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '70', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantRestore'); diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql index d59c8b7..e2e74b6 100644 --- a/server/storage/data/generate/im_user_menu.sql +++ b/server/storage/data/generate/im_user_menu.sql @@ -26,10 +26,48 @@ WHERE @userListId IS NULL; SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1; +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, @userListId, '创建用户', 'imUserCreate', '', '', '3', '', '/imUser/create', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '1', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserCreate'); + +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, @userListId, '批量创建用户', 'imUserBatchCreate', '', '', '3', '', '/imUser/batchCreate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '2', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBatchCreate'); + +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, @userListId, '导入用户', 'imUserImport', '', '', '3', '', '/imUser/import', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '3', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserImport'); + +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, @userListId, '下载用户模板', 'imUserDownloadTemplate', '', '', '3', '', '/imUser/downloadTemplate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '4', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDownloadTemplate'); + +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, @userDirId, '用户列表', 'imUserIndex', 'index', '', '2', '', '/imUser/list', '', '/imUser/index', '1', 'imUser', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' '), '10', '', '1', @now, @now +WHERE @userListId IS NULL; + +SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1; + 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, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView'); +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, @userListId, '重置用户密码', 'imUserResetPassword', '', '', '3', '', '/imUser/resetPassword', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '11', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserResetPassword'); + +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, @userListId, '查看用户IP白名单', 'imUserIPWhitelist', '', '', '3', '', '/imUser/ipWhitelist', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '12', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserIPWhitelist'); + +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, @userListId, '保存用户IP白名单', 'imUserIPWhitelistSave', '', '', '3', '', '/imUser/ipWhitelistSave', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '13', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserIPWhitelistSave'); + +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, @userListId, '调整用户分组', 'imUserAdjustGroup', '', '', '3', '', '/imUser/adjustGroup', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '14', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAdjustGroup'); + 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, @userListId, '封禁用户', 'imUserBan', '', '', '3', '', '/imUser/ban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '20', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBan'); @@ -61,3 +99,12 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountCan 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, @userListId, '删除账号', 'imUserAccountDelete', '', '', '3', '', '/imUser/accountDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '90', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountDelete'); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT parent_role.`role_id`, child.`id` +FROM `hg_admin_role_menu` parent_role +JOIN `hg_admin_menu` parent ON parent.`id` = parent_role.`menu_id` AND parent.`name` = 'imUserIndex' +JOIN `hg_admin_menu` child ON child.`pid` = parent.`id` +WHERE child.`name` IN ('imUserCreate', 'imUserBatchCreate', 'imUserImport', 'imUserDownloadTemplate', + 'imUserView', 'imUserResetPassword', 'imUserIPWhitelist', 'imUserIPWhitelistSave', 'imUserAdjustGroup', + 'imUserBan', 'imUserUnban', 'imUserLogoutAll', 'imUserAccountCancel'); diff --git a/server/storage/data/generate/im_user_policy_group_seed.sql b/server/storage/data/generate/im_user_policy_group_seed.sql new file mode 100644 index 0000000..4d933f1 --- /dev/null +++ b/server/storage/data/generate/im_user_policy_group_seed.sql @@ -0,0 +1,46 @@ +-- Initialize assignable policy groups for every active IM tenant. +-- Safe to rerun: tenant_id + group_code is unique. +SET NAMES utf8mb4; +USE `hotgo`; + +INSERT INTO `im_user_policy_groups` + (`tenant_id`, `group_code`, `group_name`, `priority`, `status`, `policy_json`, `ip_whitelist`, `ip_blacklist`, `version`, `remark`, `created_by`, `updated_by`) +SELECT + `id`, 'default_standard', '默认用户组', 100, 1, + JSON_OBJECT( + 'allow_add_friend', TRUE, 'allow_join_group', TRUE, 'max_friend_count', 5000, + 'allow_upload_file', TRUE, 'direct_add_friend', FALSE, 'allow_create_group', TRUE, + 'allow_group_message', TRUE, 'allow_private_message', TRUE, + 'allow_qrcode_add_friend', TRUE, 'allow_group_member_profile', TRUE, + 'hide_profile_when_friend_disabled', TRUE + ), JSON_ARRAY(), JSON_ARRAY(), 1, '系统初始化:默认用户权限', 0, 0 +FROM `im_tenant` WHERE `state` = 1 +ON DUPLICATE KEY UPDATE `group_name` = VALUES(`group_name`), `status` = 1, `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `im_user_policy_groups` + (`tenant_id`, `group_code`, `group_name`, `priority`, `status`, `policy_json`, `ip_whitelist`, `ip_blacklist`, `version`, `remark`, `created_by`, `updated_by`) +SELECT + `id`, 'default_trusted', '可信用户组', 90, 1, + JSON_OBJECT( + 'allow_add_friend', TRUE, 'allow_join_group', TRUE, 'max_friend_count', 10000, + 'allow_upload_file', TRUE, 'direct_add_friend', TRUE, 'allow_create_group', TRUE, + 'allow_group_message', TRUE, 'allow_private_message', TRUE, + 'allow_qrcode_add_friend', TRUE, 'allow_group_member_profile', TRUE, + 'hide_profile_when_friend_disabled', TRUE + ), JSON_ARRAY(), JSON_ARRAY(), 1, '系统初始化:可信用户权限', 0, 0 +FROM `im_tenant` WHERE `state` = 1 +ON DUPLICATE KEY UPDATE `group_name` = VALUES(`group_name`), `status` = 1, `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `im_user_policy_groups` + (`tenant_id`, `group_code`, `group_name`, `priority`, `status`, `policy_json`, `ip_whitelist`, `ip_blacklist`, `version`, `remark`, `created_by`, `updated_by`) +SELECT + `id`, 'default_guest', '访客用户组', 60, 1, + JSON_OBJECT( + 'allow_add_friend', FALSE, 'allow_join_group', TRUE, 'max_friend_count', 0, + 'allow_upload_file', FALSE, 'direct_add_friend', FALSE, 'allow_create_group', FALSE, + 'allow_group_message', TRUE, 'allow_private_message', TRUE, + 'allow_qrcode_add_friend', FALSE, 'allow_group_member_profile', FALSE, + 'hide_profile_when_friend_disabled', TRUE + ), JSON_ARRAY(), JSON_ARRAY(), 1, '系统初始化:访客用户权限', 0, 0 +FROM `im_tenant` WHERE `state` = 1 +ON DUPLICATE KEY UPDATE `group_name` = VALUES(`group_name`), `status` = 1, `updated_at` = CURRENT_TIMESTAMP(3); diff --git a/server/storage/data/generate/im_workbench_menu.sql b/server/storage/data/generate/im_workbench_menu.sql new file mode 100644 index 0000000..7cb95e5 --- /dev/null +++ b/server/storage/data/generate/im_workbench_menu.sql @@ -0,0 +1,85 @@ +-- Keep optional workbench features outside the core IM menu tree. +SET NAMES utf8mb4; +SET @now := NOW(); + +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 0, '工作台管理', 'workbenchManager', '/workbench', 'AppstoreOutlined', 1, '/workbench/config/index', '', '', 'LAYOUT', 1, '', 1, 0, '', 0, 0, 0, 1, '', 126, '附加功能独立管理入口', 1, @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'workbenchManager'); + +SET @workbenchRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'workbenchManager' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = 0, `title` = '工作台管理', `path` = '/workbench', `icon` = 'AppstoreOutlined', + `type` = 1, `redirect` = '/workbench/config/index', `component` = 'LAYOUT', + `always_show` = 1, `active_menu` = '', `is_root` = 1, `hidden` = 0, + `level` = 1, `tree` = CONCAT('tr_', `id`, ' '), `sort` = 126, `status` = 1, + `updated_at` = @now +WHERE `id` = @workbenchRootId; + +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 @workbenchRootId, '工作台配置', 'imWorkbenchIndex', 'config/index', '', 2, '', '/imWorkbench/list', '', '/imWorkbench/index', 1, 'workbenchManager', 0, 0, '', 0, 0, 0, 2, CONCAT('tr_', @workbenchRootId, ' '), 10, '', 1, @now, @now +WHERE @workbenchRootId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imWorkbenchIndex'); + +SET @imWorkbenchId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkbenchIndex' LIMIT 1); +SET @imOrgId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @workbenchRootId, `title` = '工作台配置', `path` = 'config/index', + `type` = 2, `permissions` = '/imWorkbench/list', `component` = '/imWorkbench/index', + `always_show` = 1, `active_menu` = 'workbenchManager', `is_root` = 0, `hidden` = 0, + `level` = 2, `tree` = CONCAT('tr_', @workbenchRootId, ' '), `sort` = 10, `status` = 1, + `updated_at` = @now +WHERE `id` = @imWorkbenchId; + +UPDATE `hg_admin_menu` +SET `pid` = @workbenchRootId, `title` = '组织架构', `path` = 'org/index', + `type` = 2, `permissions` = '/imOrg/departmentList', `component` = '/imOrg/index', + `always_show` = 1, `active_menu` = 'workbenchManager', `is_root` = 0, `hidden` = 0, + `level` = 2, `tree` = CONCAT('tr_', @workbenchRootId, ' '), `sort` = 20, `status` = 1, + `updated_at` = @now +WHERE `id` = @imOrgId; + +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 @imWorkbenchId, item.title, item.name, '', '', 3, '', item.permissions, '', '', 1, '', 0, 0, '', 0, 1, 0, 3, CONCAT('tr_', @workbenchRootId, ' tr_', @imWorkbenchId, ' '), item.sort, '', 1, @now, @now +FROM ( + SELECT '模块编辑' title, 'imWorkbenchEdit' name, '/imWorkbench/edit' permissions, 10 sort UNION ALL + SELECT '模块删除', 'imWorkbenchDelete', '/imWorkbench/delete', 20 UNION ALL + SELECT '模块排序', 'imWorkbenchReorder', '/imWorkbench/reorder', 30 +) item +WHERE @imWorkbenchId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` menu WHERE menu.`name` = item.name); + +UPDATE `hg_admin_menu` +SET `level` = 3, `tree` = CONCAT('tr_', @workbenchRootId, ' tr_', @imWorkbenchId, ' '), + `hidden` = 1, `updated_at` = @now +WHERE `pid` = @imWorkbenchId AND `type` = 3; + +SET @mailboxConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMailboxConfigIndex' LIMIT 1); +SET @feedbackConfigId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imFeedbackConfigIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @workbenchRootId, `path` = 'mailbox-config/index', `active_menu` = 'workbenchManager', + `level` = 2, `tree` = CONCAT('tr_', @workbenchRootId, ' '), `sort` = 30, + `status` = 1, `hidden` = 0, `updated_at` = @now +WHERE `id` = @mailboxConfigId; + +UPDATE `hg_admin_menu` +SET `pid` = @workbenchRootId, `path` = 'feedback-config/index', `active_menu` = 'workbenchManager', + `level` = 2, `tree` = CONCAT('tr_', @workbenchRootId, ' '), `sort` = 40, + `status` = 1, `hidden` = 0, `updated_at` = @now +WHERE `id` = @feedbackConfigId; + +UPDATE `hg_admin_menu` child +JOIN `hg_admin_menu` page ON page.`id` = child.`pid` +SET child.`level` = 3, + child.`tree` = CONCAT('tr_', @workbenchRootId, ' tr_', page.`id`, ' '), + child.`updated_at` = @now +WHERE page.`id` IN (@imOrgId, @mailboxConfigId, @feedbackConfigId) + AND child.`type` = 3; + +-- Preserve access for every role which could already open a workbench feature. +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT `role_id`, @workbenchRootId +FROM `hg_admin_role_menu` +WHERE `menu_id` IN (@imWorkbenchId, @imOrgId, @mailboxConfigId, @feedbackConfigId); diff --git a/server/storage/data/im_registration_invite_hotgo.sql b/server/storage/data/im_registration_invite_hotgo.sql index 14b1dd9..eed8f80 100644 --- a/server/storage/data/im_registration_invite_hotgo.sql +++ b/server/storage/data/im_registration_invite_hotgo.sql @@ -1,8 +1,6 @@ -- IM registration SMS switch and registration invite admin migration. -- This file is idempotent for MySQL deployments using HotGo's default hg_ prefix. -SET NAMES utf8mb4; - -- im_tenant is the only tenant registry. Admin identities reference it directly. SET @tenant_column_exists := ( SELECT COUNT(*) FROM information_schema.COLUMNS @@ -77,6 +75,48 @@ CREATE TABLE IF NOT EXISTS im_admin_operation_audit ( UNIQUE KEY uk_im_admin_audit_request_action (request_id, action) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Invitation definitions belong to the HotGo control database. Tenant +-- databases retain only im_registration_invite_uses. +CREATE TABLE IF NOT EXISTS im_registration_invite_batches ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + quantity INT UNSIGNED NOT NULL, + max_uses_per_code INT UNSIGNED NOT NULL DEFAULT 0, + idempotency_key VARCHAR(128) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key), + KEY idx_im_registration_invite_batch_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS im_registration_invite_codes ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + batch_id BIGINT NULL, + code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) NOT NULL, + expires_at DATETIME(3) NOT NULL, + max_uses INT UNSIGNED NOT NULL DEFAULT 0, + bound_user_id BIGINT NULL, + bound_user_label VARCHAR(128) NOT NULL DEFAULT '', + usage_count INT UNSIGNED NOT NULL DEFAULT 0, + last_used_at DATETIME(3) NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_im_registration_invite_code_tenant (tenant_id, code), + KEY idx_im_registration_invite_tenant_bound_user (tenant_id, bound_user_id), + KEY idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at), + KEY idx_im_registration_invite_tenant_batch (tenant_id, batch_id), + KEY idx_im_registration_invite_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + INSERT INTO hg_sys_config (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) SELECT @@ -104,6 +144,25 @@ WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imRegistrationInvite SET @invitePage := (SELECT id FROM hg_admin_menu WHERE name = 'imRegistrationInviteIndex' LIMIT 1); UPDATE hg_admin_menu SET tree = CONCAT('tr_', id, ' tr_', @imRoot, ' ') WHERE id = @invitePage; +SET @imTenantPage := (SELECT id FROM hg_admin_menu WHERE name = 'imTenantIndex' LIMIT 1); +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imTenantPage, 4, CONCAT('tr_', @imTenantPage, ' '), '租户用量与健康', 'imTenantUsage', '', '', 3, + '/imTenant/usage', '', 1, 40, 1, NOW(), NOW() +WHERE @imTenantPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imTenantUsage'); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imTenantPage, 4, CONCAT('tr_', @imTenantPage, ' '), title, name, '', '', 3, permissions, '', 1, sort, 1, NOW(), NOW() +FROM ( + SELECT '租户删除前检查' title, 'imTenantDeletionCheck' name, '/imTenant/deletionCheck' permissions, 50 sort UNION ALL + SELECT '租户进入删除流程', 'imTenantEnterDeleting', '/imTenant/enterDeleting', 60 UNION ALL + SELECT '恢复租户', 'imTenantRestore', '/imTenant/restore', 70 +) p +WHERE @imTenantPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); + INSERT INTO hg_admin_menu (pid, level, tree, title, name, path, icon, type, permissions, component, sort, status, created_at, updated_at) SELECT @invitePage, 3, CONCAT('tr_', @invitePage, ' tr_', @imRoot, ' '), title, name, '', '', 3, permissions, '', sort, 1, NOW(), NOW() @@ -112,7 +171,48 @@ FROM ( SELECT '批量生成邀请码', 'imInviteBatchCreate', '/imInvite/batchCreate', 20 UNION ALL SELECT '批次详情', 'imInviteBatchView', '/imInvite/batchView', 30 UNION ALL SELECT '撤销邀请码', 'imInviteRevoke', '/imInvite/revoke', 40 UNION ALL - SELECT '批量撤销邀请码', 'imInviteBatchRevoke', '/imInvite/batchRevoke', 50 UNION ALL - SELECT '导出邀请码', 'imInviteExport', '/imInvite/export', 60 + SELECT '绑定用户', 'imInviteBindUser', '/imInvite/bindUser', 50 UNION ALL + SELECT '批量撤销邀请码', 'imInviteBatchRevoke', '/imInvite/batchRevoke', 60 UNION ALL + SELECT '导出邀请码', 'imInviteExport', '/imInvite/export', 70 ) p WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); + +SET @systemConfigPage := (SELECT id FROM hg_admin_menu WHERE name = 'system_config' LIMIT 1); +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @systemConfigPage, 3, CONCAT('tr_', @systemConfigPage, ' '), title, name, '', '', 3, permissions, '', 1, sort, 1, NOW(), NOW() +FROM ( + SELECT '查看IM注册配置' title, 'imRegistrationConfigGet' name, '/imRegistration/config/get' permissions, 80 sort UNION ALL + SELECT '更新IM注册配置', 'imRegistrationConfigUpdate', '/imRegistration/config/update', 90 +) p +WHERE @systemConfigPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); + +SET @imUserPage := (SELECT id FROM hg_admin_menu WHERE name = 'imUserIndex' LIMIT 1); +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '重置用户密码', 'imUserResetPassword', '', '', 3, + '/imUser/resetPassword', '', 1, 20, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserResetPassword'); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '用户设备列表', 'imUserDevices', '', '', 3, + '/imUser/devices', '', 1, 30, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserDevices'); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '撤销用户会话', 'imUserRevokeSessions', '', '', 3, + '/imUser/revokeSessions', '', 1, 40, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserRevokeSessions'); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '更新用户状态', 'imUserStatus', '', '', 3, + '/imUser/status', '', 1, 50, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserStatus'); diff --git a/web/src/api/im/registrationInvite.ts b/web/src/api/im/registrationInvite.ts index 3bc8c48..9c192c5 100644 --- a/web/src/api/im/registrationInvite.ts +++ b/web/src/api/im/registrationInvite.ts @@ -40,6 +40,14 @@ export function Revoke(params) { }); } +export function BindUser(params) { + return http.request({ + url: '/imInvite/bindUser', + method: 'POST', + params, + }); +} + export function BatchRevoke(params) { return http.request({ url: '/imInvite/batchRevoke', diff --git a/web/src/api/imAbuseReason/index.ts b/web/src/api/imAbuseReason/index.ts new file mode 100644 index 0000000..acfc94d --- /dev/null +++ b/web/src/api/imAbuseReason/index.ts @@ -0,0 +1,13 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imAbuseReason/list', method: 'GET', params }); +} + +export function Edit(params) { + return http.request({ url: '/imAbuseReason/edit', method: 'POST', params }); +} + +export function Delete(params) { + return http.request({ url: '/imAbuseReason/delete', method: 'POST', params }); +} diff --git a/web/src/api/imAppRelease/index.ts b/web/src/api/imAppRelease/index.ts new file mode 100644 index 0000000..a77503b --- /dev/null +++ b/web/src/api/imAppRelease/index.ts @@ -0,0 +1,9 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imAppRelease/list', method: 'GET', params }); +} + +export function Edit(params) { + return http.request({ url: '/imAppRelease/edit', method: 'POST', params }); +} diff --git a/web/src/api/imAuditLog/index.ts b/web/src/api/imAuditLog/index.ts index 52bdfc8..1e43cba 100644 --- a/web/src/api/imAuditLog/index.ts +++ b/web/src/api/imAuditLog/index.ts @@ -1,4 +1,4 @@ -import { http } from '@/utils/http/axios'; +import { http, jumpExport } from '@/utils/http/axios'; // 获取IM审计日志列表 export function List(params) { @@ -17,3 +17,8 @@ export function View(params) { params, }); } + +// 导出IM审计日志 +export function Export(params) { + jumpExport('/imAuditLog/export', params); +} diff --git a/web/src/api/imDashboard/index.ts b/web/src/api/imDashboard/index.ts new file mode 100644 index 0000000..bcec7ee --- /dev/null +++ b/web/src/api/imDashboard/index.ts @@ -0,0 +1,9 @@ +import { http } from '@/utils/http/axios'; + +export function Overview(params) { + return http.request({ url: '/imDashboard/overview', method: 'GET', params }); +} + +export function Trends(params) { + return http.request({ url: '/imDashboard/trends', method: 'GET', params }); +} diff --git a/web/src/api/imExternalConfig/index.ts b/web/src/api/imExternalConfig/index.ts new file mode 100644 index 0000000..363b555 --- /dev/null +++ b/web/src/api/imExternalConfig/index.ts @@ -0,0 +1,17 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imExternalConfig/list', method: 'GET', params }); +} + +export function View(params) { + return http.request({ url: '/imExternalConfig/view', method: 'GET', params }); +} + +export function Update(params) { + return http.request({ url: '/imExternalConfig/update', method: 'POST', params }); +} + +export function Check(params) { + return http.request({ url: '/imExternalConfig/check', method: 'POST', params }); +}