消息模块、爬虫

This commit is contained in:
2025-11-13 19:00:27 +08:00
parent 2982d53800
commit e20a7755f8
85 changed files with 8637 additions and 201 deletions

View File

@@ -0,0 +1,145 @@
-- =====================================================
-- 消息通知模块 - 数据库表结构
-- 包含3张表消息主体表、消息接收对象表、用户消息表
-- =====================================================
-- 1. 消息主体表
DROP TABLE IF EXISTS tb_sys_message;
CREATE TABLE tb_sys_message (
-- 基础标识
id VARCHAR(50) PRIMARY KEY COMMENT '主键ID',
message_id VARCHAR(50) NOT NULL UNIQUE COMMENT '消息唯一标识',
-- 消息内容
title VARCHAR(200) NOT NULL COMMENT '消息标题',
content TEXT NOT NULL COMMENT '消息内容',
message_type VARCHAR(20) NOT NULL DEFAULT 'notification' COMMENT '消息类型notification-通知/announcement-公告/warning-预警',
priority VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '优先级normal-普通/important-重要/urgent-紧急',
-- 发送人信息
sender_id VARCHAR(50) NOT NULL COMMENT '发送人用户ID',
sender_name VARCHAR(100) COMMENT '发送人姓名(冗余字段)',
sender_dept_id VARCHAR(50) NOT NULL COMMENT '发送人部门ID',
sender_dept_name VARCHAR(100) COMMENT '发送人部门名称(冗余字段)',
-- 发送时间控制(定时发送功能)
send_mode VARCHAR(20) NOT NULL DEFAULT 'immediate' COMMENT '发送模式immediate-立即发送/scheduled-定时发送',
scheduled_time DATETIME COMMENT '计划发送时间sendMode=scheduled时必填',
actual_send_time DATETIME COMMENT '实际发送时间',
-- 状态管理
status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '状态draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-失败/cancelled-已取消',
-- 统计信息
target_user_count INT DEFAULT 0 COMMENT '目标用户总数',
sent_count INT DEFAULT 0 COMMENT '已发送数量',
success_count INT DEFAULT 0 COMMENT '发送成功数量',
failed_count INT DEFAULT 0 COMMENT '发送失败数量',
read_count INT DEFAULT 0 COMMENT '已读数量',
-- 失败处理和重试机制
retry_count INT DEFAULT 0 COMMENT '当前重试次数',
max_retry_count INT DEFAULT 3 COMMENT '最大重试次数',
last_error TEXT COMMENT '最后错误信息',
-- 基础字段
creator VARCHAR(50) COMMENT '创建人ID',
updater VARCHAR(50) COMMENT '更新人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time DATETIME COMMENT '删除时间',
deleted BIT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
-- 索引
INDEX idx_message_id (message_id),
INDEX idx_sender_id (sender_id),
INDEX idx_sender_dept_id (sender_dept_id),
INDEX idx_status (status),
INDEX idx_send_mode (send_mode),
INDEX idx_scheduled_time (scheduled_time),
INDEX idx_create_time (create_time),
INDEX idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='消息主体表';
-- 2. 消息发送方式接收对象表
DROP TABLE IF EXISTS tb_sys_message_target;
CREATE TABLE tb_sys_message_target (
-- 基础标识
id VARCHAR(50) PRIMARY KEY COMMENT '主键ID',
message_id VARCHAR(50) NOT NULL COMMENT '消息ID关联tb_sys_message.message_id',
-- 发送方式
send_method VARCHAR(100) NOT NULL COMMENT '发送方式system-系统消息/email-邮件/sms-短信多选时逗号分隔system,email',
-- 接收对象
target_type VARCHAR(20) NOT NULL COMMENT '接收对象类型dept-部门/role-角色/user-人员',
target_id VARCHAR(50) NOT NULL COMMENT '接收对象ID部门ID/角色ID/用户ID',
target_name VARCHAR(100) COMMENT '接收对象名称(冗余字段,便于展示)',
-- 作用域部门(关键字段:限制角色的部门范围)
scope_dept_id VARCHAR(50) NOT NULL COMMENT '作用域部门IDdept时与target_id相同role时表示该角色限定在哪个部门及其子部门范围内user时为用户所属部门ID',
-- 基础字段
creator VARCHAR(50) COMMENT '创建人ID',
updater VARCHAR(50) COMMENT '更新人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time DATETIME COMMENT '删除时间',
deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
-- 索引
INDEX idx_messageID (message_id),
INDEX idx_target_type (target_type),
INDEX idx_scope_dept_id (scope_dept_id),
INDEX idx_delete_time (delete_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='消息发送方式接收对象表';
-- 3. 用户接收消息表
DROP TABLE IF EXISTS tb_sys_message_user;
CREATE TABLE tb_sys_message_user (
-- 基础标识
id VARCHAR(50) PRIMARY KEY COMMENT '主键ID',
message_id VARCHAR(50) NOT NULL COMMENT '消息ID关联tb_sys_message.message_id',
user_id VARCHAR(50) NOT NULL COMMENT '接收用户ID',
-- 发送方式
send_method VARCHAR(20) NOT NULL COMMENT '实际发送方式system-系统消息/email-邮件/sms-短信',
-- 阅读状态
is_read tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已读0-未读1-已读',
read_time DATETIME COMMENT '阅读时间',
-- 发送状态
send_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '发送状态pending-待发送/success-发送成功/failed-发送失败',
fail_reason TEXT COMMENT '失败原因',
-- 基础字段
creator VARCHAR(50) COMMENT '创建人ID',
updater VARCHAR(50) COMMENT '更新人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
delete_time DATETIME COMMENT '删除时间',
deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
-- 索引
INDEX idx_message_id (message_id),
INDEX idx_user_id (user_id),
INDEX idx_is_read (is_read),
INDEX idx_send_status (send_status),
INDEX idx_deleted (deleted),
INDEX idx_user_read (user_id, is_read, deleted) COMMENT '用户未读消息查询索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户接收消息表';
-- =====================================================
-- 初始化说明
-- =====================================================
-- 1. 执行本SQL脚本创建3张表
-- 2. 执行message_menu.sql插入菜单权限数据
-- 3. 表结构说明:
-- - tb_sys_message存储消息主体信息支持定时发送
-- - tb_sys_message_target存储接收对象配置scopeDeptID限制权限范围
-- - tb_sys_message_user存储每个用户的消息记录支持已读/未读状态
-- =====================================================

View File

@@ -39,6 +39,8 @@ SOURCE createTableAchievement.sql;
SOURCE createTableCrontab.sql; SOURCE createTableCrontab.sql;
SOURCE createTableMessage.sql;
-- ===================================================== -- =====================================================
-- 插入初始数据 -- 插入初始数据
-- ===================================================== -- =====================================================

View File

@@ -40,7 +40,8 @@ INSERT INTO `tb_sys_module` (id, module_id, name, code, description, icon, order
('4', 'module_ai', 'AI管理', 'ai', 'AI管理模块', 'el-icon-cpu', 4, 1, '1', now()), ('4', 'module_ai', 'AI管理', 'ai', 'AI管理模块', 'el-icon-cpu', 4, 1, '1', now()),
('5', 'module_usercenter', '用户中心', 'usercenter', '用户中心模块', 'el-icon-user', 5, 1, '1', now()), ('5', 'module_usercenter', '用户中心', 'usercenter', '用户中心模块', 'el-icon-user', 5, 1, '1', now()),
('6', 'module_file', '文件管理', 'file', '文件管理模块', 'el-icon-folder', 6, 1, '1', now()), ('6', 'module_file', '文件管理', 'file', '文件管理模块', 'el-icon-folder', 6, 1, '1', now()),
('7', 'module_crontab', '定时任务', 'crontab', '定时任务管理模块', 'el-icon-alarm-clock', 7, 1, '1', now()); ('7', 'module_crontab', '定时任务', 'crontab', '定时任务管理模块', 'el-icon-alarm-clock', 7, 1, '1', now()),
('8', 'module_message', '消息通知', 'message', '消息通知管理模块', 'el-icon-message', 8, 1, '1', now());
-- 插入权限数据 -- 插入权限数据
INSERT INTO `tb_sys_permission` (id,permission_id, name, code, description, module_id, creator, create_time) VALUES INSERT INTO `tb_sys_permission` (id,permission_id, name, code, description, module_id, creator, create_time) VALUES
@@ -60,7 +61,10 @@ INSERT INTO `tb_sys_permission` (id,permission_id, name, code, description, modu
('12','perm_usercenter_manage', '用户中心管理', 'usercenter:manage', '用户中心管理权限', 'module_usercenter', '1', now()), ('12','perm_usercenter_manage', '用户中心管理', 'usercenter:manage', '用户中心管理权限', 'module_usercenter', '1', now()),
('13','perm_file_manage', '文件管理', 'file:manage', '文件管理权限', 'module_file', '1', now()), ('13','perm_file_manage', '文件管理', 'file:manage', '文件管理权限', 'module_file', '1', now()),
('14','perm_crontab_manage', '定时任务管理', 'crontab:manage', '定时任务管理权限', 'module_crontab', '1', now()), ('14','perm_crontab_manage', '定时任务管理', 'crontab:manage', '定时任务管理权限', 'module_crontab', '1', now()),
('15','perm_crontab_execute', '定时任务执行', 'crontab:execute', '定时任务执行权限', 'module_crontab', '1', now()); ('15','perm_crontab_execute', '定时任务执行', 'crontab:execute', '定时任务执行权限', 'module_crontab', '1', now()),
('16','perm_message_manage', '消息管理', 'message:manage', '消息管理权限(管理端)', 'module_message', '1', now()),
('17','perm_message_send', '消息发送', 'message:send', '消息发送权限', 'module_message', '1', now()),
('18','perm_message_view', '消息查看', 'message:view', '消息查看权限(用户端)', 'module_message', '1', now());
-- 插入角色-权限关联数据 -- 插入角色-权限关联数据
INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, create_time) VALUES INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, create_time) VALUES
@@ -82,6 +86,9 @@ INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, creat
('15', 'superadmin', 'perm_file_manage', '1', now()), ('15', 'superadmin', 'perm_file_manage', '1', now()),
('16', 'superadmin', 'perm_crontab_manage', '1', now()), ('16', 'superadmin', 'perm_crontab_manage', '1', now()),
('17', 'superadmin', 'perm_crontab_execute', '1', now()), ('17', 'superadmin', 'perm_crontab_execute', '1', now()),
('18', 'superadmin', 'perm_message_manage', '1', now()),
('19', 'superadmin', 'perm_message_send', '1', now()),
('19.1', 'superadmin', 'perm_message_view', '1', now()),
-- 管理员:拥有业务管理权限,但没有系统日志等系统管理权限 -- 管理员:拥有业务管理权限,但没有系统日志等系统管理权限
('20', 'admin', 'perm_default', '1', now()), ('20', 'admin', 'perm_default', '1', now()),
@@ -92,13 +99,17 @@ INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, creat
('25', 'admin', 'perm_ai_manage', '1', now()), ('25', 'admin', 'perm_ai_manage', '1', now()),
('26', 'admin', 'perm_usercenter_manage', '1', now()), ('26', 'admin', 'perm_usercenter_manage', '1', now()),
('27', 'admin', 'perm_file_manage', '1', now()), ('27', 'admin', 'perm_file_manage', '1', now()),
('28', 'admin', 'perm_message_manage', '1', now()),
('29', 'admin', 'perm_message_send', '1', now()),
('29.1', 'admin', 'perm_message_view', '1', now()),
-- 自由角色:拥有用户视图相关的所有权限(前台用户权限) -- 自由角色:拥有用户视图相关的所有权限(前台用户权限)
('30', 'freedom', 'perm_default', '1', now()), ('30', 'freedom', 'perm_default', '1', now()),
('31', 'freedom', 'perm_news_article_add', '1', now()), ('31', 'freedom', 'perm_news_article_add', '1', now()),
('32', 'freedom', 'perm_ai_manage', '1', now()), ('32', 'freedom', 'perm_ai_manage', '1', now()),
('33', 'freedom', 'perm_usercenter_manage', '1', now()), ('33', 'freedom', 'perm_usercenter_manage', '1', now()),
('34', 'freedom', 'perm_file_manage', '1', now()); ('34', 'freedom', 'perm_file_manage', '1', now()),
('35', 'freedom', 'perm_message_view', '1', now());
-- 插入前端菜单数据 -- 插入前端菜单数据
INSERT INTO `tb_sys_menu` VALUES INSERT INTO `tb_sys_menu` VALUES
@@ -106,7 +117,7 @@ INSERT INTO `tb_sys_menu` VALUES
('100', 'menu_home', '首页', NULL, '/home', 'user/home/HomeView', NULL, 1, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0), ('100', 'menu_home', '首页', NULL, '/home', 'user/home/HomeView', NULL, 1, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0),
('101', 'menu_resource_hot', '热门资源', NULL, '/resource-hot', 'user/resource-center/HotResourceView', NULL, 2, 3, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0), ('101', 'menu_resource_hot', '热门资源', NULL, '/resource-hot', 'user/resource-center/HotResourceView', NULL, 2, 3, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0),
('200', 'menu_resource_center', '资源中心', NULL, '/resource-center', 'user/resource-center/ResourceCenterView', NULL, 2, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0), ('200', 'menu_resource_center', '资源中心', NULL, '/resource-center', 'user/resource-center/ResourceCenterView', NULL, 2, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0),
('300', 'menu_study_plan', '学习计划', NULL, '/study-plan', 'user/study-plan/StudyPlanView', NULL, 3, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('300', 'menu_study_plan', '学习计划', NULL, '/study-plan', '', NULL, 3, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('301', 'menu_study_tasks', '学习任务', 'menu_study_plan', '/study-plan/tasks', 'user/study-plan/StudyTasksView', NULL, 1, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('301', 'menu_study_tasks', '学习任务', 'menu_study_plan', '/study-plan/tasks', 'user/study-plan/StudyTasksView', NULL, 1, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('302', 'menu_course_center', '课程中心', 'menu_study_plan', '/study-plan/course', 'user/study-plan/CourseCenterView', NULL, 2, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('302', 'menu_course_center', '课程中心', 'menu_study_plan', '/study-plan/course', 'user/study-plan/CourseCenterView', NULL, 2, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('303', 'menu_task_detail', '任务详情', 'menu_study_plan', '/study-plan/task-detail', 'user/study-plan/LearningTaskDetailView', NULL, 3, 3, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('303', 'menu_task_detail', '任务详情', 'menu_study_plan', '/study-plan/task-detail', 'user/study-plan/LearningTaskDetailView', NULL, 3, 3, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
@@ -120,7 +131,6 @@ INSERT INTO `tb_sys_menu` VALUES
('500', 'menu_profile', '账号中心', 'menu_user_dropdown', '/profile', 'user/profile/ProfileView', NULL, 5, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0), ('500', 'menu_profile', '账号中心', 'menu_user_dropdown', '/profile', 'user/profile/ProfileView', NULL, 5, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:49:56', NULL, 0),
('501', 'menu_personal_info', '个人信息', 'menu_profile', '/profile/personal-info', 'user/profile/PersonalInfoView', NULL, 1, 0, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('501', 'menu_personal_info', '个人信息', 'menu_profile', '/profile/personal-info', 'user/profile/PersonalInfoView', NULL, 1, 0, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('502', 'menu_account_settings', '账号设置', 'menu_profile', '/profile/account-settings', 'user/profile/AccountSettingsView', NULL, 2, 0, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('502', 'menu_account_settings', '账号设置', 'menu_profile', '/profile/account-settings', 'user/profile/AccountSettingsView', NULL, 2, 0, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('600', 'menu_ai_assistant', '智能体模块', NULL, '/ai-assistant', 'user/ai-assistant/AIAssistantView', NULL, 6, 1, 'NavigationLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
-- 管理后台菜单 (1000-8999) -- 管理后台菜单 (1000-8999)
('1000', 'menu_admin_overview', '系统总览', NULL, '/admin/overview', 'admin/overview/SystemOverviewView', 'admin/overview.svg', 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:32', NULL, 0), ('1000', 'menu_admin_overview', '系统总览', NULL, '/admin/overview', 'admin/overview/SystemOverviewView', 'admin/overview.svg', 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:32', NULL, 0),
('2000', 'menu_sys_manage', '系统管理', NULL, '', '', 'admin/settings.svg', 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:35', NULL, 0), ('2000', 'menu_sys_manage', '系统管理', NULL, '', '', 'admin/settings.svg', 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:35', NULL, 0),
@@ -157,7 +167,12 @@ INSERT INTO `tb_sys_menu` VALUES
('8000', 'menu_admin_crontab_manage', '定时任务管理', NULL, '', '', NULL, 8, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('8000', 'menu_admin_crontab_manage', '定时任务管理', NULL, '', '', NULL, 8, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('8001', 'menu_admin_crontab_task', '任务管理', 'menu_admin_crontab_manage', '/admin/manage/crontab/task', 'admin/manage/crontab/TaskManagementView', NULL, 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('8001', 'menu_admin_crontab_task', '任务管理', 'menu_admin_crontab_manage', '/admin/manage/crontab/task', 'admin/manage/crontab/TaskManagementView', NULL, 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('8002', 'menu_admin_crontab_log', '执行日志', 'menu_admin_crontab_manage', '/admin/manage/crontab/log', 'admin/manage/crontab/LogManagementView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0), ('8002', 'menu_admin_crontab_log', '执行日志', 'menu_admin_crontab_manage', '/admin/manage/crontab/log', 'admin/manage/crontab/LogManagementView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
('8003', 'menu_admin_news_crawler', '新闻爬虫配置', 'menu_admin_crontab_manage', '/admin/manage/crontab/news-crawler', 'admin/manage/crontab/NewsCrawlerView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0); ('8003', 'menu_admin_news_crawler', '新闻爬虫配置', 'menu_admin_crontab_manage', '/admin/manage/crontab/news-crawler', 'admin/manage/crontab/NewsCrawlerView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
-- 消息通知模块菜单 (9000-9999)
('9001', 'menu_admin_message_manage', '消息管理', NULL, '/admin/manage/message', 'admin/manage/message/MessageManageView', 'admin/message.svg', 9, 0, 'SidebarLayout', '1', NULL, '2025-11-13 10:00:00', '2025-11-13 10:00:00', NULL, 0),
-- 用户端消息中心菜单 (650-699)
('650', 'menu_user_message_center', '消息中心', NULL, '/user/message', 'user/message/MyMessageListView', NULL, 7, 1, 'NavigationLayout', '1', NULL, '2025-11-13 10:00:00', '2025-11-13 10:00:00', NULL, 0),
('651', 'menu_user_message_detail', '消息详情', 'menu_user_message_center', '/user/message/detail/:messageID', 'user/message/MyMessageDetailView', NULL, 1, 3, 'NavigationLayout', '1', NULL, '2025-11-13 10:00:00', '2025-11-13 10:00:00', NULL, 0);
-- 插入菜单权限关联数据 -- 插入菜单权限关联数据
INSERT INTO `tb_sys_menu_permission` (id, permission_id, menu_id, creator, create_time) VALUES INSERT INTO `tb_sys_menu_permission` (id, permission_id, menu_id, creator, create_time) VALUES
-- 前端菜单权限关联 -- 前端菜单权限关联
@@ -174,7 +189,6 @@ INSERT INTO `tb_sys_menu_permission` (id, permission_id, menu_id, creator, creat
('115', 'perm_default', 'menu_profile', '1', now()), ('115', 'perm_default', 'menu_profile', '1', now()),
('116', 'perm_default', 'menu_personal_info', '1', now()), ('116', 'perm_default', 'menu_personal_info', '1', now()),
('117', 'perm_default', 'menu_account_settings', '1', now()), ('117', 'perm_default', 'menu_account_settings', '1', now()),
('118', 'perm_ai_manage', 'menu_ai_assistant', '1', now()),
('119', 'perm_default', 'menu_user_dropdown', '1', now()), ('119', 'perm_default', 'menu_user_dropdown', '1', now()),
('120', 'perm_news_article_add', 'menu_article_add', '1', now()), ('120', 'perm_news_article_add', 'menu_article_add', '1', now()),
('121', 'perm_default', 'menu_task_detail', '1', now()), ('121', 'perm_default', 'menu_task_detail', '1', now()),
@@ -220,4 +234,15 @@ INSERT INTO `tb_sys_menu_permission` (id, permission_id, menu_id, creator, creat
('232', 'perm_crontab_manage', 'menu_admin_crontab_manage', '1', now()), ('232', 'perm_crontab_manage', 'menu_admin_crontab_manage', '1', now()),
('233', 'perm_crontab_manage', 'menu_admin_crontab_task', '1', now()), ('233', 'perm_crontab_manage', 'menu_admin_crontab_task', '1', now()),
('234', 'perm_crontab_manage', 'menu_admin_crontab_log', '1', now()), ('234', 'perm_crontab_manage', 'menu_admin_crontab_log', '1', now()),
('235', 'perm_crontab_manage', 'menu_admin_news_crawler', '1', now()); ('235', 'perm_crontab_manage', 'menu_admin_news_crawler', '1', now()),
-- 消息通知管理菜单权限关联
('240', 'perm_message_manage', 'menu_admin_message_manage', '1', now()),
('241', 'perm_message_manage', 'menu_admin_message_list', '1', now()),
('242', 'perm_message_send', 'menu_admin_message_create', '1', now()),
('243', 'perm_message_manage', 'menu_admin_message_detail', '1', now()),
-- 用户端消息中心权限关联
('250', 'perm_message_view', 'menu_user_message_center', '1', now()),
('251', 'perm_message_view', 'menu_user_message_detail', '1', now());

View File

@@ -77,7 +77,7 @@ org.xyzh.achievement.controller.AchievementController
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-usercenter</artifactId> <!-- 包含成就接口 --> <artifactId>api-usercenter</artifactId> <!-- 包含成就接口 -->
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
``` ```
@@ -87,14 +87,14 @@ org.xyzh.achievement.controller.AchievementController
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-achievement</artifactId> <artifactId>api-achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- 如果需要使用实现类通常不需要使用Dubbo远程调用 --> <!-- 如果需要使用实现类通常不需要使用Dubbo远程调用 -->
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>achievement</artifactId> <artifactId>achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
``` ```

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>achievement</artifactId> <artifactId>achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>achievement</name> <name>achievement</name>
<description>成就模块</description> <description>成就模块</description>
@@ -25,28 +25,28 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-achievement</artifactId> <artifactId>api-achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-study</artifactId> <artifactId>api-study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>admin</artifactId> <artifactId>admin</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -34,57 +34,62 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>auth</artifactId> <artifactId>auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-all</artifactId> <artifactId>api-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>news</artifactId> <artifactId>news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>study</artifactId> <artifactId>study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>usercenter</artifactId> <artifactId>usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>achievement</artifactId> <artifactId>achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>file</artifactId> <artifactId>file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>ai</artifactId> <artifactId>ai</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>crontab</artifactId> <artifactId>crontab</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>message</artifactId>
<version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@@ -17,7 +17,8 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "org.xyzh") @SpringBootApplication(scanBasePackages = "org.xyzh")
@EnableScheduling @EnableScheduling
@MapperScan({"org.xyzh.system.mapper", "org.xyzh.file.mapper", "org.xyzh.news.mapper", "org.xyzh.study.mapper", @MapperScan({"org.xyzh.system.mapper", "org.xyzh.file.mapper", "org.xyzh.news.mapper", "org.xyzh.study.mapper",
"org.xyzh.usercenter.mapper", "org.xyzh.ai.mapper", "org.xyzh.achievement.mapper", "org.xyzh.crontab.mapper"}) "org.xyzh.usercenter.mapper", "org.xyzh.ai.mapper", "org.xyzh.achievement.mapper", "org.xyzh.crontab.mapper",
"org.xyzh.message.mapper"})
public class App { public class App {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -114,7 +114,9 @@
<Logger name="org.xyzh.crontab.mapper" level="debug" additivity="false"> <Logger name="org.xyzh.crontab.mapper" level="debug" additivity="false">
<AppenderRef ref="Console"/> <AppenderRef ref="Console"/>
</Logger> </Logger>
<Logger name="org.xyzh.message.mapper" level="debug" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<!-- 项目包日志配置 - Auth模块 --> <!-- 项目包日志配置 - Auth模块 -->
<Logger name="org.xyzh.auth" level="debug" additivity="false"> <Logger name="org.xyzh.auth" level="debug" additivity="false">
<AppenderRef ref="Console"/> <AppenderRef ref="Console"/>
@@ -173,6 +175,14 @@
<AppenderRef ref="RollingFileError"/> <AppenderRef ref="RollingFileError"/>
<AppenderRef ref="DatabaseAppender"/> <AppenderRef ref="DatabaseAppender"/>
</Logger> </Logger>
<Logger name="org.xyzh.message" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="Filelog"/>
<AppenderRef ref="RollingFileInfo"/>
<AppenderRef ref="RollingFileWarn"/>
<AppenderRef ref="RollingFileError"/>
<AppenderRef ref="DatabaseAppender"/>
</Logger>
<root level="info"> <root level="info">
<appender-ref ref="Console"/> <appender-ref ref="Console"/>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>ai</artifactId> <artifactId>ai</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>ai</name> <name>ai</name>
<description>智能体模块</description> <description>智能体模块</description>
@@ -25,18 +25,18 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-ai</artifactId> <artifactId>api-ai</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId> <artifactId>api-file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-achievement</artifactId> <artifactId>api-achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>api-achievement</name> <name>api-achievement</name>
<description>成就模块API接口</description> <description>成就模块API接口</description>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-ai</artifactId> <artifactId>api-ai</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<name>api-ai</name> <name>api-ai</name>
<description>智能体API接口定义</description> <description>智能体API接口定义</description>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-all</artifactId> <artifactId>api-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
@@ -57,5 +57,9 @@
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-crontab</artifactId> <artifactId>api-crontab</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-message</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-auth</artifactId> <artifactId>api-auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-crontab</artifactId> <artifactId>api-crontab</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -22,12 +22,12 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId> <artifactId>api-file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>api-file</name> <name>api-file</name>
<description>文件模块API</description> <description>文件模块API</description>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.xyzh</groupId>
<artifactId>api</artifactId>
<version>1.0.0</version>
</parent>
<groupId>org.xyzh</groupId>
<artifactId>api-message</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,207 @@
package org.xyzh.api.message;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.dto.message.*;
import java.util.List;
import java.util.Map;
/**
* 消息服务接口
*
* @description 消息通知模块的服务接口定义
* @filename MessageService.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
public interface MessageService {
// ================== 消息管理 ==================
/**
* 创建消息
*
* @param message 消息对象
* @return ResultDomain<TbSysMessage> 创建结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> createMessage(TbSysMessage message);
/**
* 更新消息(仅允许更新草稿状态的消息)
*
* @param message 消息对象
* @return ResultDomain<TbSysMessage> 更新结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> updateMessage(TbSysMessage message);
/**
* 删除消息(逻辑删除)
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage> 删除结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> deleteMessage(String messageID);
/**
* 根据ID查询消息详情
*
* @param messageID 消息ID
* @return ResultDomain<MessageVO> 消息详情
* @author Claude
* @since 2025-11-13
*/
ResultDomain<MessageVO> getMessageById(String messageID);
/**
* 分页查询消息列表(管理端)
*
* @param filter 过滤条件
* @param pageParam 分页参数
* @return ResultDomain<MessageVO> 消息列表
* @author Claude
* @since 2025-11-13
*/
ResultDomain<MessageVO> getMessagePage(TbSysMessage filter, PageParam pageParam);
// ================== 消息发送 ==================
/**
* 发送消息(立即发送或定时发送)
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage> 发送结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> sendMessage(String messageID);
/**
* 立即发送(将定时消息改为立即发送)
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage> 发送结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> sendNow(String messageID);
/**
* 取消定时消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage> 取消结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> cancelMessage(String messageID);
/**
* 修改定时发送时间
*
* @param messageID 消息ID
* @param scheduledTime 新的发送时间
* @return ResultDomain<TbSysMessage> 修改结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> rescheduleMessage(String messageID, java.util.Date scheduledTime);
/**
* 重试失败消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage> 重试结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessage> retryMessage(String messageID);
// ================== 用户消息 ==================
/**
* 分页查询我的消息列表(用户端)
*
* @param filter 过滤条件
* @param pageParam 分页参数
* @return ResultDomain<MessageUserVO> 我的消息列表
* @author Claude
* @since 2025-11-13
*/
ResultDomain<MessageUserVO> getMyMessagesPage(MessageUserVO filter, PageParam pageParam);
/**
* 查询我的消息详情(用户端)
*
* @param messageID 消息ID
* @return ResultDomain<MessageUserVO> 消息详情
* @author Claude
* @since 2025-11-13
*/
ResultDomain<MessageUserVO> getMyMessageDetail(String messageID);
/**
* 标记消息为已读
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessageUser> 标记结果
* @author Claude
* @since 2025-11-13
*/
ResultDomain<TbSysMessageUser> markAsRead(String messageID);
/**
* 批量标记消息为已读
*
* @param messageIDs 消息ID列表
* @return ResultDomain<Integer> 标记成功的数量
* @author Claude
* @since 2025-11-13
*/
ResultDomain<Integer> batchMarkAsRead(List<String> messageIDs);
/**
* 获取未读消息数量
*
* @return ResultDomain<Integer> 未读消息数量
* @author Claude
* @since 2025-11-13
*/
ResultDomain<Integer> getUnreadCount();
// ================== 辅助接口 ==================
/**
* 获取可选的部门树(当前部门及子部门)
*
* @return ResultDomain<Map> 部门树数据
* @author Claude
* @since 2025-11-13
*/
ResultDomain<Map<String, Object>> getTargetDepts();
/**
* 获取可选的角色列表(当前部门及子部门的角色)
*
* @return ResultDomain<Map> 角色列表数据
* @author Claude
* @since 2025-11-13
*/
ResultDomain<Map<String, Object>> getTargetRoles();
/**
* 获取可选的用户列表(当前部门及子部门的用户)
*
* @return ResultDomain<Map> 用户列表数据
* @author Claude
* @since 2025-11-13
*/
ResultDomain<Map<String, Object>> getTargetUsers();
}

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-news</artifactId> <artifactId>api-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>api-news</name> <name>api-news</name>
<description>新闻API接口定义</description> <description>新闻API接口定义</description>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-study</artifactId> <artifactId>api-study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-usercenter</artifactId> <artifactId>api-usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<name>api-usercenter</name> <name>api-usercenter</name>
<description>个人中心API接口定义</description> <description>个人中心API接口定义</description>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<modules> <modules>
<module>api-all</module> <module>api-all</module>
@@ -24,6 +24,7 @@
<module>api-news</module> <module>api-news</module>
<module>api-file</module> <module>api-file</module>
<module>api-crontab</module> <module>api-crontab</module>
<module>api-message</module>
</modules> </modules>
<properties> <properties>
@@ -36,57 +37,62 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-all</artifactId> <artifactId>api-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-auth</artifactId> <artifactId>api-auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-course</artifactId> <artifactId>api-course</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-study</artifactId> <artifactId>api-study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-usercenter</artifactId> <artifactId>api-usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-achievement</artifactId> <artifactId>api-achievement</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-ai</artifactId> <artifactId>api-ai</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-news</artifactId> <artifactId>api-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId> <artifactId>api-file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-crontab</artifactId> <artifactId>api-crontab</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-message</artifactId>
<version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -94,12 +100,12 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>auth</artifactId> <artifactId>auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
@@ -24,38 +24,38 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-exception</artifactId> <artifactId>common-exception</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-redis</artifactId> <artifactId>common-redis</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-util</artifactId> <artifactId>common-util</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-auth</artifactId> <artifactId>api-auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>Common All-in-One</name> <name>Common All-in-One</name>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-annotation</artifactId> <artifactId>common-annotation</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,16 +6,22 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
</properties> </properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project> </project>

View File

@@ -0,0 +1,95 @@
package org.xyzh.common.dto.message;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 创建消息请求DTO
*
* @description 用于接收前端创建消息的请求参数
* @filename MessageCreateDTO.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
public class MessageCreateDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 消息标题(必填)
*/
private String title;
/**
* 消息内容(必填)
*/
private String content;
/**
* 消息类型notification-通知/announcement-公告/warning-预警
* 默认notification
*/
private String messageType = "notification";
/**
* 优先级normal-普通/important-重要/urgent-紧急
* 默认normal
*/
private String priority = "normal";
/**
* 发送模式immediate-立即发送/scheduled-定时发送
* 默认immediate
*/
private String sendMode = "immediate";
/**
* 计划发送时间sendMode=scheduled时必填
*/
private Date scheduledTime;
/**
* 接收对象列表(必填)
*/
private List<MessageTargetDTO> targets;
/**
* 内部类接收对象DTO
*/
@Data
public static class MessageTargetDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 发送方式system/email/sms多选时逗号分隔
* 默认system
*/
private String sendMethod = "system";
/**
* 接收对象类型dept-部门/role-角色/user-人员
*/
private String targetType;
/**
* 接收对象ID
*/
private String targetID;
/**
* 接收对象名称(前端传递,用于冗余存储)
*/
private String targetName;
/**
* 作用域部门ID关键字段
* - dept时与targetID相同
* - role时限定该角色的部门范围
* - user时用户所属部门ID
*/
private String scopeDeptID;
}
}

View File

@@ -0,0 +1,120 @@
package org.xyzh.common.dto.message;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 用户消息视图对象
*
* @description 用于前端展示的用户消息对象,包含用户信息和消息阅读状态
* @filename MessageUserVO.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
public class MessageUserVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
private String id;
/**
* 消息ID
*/
private String messageID;
/**
* 用户ID
*/
private String userID;
/**
* 用户名
*/
private String username;
/**
* 用户姓名
*/
private String fullName;
/**
* 用户所属部门ID
*/
private String deptID;
/**
* 用户所属部门名称
*/
private String deptName;
/**
* 实际发送方式system-系统消息/email-邮件/sms-短信
*/
private String sendMethod;
/**
* 是否已读0-未读1-已读
*/
private Boolean isRead;
/**
* 阅读时间
*/
private Date readTime;
/**
* 发送状态pending-待发送/success-发送成功/failed-发送失败
*/
private String sendStatus;
/**
* 失败原因
*/
private String failReason;
/**
* 创建时间
*/
private Date createTime;
// ========== 消息主体信息(用户端查看消息列表时需要) ==========
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 消息类型
*/
private String messageType;
/**
* 优先级
*/
private String priority;
/**
* 发送人姓名
*/
private String senderName;
/**
* 发送人部门名称
*/
private String senderDeptName;
/**
* 实际发送时间
*/
private Date actualSendTime;
}

View File

@@ -0,0 +1,192 @@
package org.xyzh.common.dto.message;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 消息视图对象
*
* @description 用于前端展示的消息对象,包含消息主体信息和关联的接收对象列表
* @filename MessageVO.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
public class MessageVO implements Serializable {
private static final long serialVersionUID = 1L;
// ========== 消息主体信息 ==========
/**
* 主键ID
*/
private String id;
/**
* 消息唯一标识
*/
private String messageID;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 消息类型notification-通知/announcement-公告/warning-预警
*/
private String messageType;
/**
* 优先级normal-普通/important-重要/urgent-紧急
*/
private String priority;
/**
* 发送人用户ID
*/
private String senderID;
/**
* 发送人姓名
*/
private String senderName;
/**
* 发送人部门ID
*/
private String senderDeptID;
/**
* 发送人部门名称
*/
private String senderDeptName;
/**
* 发送模式immediate-立即发送/scheduled-定时发送
*/
private String sendMode;
/**
* 计划发送时间
*/
private Date scheduledTime;
/**
* 实际发送时间
*/
private Date actualSendTime;
/**
* 状态draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-失败/cancelled-已取消
*/
private String status;
/**
* 目标用户总数
*/
private Integer targetUserCount;
/**
* 已发送数量
*/
private Integer sentCount;
/**
* 发送成功数量
*/
private Integer successCount;
/**
* 发送失败数量
*/
private Integer failedCount;
/**
* 已读数量
*/
private Integer readCount;
/**
* 当前重试次数
*/
private Integer retryCount;
/**
* 最大重试次数
*/
private Integer maxRetryCount;
/**
* 最后错误信息
*/
private String lastError;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
// ========== 关联信息 ==========
/**
* 接收对象列表
*/
private List<TbSysMessageTarget> targets;
/**
* 用户接收记录列表(仅在详情查询时返回)
*/
private List<MessageUserVO> userMessages;
// ========== 计算属性 ==========
/**
* 发送进度百分比0-100
*/
public Integer getSendProgress() {
if (targetUserCount == null || targetUserCount == 0) {
return 0;
}
if (sentCount == null) {
return 0;
}
return (int) Math.round((sentCount * 100.0) / targetUserCount);
}
/**
* 成功率百分比0-100
*/
public Integer getSuccessRate() {
if (sentCount == null || sentCount == 0) {
return 0;
}
if (successCount == null) {
return 0;
}
return (int) Math.round((successCount * 100.0) / sentCount);
}
/**
* 已读率百分比0-100
*/
public Integer getReadRate() {
if (successCount == null || successCount == 0) {
return 0;
}
if (readCount == null) {
return 0;
}
return (int) Math.round((readCount * 100.0) / successCount);
}
}

View File

@@ -0,0 +1,148 @@
package org.xyzh.common.dto.message;
import org.xyzh.common.dto.BaseDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
import java.util.List;
/**
* 消息主体表实体类
*
* @description 消息通知模块的主体表,存储消息的基本信息、发送配置和统计数据
* @filename TbSysMessage.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class TbSysMessage extends BaseDTO {
private static final long serialVersionUID = 1L;
/**
* 消息唯一标识
*/
private String messageID;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 消息类型notification-通知/announcement-公告/warning-预警
*/
private String messageType;
/**
* 优先级normal-普通/important-重要/urgent-紧急
*/
private String priority;
/**
* 发送人用户ID
*/
private String senderID;
/**
* 发送人姓名(冗余字段)
*/
private String senderName;
/**
* 发送人部门ID
*/
private String senderDeptID;
/**
* 发送人部门名称(冗余字段)
*/
private String senderDeptName;
/**
* 发送模式immediate-立即发送/scheduled-定时发送
*/
private String sendMode;
/**
* 计划发送时间sendMode=scheduled时必填
*/
private Date scheduledTime;
/**
* 实际发送时间
*/
private Date actualSendTime;
/**
* 状态draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-失败/cancelled-已取消
*/
private String status;
/**
* 目标用户总数
*/
private Integer targetUserCount;
/**
* 已发送数量
*/
private Integer sentCount;
/**
* 发送成功数量
*/
private Integer successCount;
/**
* 发送失败数量
*/
private Integer failedCount;
/**
* 已读数量
*/
private Integer readCount;
/**
* 当前重试次数
*/
private Integer retryCount;
/**
* 最大重试次数
*/
private Integer maxRetryCount;
/**
* 最后错误信息
*/
private String lastError;
/**
* 创建人ID
*/
private String creator;
/**
* 更新人ID
*/
private String updater;
/**
* 发送目标配置列表(前端辅助字段,不映射到数据库)
*/
private List<TbSysMessageTarget> targets;
/**
* 发送方式列表前端辅助字段从targets聚合
*/
private List<String> sendMethods;
}

View File

@@ -0,0 +1,64 @@
package org.xyzh.common.dto.message;
import org.xyzh.common.dto.BaseDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 消息发送方式接收对象表实体类
*
* @description 存储消息的接收对象配置,包括发送方式、目标类型和作用域部门
* @filename TbSysMessageTarget.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class TbSysMessageTarget extends BaseDTO {
private static final long serialVersionUID = 1L;
/**
* 消息ID关联tb_sys_message.messageID
*/
private String messageID;
/**
* 发送方式system-系统消息/email-邮件/sms-短信
* 多选时逗号分隔system,email
*/
private String sendMethod;
/**
* 接收对象类型dept-部门/role-角色/user-人员
*/
private String targetType;
/**
* 接收对象ID部门ID/角色ID/用户ID
*/
private String targetID;
/**
* 接收对象名称(冗余字段,便于展示)
*/
private String targetName;
/**
* 作用域部门ID
* - dept时与targetID相同
* - role时表示该角色限定在哪个部门及其子部门范围内
* - user时用户所属部门ID
*/
private String scopeDeptID;
/**
* 创建人ID
*/
private String creator;
/**
* 更新人ID
*/
private String updater;
}

View File

@@ -0,0 +1,68 @@
package org.xyzh.common.dto.message;
import org.xyzh.common.dto.BaseDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
/**
* 用户接收消息表实体类
*
* @description 存储每个用户接收到的消息记录,包括阅读状态和发送状态
* @filename TbSysMessageUser.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class TbSysMessageUser extends BaseDTO {
private static final long serialVersionUID = 1L;
/**
* 消息ID关联tb_sys_message.messageID
*/
private String messageID;
/**
* 接收用户ID
*/
private String userID;
/**
* 实际发送方式system-系统消息/email-邮件/sms-短信
*/
private String sendMethod;
/**
* 是否已读0-未读1-已读
*/
private Boolean isRead;
/**
* 阅读时间
*/
private Date readTime;
/**
* 发送状态pending-待发送/success-发送成功/failed-发送失败
*/
private String sendStatus;
/**
* 失败原因
*/
private String failReason;
/**
* 创建人ID
*/
private String creator;
/**
* 更新人ID
*/
private String updater;
}

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-exception</artifactId> <artifactId>common-exception</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -1,7 +0,0 @@
package org.xyzh;
public class Main {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-jdbc</artifactId> <artifactId>common-jdbc</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
@@ -36,7 +36,7 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-redis</artifactId> <artifactId>common-redis</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-util</artifactId> <artifactId>common-util</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<modules> <modules>
<module>common-core</module> <module>common-core</module>
@@ -42,37 +42,37 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-dto</artifactId> <artifactId>common-dto</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-annotation</artifactId> <artifactId>common-annotation</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-exception</artifactId> <artifactId>common-exception</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-redis</artifactId> <artifactId>common-redis</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-jdbc</artifactId> <artifactId>common-jdbc</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-util</artifactId> <artifactId>common-util</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>crontab</artifactId> <artifactId>crontab</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -23,25 +23,25 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-crontab</artifactId> <artifactId>api-crontab</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-news</artifactId> <artifactId>api-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- Common模块依赖 --> <!-- Common模块依赖 -->
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- System模块依赖 --> <!-- System模块依赖 -->
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>file</artifactId> <artifactId>file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>file</name> <name>file</name>
<description>文件模块</description> <description>文件模块</description>
@@ -27,14 +27,14 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId> <artifactId>api-file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- 通用模块 --> <!-- 通用模块 -->
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- Spring Boot Web --> <!-- Spring Boot Web -->

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId>
<version>1.0.0</version>
</parent>
<groupId>org.xyzh</groupId>
<artifactId>message</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<!-- API依赖 -->
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-message</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Common模块依赖 -->
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId>
<version>1.0.0</version>
</dependency>
<!-- System模块依赖需要部门、角色、用户相关服务-->
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>system</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,70 @@
package org.xyzh.message.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import java.util.concurrent.Executor;
/**
* 定时任务和异步任务配置类
*
* @description 配置消息模块的定时任务线程池和异步任务线程池
* @filename SchedulingConfig.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Configuration
@EnableAsync
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(messageSchedulerExecutor());
}
@Bean(name = "messageSchedulerExecutor")
public TaskScheduler messageSchedulerExecutor() {
ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler();
// 线程池大小5个线程用于定时任务扫描
executor.setPoolSize(5);
// 线程名称前缀
executor.setThreadNamePrefix("message-scheduler-");
// 等待所有任务完成后再关闭
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间(秒)
executor.setAwaitTerminationSeconds(60);
// 初始化
executor.initialize();
return executor;
}
/**
* 异步任务线程池配置
*/
@Bean(name = "messageAsyncExecutor")
public Executor messageAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数10个线程用于异步发送消息
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(500);
// 线程名称前缀
executor.setThreadNamePrefix("message-async-");
// 等待所有任务完成后再关闭
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间(秒)
executor.setAwaitTerminationSeconds(60);
// 初始化
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,251 @@
package org.xyzh.message.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.xyzh.api.message.MessageService;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.dto.message.*;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 消息控制器
*
* @description 消息通知模块的REST API接口
* @filename MessageController.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@RestController
@RequestMapping("/message")
public class MessageController {
private static final Logger logger = LoggerFactory.getLogger(MessageController.class);
@Autowired
private MessageService messageService;
// ================== 消息管理接口(管理端) ==================
/**
* 创建消息
*
* @param message 消息对象
* @return ResultDomain<TbSysMessage>
*/
@PostMapping
public ResultDomain<TbSysMessage> createMessage(@RequestBody TbSysMessage message) {
logger.info("创建消息:{}", message.getTitle());
return messageService.createMessage(message);
}
/**
* 更新消息(仅草稿状态)
*
* @param message 消息对象
* @return ResultDomain<TbSysMessage>
*/
@PutMapping
public ResultDomain<TbSysMessage> updateMessage(@RequestBody TbSysMessage message) {
logger.info("更新消息:{}", message.getMessageID());
return messageService.updateMessage(message);
}
/**
* 删除消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage>
*/
@DeleteMapping
public ResultDomain<TbSysMessage> deleteMessage(@RequestBody TbSysMessage message) {
return messageService.deleteMessage(message.getMessageID());
}
/**
* 查询消息详情
*
* @param messageID 消息ID
* @return ResultDomain<MessageVO>
*/
@GetMapping("/detail/{messageID}")
public ResultDomain<MessageVO> getMessageById(@PathVariable String messageID) {
return messageService.getMessageById(messageID);
}
/**
* 分页查询消息列表(管理端)
*
* @param filter 过滤条件
* @param pageParam 分页参数
* @return ResultDomain<MessageVO>
*/
@PostMapping("/page")
public ResultDomain<MessageVO> getMessagePage(@RequestBody TbSysMessage filter, PageParam pageParam) {
return messageService.getMessagePage(filter, pageParam);
}
// ================== 消息发送接口 ==================
/**
* 发送消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage>
*/
@PostMapping("/send/{messageID}")
public ResultDomain<TbSysMessage> sendMessage(@PathVariable String messageID) {
logger.info("发送消息:{}", messageID);
return messageService.sendMessage(messageID);
}
/**
* 立即发送(定时消息改为立即发送)
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage>
*/
@PostMapping("/sendNow/{messageID}")
public ResultDomain<TbSysMessage> sendNow(@PathVariable String messageID) {
logger.info("立即发送消息:{}", messageID);
return messageService.sendNow(messageID);
}
/**
* 取消定时消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage>
*/
@PostMapping("/cancel/{messageID}")
public ResultDomain<TbSysMessage> cancelMessage(@PathVariable String messageID) {
logger.info("取消消息:{}", messageID);
return messageService.cancelMessage(messageID);
}
/**
* 修改定时发送时间
*
* @param messageID 消息ID
* @param request 包含scheduledTime的请求对象
* @return ResultDomain<TbSysMessage>
*/
@PutMapping("/reschedule/{messageID}")
public ResultDomain<TbSysMessage> rescheduleMessage(@PathVariable String messageID,
@RequestBody Map<String, Object> request) {
logger.info("修改消息发送时间:{}", messageID);
Date scheduledTime = (Date) request.get("scheduledTime");
return messageService.rescheduleMessage(messageID, scheduledTime);
}
/**
* 重试失败消息
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessage>
*/
@PostMapping("/retry/{messageID}")
public ResultDomain<TbSysMessage> retryMessage(@PathVariable String messageID) {
logger.info("重试消息:{}", messageID);
return messageService.retryMessage(messageID);
}
// ================== 用户消息接口(用户端) ==================
/**
* 分页查询我的消息列表
*
* @param filter 过滤条件
* @param pageParam 分页参数
* @return ResultDomain<MessageUserVO>
*/
@PostMapping("/my/page")
public ResultDomain<MessageUserVO> getMyMessagesPage(@RequestBody PageRequest<MessageUserVO> pageRequest) {
return messageService.getMyMessagesPage(pageRequest.getFilter(), pageRequest.getPageParam());
}
/**
* 查询我的消息详情
*
* @param messageID 消息ID
* @return ResultDomain<MessageUserVO>
*/
@GetMapping("/my/detail/{messageID}")
public ResultDomain<MessageUserVO> getMyMessageDetail(@PathVariable String messageID) {
return messageService.getMyMessageDetail(messageID);
}
/**
* 标记消息为已读
*
* @param messageID 消息ID
* @return ResultDomain<TbSysMessageUser>
*/
@PostMapping("/my/markRead/{messageID}")
public ResultDomain<TbSysMessageUser> markAsRead(@PathVariable String messageID) {
return messageService.markAsRead(messageID);
}
/**
* 批量标记消息为已读
*
* @param request 包含messageIDs的请求对象
* @return ResultDomain<Integer>
*/
@PostMapping("/my/batchMarkRead")
public ResultDomain<Integer> batchMarkAsRead(@RequestBody Map<String, List<String>> request) {
List<String> messageIDs = request.get("messageIDs");
return messageService.batchMarkAsRead(messageIDs);
}
/**
* 获取未读消息数量
*
* @return ResultDomain<Integer>
*/
@GetMapping("/my/unreadCount")
public ResultDomain<Integer> getUnreadCount() {
return messageService.getUnreadCount();
}
// ================== 辅助接口 ==================
/**
* 获取可选的部门树
*
* @return ResultDomain<Map>
*/
@GetMapping("/targets/depts")
public ResultDomain<Map<String, Object>> getTargetDepts() {
return messageService.getTargetDepts();
}
/**
* 获取可选的角色列表
*
* @return ResultDomain<Map>
*/
@GetMapping("/targets/roles")
public ResultDomain<Map<String, Object>> getTargetRoles() {
return messageService.getTargetRoles();
}
/**
* 获取可选的用户列表
*
* @return ResultDomain<Map>
*/
@GetMapping("/targets/users")
public ResultDomain<Map<String, Object>> getTargetUsers() {
return messageService.getTargetUsers();
}
}

View File

@@ -0,0 +1,147 @@
package org.xyzh.message.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.common.dto.message.TbSysMessage;
import org.xyzh.common.dto.message.MessageVO;
import java.time.LocalDateTime;
import java.util.List;
/**
* 消息Mapper接口
*
* @description 消息主体表的数据访问接口
* @filename MessageMapper.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Mapper
public interface MessageMapper extends BaseMapper<TbSysMessage> {
/**
* 根据消息ID查询消息
*
* @param messageID 消息ID
* @return TbSysMessage 消息信息
* @author Claude
* @since 2025-11-13
*/
TbSysMessage selectMessageById(@Param("messageID") String messageID);
/**
* 插入消息
*
* @param message 消息信息
* @return int 影响行数
* @author Claude
* @since 2025-11-13
*/
int insertMessage(@Param("message") TbSysMessage message);
/**
* 更新消息
*
* @param message 消息信息
* @return int 影响行数
* @author Claude
* @since 2025-11-13
*/
int updateMessage(@Param("message") TbSysMessage message);
/**
* 删除消息(逻辑删除)
*
* @param messageID 消息ID
* @return int 影响行数
* @author Claude
* @since 2025-11-13
*/
int deleteMessage(@Param("messageID") String messageID);
/**
* 统计消息总数(带权限过滤)
*
* @param filter 过滤条件
* @param currentUserDeptID 当前用户部门ID
* @return int 总数
* @author Claude
* @since 2025-11-13
*/
int countMessage(@Param("filter") TbSysMessage filter,
@Param("currentUserDeptID") String currentUserDeptID);
/**
* 分页查询消息列表(带权限过滤)
*
* @param filter 过滤条件
* @param currentUserDeptID 当前用户部门ID
* @return List<MessageVO> 消息列表
* @author Claude
* @since 2025-11-13
*/
List<MessageVO> selectMessagePage(@Param("filter") TbSysMessage filter,
@Param("currentUserDeptID") String currentUserDeptID);
/**
* 查询消息详情
*
* @param messageID 消息ID
* @return MessageVO 消息详情
* @author Claude
* @since 2025-11-13
*/
MessageVO selectMessageDetail(@Param("messageID") String messageID);
/**
* 查询待发送的定时消息
*
* @param currentTime 当前时间
* @return List<TbSysMessage> 待发送的消息列表
* @author Claude
* @since 2025-11-13
*/
List<TbSysMessage> selectPendingScheduledMessages(@Param("currentTime") LocalDateTime currentTime);
/**
* CAS更新消息状态防止并发
*
* @param messageID 消息ID
* @param expectedStatus 期望的当前状态
* @param newStatus 新状态
* @return int 更新的行数1表示成功0表示失败
* @author Claude
* @since 2025-11-13
*/
int compareAndSetStatus(@Param("messageID") String messageID,
@Param("expectedStatus") String expectedStatus,
@Param("newStatus") String newStatus);
/**
* 更新消息统计信息
*
* @param messageID 消息ID
* @param sentCount 已发送数量
* @param successCount 成功数量
* @param failedCount 失败数量
* @return int 更新的行数
* @author Claude
* @since 2025-11-13
*/
int updateStatistics(@Param("messageID") String messageID,
@Param("sentCount") Integer sentCount,
@Param("successCount") Integer successCount,
@Param("failedCount") Integer failedCount);
/**
* 更新已读数量
*
* @param messageID 消息ID
* @return int 更新的行数
* @author Claude
* @since 2025-11-13
*/
int incrementReadCount(@Param("messageID") String messageID);
}

View File

@@ -0,0 +1,51 @@
package org.xyzh.message.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.common.dto.message.TbSysMessageTarget;
import java.util.List;
/**
* 消息接收对象Mapper接口
*
* @description 消息接收对象表的数据访问接口
* @filename MessageTargetMapper.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Mapper
public interface MessageTargetMapper extends BaseMapper<TbSysMessageTarget> {
/**
* 根据消息ID查询接收对象列表
*
* @param messageID 消息ID
* @return List<TbSysMessageTarget> 接收对象列表
* @author Claude
* @since 2025-11-13
*/
List<TbSysMessageTarget> selectByMessageID(@Param("messageID") String messageID);
/**
* 批量插入接收对象
*
* @param targets 接收对象列表
* @return int 插入的行数
* @author Claude
* @since 2025-11-13
*/
int batchInsert(@Param("targets") List<TbSysMessageTarget> targets);
/**
* 根据消息ID删除接收对象
*
* @param messageID 消息ID
* @return int 删除的行数
* @author Claude
* @since 2025-11-13
*/
int deleteByMessageID(@Param("messageID") String messageID);
}

View File

@@ -0,0 +1,168 @@
package org.xyzh.message.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.common.dto.message.TbSysMessageUser;
import org.xyzh.common.dto.message.MessageUserVO;
import java.util.List;
/**
* 用户消息Mapper接口
*
* @description 用户接收消息表的数据访问接口
* @filename MessageUserMapper.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Mapper
public interface MessageUserMapper extends BaseMapper<TbSysMessageUser> {
/**
* 根据消息ID查询用户消息列表
*
* @param messageID 消息ID
* @return List<MessageUserVO> 用户消息列表
* @author Claude
* @since 2025-11-13
*/
List<MessageUserVO> selectByMessageID(@Param("messageID") String messageID);
/**
* 批量插入用户消息
*
* @param userMessages 用户消息列表
* @return int 插入的行数
* @author Claude
* @since 2025-11-13
*/
int batchInsert(@Param("userMessages") List<TbSysMessageUser> userMessages);
/**
* 分页查询当前用户的消息列表
*
* @param userID 用户ID
* @param filter 过滤条件
* @return List<MessageUserVO> 消息列表
* @author Claude
* @since 2025-11-13
*/
List<MessageUserVO> selectMyMessages(@Param("userID") String userID,
@Param("filter") TbSysMessageUser filter);
/**
* 查询当前用户的消息详情
*
* @param userID 用户ID
* @param messageID 消息ID
* @return MessageUserVO 消息详情
* @author Claude
* @since 2025-11-13
*/
MessageUserVO selectMyMessageDetail(@Param("userID") String userID,
@Param("messageID") String messageID);
/**
* 标记消息为已读
*
* @param userID 用户ID
* @param messageID 消息ID
* @return int 更新的行数
* @author Claude
* @since 2025-11-13
*/
int markAsRead(@Param("userID") String userID,
@Param("messageID") String messageID);
/**
* 批量标记消息为已读
*
* @param userID 用户ID
* @param messageIDs 消息ID列表
* @return int 更新的行数
* @author Claude
* @since 2025-11-13
*/
int batchMarkAsRead(@Param("userID") String userID,
@Param("messageIDs") List<String> messageIDs);
/**
* 查询未读消息数量
*
* @param userID 用户ID
* @return Integer 未读消息数量
* @author Claude
* @since 2025-11-13
*/
Integer countUnread(@Param("userID") String userID);
/**
* 动态计算未读消息数量(基于 target 配置)
*
* @param userID 用户ID
* @return Integer 未读消息数量
* @author Claude
* @since 2025-11-13
*/
Integer countUnreadWithDynamicTargets(@Param("userID") String userID);
/**
* 更新用户消息的发送状态
*
* @param id 用户消息ID
* @param sendStatus 发送状态
* @param failReason 失败原因(可选)
* @return int 更新的行数
* @author Claude
* @since 2025-11-13
*/
int updateSendStatus(@Param("id") String id,
@Param("sendStatus") String sendStatus,
@Param("failReason") String failReason);
/**
* 查询待发送的用户消息列表
*
* @param filter 过滤条件
* @return List<TbSysMessageUser> 用户消息列表
* @author Claude
* @since 2025-11-13
*/
List<TbSysMessageUser> selectPendingUserMessages(@Param("filter") TbSysMessageUser filter);
/**
* 动态查询当前用户可见的消息列表(基于 target 配置计算)
*
* @param userID 用户ID
* @param filter 过滤条件
* @return List<MessageUserVO> 消息列表
* @author Claude
* @since 2025-11-13
*/
List<MessageUserVO> selectMyMessagesWithDynamicTargets(@Param("userID") String userID,
@Param("filter") MessageUserVO filter);
/**
* 查询或创建用户消息记录upsert
*
* @param userID 用户ID
* @param messageID 消息ID
* @return MessageUserVO 用户消息记录
* @author Claude
* @since 2025-11-13
*/
MessageUserVO selectOrCreateUserMessage(@Param("userID") String userID,
@Param("messageID") String messageID);
/**
* 插入用户消息记录(如果不存在)
*
* @param userMessage 用户消息
* @return int 插入的行数
* @author Claude
* @since 2025-11-13
*/
int insertIfNotExists(@Param("userMessage") TbSysMessageUser userMessage);
}

View File

@@ -0,0 +1,195 @@
package org.xyzh.message.scheduler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.xyzh.common.dto.message.TbSysMessage;
import org.xyzh.message.mapper.MessageMapper;
import org.xyzh.message.service.impl.MessageSendService;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
/**
* 消息定时任务扫描器
*
* @description 定时扫描待发送的定时消息并触发发送
* @filename MessageScheduler.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Component
public class MessageScheduler {
private static final Logger logger = LoggerFactory.getLogger(MessageScheduler.class);
@Autowired
private MessageMapper messageMapper;
@Autowired
private MessageSendService messageSendService;
/**
* 扫描待发送的定时消息
* 每分钟执行一次
*/
@Scheduled(cron = "0 * * * * ?")
public void scanPendingScheduledMessages() {
try {
logger.debug("开始扫描待发送的定时消息...");
LocalDateTime now = LocalDateTime.now();
// 查询满足条件的消息:
// 1. status=pending待发送
// 2. sendMode=scheduled定时发送
// 3. scheduledTime <= 当前时间
// 4. deleted=0未删除
List<TbSysMessage> messages = messageMapper.selectPendingScheduledMessages(now);
if (messages.isEmpty()) {
logger.debug("没有待发送的定时消息");
return;
}
logger.info("发现 {} 条待发送的定时消息", messages.size());
// 处理每条消息
for (TbSysMessage message : messages) {
try {
// 使用CASCompare-And-Set更新状态防止并发重复触发
boolean updated = messageMapper.compareAndSetStatus(
message.getMessageID(),
"pending", // 期望的当前状态
"sending" // 新状态
) > 0;
if (!updated) {
logger.warn("消息 {} 状态已被修改,跳过处理", message.getMessageID());
continue;
}
// 更新实际发送时间和状态
message.setActualSendTime(new Date());
message.setStatus("sending");
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
// 异步发送消息
messageSendService.sendMessageAsync(message.getMessageID());
logger.info("定时消息 {} 已触发发送", message.getMessageID());
} catch (Exception e) {
logger.error("处理定时消息失败:{}", message.getMessageID(), e);
handleSendError(message, e);
}
}
logger.info("定时消息扫描完成,已处理 {} 条消息", messages.size());
} catch (Exception e) {
logger.error("扫描定时消息时发生错误", e);
}
}
/**
* 处理发送失败,支持重试机制
*
* @param message 消息对象
* @param e 异常信息
*/
private void handleSendError(TbSysMessage message, Exception e) {
try {
int retryCount = message.getRetryCount() != null ? message.getRetryCount() : 0;
int maxRetryCount = message.getMaxRetryCount() != null ? message.getMaxRetryCount() : 3;
if (retryCount < maxRetryCount) {
// 未达到最大重试次数,增加重试计数
message.setRetryCount(retryCount + 1);
message.setStatus("pending"); // 重新置为待发送状态
// 设置下次重试时间5分钟后
LocalDateTime nextRetryTime = LocalDateTime.now().plusMinutes(5);
message.setScheduledTime(convertToDate(nextRetryTime));
message.setLastError(e.getMessage());
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
logger.warn("消息 {} 发送失败将在5分钟后重试 ({}/{})",
message.getMessageID(), retryCount + 1, maxRetryCount);
} else {
// 超过最大重试次数,标记为失败
message.setStatus("failed");
message.setLastError("超过最大重试次数:" + e.getMessage());
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
logger.error("消息 {} 发送失败且已超过最大重试次数", message.getMessageID());
}
} catch (Exception ex) {
logger.error("处理发送错误时发生异常", ex);
}
}
/**
* 将LocalDateTime转换为Date
*/
private Date convertToDate(LocalDateTime localDateTime) {
return java.sql.Timestamp.valueOf(localDateTime);
}
/**
* 每小时清理一次已取消和已失败的旧消息(可选功能)
* 将超过30天的已取消/失败消息标记为删除
*/
@Scheduled(cron = "0 0 * * * ?")
public void cleanupOldMessages() {
try {
logger.debug("开始清理旧消息...");
// 计算30天前的时间
LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30);
// TODO: 实现清理逻辑
// 清理条件:
// 1. status in ('cancelled', 'failed')
// 2. updateTime < 30天前
// 3. deleted = 0
logger.debug("旧消息清理完成");
} catch (Exception e) {
logger.error("清理旧消息时发生错误", e);
}
}
/**
* 每天凌晨生成消息统计报告(可选功能)
*/
@Scheduled(cron = "0 0 0 * * ?")
public void generateDailyReport() {
try {
logger.info("开始生成每日消息统计报告...");
// TODO: 实现统计报告生成逻辑
// 统计内容:
// 1. 今日发送消息总数
// 2. 发送成功率
// 3. 已读率
// 4. 各发送方式的使用情况
logger.info("每日消息统计报告生成完成");
} catch (Exception e) {
logger.error("生成每日报告时发生错误", e);
}
}
}

View File

@@ -0,0 +1,319 @@
package org.xyzh.message.service.impl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.xyzh.common.dto.message.TbSysMessage;
import org.xyzh.common.dto.message.TbSysMessageUser;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.common.utils.EmailUtils;
import org.xyzh.common.utils.SmsUtils;
import org.xyzh.message.mapper.MessageMapper;
import org.xyzh.message.mapper.MessageUserMapper;
import org.xyzh.system.mapper.UserMapper;
import java.util.Date;
import java.util.List;
/**
* 消息发送服务
*
* @description 异步发送消息的服务类
* @filename MessageSendService.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Service
public class MessageSendService {
private static final Logger logger = LoggerFactory.getLogger(MessageSendService.class);
@Autowired
private MessageMapper messageMapper;
@Autowired
private MessageUserMapper messageUserMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private EmailUtils emailUtils;
@Autowired
private SmsUtils smsUtils;
/**
* 异步发送消息
*
* @param messageID 消息ID
*/
@Async("messageAsyncExecutor")
@Transactional(rollbackFor = Exception.class)
public void sendMessageAsync(String messageID) {
try {
logger.info("开始发送消息:{}", messageID);
// 1. 查询消息
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
logger.error("消息不存在:{}", messageID);
return;
}
// 2. 查询所有待发送的用户消息
TbSysMessageUser filter = new TbSysMessageUser();
filter.setMessageID(messageID);
filter.setSendStatus("pending");
filter.setDeleted(false);
List<TbSysMessageUser> userMessages = messageUserMapper.selectPendingUserMessages(filter);
if (userMessages.isEmpty()) {
logger.warn("没有待发送的用户消息:{}", messageID);
updateMessageStatus(message, "sent");
return;
}
// 3. 遍历发送
int successCount = 0;
int failedCount = 0;
for (TbSysMessageUser userMessage : userMessages) {
try {
// 查询用户信息
TbSysUser user = userMapper.selectUserById(userMessage.getUserID());
if (user == null || user.getDeleted()) {
logger.warn("用户不存在:{}", userMessage.getUserID());
updateUserMessageStatus(userMessage.getID(), "failed", "用户不存在");
failedCount++;
continue;
}
// 根据发送方式发送消息
String[] methods = userMessage.getSendMethod().split(",");
boolean sent = false;
for (String method : methods) {
method = method.trim();
try {
switch (method) {
case "system":
// 系统消息已在数据库中,无需额外操作
sent = true;
break;
case "email":
sent = sendEmail(user, message);
break;
case "sms":
sent = sendSms(user, message);
break;
default:
logger.warn("未知的发送方式:{}", method);
}
if (sent) {
break; // 任意一种方式成功即可
}
} catch (Exception e) {
logger.error("发送消息失败 [{}] - 用户:{}, 方式:{}", messageID, user.getUsername(), method, e);
}
}
// 更新用户消息发送状态
if (sent) {
updateUserMessageStatus(userMessage.getID(), "success", null);
successCount++;
} else {
updateUserMessageStatus(userMessage.getID(), "failed", "所有发送方式均失败");
failedCount++;
}
} catch (Exception e) {
logger.error("处理用户消息失败:{}", userMessage.getID(), e);
updateUserMessageStatus(userMessage.getID(), "failed", e.getMessage());
failedCount++;
}
}
// 4. 更新消息统计信息
int sentCount = successCount + failedCount;
messageMapper.updateStatistics(messageID, sentCount, successCount, failedCount);
// 5. 更新消息状态
if (failedCount == userMessages.size()) {
// 全部失败
updateMessageStatus(message, "failed");
} else {
// 至少有一个成功
updateMessageStatus(message, "sent");
}
logger.info("消息发送完成:{} - 成功:{}, 失败:{}", messageID, successCount, failedCount);
} catch (Exception e) {
logger.error("发送消息异常:{}", messageID, e);
// 更新消息状态为失败
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message != null && !message.getDeleted()) {
message.setStatus("failed");
message.setLastError(e.getMessage());
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
}
} catch (Exception ex) {
logger.error("更新消息状态失败", ex);
}
}
}
/**
* 发送邮件
*
* @param user 用户
* @param message 消息
* @return 是否成功
*/
private boolean sendEmail(TbSysUser user, TbSysMessage message) {
try {
if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
logger.warn("用户 {} 没有邮箱地址", user.getUsername());
return false;
}
// 构建HTML邮件内容
String htmlContent = buildEmailHtml(message);
// 发送邮件
boolean result = emailUtils.sendHtmlEmail(
user.getEmail(),
message.getTitle(),
htmlContent
);
if (result) {
logger.info("邮件发送成功 - 用户:{}, 邮箱:{}", user.getUsername(), user.getEmail());
} else {
logger.warn("邮件发送失败 - 用户:{}, 邮箱:{}", user.getUsername(), user.getEmail());
}
return result;
} catch (Exception e) {
logger.error("发送邮件异常 - 用户:{}", user.getUsername(), e);
return false;
}
}
/**
* 发送短信
*
* @param user 用户
* @param message 消息
* @return 是否成功
*/
private boolean sendSms(TbSysUser user, TbSysMessage message) {
try {
if (user.getPhone() == null || user.getPhone().trim().isEmpty()) {
logger.warn("用户 {} 没有手机号", user.getUsername());
return false;
}
// 短信内容(限制长度)
String smsContent = message.getTitle();
if (message.getContent() != null && message.getContent().length() < 50) {
smsContent += "" + message.getContent();
}
// 发送短信
boolean result = smsUtils.sendSms(user.getPhone(), smsContent, "xx");
if (result) {
logger.info("短信发送成功 - 用户:{}, 手机:{}", user.getUsername(), user.getPhone());
} else {
logger.warn("短信发送失败 - 用户:{}, 手机:{}", user.getUsername(), user.getPhone());
}
return result;
} catch (Exception e) {
logger.error("发送短信异常 - 用户:{}", user.getUsername(), e);
return false;
}
}
/**
* 构建邮件HTML内容
*/
private String buildEmailHtml(TbSysMessage message) {
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html>");
html.append("<html>");
html.append("<head>");
html.append("<meta charset=\"UTF-8\">");
html.append("<style>");
html.append("body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }");
html.append(".container { max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 5px; }");
html.append(".header { background-color: #c8232c; color: white; padding: 15px; border-radius: 5px 5px 0 0; }");
html.append(".content { padding: 20px; background-color: #f9f9f9; }");
html.append(".footer { text-align: center; padding: 15px; font-size: 12px; color: #999; }");
html.append(".priority-urgent { border-left: 4px solid #ff0000; }");
html.append(".priority-important { border-left: 4px solid #ff9900; }");
html.append(".priority-normal { border-left: 4px solid #00aa00; }");
html.append("</style>");
html.append("</head>");
html.append("<body>");
html.append("<div class=\"container priority-").append(message.getPriority()).append("\">");
html.append("<div class=\"header\">");
html.append("<h2>").append(message.getTitle()).append("</h2>");
html.append("</div>");
html.append("<div class=\"content\">");
html.append(message.getContent());
html.append("</div>");
html.append("<div class=\"footer\">");
html.append("<p>发送人:").append(message.getSenderName()).append(" (").append(message.getSenderDeptName()).append(")</p>");
html.append("<p>发送时间:").append(message.getActualSendTime() != null ? message.getActualSendTime().toString() : "").append("</p>");
html.append("<p>此邮件由系统自动发送,请勿回复。</p>");
html.append("</div>");
html.append("</div>");
html.append("</body>");
html.append("</html>");
return html.toString();
}
/**
* 更新消息状态
*/
private void updateMessageStatus(TbSysMessage message, String status) {
try {
message.setStatus(status);
if ("sent".equals(status)) {
message.setActualSendTime(new Date());
}
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
} catch (Exception e) {
logger.error("更新消息状态失败", e);
}
}
/**
* 更新用户消息发送状态
*/
private void updateUserMessageStatus(String id, String status, String failReason) {
try {
messageUserMapper.updateSendStatus(id, status, failReason);
} catch (Exception e) {
logger.error("更新用户消息状态失败", e);
}
}
}

View File

@@ -0,0 +1,746 @@
package org.xyzh.message.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xyzh.api.message.MessageService;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.dto.message.*;
import org.xyzh.common.utils.IDUtils;
import org.xyzh.message.mapper.MessageMapper;
import org.xyzh.message.mapper.MessageTargetMapper;
import org.xyzh.message.mapper.MessageUserMapper;
import org.xyzh.system.mapper.DepartmentMapper;
import org.xyzh.system.mapper.UserMapper;
import java.util.*;
/**
* 消息服务实现类
*
* @description 消息通知模块的服务实现
* @filename MessageServiceImpl.java
* @author Claude
* @copyright xyzh
* @since 2025-11-13
*/
@Service
public class MessageServiceImpl implements MessageService {
private static final Logger logger = LoggerFactory.getLogger(MessageServiceImpl.class);
@Autowired
private MessageMapper messageMapper;
@Autowired
private MessageTargetMapper messageTargetMapper;
@Autowired
private MessageUserMapper messageUserMapper;
@Autowired
private DepartmentMapper departmentMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private MessageSendService messageSendService;
/**
* 创建消息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> createMessage(TbSysMessage message) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
// 1. 获取当前用户信息从Session或SecurityContext获取
String currentUserID = getCurrentUserID();
String currentDeptID = getCurrentUserDeptID();
// 2. 设置消息主体基本信息
if (message.getID() == null) {
message.setID(IDUtils.generateID());
}
if (message.getMessageID() == null) {
message.setMessageID(IDUtils.generateID());
}
message.setSenderID(currentUserID);
message.setSenderDeptID(currentDeptID);
// 设置状态
if (message.getStatus() == null) {
if ("immediate".equals(message.getSendMode())) {
message.setStatus("sending");
} else {
message.setStatus("pending");
}
}
if (message.getTargetUserCount() == null) {
message.setTargetUserCount(0);
}
if (message.getRetryCount() == null) {
message.setRetryCount(0);
}
if (message.getMaxRetryCount() == null) {
message.setMaxRetryCount(3);
}
message.setCreator(currentUserID);
message.setCreateTime(new Date());
message.setUpdateTime(new Date());
message.setDeleted(false);
// 保存消息主体
messageMapper.insertMessage(message);
// 3. 保存接收对象配置
List<TbSysMessageTarget> targets = message.getTargets();
if (targets != null && !targets.isEmpty()) {
for (TbSysMessageTarget target : targets) {
// 权限校验scopeDeptID必须是当前部门或子部门
if (!isCurrentOrSubDept(currentDeptID, target.getScopeDeptID())) {
rt.fail("无权向该部门发送消息");
return rt;
}
if (target.getID() == null) {
target.setID(IDUtils.generateID());
}
target.setMessageID(message.getMessageID());
target.setCreator(currentUserID);
target.setCreateTime(new Date());
target.setUpdateTime(new Date());
target.setDeleted(false);
}
messageTargetMapper.batchInsert(targets);
// 4. 解析接收对象,生成用户消息列表
List<TbSysMessageUser> userMessages = resolveTargetUsers(message.getMessageID(), targets, currentUserID);
if (!userMessages.isEmpty()) {
messageUserMapper.batchInsert(userMessages);
}
// 5. 更新目标用户总数
message.setTargetUserCount(userMessages.size());
messageMapper.updateMessage(message);
// 6. 如果是立即发送,异步发送消息
if ("immediate".equals(message.getSendMode())) {
messageSendService.sendMessageAsync(message.getMessageID());
}
}
rt.success("创建成功", message);
return rt;
} catch (Exception e) {
logger.error("创建消息失败", e);
rt.fail("创建消息失败:" + e.getMessage());
return rt;
}
}
/**
* 更新消息(仅允许更新草稿状态的消息)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> updateMessage(TbSysMessage message) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
// 查询原消息
TbSysMessage existingMessage = messageMapper.selectMessageById(message.getMessageID());
if (existingMessage == null || existingMessage.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
// 只允许更新草稿状态的消息
if (!"draft".equals(existingMessage.getStatus())) {
rt.fail("只能更新草稿状态的消息");
return rt;
}
message.setUpdateTime(new Date());
message.setUpdater(getCurrentUserID());
messageMapper.updateMessage(message);
rt.success("更新成功", message);
return rt;
} catch (Exception e) {
logger.error("更新消息失败", e);
rt.fail("更新消息失败:"+e.getMessage());
return rt;
}
}
/**
* 删除消息(逻辑删除)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> deleteMessage(String messageID) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
int result = messageMapper.deleteMessage(messageID);
if (result > 0) {
// 同时删除关联的接收对象和用户消息
messageTargetMapper.deleteByMessageID(messageID);
rt.success("删除成功", message);
} else {
rt.fail("删除失败");
}
return rt;
} catch (Exception e) {
logger.error("删除消息失败", e);
rt.fail("删除消息失败:" + e.getMessage());
return rt;
}
}
/**
* 根据ID查询消息详情
*/
@Override
public ResultDomain<MessageVO> getMessageById(String messageID) {
ResultDomain<MessageVO> rt = new ResultDomain<>();
try {
MessageVO messageVO = messageMapper.selectMessageDetail(messageID);
if (messageVO == null) {
rt.fail("消息不存在");
return rt;
}
// 查询接收对象列表
List<TbSysMessageTarget> targets = messageTargetMapper.selectByMessageID(messageID);
messageVO.setTargets(targets);
// 查询用户接收记录
List<MessageUserVO> userMessages = messageUserMapper.selectByMessageID(messageID);
messageVO.setUserMessages(userMessages);
rt.success("查询成功", messageVO);
return rt;
} catch (Exception e) {
logger.error("查询消息详情失败", e);
rt.fail("查询消息详情失败:" + e.getMessage());
return rt;
}
}
/**
* 分页查询消息列表(管理端)
*/
@Override
public ResultDomain<MessageVO> getMessagePage(TbSysMessage filter, PageParam pageParam) {
ResultDomain<MessageVO> rt = new ResultDomain<>();
try {
if (filter == null) {
filter = new TbSysMessage();
}
filter.setDeleted(false);
if (pageParam == null) {
pageParam = new PageParam();
}
String currentDeptID = getCurrentUserDeptID();
List<MessageVO> list = messageMapper.selectMessagePage(filter, currentDeptID);
int total = messageMapper.countMessage(filter, currentDeptID);
PageDomain<MessageVO> pageDomain = new PageDomain<>();
pageDomain.setDataList(list);
pageParam.setTotalElements(total);
pageParam.setTotalPages((int) Math.ceil((double) total / pageParam.getPageSize()));
pageDomain.setPageParam(pageParam);
rt.success("查询成功", pageDomain);
return rt;
} catch (Exception e) {
logger.error("查询消息列表失败", e);
rt.fail("查询消息列表失败:" + e.getMessage());
return rt;
}
}
/**
* 发送消息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> sendMessage(String messageID) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
// 更新状态为发送中
message.setStatus("sending");
message.setActualSendTime(new Date());
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
// 异步发送
messageSendService.sendMessageAsync(messageID);
rt.success("发送成功", message);
return rt;
} catch (Exception e) {
logger.error("发送消息失败", e);
rt.fail("发送消息失败:" + e.getMessage());
return rt;
}
}
/**
* 立即发送(将定时消息改为立即发送)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> sendNow(String messageID) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
if (!"pending".equals(message.getStatus())) {
rt.fail("只能立即发送待发送状态的消息");
return rt;
}
// 更新为立即发送模式
message.setSendMode("immediate");
message.setStatus("sending");
message.setActualSendTime(new Date());
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
// 异步发送
messageSendService.sendMessageAsync(messageID);
rt.success("立即发送成功", message);
return rt;
} catch (Exception e) {
logger.error("立即发送消息失败", e);
rt.fail("立即发送消息失败:" + e.getMessage());
return rt;
}
}
/**
* 取消定时消息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> cancelMessage(String messageID) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
if (!"pending".equals(message.getStatus())) {
rt.fail("只能取消待发送状态的消息");
return rt;
}
message.setStatus("cancelled");
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
rt.success("取消成功", message);
return rt;
} catch (Exception e) {
logger.error("取消消息失败", e);
rt.fail("取消消息失败:" + e.getMessage());
return rt;
}
}
/**
* 修改定时发送时间
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> rescheduleMessage(String messageID, Date scheduledTime) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
if (!"pending".equals(message.getStatus())) {
rt.fail("只能修改待发送状态的消息");
return rt;
}
message.setScheduledTime(scheduledTime);
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
rt.success("修改成功", message);
return rt;
} catch (Exception e) {
logger.error("修改定时时间失败", e);
rt.fail("修改定时时间失败:" + e.getMessage());
return rt;
}
}
/**
* 重试失败消息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessage> retryMessage(String messageID) {
ResultDomain<TbSysMessage> rt = new ResultDomain<>();
try {
TbSysMessage message = messageMapper.selectMessageById(messageID);
if (message == null || message.getDeleted()) {
rt.fail("消息不存在");
return rt;
}
if (!"failed".equals(message.getStatus())) {
rt.fail("只能重试失败状态的消息");
return rt;
}
// 重置状态
message.setStatus("sending");
message.setRetryCount(message.getRetryCount() + 1);
message.setUpdateTime(new Date());
messageMapper.updateMessage(message);
// 异步发送
messageSendService.sendMessageAsync(messageID);
rt.success("重试成功", message);
return rt;
} catch (Exception e) {
logger.error("重试消息失败", e);
rt.fail("重试消息失败:" + e.getMessage());
return rt;
}
}
// ================== 用户消息相关方法 ==================
@Override
public ResultDomain<MessageUserVO> getMyMessagesPage(MessageUserVO filter, PageParam pageParam) {
ResultDomain<MessageUserVO> rt = new ResultDomain<>();
try {
if (filter == null) {
filter = new MessageUserVO();
}
if (pageParam == null) {
pageParam = new PageParam();
}
String currentUserID = getCurrentUserID();
// 使用新的动态查询方法
List<MessageUserVO> list = messageUserMapper.selectMyMessagesWithDynamicTargets(currentUserID, filter);
PageDomain<MessageUserVO> pageDomain = new PageDomain<>();
pageDomain.setDataList(list);
pageParam.setTotalElements(list.size());
pageParam.setTotalPages((int) Math.ceil((double) list.size() / pageParam.getPageSize()));
pageDomain.setPageParam(pageParam);
rt.success("查询成功", pageDomain);
return rt;
} catch (Exception e) {
logger.error("查询我的消息失败", e);
rt.fail("查询我的消息失败:" + e.getMessage());
return rt;
}
}
@Override
public ResultDomain<MessageUserVO> getMyMessageDetail(String messageID) {
ResultDomain<MessageUserVO> rt = new ResultDomain<>();
try {
String currentUserID = getCurrentUserID();
MessageUserVO messageUserVO = messageUserMapper.selectOrCreateUserMessage(currentUserID, messageID);
if (messageUserVO == null) {
rt.fail("消息不存在");
return rt;
}
// 如果用户消息记录不存在id 为 null创建新记录
if (messageUserVO.getId() == null) {
logger.info("用户首次查看消息创建用户消息记录userID={}, messageID={}", currentUserID, messageID);
TbSysMessageUser userMessage = new TbSysMessageUser();
userMessage.setID(IDUtils.generateID());
userMessage.setMessageID(messageID);
userMessage.setUserID(currentUserID);
userMessage.setSendMethod("system"); // 默认发送方式
userMessage.setIsRead(false); // 初始为未读
userMessage.setSendStatus("success");
userMessage.setCreator(currentUserID);
userMessage.setCreateTime(new Date());
userMessage.setUpdateTime(new Date());
userMessage.setDeleted(false);
// 插入新记录
messageUserMapper.insertIfNotExists(userMessage);
// 重新查询以获取完整信息
messageUserVO = messageUserMapper.selectMyMessageDetail(currentUserID, messageID);
}
rt.success("查询成功", messageUserVO);
return rt;
} catch (Exception e) {
logger.error("查询消息详情失败", e);
rt.fail("查询消息详情失败:" + e.getMessage());
return rt;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbSysMessageUser> markAsRead(String messageID) {
ResultDomain<TbSysMessageUser> rt = new ResultDomain<>();
try {
String currentUserID = getCurrentUserID();
// 先尝试更新已有记录
int result = messageUserMapper.markAsRead(currentUserID, messageID);
// 如果没有更新任何记录,说明用户消息记录不存在,需要先插入
if (result == 0) {
logger.info("用户消息记录不存在创建新记录userID={}, messageID={}", currentUserID, messageID);
TbSysMessageUser userMessage = new TbSysMessageUser();
userMessage.setID(IDUtils.generateID());
userMessage.setMessageID(messageID);
userMessage.setUserID(currentUserID);
userMessage.setSendMethod("system"); // 默认发送方式
userMessage.setIsRead(true); // 直接设置为已读
userMessage.setSendStatus("success");
userMessage.setCreator(currentUserID);
userMessage.setCreateTime(new Date());
userMessage.setUpdateTime(new Date());
userMessage.setDeleted(false);
// 插入新记录
int insertResult = messageUserMapper.insertIfNotExists(userMessage);
if (insertResult > 0) {
// 插入成功后再次标记为已读(设置 read_time
messageUserMapper.markAsRead(currentUserID, messageID);
result = 1;
}
}
if (result > 0) {
// 更新消息的已读数量
messageMapper.incrementReadCount(messageID);
TbSysMessageUser messageUser = new TbSysMessageUser();
rt.success("标记成功", messageUser);
} else {
rt.fail("标记失败");
}
return rt;
} catch (Exception e) {
logger.error("标记已读失败", e);
rt.fail("标记已读失败:" + e.getMessage());
return rt;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<Integer> batchMarkAsRead(List<String> messageIDs) {
ResultDomain<Integer> rt = new ResultDomain<>();
try {
String currentUserID = getCurrentUserID();
int count = messageUserMapper.batchMarkAsRead(currentUserID, messageIDs);
// 更新每条消息的已读数量
for (String messageID : messageIDs) {
messageMapper.incrementReadCount(messageID);
}
rt.success("批量标记成功", count);
return rt;
} catch (Exception e) {
logger.error("批量标记已读失败", e);
rt.fail("批量标记已读失败:" + e.getMessage());
return rt;
}
}
@Override
public ResultDomain<Integer> getUnreadCount() {
ResultDomain<Integer> rt = new ResultDomain<>();
try {
String currentUserID = getCurrentUserID();
// 使用动态计算方法,统计用户应该看到的所有未读消息
Integer count = messageUserMapper.countUnreadWithDynamicTargets(currentUserID);
rt.success("查询成功", count != null ? count : 0);
return rt;
} catch (Exception e) {
logger.error("查询未读数量失败", e);
rt.fail("查询未读数量失败:" + e.getMessage());
return rt;
}
}
// ================== 辅助方法 ==================
@Override
public ResultDomain<Map<String, Object>> getTargetDepts() {
ResultDomain<Map<String, Object>> rt = new ResultDomain<>();
// TODO: 实现获取可选部门树
rt.success("查询成功", new HashMap<>());
return rt;
}
@Override
public ResultDomain<Map<String, Object>> getTargetRoles() {
ResultDomain<Map<String, Object>> rt = new ResultDomain<>();
// TODO: 实现获取可选角色列表
rt.success("查询成功", new HashMap<>());
return rt;
}
@Override
public ResultDomain<Map<String, Object>> getTargetUsers() {
ResultDomain<Map<String, Object>> rt = new ResultDomain<>();
// TODO: 实现获取可选用户列表
rt.success("查询成功", new HashMap<>());
return rt;
}
// ================== 私有辅助方法 ==================
/**
* 解析接收对象,生成用户消息列表
*/
private List<TbSysMessageUser> resolveTargetUsers(String messageID, List<TbSysMessageTarget> targets, String creator) {
Set<String> userIDSet = new HashSet<>();
Map<String, String> userMethodMap = new HashMap<>();
for (TbSysMessageTarget target : targets) {
List<String> userIDs = new ArrayList<>();
switch (target.getTargetType()) {
case "dept":
// 查询该部门及所有子部门的用户
userIDs = userMapper.selectUserIdsByDeptId(target.getTargetID());
logger.info("部门 {} 解析到 {} 个用户", target.getTargetID(), userIDs.size());
break;
case "role":
// 查询scopeDeptID及子部门中该角色的用户
String scopeDeptID = target.getScopeDeptID();
if (scopeDeptID == null || scopeDeptID.isEmpty()) {
logger.warn("角色目标缺少 scopeDeptID跳过{}", target.getTargetID());
break;
}
userIDs = userMapper.selectUserIdsByDeptRole(scopeDeptID, target.getTargetID());
logger.info("部门 {} 中角色 {} 解析到 {} 个用户", scopeDeptID, target.getTargetID(), userIDs.size());
break;
case "user":
userIDs.add(target.getTargetID());
break;
}
for (String userID : userIDs) {
userIDSet.add(userID);
userMethodMap.put(userID, target.getSendMethod());
}
}
// 生成用户消息列表
List<TbSysMessageUser> userMessages = new ArrayList<>();
for (String userID : userIDSet) {
TbSysMessageUser userMessage = new TbSysMessageUser();
userMessage.setID(IDUtils.generateID());
userMessage.setMessageID(messageID);
userMessage.setUserID(userID);
userMessage.setSendMethod(userMethodMap.get(userID));
userMessage.setIsRead(false);
userMessage.setSendStatus("pending");
userMessage.setCreator(creator);
userMessage.setCreateTime(new Date());
userMessage.setUpdateTime(new Date());
userMessage.setDeleted(false);
userMessages.add(userMessage);
}
logger.info("消息 {} 共解析到 {} 个目标用户", messageID, userMessages.size());
return userMessages;
}
/**
* 检查目标部门是否是当前部门或子部门
*/
private boolean isCurrentOrSubDept(String currentDeptID, String targetDeptID) {
// TODO: 实现部门层级检查
return true;
}
/**
* 获取当前用户ID
*/
private String getCurrentUserID() {
// TODO: 从SecurityContext或Session获取
return "1";
}
/**
* 获取当前用户部门ID
*/
private String getCurrentUserDeptID() {
// TODO: 从SecurityContext或Session获取
return "root_department";
}
}

View File

@@ -0,0 +1,44 @@
# Message模块配置文件
# 此配置文件定义message模块独立运行时的配置
# 如需作为微服务集成请参考admin模块的bootstrap.yml配置
# 消息模块配置
message:
# 定时任务扫描配置
scheduler:
# 扫描频率cron表达式默认每分钟扫描一次
cron: "0 * * * * ?"
# 是否启用定时任务扫描
enabled: true
# 消息发送配置
send:
# 异步发送线程池大小
thread-pool-size: 10
# 批量发送每批次大小
batch-size: 100
# Spring Boot配置如果需要独立运行
# server:
# port: 8087
#
# spring:
# application:
# name: message-service
#
# datasource:
# url: jdbc:mysql://localhost:3306/school_news?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
# username: root
# password: your_password
# driver-class-name: com.mysql.cj.jdbc.Driver
# hikari:
# maximum-pool-size: 20
# minimum-idle: 5
# connection-timeout: 30000
#
# mybatis-plus:
# mapper-locations: classpath:mapper/*.xml
# type-aliases-package: org.xyzh.common.dto.message
# configuration:
# map-underscore-to-camel-case: true
# log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

View File

@@ -0,0 +1,279 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzh.message.mapper.MessageMapper">
<!-- Result Map -->
<resultMap id="BaseResultMap" type="org.xyzh.common.dto.message.TbSysMessage">
<id column="id" property="id" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="title" property="title" jdbcType="VARCHAR"/>
<result column="content" property="content" jdbcType="VARCHAR"/>
<result column="message_type" property="messageType" jdbcType="VARCHAR"/>
<result column="priority" property="priority" jdbcType="VARCHAR"/>
<result column="sender_id" property="senderID" jdbcType="VARCHAR"/>
<result column="sender_name" property="senderName" jdbcType="VARCHAR"/>
<result column="sender_dept_id" property="senderDeptID" jdbcType="VARCHAR"/>
<result column="sender_dept_name" property="senderDeptName" jdbcType="VARCHAR"/>
<result column="send_mode" property="sendMode" jdbcType="VARCHAR"/>
<result column="scheduled_time" property="scheduledTime" jdbcType="TIMESTAMP"/>
<result column="actual_send_time" property="actualSendTime" jdbcType="TIMESTAMP"/>
<result column="status" property="status" jdbcType="VARCHAR"/>
<result column="target_user_count" property="targetUserCount" jdbcType="INTEGER"/>
<result column="sent_count" property="sentCount" jdbcType="INTEGER"/>
<result column="success_count" property="successCount" jdbcType="INTEGER"/>
<result column="failed_count" property="failedCount" jdbcType="INTEGER"/>
<result column="read_count" property="readCount" jdbcType="INTEGER"/>
<result column="retry_count" property="retryCount" jdbcType="INTEGER"/>
<result column="max_retry_count" property="maxRetryCount" jdbcType="INTEGER"/>
<result column="last_error" property="lastError" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="BOOLEAN"/>
</resultMap>
<resultMap id="MessageVOMap" type="org.xyzh.common.dto.message.MessageVO">
<id column="id" property="id" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="title" property="title" jdbcType="VARCHAR"/>
<result column="content" property="content" jdbcType="VARCHAR"/>
<result column="message_type" property="messageType" jdbcType="VARCHAR"/>
<result column="priority" property="priority" jdbcType="VARCHAR"/>
<result column="sender_id" property="senderID" jdbcType="VARCHAR"/>
<result column="sender_name" property="senderName" jdbcType="VARCHAR"/>
<result column="sender_dept_id" property="senderDeptID" jdbcType="VARCHAR"/>
<result column="sender_dept_name" property="senderDeptName" jdbcType="VARCHAR"/>
<result column="send_mode" property="sendMode" jdbcType="VARCHAR"/>
<result column="scheduled_time" property="scheduledTime" jdbcType="TIMESTAMP"/>
<result column="actual_send_time" property="actualSendTime" jdbcType="TIMESTAMP"/>
<result column="status" property="status" jdbcType="VARCHAR"/>
<result column="target_user_count" property="targetUserCount" jdbcType="INTEGER"/>
<result column="sent_count" property="sentCount" jdbcType="INTEGER"/>
<result column="success_count" property="successCount" jdbcType="INTEGER"/>
<result column="failed_count" property="failedCount" jdbcType="INTEGER"/>
<result column="read_count" property="readCount" jdbcType="INTEGER"/>
<result column="retry_count" property="retryCount" jdbcType="INTEGER"/>
<result column="max_retry_count" property="maxRetryCount" jdbcType="INTEGER"/>
<result column="last_error" property="lastError" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
</resultMap>
<!-- 分页查询消息列表(带权限过滤) -->
<select id="selectMessagePage" resultMap="MessageVOMap">
SELECT
m.*
FROM tb_sys_message m
WHERE m.deleted = 0
<if test="filter != null">
<if test="filter.title != null and filter.title != ''">
AND m.title LIKE CONCAT('%', #{filter.title}, '%')
</if>
<if test="filter.messageType != null and filter.messageType != ''">
AND m.message_type = #{filter.messageType}
</if>
<if test="filter.status != null and filter.status != ''">
AND m.status = #{filter.status}
</if>
<if test="filter.sendMode != null and filter.sendMode != ''">
AND m.send_mode = #{filter.sendMode}
</if>
<if test="filter.priority != null and filter.priority != ''">
AND m.priority = #{filter.priority}
</if>
</if>
<!-- 权限过滤:只能查看自己部门及子部门的消息 -->
AND (
m.sender_dept_id = #{currentUserDeptID}
OR m.sender_dept_id IN (
WITH RECURSIVE dept_tree AS (
SELECT dept_id FROM tb_sys_dept
WHERE dept_id = #{currentUserDeptID} AND deleted = 0
UNION ALL
SELECT d.dept_id FROM tb_sys_dept d
INNER JOIN dept_tree dt ON d.parent_id = dt.dept_id
WHERE d.deleted = 0
)
SELECT dept_id FROM dept_tree
)
)
ORDER BY m.create_time DESC
</select>
<!-- 查询消息详情 -->
<select id="selectMessageDetail" resultMap="MessageVOMap">
SELECT *
FROM tb_sys_message
WHERE message_id = #{messageID}
AND deleted = 0
</select>
<!-- 查询待发送的定时消息 -->
<select id="selectPendingScheduledMessages" resultMap="BaseResultMap">
SELECT *
FROM tb_sys_message
WHERE status = 'pending'
AND send_mode = 'scheduled'
AND scheduled_time <![CDATA[ <= ]]> #{currentTime}
AND deleted = 0
ORDER BY scheduled_time ASC
LIMIT 100
</select>
<!-- CAS更新消息状态 -->
<update id="compareAndSetStatus">
UPDATE tb_sys_message
SET status = #{newStatus},
update_time = NOW()
WHERE message_id = #{messageID}
AND status = #{expectedStatus}
AND deleted = 0
</update>
<!-- 更新消息统计信息 -->
<update id="updateStatistics">
UPDATE tb_sys_message
SET sent_count = #{sentCount},
success_count = #{successCount},
failed_count = #{failedCount},
update_time = NOW()
WHERE message_id = #{messageID}
AND deleted = 0
</update>
<!-- 更新已读数量 -->
<update id="incrementReadCount">
UPDATE tb_sys_message
SET read_count = read_count + 1,
update_time = NOW()
WHERE message_id = #{messageID}
AND deleted = 0
</update>
<!-- 根据消息ID查询消息 -->
<select id="selectMessageById" resultMap="BaseResultMap">
SELECT *
FROM tb_sys_message
WHERE message_id = #{messageID}
AND deleted = 0
</select>
<!-- 插入消息 -->
<insert id="insertMessage">
INSERT INTO tb_sys_message
(id, message_id, title, content, message_type, priority, sender_id, sender_dept_id,
send_mode, scheduled_time, status, target_user_count, retry_count, max_retry_count,
creator, create_time, update_time, deleted)
VALUES
(#{message.id}, #{message.messageID}, #{message.title}, #{message.content},
#{message.messageType}, #{message.priority}, #{message.senderID}, #{message.senderDeptID},
#{message.sendMode}, #{message.scheduledTime}, #{message.status}, #{message.targetUserCount},
#{message.retryCount}, #{message.maxRetryCount}, #{message.creator}, #{message.createTime},
#{message.updateTime}, #{message.deleted})
</insert>
<!-- 更新消息 -->
<update id="updateMessage">
UPDATE tb_sys_message
<set>
<if test="message.title != null and message.title != ''">
title = #{message.title},
</if>
<if test="message.content != null">
content = #{message.content},
</if>
<if test="message.messageType != null and message.messageType != ''">
message_type = #{message.messageType},
</if>
<if test="message.priority != null and message.priority != ''">
priority = #{message.priority},
</if>
<if test="message.sendMode != null and message.sendMode != ''">
send_mode = #{message.sendMode},
</if>
<if test="message.scheduledTime != null">
scheduled_time = #{message.scheduledTime},
</if>
<if test="message.actualSendTime != null">
actual_send_time = #{message.actualSendTime},
</if>
<if test="message.status != null and message.status != ''">
status = #{message.status},
</if>
<if test="message.targetUserCount != null">
target_user_count = #{message.targetUserCount},
</if>
<if test="message.retryCount != null">
retry_count = #{message.retryCount},
</if>
<if test="message.maxRetryCount != null">
max_retry_count = #{message.maxRetryCount},
</if>
<if test="message.lastError != null">
last_error = #{message.lastError},
</if>
<if test="message.updater != null and message.updater != ''">
updater = #{message.updater},
</if>
<if test="message.updateTime != null">
update_time = #{message.updateTime},
</if>
</set>
WHERE message_id = #{message.messageID}
AND deleted = 0
</update>
<!-- 删除消息(逻辑删除) -->
<update id="deleteMessage">
UPDATE tb_sys_message
SET deleted = 1,
delete_time = NOW(),
update_time = NOW()
WHERE message_id = #{messageID}
AND deleted = 0
</update>
<!-- 统计消息总数(带权限过滤) -->
<select id="countMessage" resultType="java.lang.Integer">
SELECT COUNT(*)
FROM tb_sys_message m
WHERE m.deleted = 0
<if test="filter != null">
<if test="filter.title != null and filter.title != ''">
AND m.title LIKE CONCAT('%', #{filter.title}, '%')
</if>
<if test="filter.messageType != null and filter.messageType != ''">
AND m.message_type = #{filter.messageType}
</if>
<if test="filter.status != null and filter.status != ''">
AND m.status = #{filter.status}
</if>
<if test="filter.sendMode != null and filter.sendMode != ''">
AND m.send_mode = #{filter.sendMode}
</if>
<if test="filter.priority != null and filter.priority != ''">
AND m.priority = #{filter.priority}
</if>
</if>
<!-- 权限过滤:只能查看自己部门及子部门的消息 -->
AND (
m.sender_dept_id = #{currentUserDeptID}
OR m.sender_dept_id IN (
WITH RECURSIVE dept_tree AS (
SELECT dept_id FROM tb_sys_dept
WHERE dept_id = #{currentUserDeptID} AND deleted = 0
UNION ALL
SELECT d.dept_id FROM tb_sys_dept d
INNER JOIN dept_tree dt ON d.parent_id = dt.dept_id
WHERE d.deleted = 0
)
SELECT dept_id FROM dept_tree
)
)
</select>
</mapper>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzh.message.mapper.MessageTargetMapper">
<!-- Result Map -->
<resultMap id="BaseResultMap" type="org.xyzh.common.dto.message.TbSysMessageTarget">
<id column="id" property="id" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="send_method" property="sendMethod" jdbcType="VARCHAR"/>
<result column="target_type" property="targetType" jdbcType="VARCHAR"/>
<result column="target_id" property="targetID" jdbcType="VARCHAR"/>
<result column="target_name" property="targetName" jdbcType="VARCHAR"/>
<result column="scope_dept_id" property="scopeDeptID" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="BOOLEAN"/>
</resultMap>
<!-- 根据消息ID查询接收对象列表 -->
<select id="selectByMessageID" resultMap="BaseResultMap">
SELECT *
FROM tb_sys_message_target
WHERE message_id = #{messageID}
AND deleted = 0
ORDER BY create_time ASC
</select>
<!-- 批量插入接收对象 -->
<insert id="batchInsert">
INSERT INTO tb_sys_message_target
(id, message_id, send_method, target_type, target_id, target_name, scope_dept_id, creator, create_time, update_time, deleted)
VALUES
<foreach collection="targets" item="item" separator=",">
(#{item.id}, #{item.messageID}, #{item.sendMethod}, #{item.targetType}, #{item.targetID},
#{item.targetName}, #{item.scopeDeptID}, #{item.creator}, NOW(), NOW(), 0)
</foreach>
</insert>
<!-- 根据消息ID删除接收对象 -->
<update id="deleteByMessageID">
UPDATE tb_sys_message_target
SET deleted = 1,
delete_time = NOW(),
update_time = NOW()
WHERE message_id = #{messageID}
AND deleted = 0
</update>
</mapper>

View File

@@ -0,0 +1,352 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzh.message.mapper.MessageUserMapper">
<!-- Result Map -->
<resultMap id="BaseResultMap" type="org.xyzh.common.dto.message.TbSysMessageUser">
<id column="id" property="id" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="user_id" property="userID" jdbcType="VARCHAR"/>
<result column="send_method" property="sendMethod" jdbcType="VARCHAR"/>
<result column="is_read" property="isRead" jdbcType="BOOLEAN"/>
<result column="read_time" property="readTime" jdbcType="TIMESTAMP"/>
<result column="send_status" property="sendStatus" jdbcType="VARCHAR"/>
<result column="fail_reason" property="failReason" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="delete_time" property="deleteTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="BOOLEAN"/>
</resultMap>
<resultMap id="MessageUserVOMap" type="org.xyzh.common.dto.message.MessageUserVO">
<id column="id" property="id" jdbcType="VARCHAR"/>
<result column="message_id" property="messageID" jdbcType="VARCHAR"/>
<result column="user_id" property="userID" jdbcType="VARCHAR"/>
<result column="username" property="username" jdbcType="VARCHAR"/>
<result column="full_name" property="fullName" jdbcType="VARCHAR"/>
<result column="dept_id" property="deptID" jdbcType="VARCHAR"/>
<result column="name" property="deptName" jdbcType="VARCHAR"/>
<result column="send_method" property="sendMethod" jdbcType="VARCHAR"/>
<result column="is_read" property="isRead" jdbcType="BOOLEAN"/>
<result column="read_time" property="readTime" jdbcType="TIMESTAMP"/>
<result column="send_status" property="sendStatus" jdbcType="VARCHAR"/>
<result column="fail_reason" property="failReason" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="title" property="title" jdbcType="VARCHAR"/>
<result column="content" property="content" jdbcType="VARCHAR"/>
<result column="message_type" property="messageType" jdbcType="VARCHAR"/>
<result column="priority" property="priority" jdbcType="VARCHAR"/>
<result column="sender_name" property="senderName" jdbcType="VARCHAR"/>
<result column="sender_dept_name" property="senderDeptName" jdbcType="VARCHAR"/>
<result column="actual_send_time" property="actualSendTime" jdbcType="TIMESTAMP"/>
</resultMap>
<!-- 根据消息ID查询用户消息列表 -->
<select id="selectByMessageID" resultMap="MessageUserVOMap">
SELECT
mu.*,
u.username,
ui.full_name as full_name,
d.dept_id as dept_id,
d.name as deptName
FROM tb_sys_message_user mu
LEFT JOIN tb_sys_user u ON mu.user_id = u.id
LEFT JOIN tb_sys_user_info ui ON u.id = ui.user_id
LEFT JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
LEFT JOIN tb_sys_dept d ON udr.dept_id = d.dept_id AND d.deleted = 0
WHERE mu.message_id = #{messageID}
AND mu.deleted = 0
ORDER BY mu.create_time DESC
</select>
<!-- 批量插入用户消息 -->
<insert id="batchInsert">
INSERT INTO tb_sys_message_user
(id, message_id, user_id, send_method, is_read, send_status, creator, create_time, update_time, deleted)
VALUES
<foreach collection="userMessages" item="item" separator=",">
(#{item.id}, #{item.messageID}, #{item.userID}, #{item.sendMethod},
0, 'pending', #{item.creator}, NOW(), NOW(), 0)
</foreach>
</insert>
<!-- 分页查询当前用户的消息列表 -->
<select id="selectMyMessages" resultMap="MessageUserVOMap">
SELECT
mu.*,
m.title,
m.content,
m.message_type as message_type,
m.priority,
m.sender_name as sender_name,
m.sender_dept_name as sender_dept_name,
m.actual_send_time as actual_send_time
FROM tb_sys_message_user mu
INNER JOIN tb_sys_message m ON mu.message_id = m.message_id
WHERE mu.user_id = #{userID}
AND mu.deleted = 0
AND m.deleted = 0
<if test="filter != null">
<if test="filter.isRead != null">
AND mu.is_read = #{filter.isRead}
</if>
<if test="filter.sendMethod != null and filter.sendMethod != ''">
AND mu.send_method = #{filter.sendMethod}
</if>
</if>
ORDER BY m.actual_send_time DESC, mu.create_time DESC
</select>
<!-- 查询当前用户的消息详情 -->
<select id="selectMyMessageDetail" resultMap="MessageUserVOMap">
SELECT
mu.*,
m.title,
m.content,
m.message_type as message_type,
m.priority,
m.sender_name as sender_name,
m.sender_dept_name as sender_dept_name,
m.actual_send_time as actual_send_time
FROM tb_sys_message_user mu
INNER JOIN tb_sys_message m ON mu.message_id = m.message_id
WHERE mu.user_id = #{userID}
AND mu.message_id = #{messageID}
AND mu.deleted = 0
AND m.deleted = 0
</select>
<!-- 标记消息为已读 -->
<update id="markAsRead">
UPDATE tb_sys_message_user
SET is_read = 1,
send_status = 'sent',
read_time = NOW(),
update_time = NOW()
WHERE user_id = #{userID}
AND message_id = #{messageID}
AND deleted = 0
</update>
<!-- 批量标记消息为已读 -->
<update id="batchMarkAsRead">
UPDATE tb_sys_message_user
SET is_read = 1,
read_time = NOW(),
update_time = NOW()
WHERE user_id = #{userID}
AND message_id IN
<foreach collection="messageIDs" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
AND deleted = 0
</update>
<!-- 查询未读消息数量 -->
<select id="countUnread" resultType="java.lang.Integer">
SELECT COUNT(*)
FROM tb_sys_message_user
WHERE user_id = #{userID}
AND is_read = 0
AND deleted = 0
</select>
<!-- 动态计算未读消息数量(基于 target 配置) -->
<select id="countUnreadWithDynamicTargets" resultType="java.lang.Integer">
SELECT COUNT(DISTINCT m.message_id)
FROM tb_sys_message m
INNER JOIN tb_sys_message_target mt ON m.message_id = mt.message_id AND mt.deleted = 0
LEFT JOIN tb_sys_message_user mu ON m.message_id = mu.message_id AND mu.user_id = #{userID} AND mu.deleted = 0
WHERE m.deleted = 0
AND m.status IN ('sent', 'sending', 'failed')
AND COALESCE(mu.is_read, 0) = 0
AND (
-- 用户类型目标:直接匹配
(mt.target_type = 'user' AND mt.target_id = #{userID})
OR
-- 部门类型目标:用户所在部门是目标部门或其子部门
(mt.target_type = 'dept' AND EXISTS (
SELECT 1 FROM tb_sys_user_dept_role udr
INNER JOIN tb_sys_dept d ON udr.dept_id = d.dept_id AND d.deleted = 0
INNER JOIN tb_sys_dept target_dept ON target_dept.dept_id = mt.target_id AND target_dept.deleted = 0
WHERE udr.user_id = #{userID}
AND udr.deleted = 0
AND (
d.dept_id = target_dept.dept_id
OR d.dept_path LIKE CONCAT(target_dept.dept_path, '%')
)
))
OR
-- 角色类型目标:用户在指定部门范围内拥有该角色
(mt.target_type = 'role' AND EXISTS (
SELECT 1 FROM tb_sys_user_dept_role udr
INNER JOIN tb_sys_dept d ON udr.dept_id = d.dept_id AND d.deleted = 0
WHERE udr.user_id = #{userID}
AND udr.deleted = 0
AND udr.role_id = mt.target_id
AND d.dept_path LIKE CONCAT(
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = mt.scope_dept_id AND deleted = 0),
'%'
)
))
)
</select>
<!-- 更新用户消息的发送状态 -->
<update id="updateSendStatus">
UPDATE tb_sys_message_user
SET send_status = #{sendStatus},
<if test="failReason != null and failReason != ''">
fail_reason = #{failReason},
</if>
update_time = NOW()
WHERE id = #{id}
AND deleted = 0
</update>
<!-- 查询待发送的用户消息列表 -->
<select id="selectPendingUserMessages" resultMap="BaseResultMap">
SELECT *
FROM tb_sys_message_user
WHERE deleted = 0
<if test="filter != null">
<if test="filter.messageID != null and filter.messageID != ''">
AND message_id = #{filter.messageID}
</if>
<if test="filter.sendStatus != null and filter.sendStatus != ''">
AND send_status = #{filter.sendStatus}
</if>
<if test="filter.deleted != null">
AND deleted = #{filter.deleted}
</if>
</if>
ORDER BY create_time ASC
</select>
<!-- 动态查询当前用户可见的消息列表(基于 target 配置计算) -->
<select id="selectMyMessagesWithDynamicTargets" resultMap="MessageUserVOMap">
SELECT
m.message_id,
m.title,
m.content,
m.message_type,
m.priority,
m.sender_name,
m.sender_dept_name,
m.actual_send_time,
MAX(COALESCE(mu.is_read, 0)) as is_read,
MAX(mu.read_time) as read_time,
MAX(COALESCE(mu.send_status, 'pending')) as send_status,
MAX(mu.id) as id,
MAX(mu.user_id) as user_id,
MAX(mu.send_method) as send_method,
MAX(mu.fail_reason) as fail_reason,
MAX(mu.create_time) as create_time
FROM tb_sys_message m
INNER JOIN tb_sys_message_target mt ON m.message_id = mt.message_id AND mt.deleted = 0
LEFT JOIN tb_sys_message_user mu ON m.message_id = mu.message_id AND mu.user_id = #{userID} AND mu.deleted = 0
WHERE m.deleted = 0
AND m.status IN ('sent', 'sending', 'failed')
AND (
-- 用户类型目标:直接匹配
(mt.target_type = 'user' AND mt.target_id = #{userID})
OR
-- 部门类型目标:用户所在部门是目标部门或其子部门
(mt.target_type = 'dept' AND EXISTS (
SELECT 1 FROM tb_sys_user_dept_role udr
INNER JOIN tb_sys_dept d ON udr.dept_id = d.dept_id AND d.deleted = 0
INNER JOIN tb_sys_dept target_dept ON target_dept.dept_id = mt.target_id AND target_dept.deleted = 0
WHERE udr.user_id = #{userID}
AND udr.deleted = 0
AND (
d.dept_id = target_dept.dept_id
OR d.dept_path LIKE CONCAT(target_dept.dept_path, '%')
)
))
OR
-- 角色类型目标:用户在指定部门范围内拥有该角色
(mt.target_type = 'role' AND EXISTS (
SELECT 1 FROM tb_sys_user_dept_role udr
INNER JOIN tb_sys_dept d ON udr.dept_id = d.dept_id AND d.deleted = 0
WHERE udr.user_id = #{userID}
AND udr.deleted = 0
AND udr.role_id = mt.target_id
AND d.dept_path LIKE CONCAT(
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = mt.scope_dept_id AND deleted = 0),
'%'
)
))
)
<if test="filter != null">
<if test="filter.isRead != null">
AND COALESCE(mu.is_read, 0) = #{filter.isRead}
</if>
<if test="filter.sendMethod != null and filter.sendMethod != ''">
AND mt.send_method = #{filter.sendMethod}
</if>
<if test="filter.sendStatus != null and filter.sendStatus != ''">
AND mu.send_status = #{filter.sendStatus}
</if>
<if test="filter.priority != null and filter.priority != ''">
AND m.priority = #{filter.priority}
</if>
<if test="filter.messageType != null and filter.messageType != ''">
AND m.message_type = #{filter.messageType}
</if>
<if test="filter.title != null and filter.title != ''">
AND m.title LIKE CONCAT('%', #{filter.title}, '%')
</if>
</if>
GROUP BY m.message_id, m.title, m.content, m.message_type, m.priority,
m.sender_name, m.sender_dept_name, m.actual_send_time
ORDER BY m.actual_send_time DESC, m.create_time DESC
</select>
<!-- 查询或创建用户消息记录 -->
<select id="selectOrCreateUserMessage" resultMap="MessageUserVOMap">
SELECT
mu.*,
m.title,
m.content,
m.message_type,
m.priority,
m.sender_name,
m.sender_dept_name,
m.actual_send_time
FROM tb_sys_message m
LEFT JOIN tb_sys_message_user mu ON m.message_id = mu.message_id
AND mu.user_id = #{userID}
AND mu.deleted = 0
WHERE m.message_id = #{messageID}
AND m.deleted = 0
</select>
<!-- 插入用户消息记录(如果不存在) -->
<insert id="insertIfNotExists">
INSERT INTO tb_sys_message_user
(id, message_id, user_id, send_method, is_read, send_status, creator, create_time, update_time, deleted)
SELECT
#{userMessage.id},
#{userMessage.messageID},
#{userMessage.userID},
#{userMessage.sendMethod},
0,
'pending',
#{userMessage.creator},
NOW(),
NOW(),
0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM tb_sys_message_user
WHERE message_id = #{userMessage.messageID}
AND user_id = #{userMessage.userID}
AND deleted = 0
)
</insert>
</mapper>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>news</artifactId> <artifactId>news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -22,22 +22,22 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-news</artifactId> <artifactId>api-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-usercenter</artifactId> <artifactId>api-usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -6,7 +6,7 @@
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<packaging>pom</packaging> <packaging>pom</packaging>
<version>${school-news.version}</version> <version>1.0.0</version>
<modules> <modules>
<module>api</module> <module>api</module>
@@ -21,6 +21,7 @@
<module>ai</module> <module>ai</module>
<module>file</module> <module>file</module>
<module>crontab</module> <module>crontab</module>
<module>message</module>
</modules> </modules>
<properties> <properties>
@@ -67,63 +68,68 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api</artifactId> <artifactId>api</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common</artifactId> <artifactId>common</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>auth</artifactId> <artifactId>auth</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>news</artifactId> <artifactId>news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>course</artifactId> <artifactId>course</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>study</artifactId> <artifactId>study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>usercenter</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>achievement</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>ai</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>crontab</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>message</artifactId>
<version>1.0.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>usercenter</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>achievement</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>ai</artifactId>
<version>${school-news.version}</version>
</dependency>
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>crontab</artifactId>
<version>${school-news.version}</version>
</dependency>
<!-- Spring相关依赖 --> <!-- Spring相关依赖 -->
<!-- 覆盖SpringFramework的依赖配置--> <!-- 覆盖SpringFramework的依赖配置-->
<dependency> <dependency>
@@ -275,9 +281,9 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version> <version>${poi.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@@ -1,9 +0,0 @@
-- 为 tb_data_collection_item 表添加单条新闻执行状态和消息字段
-- 执行日期: 2025-11-12
ALTER TABLE tb_data_collection_item
ADD COLUMN execute_status INT DEFAULT 1 COMMENT '单条新闻执行状态(0:失败 1:成功)' AFTER processor,
ADD COLUMN execute_message VARCHAR(500) DEFAULT NULL COMMENT '单条新闻执行消息(记录错误信息或成功提示)' AFTER execute_status;
-- 为现有数据设置默认值
UPDATE tb_data_collection_item SET execute_status = 1 WHERE execute_status IS NULL;

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>study</artifactId> <artifactId>study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>study</name> <name>study</name>
<description>学习管理模块</description> <description>学习管理模块</description>
@@ -25,17 +25,17 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-study</artifactId> <artifactId>api-study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -23,14 +23,14 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- Common模块依赖 --> <!-- Common模块依赖 -->
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<!-- Spring Boot Web --> <!-- Spring Boot Web -->

View File

@@ -50,6 +50,15 @@ public interface UserMapper extends BaseMapper<TbSysUser> {
*/ */
int deleteUser(@Param("userID") String userID); int deleteUser(@Param("userID") String userID);
/**
* @description 根据用户ID查询用户
* @param userID 用户ID
* @return TbSysUser 用户信息
* @author yslg
* @since 2025-11-13
*/
TbSysUser selectUserById(@Param("userID") String userID);
/** /**
* @description 根据用户名查询用户 * @description 根据用户名查询用户
* @param username 用户名 * @param username 用户名
@@ -184,4 +193,19 @@ public interface UserMapper extends BaseMapper<TbSysUser> {
int countDeptUser(@Param("deptId") String deptId); int countDeptUser(@Param("deptId") String deptId);
/**
* @description 查询部门及其子部门的所有用户ID
* @param deptId 部门ID
* @return List<String> 用户ID列表
*/
List<String> selectUserIdsByDeptId(@Param("deptId") String deptId);
/**
* @description 查询指定部门及其子部门中指定角色的所有用户ID
* @param deptId 部门ID
* @param roleId 角色ID
* @return List<String> 用户ID列表
*/
List<String> selectUserIdsByDeptRole(@Param("deptId") String deptId, @Param("roleId") String roleId);
} }

View File

@@ -143,6 +143,15 @@
</sql> </sql>
<!-- 根据用户名查询用户 --> <!-- 根据用户名查询用户 -->
<!-- 根据用户ID查询用户 -->
<select id="selectUserById" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM tb_sys_user
WHERE id = #{userID}
AND deleted = 0
</select>
<select id="selectByUsername" resultMap="BaseResultMap"> <select id="selectByUsername" resultMap="BaseResultMap">
SELECT SELECT
<include refid="Base_Column_List"/> <include refid="Base_Column_List"/>
@@ -573,6 +582,35 @@
) )
</select> </select>
<!-- selectUserIdsByDeptId - 查询部门及其子部门的所有用户ID -->
<select id="selectUserIdsByDeptId" resultType="java.lang.String">
SELECT DISTINCT tudr.user_id
FROM tb_sys_user_dept_role tudr
INNER JOIN tb_sys_dept d ON tudr.dept_id = d.dept_id AND d.deleted = 0
INNER JOIN tb_sys_user u ON tudr.user_id = u.id AND u.deleted = 0
WHERE tudr.deleted = 0
AND u.status = 1
AND d.dept_path LIKE CONCAT(
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = #{deptId} AND deleted = 0),
'%'
)
</select>
<!-- selectUserIdsByDeptRole - 查询指定部门及其子部门中指定角色的所有用户ID -->
<select id="selectUserIdsByDeptRole" resultType="java.lang.String">
SELECT DISTINCT tudr.user_id
FROM tb_sys_user_dept_role tudr
INNER JOIN tb_sys_dept d ON tudr.dept_id = d.dept_id AND d.deleted = 0
INNER JOIN tb_sys_user u ON tudr.user_id = u.id AND u.deleted = 0
WHERE tudr.deleted = 0
AND u.status = 1
AND tudr.role_id = #{roleId}
AND d.dept_path LIKE CONCAT(
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = #{deptId} AND deleted = 0),
'%'
)
</select>
<!-- selectLoginUser --> <!-- selectLoginUser -->
<select id="selectLoginUser"> <select id="selectLoginUser">

View File

@@ -6,12 +6,12 @@
<parent> <parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>school-news</artifactId> <artifactId>school-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</parent> </parent>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>usercenter</artifactId> <artifactId>usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>usercenter</name> <name>usercenter</name>
<description>个人中心模块</description> <description>个人中心模块</description>
@@ -25,33 +25,33 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-usercenter</artifactId> <artifactId>api-usercenter</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-system</artifactId> <artifactId>api-system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-news</artifactId> <artifactId>api-news</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-study</artifactId> <artifactId>api-study</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>common-all</artifactId> <artifactId>common-all</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>system</artifactId> <artifactId>system</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -93,7 +93,7 @@
<dependency> <dependency>
<groupId>org.xyzh</groupId> <groupId>org.xyzh</groupId>
<artifactId>api-file</artifactId> <artifactId>api-file</artifactId>
<version>${school-news.version}</version> <version>1.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@@ -58,7 +58,7 @@ export const aiAgentConfigApi = {
* @returns Promise<ResultDomain<AiAgentConfig[]>> * @returns Promise<ResultDomain<AiAgentConfig[]>>
*/ */
async listEnabledAgents(): Promise<ResultDomain<AiAgentConfig>> { async listEnabledAgents(): Promise<ResultDomain<AiAgentConfig>> {
const response = await api.get<AiAgentConfig>('/ai/agent/enabled', { const response = await api.get<AiAgentConfig>('/ai/agent/enabled',{}, {
showLoading: false showLoading: false
}); });
return response.data; return response.data;

View File

@@ -85,8 +85,8 @@ request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const customConfig = config as CustomAxiosRequestConfig; const customConfig = config as CustomAxiosRequestConfig;
// 显示加载动画 // 默认不显示加载动画,只有显式开启时才展示
if (customConfig.showLoading !== false) { if (customConfig.showLoading) {
loadingInstance = ElLoading.service({ loadingInstance = ElLoading.service({
lock: true, lock: true,
text: "加载中...", text: "加载中...",

View File

@@ -0,0 +1,7 @@
/**
* @description 消息通知API导出
* @author Claude
* @since 2025-11-13
*/
export * from './message';

View File

@@ -0,0 +1,216 @@
/**
* @description 消息通知相关API
* @author Claude
* @since 2025-11-13
*/
import { api } from '@/apis/index';
import type {
TbSysMessage,
TbSysMessageUser,
MessageVO,
MessageUserVO,
ResultDomain,
PageParam,
PageRequest
} from '@/types';
/**
* 消息API服务
*/
export const messageApi = {
/**
* @description 创建消息
* @param message 消息对象
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async createMessage(message: TbSysMessage): Promise<ResultDomain<TbSysMessage>> {
const response = await api.post<TbSysMessage>('/message', message);
return response.data;
},
/**
* @description 更新消息
* @param messageIDOrMessage 消息ID 或 完整消息对象
* @param messageData 消息数据当第一个参数为ID时使用
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async updateMessage(message: TbSysMessage): Promise<ResultDomain<TbSysMessage>> {
const response = await api.put<TbSysMessage>(`/message`, message);
return response.data;
},
/**
* @description 删除消息
* @param messageID 消息ID
* @returns Promise<ResultDomain<boolean>>
*/
async deleteMessage(messageID: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`/message/${messageID}`);
return response.data;
},
/**
* @description 获取消息分页列表(管理端)
* @param pageRequest 分页请求对象
* @returns Promise<ResultDomain<MessageVO>>
*/
async getMessagePage(pageRequest: PageRequest<TbSysMessage>): Promise<ResultDomain<MessageVO>> {
const response = await api.post<MessageVO>('/message/page', pageRequest);
return response.data;
},
/**
* @description 获取消息详情(管理端)
* @param messageID 消息ID
* @returns Promise<ResultDomain<MessageVO>>
*/
async getMessage(messageID: string): Promise<ResultDomain<MessageVO>> {
return this.getMessageDetail(messageID);
},
/**
* @description 获取消息详情(管理端)
* @param messageID 消息ID
* @returns Promise<ResultDomain<MessageVO>>
*/
async getMessageDetail(messageID: string): Promise<ResultDomain<MessageVO>> {
const response = await api.get<MessageVO>(`/message/detail/${messageID}`);
return response.data;
},
/**
* @description 立即发送消息
* @param messageID 消息ID
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async sendMessage(messageID: string): Promise<ResultDomain<TbSysMessage>> {
const response = await api.post<TbSysMessage>(`/message/send/${messageID}`);
return response.data;
},
/**
* @description 立即发送(将定时消息改为立即发送)
* @param messageID 消息ID
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async sendNow(messageID: string): Promise<ResultDomain<TbSysMessage>> {
const response = await api.post<TbSysMessage>(`/message/sendNow/${messageID}`);
return response.data;
},
/**
* @description 取消定时消息
* @param messageID 消息ID
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async cancelMessage(messageID: string): Promise<ResultDomain<TbSysMessage>> {
const response = await api.post<TbSysMessage>(`/message/cancel/${messageID}`);
return response.data;
},
/**
* @description 重新安排定时消息时间
* @param messageID 消息ID
* @param newScheduledTime 新的定时发送时间
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async rescheduleMessage(messageID: string, newScheduledTime: string): Promise<ResultDomain<TbSysMessage>> {
const response = await api.put<TbSysMessage>(`/message/reschedule/${messageID}`, {
scheduledTime: newScheduledTime
});
return response.data;
},
/**
* @description 重试发送失败的消息
* @param messageID 消息ID
* @returns Promise<ResultDomain<TbSysMessage>>
*/
async retryMessage(messageID: string): Promise<ResultDomain<TbSysMessage>> {
const response = await api.post<TbSysMessage>(`/message/retry/${messageID}`);
return response.data;
},
/**
* @description 获取当前用户的消息列表(用户端)
* @param pageParam 分页参数
* @param filter 过滤条件
* @returns Promise<ResultDomain<MessageUserVO>>
*/
async getMyMessages(pageParam: PageParam, filter?: TbSysMessageUser): Promise<ResultDomain<MessageUserVO>> {
const response = await api.post<MessageUserVO>('/message/my/page', {
pageParam,
filter
});
return response.data;
},
/**
* @description 获取当前用户的消息详情(用户端)
* @param messageID 消息ID
* @returns Promise<ResultDomain<MessageUserVO>>
*/
async getMyMessageDetail(messageID: string): Promise<ResultDomain<MessageUserVO>> {
const response = await api.get<MessageUserVO>(`/message/my/detail/${messageID}`);
return response.data;
},
/**
* @description 标记消息为已读
* @param messageID 消息ID
* @returns Promise<ResultDomain<boolean>>
*/
async markAsRead(messageID: string): Promise<ResultDomain<boolean>> {
const response = await api.post<boolean>(`/message/my/markRead/${messageID}`);
return response.data;
},
/**
* @description 批量标记消息为已读
* @param messageIDs 消息ID列表
* @returns Promise<ResultDomain<boolean>>
*/
async batchMarkAsRead(messageIDs: string[]): Promise<ResultDomain<boolean>> {
const response = await api.post<boolean>('/message/my/batchMarkRead', { messageIDs });
return response.data;
},
/**
* @description 获取未读消息数量
* @returns Promise<ResultDomain<number>>
*/
async getUnreadCount(): Promise<ResultDomain<number>> {
const response = await api.get<number>('/message/my/unreadCount');
return response.data;
},
/**
* @description 获取可选择的部门列表(当前部门及子部门)
* @returns Promise<ResultDomain<any>>
*/
async getTargetDepts(): Promise<ResultDomain<any>> {
const response = await api.get<any>('/message/targets/depts');
return response.data;
},
/**
* @description 获取可选择的角色列表(指定部门范围内的角色)
* @param scopeDeptID 作用域部门ID
* @returns Promise<ResultDomain<any>>
*/
async getTargetRoles(scopeDeptID: string): Promise<ResultDomain<any>> {
const response = await api.get<any>(`/message/targets/roles?scopeDeptID=${scopeDeptID}`);
return response.data;
},
/**
* @description 获取可选择的用户列表(指定部门范围内的用户)
* @param scopeDeptID 作用域部门ID
* @returns Promise<ResultDomain<any>>
*/
async getTargetUsers(scopeDeptID: string): Promise<ResultDomain<any>> {
const response = await api.get<any>(`/message/targets/users?scopeDeptID=${scopeDeptID}`);
return response.data;
}
};

View File

@@ -0,0 +1,59 @@
<template>
<span class="message-priority-badge" :class="`priority-${priority}`">
{{ priorityText }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 优先级urgent-紧急/important-重要/normal-普通 */
priority: string;
}
const props = defineProps<Props>();
/** 优先级文本映射 */
const priorityText = computed(() => {
const map: Record<string, string> = {
urgent: '紧急',
important: '重要',
normal: '普通'
};
return map[props.priority] || props.priority;
});
</script>
<style lang="scss" scoped>
.message-priority-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
font-size: 12px;
border-radius: 12px;
font-weight: 500;
white-space: nowrap;
line-height: 1;
&.priority-urgent {
background-color: #fee;
color: #c00;
border: 1px solid #fcc;
}
&.priority-important {
background-color: #fff3e0;
color: #f57c00;
border: 1px solid #ffcc80;
}
&.priority-normal {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="message-send-method-selector">
<div class="method-option">
<label class="method-label">
<input
type="checkbox"
value="system"
:checked="selectedMethods.includes('system')"
@change="toggleMethod('system')"
:disabled="disabled"
/>
<span class="method-text">
<i class="el-icon-message"></i>
系统消息
</span>
</label>
<span class="method-desc">在系统内推送消息通知</span>
</div>
<div class="method-option">
<label class="method-label">
<input
type="checkbox"
value="email"
:checked="selectedMethods.includes('email')"
@change="toggleMethod('email')"
:disabled="disabled"
/>
<span class="method-text">
<i class="el-icon-message-solid"></i>
邮件通知
</span>
</label>
<span class="method-desc">发送到用户邮箱</span>
</div>
<div class="method-option">
<label class="method-label">
<input
type="checkbox"
value="sms"
:checked="selectedMethods.includes('sms')"
@change="toggleMethod('sms')"
:disabled="disabled"
/>
<span class="method-text">
<i class="el-icon-phone"></i>
短信通知
</span>
</label>
<span class="method-desc">发送到用户手机</span>
</div>
<div v-if="error" class="error-tip">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 已选择的发送方式列表 */
modelValue: string[];
/** 是否禁用 */
disabled?: boolean;
/** 是否必须至少选择一个 */
required?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: string[]): void;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
required: true
});
const emit = defineEmits<Emits>();
const selectedMethods = computed(() => props.modelValue);
const error = computed(() => {
if (props.required && selectedMethods.value.length === 0) {
return '请至少选择一种发送方式';
}
return '';
});
/** 切换发送方式 */
function toggleMethod(method: string) {
if (props.disabled) return;
const methods = [...selectedMethods.value];
const index = methods.indexOf(method);
if (index > -1) {
// 如果是必选且只剩一个,不允许取消
if (props.required && methods.length === 1) {
return;
}
methods.splice(index, 1);
} else {
methods.push(method);
}
emit('update:modelValue', methods);
}
</script>
<style lang="scss" scoped>
.message-send-method-selector {
display: flex;
flex-direction: column;
gap: 12px;
.method-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
transition: all 0.2s;
&:hover {
background-color: #f9f9f9;
border-color: #c8232c;
}
.method-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
.method-text {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: #333;
i {
font-size: 16px;
}
}
}
.method-desc {
margin-left: 24px;
font-size: 12px;
color: #999;
}
}
.error-tip {
font-size: 12px;
color: #c00;
margin-top: 4px;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<span class="message-status-badge" :class="`status-${status}`">
{{ statusText }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 消息状态draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-发送失败/cancelled-已取消 */
status: string;
}
const props = defineProps<Props>();
/** 状态文本映射 */
const statusText = computed(() => {
const map: Record<string, string> = {
draft: '草稿',
pending: '待发送',
sending: '发送中',
sent: '已发送',
failed: '发送失败',
cancelled: '已取消'
};
return map[props.status] || props.status;
});
</script>
<style lang="scss" scoped>
.message-status-badge {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
&.status-draft {
background-color: #f5f5f5;
color: #666;
border: 1px solid #ddd;
}
&.status-pending {
background-color: #fff3e0;
color: #f57c00;
border: 1px solid #ffcc80;
}
&.status-sending {
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #90caf9;
}
&.status-sent {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
&.status-failed {
background-color: #fee;
color: #c00;
border: 1px solid #fcc;
}
&.status-cancelled {
background-color: #fafafa;
color: #999;
border: 1px solid #e0e0e0;
}
}
</style>

View File

@@ -0,0 +1,649 @@
<template>
<div class="message-target-selector">
<!-- Tab切换 -->
<div class="target-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
<i :class="tab.icon"></i>
{{ tab.label }}
</div>
</div>
<!-- Tab内容 -->
<div class="target-content">
<!-- 部门选择 -->
<div v-if="activeTab === 'dept'" class="target-panel">
<div class="panel-header">
<span>已选择 {{ selectedDepts.length }} 个部门</span>
<button class="btn-select" @click="showDeptSelector">
<i class="el-icon-plus"></i> 选择部门
</button>
</div>
<div class="selected-list">
<div
v-for="item in selectedDepts"
:key="item.id"
class="selected-item"
>
<i class="el-icon-office-building"></i>
<span>{{ item.name }}</span>
<i class="el-icon-close remove-btn" @click="removeDept(item.id)"></i>
</div>
<div v-if="selectedDepts.length === 0" class="empty-tip">
请选择部门
</div>
</div>
</div>
<!-- 角色选择 -->
<div v-if="activeTab === 'role'" class="target-panel">
<div class="scope-selector">
<label>作用域部门</label>
<select v-model="roleScopeDeptID" class="dept-select" @change="onRoleScopeChange">
<option value="">请选择部门范围</option>
<option v-for="dept in availableDepts" :key="dept.id" :value="dept.id">
{{ dept.name }}
</option>
</select>
<span class="tip">限制该角色只能向此部门及子部门的用户发送</span>
</div>
<div v-if="roleScopeDeptID" class="panel-header">
<span>已选择 {{ selectedRoles.length }} 个角色</span>
<button class="btn-select" @click="showRoleSelector">
<i class="el-icon-plus"></i> 选择角色
</button>
</div>
<div v-if="roleScopeDeptID" class="selected-list">
<div
v-for="item in selectedRoles"
:key="item.id"
class="selected-item"
>
<i class="el-icon-user"></i>
<span>{{ item.name }}</span>
<span class="item-sublabel">{{ item.deptName }}</span>
<i class="el-icon-close remove-btn" @click="removeRole(item.id)"></i>
</div>
<div v-if="selectedRoles.length === 0" class="empty-tip">
请选择角色
</div>
</div>
</div>
<!-- 用户选择 -->
<div v-if="activeTab === 'user'" class="target-panel">
<div class="scope-selector">
<label>作用域部门</label>
<select v-model="userScopeDeptID" class="dept-select" @change="onUserScopeChange">
<option value="">请选择部门范围</option>
<option v-for="dept in availableDepts" :key="dept.id" :value="dept.id">
{{ dept.name }}
</option>
</select>
<span class="tip">只能选择此部门及子部门的用户</span>
</div>
<div v-if="userScopeDeptID" class="panel-header">
<span>已选择 {{ selectedUsers.length }} 个用户</span>
<button class="btn-select" @click="showUserSelector">
<i class="el-icon-plus"></i> 选择用户
</button>
</div>
<div v-if="userScopeDeptID" class="selected-list">
<div
v-for="item in selectedUsers"
:key="item.id"
class="selected-item"
>
<i class="el-icon-user-solid"></i>
<span>{{ item.name }}</span>
<span class="item-sublabel">{{ item.deptName }}</span>
<i class="el-icon-close remove-btn" @click="removeUser(item.id)"></i>
</div>
<div v-if="selectedUsers.length === 0" class="empty-tip">
请选择用户
</div>
</div>
</div>
</div>
<!-- 通用选择器弹窗 -->
<GenericSelector
v-model:visible="selectorVisible"
:title="selectorTitle"
:fetch-available-api="fetchAvailableApi"
:fetch-selected-api="fetchSelectedApi"
:filter-selected="filterSelected"
:item-config="itemConfig"
:unit-name="unitName"
@confirm="handleSelectorConfirm"
@cancel="selectorVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { GenericSelector } from '@/components/base';
import type { TbSysMessageTarget, ResultDomain } from '@/types';
import { deptApi, roleApi, userApi } from '@/apis/system';
// 本地类型定义
interface TargetOption {
id: string;
name: string;
deptID?: string;
deptName?: string;
}
interface Emits {
(e: 'update:modelValue', value: TbSysMessageTarget[]): void;
}
interface Props {
/** 已选择的目标配置列表 */
modelValue: TbSysMessageTarget[];
/** 发送方式用于生成TbSysMessageTarget */
sendMethod: string;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
/** Tab配置 */
const tabs = [
{ key: 'dept', label: '按部门', icon: 'el-icon-office-building' },
{ key: 'role', label: '按角色', icon: 'el-icon-user' },
{ key: 'user', label: '按用户', icon: 'el-icon-user-solid' }
] as const;
const activeTab = ref<'dept' | 'role' | 'user'>('dept');
// 可选的部门列表(当前部门及子部门)
const availableDepts = ref<TargetOption[]>([]);
// 已选择的目标
const selectedDepts = ref<TargetOption[]>([]);
const selectedRoles = ref<TargetOption[]>([]);
const selectedUsers = ref<TargetOption[]>([]);
// 作用域部门ID
const roleScopeDeptID = ref('');
const userScopeDeptID = ref('');
// 选择器状态
const selectorVisible = ref(false);
const selectorType = ref<'dept' | 'role' | 'user'>('dept');
/** 选择器标题 */
const selectorTitle = computed(() => {
const map = {
dept: '选择部门',
role: '选择角色',
user: '选择用户'
};
return map[selectorType.value];
});
/** 选择器单位名称 */
const unitName = computed(() => {
const map = {
dept: '个部门',
role: '个角色',
user: '个用户'
};
return map[selectorType.value];
});
/** 选择器字段配置 */
const itemConfig = computed(() => ({
id: 'id',
label: 'name',
sublabel: selectorType.value !== 'dept' ? 'deptName' : undefined
}));
/** 获取可选项API */
const fetchAvailableApi = computed(() => {
return async () => {
if (selectorType.value === 'dept') {
// 调用 system 模块的部门 API
const result = await deptApi.getAllDepts();
// 转换数据格式为 GenericSelector 需要的格式
if (result.success && result.dataList) {
return {
...result,
dataList: result.dataList.map((dept: any) => ({
id: dept.id,
name: dept.name,
deptID: dept.id,
deptName: dept.name
}))
};
}
return result;
} else if (selectorType.value === 'role') {
// 调用 system 模块的角色 API根据部门过滤
const filter = roleScopeDeptID.value ? { deptID: roleScopeDeptID.value } : {};
const result = await roleApi.getRoleList(filter as any);
// 转换数据格式
if (result.success && result.dataList) {
return {
...result,
dataList: result.dataList.map((role: any) => ({
id: role.id,
name: role.name,
deptID: role.deptID,
deptName: role.deptName
}))
};
}
return result;
} else {
// 调用 system 模块的用户 API根据部门过滤
const filter = userScopeDeptID.value ? { deptID: userScopeDeptID.value } : {};
const result = await userApi.getUserList(filter as any);
// 转换数据格式
if (result.success && result.dataList) {
return {
...result,
dataList: result.dataList.map((user: any) => ({
id: user.id,
name: user.realName || user.username,
deptID: user.deptID,
deptName: user.deptName
}))
};
}
return result;
}
};
});
/** 获取已选项API返回当前已选择的项 */
const fetchSelectedApi = computed(() => {
return async (): Promise<ResultDomain<TargetOption>> => {
let dataList: TargetOption[] = [];
if (selectorType.value === 'dept') {
dataList = selectedDepts.value;
} else if (selectorType.value === 'role') {
dataList = selectedRoles.value;
} else {
dataList = selectedUsers.value;
}
return {
code: 200,
message: 'success',
success: true,
login: true,
auth: true,
dataList
};
};
});
/** 过滤已选项(从可选项中移除已选项) */
const filterSelected = (available: Record<string, any>[], selected: Record<string, any>[]): Record<string, any>[] => {
const availableOptions = available as TargetOption[];
const selectedOptions = selected as TargetOption[];
const selectedIds = new Set(selectedOptions.map(item => item.id));
return availableOptions.filter(item => !selectedIds.has(item.id));
};
/** 加载可用部门列表 */
async function loadAvailableDepts() {
try {
const result = await deptApi.getAllDepts();
if (result.success && result.dataList) {
// 转换数据格式
availableDepts.value = result.dataList.map((dept: any) => ({
id: dept.id,
name: dept.name,
deptID: dept.id,
deptName: dept.name
}));
}
} catch (error) {
console.error('加载部门列表失败:', error);
}
}
/** 切换Tab */
function switchTab(tab: 'dept' | 'role' | 'user') {
activeTab.value = tab;
}
/** 显示部门选择器 */
function showDeptSelector() {
selectorType.value = 'dept';
selectorVisible.value = true;
}
/** 显示角色选择器 */
function showRoleSelector() {
if (!roleScopeDeptID.value) {
alert('请先选择作用域部门');
return;
}
selectorType.value = 'role';
selectorVisible.value = true;
}
/** 显示用户选择器 */
function showUserSelector() {
if (!userScopeDeptID.value) {
alert('请先选择作用域部门');
return;
}
selectorType.value = 'user';
selectorVisible.value = true;
}
/** 选择器确认回调 */
function handleSelectorConfirm(items: Record<string, any>[]) {
const typedItems = items as TargetOption[];
if (selectorType.value === 'dept') {
selectedDepts.value = typedItems;
} else if (selectorType.value === 'role') {
selectedRoles.value = typedItems;
} else {
selectedUsers.value = typedItems;
}
emitTargets();
}
/** 移除部门 */
function removeDept(id: string) {
selectedDepts.value = selectedDepts.value.filter(item => item.id !== id);
emitTargets();
}
/** 移除角色 */
function removeRole(id: string) {
selectedRoles.value = selectedRoles.value.filter(item => item.id !== id);
emitTargets();
}
/** 移除用户 */
function removeUser(id: string) {
selectedUsers.value = selectedUsers.value.filter(item => item.id !== id);
emitTargets();
}
/** 角色作用域变更 */
function onRoleScopeChange() {
// 清空已选择的角色
selectedRoles.value = [];
emitTargets();
}
/** 用户作用域变更 */
function onUserScopeChange() {
// 清空已选择的用户
selectedUsers.value = [];
emitTargets();
}
/** 发射目标配置 */
function emitTargets() {
const targets: TbSysMessageTarget[] = [];
// 添加部门目标
selectedDepts.value.forEach(dept => {
targets.push({
sendMethod: props.sendMethod,
targetType: 'dept',
targetID: dept.id,
targetName: dept.name,
scopeDeptID: dept.id // 部门的作用域就是自己
});
});
// 添加角色目标
selectedRoles.value.forEach(role => {
targets.push({
sendMethod: props.sendMethod,
targetType: 'role',
targetID: role.id,
targetName: role.name,
scopeDeptID: roleScopeDeptID.value
});
});
// 添加用户目标
selectedUsers.value.forEach(user => {
targets.push({
sendMethod: props.sendMethod,
targetType: 'user',
targetID: user.id,
targetName: user.name,
scopeDeptID: userScopeDeptID.value
});
});
emit('update:modelValue', targets);
}
/** 初始化已选项从modelValue恢复 */
function initSelectedTargets() {
const targets = props.modelValue || [];
selectedDepts.value = [];
selectedRoles.value = [];
selectedUsers.value = [];
targets.forEach(target => {
const option: TargetOption = {
id: target.targetID!,
name: '', // 名称需要从后端查询,暂时为空
deptID: target.scopeDeptID,
deptName: ''
};
if (target.targetType === 'dept') {
selectedDepts.value.push(option);
} else if (target.targetType === 'role') {
if (target.scopeDeptID) {
roleScopeDeptID.value = target.scopeDeptID;
}
selectedRoles.value.push(option);
} else if (target.targetType === 'user') {
if (target.scopeDeptID) {
userScopeDeptID.value = target.scopeDeptID;
}
selectedUsers.value.push(option);
}
});
}
/** 监听sendMethod变化更新所有目标的sendMethod */
watch(() => props.sendMethod, () => {
emitTargets();
});
/** 监听modelValue变化同步到内部状态 */
watch(() => props.modelValue, () => {
initSelectedTargets();
}, { immediate: true });
onMounted(() => {
loadAvailableDepts();
});
</script>
<style lang="scss" scoped>
.message-target-selector {
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
.target-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
background-color: #f5f5f5;
.tab-item {
flex: 1;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&:last-child {
border-right: none;
}
&:hover {
background-color: #eee;
}
&.active {
background-color: #fff;
color: #c8232c;
font-weight: 500;
border-bottom: 2px solid #c8232c;
}
i {
font-size: 16px;
}
}
}
.target-content {
padding: 16px;
min-height: 200px;
.target-panel {
.scope-selector {
margin-bottom: 16px;
padding: 12px;
background-color: #f9f9f9;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
label {
font-weight: 500;
color: #333;
}
.dept-select {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 200px;
font-size: 14px;
&:focus {
outline: none;
border-color: #c8232c;
}
}
.tip {
font-size: 12px;
color: #999;
}
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
span {
font-size: 14px;
color: #666;
}
.btn-select {
padding: 6px 12px;
background-color: #c8232c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
&:hover {
background-color: #a01d24;
}
i {
font-size: 14px;
}
}
}
.selected-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 100px;
.selected-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #f0f0f0;
border-radius: 4px;
font-size: 14px;
i {
font-size: 14px;
color: #666;
}
.item-sublabel {
font-size: 12px;
color: #999;
margin-left: 4px;
}
.remove-btn {
margin-left: 4px;
cursor: pointer;
color: #999;
transition: color 0.2s;
&:hover {
color: #c00;
}
}
}
.empty-tip {
width: 100%;
text-align: center;
padding: 40px 0;
color: #999;
font-size: 14px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,10 @@
/**
* @description 消息通知组件导出
* @author Claude
* @since 2025-11-13
*/
export { default as MessageTargetSelector } from './MessageTargetSelector.vue';
export { default as MessageSendMethodSelector } from './MessageSendMethodSelector.vue';
export { default as MessagePriorityBadge } from './MessagePriorityBadge.vue';
export { default as MessageStatusBadge } from './MessageStatusBadge.vue';

View File

@@ -48,6 +48,9 @@ export * from './usercenter';
// 定时任务相关 // 定时任务相关
export * from './crontab'; export * from './crontab';
// 消息通知相关
export * from './message';
// 日志相关 // 日志相关
export * from './log'; export * from './log';

View File

@@ -0,0 +1,141 @@
/**
* @description 消息通知类型定义
* @author Claude
* @since 2025-11-13
*/
import type { BaseDTO } from '../base';
/**
* 消息主体实体(对应后端 TbSysMessage
*/
export interface TbSysMessage extends BaseDTO {
/** 消息ID业务主键 */
messageID?: string;
/** 消息标题 */
title?: string;
/** 消息内容 */
content?: string;
/** 消息类型notice-通知/announcement-公告/warning-预警/system-系统消息 */
messageType?: string;
/** 优先级urgent-紧急/important-重要/normal-普通 */
priority?: string;
/** 发送人ID */
senderID?: string;
/** 发送人姓名 */
senderName?: string;
/** 发送人部门ID */
senderDeptID?: string;
/** 发送人部门名称 */
senderDeptName?: string;
/** 发送模式immediate-立即发送/scheduled-定时发送 */
sendMode?: string;
/** 定时发送时间 */
scheduledTime?: string;
/** 实际发送时间 */
actualSendTime?: string;
/** 消息状态draft-草稿/pending-待发送/sending-发送中/sent-已发送/failed-发送失败/cancelled-已取消 */
status?: string;
/** 目标用户数量 */
targetUserCount?: number;
/** 已发送数量 */
sentCount?: number;
/** 发送成功数量 */
successCount?: number;
/** 发送失败数量 */
failedCount?: number;
/** 已读数量 */
readCount?: number;
/** 重试次数 */
retryCount?: number;
/** 最大重试次数 */
maxRetryCount?: number;
/** 最后错误信息 */
lastError?: string;
/** 创建人 */
creator?: string;
/** 更新人 */
updater?: string;
/** 发送方式列表前端辅助字段从targets聚合 */
sendMethods?: string[];
/** 发送目标配置列表(前端辅助字段) */
targets?: TbSysMessageTarget[];
}
/**
* 消息接收对象实体(对应后端 TbSysMessageTarget
*/
export interface TbSysMessageTarget extends BaseDTO {
/** 消息ID */
messageID?: string;
/** 发送方式system-系统消息/email-邮件/sms-短信 */
sendMethod?: string;
/** 接收对象类型dept-部门/role-角色/user-用户 */
targetType?: string;
/** 接收对象ID */
targetID?: string;
/** 接收对象名称 */
targetName?: string;
/** 作用域部门ID限制发送范围防止越权 */
scopeDeptID?: string;
}
/**
* 用户接收消息实体(对应后端 TbSysMessageUser
*/
export interface TbSysMessageUser extends BaseDTO {
/** 消息ID */
messageID?: string;
/** 用户ID */
userID?: string;
/** 发送方式system-系统消息/email-邮件/sms-短信 */
sendMethod?: string;
/** 是否已读 */
isRead?: boolean;
/** 已读时间 */
readTime?: string;
/** 发送状态pending-待发送/success-发送成功/failed-发送失败 */
sendStatus?: string;
/** 失败原因 */
failReason?: string;
}
/**
* 消息VO对应后端 MessageVO
*/
export interface MessageVO extends TbSysMessage {
/** 发送进度百分比 */
sendProgress?: number;
/** 发送成功率 */
successRate?: number;
/** 已读率 */
readRate?: number;
}
/**
* 用户消息VO对应后端 MessageUserVO
*/
export interface MessageUserVO extends TbSysMessageUser {
/** 用户名 */
username?: string;
/** 用户真实姓名 */
realName?: string;
/** 用户邮箱 */
email?: string;
/** 用户手机 */
phone?: string;
/** 消息标题 */
title?: string;
/** 消息内容 */
content?: string;
/** 消息类型 */
messageType?: string;
/** 优先级 */
priority?: string;
/** 发送人姓名 */
senderName?: string;
/** 发送人部门名称 */
senderDeptName?: string;
/** 实际发送时间 */
actualSendTime?: string;
}

View File

@@ -172,6 +172,18 @@ function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw
} }
}); });
// 当前菜单指定了页面组件时,即使存在子菜单也应当渲染该页面
if (menu.component) {
route.children!.push({
path: '',
name: `${menu.menuID}_page`,
component: getComponent(menu.component),
meta: {
...route.meta,
}
});
}
// 只将普通子菜单加入 children // 只将普通子菜单加入 children
normalChildren.forEach(child => { normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, false); const childRoute = generateRouteFromMenu(child, false);

View File

@@ -0,0 +1,737 @@
<template>
<div class="message-manage-view">
<!-- 搜索栏 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" inline>
<el-form-item label="标题">
<el-input
v-model="searchForm.title"
placeholder="请输入标题"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="消息类型">
<el-select
v-model="searchForm.messageType"
placeholder="全部"
clearable
style="width: 150px"
>
<el-option label="通知" value="notice" />
<el-option label="公告" value="announcement" />
<el-option label="警告" value="warning" />
<el-option label="系统消息" value="system" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="全部"
clearable
style="width: 150px"
>
<el-option label="草稿" value="draft" />
<el-option label="待发送" value="pending" />
<el-option label="发送中" value="sending" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
<el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select
v-model="searchForm.priority"
placeholder="全部"
clearable
style="width: 150px"
>
<el-option label="紧急" value="urgent" />
<el-option label="重要" value="important" />
<el-option label="普通" value="normal" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<el-card class="operation-card" shadow="never">
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建消息
</el-button>
<el-button @click="loadMessages">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</el-card>
<!-- 消息列表 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="messageList"
border
stripe
style="width: 100%"
>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getMessageTypeTag(row.messageType || '')">
{{ getMessageTypeLabel(row.messageType || '') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优先级" width="100" align="center">
<template #default="{ row }">
<MessagePriorityBadge :priority="row.priority" />
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<MessageStatusBadge :status="row.status || ''" />
</template>
</el-table-column>
<el-table-column label="发送方式" width="150" align="center">
<template #default="{ row }">
<el-tag
v-for="method in row.sendMethods"
:key="method"
size="small"
style="margin: 2px"
>
{{ getSendMethodLabel(method) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发送时间" width="180" align="center">
<template #default="{ row }">
{{ row.scheduledTime || '立即发送' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="180" align="center" prop="createdAt" />
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
@click="handleViewDetail(row)"
>
详情
</el-button>
<el-button
v-if="row.status === 'draft'"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-if="['pending', 'draft'].includes(row.status || '')"
link
type="success"
@click="handleSend(row)"
>
立即发送
</el-button>
<el-button
v-if="row.status === 'failed'"
link
type="warning"
@click="handleRetry(row)"
>
重试
</el-button>
<el-button
v-if="row.status === 'pending'"
link
type="warning"
@click="handleReschedule(row)"
>
改期
</el-button>
<el-button
v-if="['pending', 'sending'].includes(row.status || '')"
link
type="danger"
@click="handleCancel(row)"
>
取消
</el-button>
<el-button
v-if="['draft', 'cancelled', 'failed'].includes(row.status || '')"
link
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadMessages"
@current-change="loadMessages"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="createDialogVisible"
:title="isEdit ? '编辑消息' : '创建消息'"
width="900px"
:close-on-click-modal="false"
>
<MessageAdd
ref="messageAddRef"
:model-value="currentMessage"
:is-edit="isEdit"
/>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button @click="handleSaveDraft">保存草稿</el-button>
<el-button type="primary" @click="handleSubmit">
{{ isEdit ? '更新' : '创建并发送' }}
</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="消息详情"
width="900px"
:close-on-click-modal="false"
>
<div v-if="currentDetail" class="detail-content">
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="标题">
{{ currentDetail.title }}
</el-descriptions-item>
<el-descriptions-item label="消息类型">
<el-tag :type="getMessageTypeTag(currentDetail.messageType || '')">
{{ getMessageTypeLabel(currentDetail.messageType || '') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<MessagePriorityBadge :priority="currentDetail.priority || ''" />
</el-descriptions-item>
<el-descriptions-item label="状态">
<MessageStatusBadge :status="currentDetail.status || ''" />
</el-descriptions-item>
<el-descriptions-item label="发送方式" :span="2">
<el-tag
v-for="method in currentDetail.sendMethods"
:key="method"
style="margin-right: 8px"
>
{{ getSendMethodLabel(method) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">
<div style="white-space: pre-wrap">{{ currentDetail.content }}</div>
</el-descriptions-item>
<el-descriptions-item label="计划发送时间">
{{ currentDetail.scheduledTime || '立即发送' }}
</el-descriptions-item>
<el-descriptions-item label="实际发送时间">
{{ currentDetail.actualSendTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ currentDetail.createTime }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ currentDetail.updateTime }}
</el-descriptions-item>
</el-descriptions>
<!-- 发送统计 -->
<el-divider content-position="left">发送统计</el-divider>
<MessageStatistic :statistics="currentDetail" />
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 改期对话框 -->
<el-dialog
v-model="rescheduleDialogVisible"
title="改期发送"
width="500px"
:close-on-click-modal="false"
>
<el-form label-width="100px">
<el-form-item label="新发送时间">
<el-date-picker
v-model="newScheduledTime"
type="datetime"
placeholder="选择新的发送时间"
:disabled-date="disablePastDate"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rescheduleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmReschedule">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
Search,
RefreshLeft,
Plus,
Refresh
} from '@element-plus/icons-vue';
import { messageApi } from '@/apis/message';
import { MessagePriorityBadge, MessageStatusBadge } from '@/components/message';
import { MessageAdd, MessageStatistic } from './components';
import type { MessageVO, TbSysMessage } from '@/types';
// 搜索表单
const searchForm = reactive<Partial<TbSysMessage> & { page?: number; size?: number }>({
title: '',
messageType: undefined,
status: undefined,
priority: undefined
});
// 分页
const pagination = reactive({
page: 1,
size: 10,
total: 0
});
// 列表数据
const loading = ref(false);
const messageList = ref<MessageVO[]>([]);
// 创建/编辑对话框
const createDialogVisible = ref(false);
const isEdit = ref(false);
const messageAddRef = ref();
const currentMessage = ref<TbSysMessage>({
title: '',
content: '',
messageType: 'notice',
priority: 'normal',
sendMode: 'immediate',
scheduledTime: '',
maxRetryCount: 3,
sendMethods: ['system'],
targets: []
});
// 详情对话框
const detailDialogVisible = ref(false);
const currentDetail = ref<MessageVO | null>(null);
// 改期对话框
const rescheduleDialogVisible = ref(false);
const currentRescheduleId = ref('');
const newScheduledTime = ref('');
/** 加载消息列表 */
async function loadMessages() {
loading.value = true;
try {
const result = await messageApi.getMessagePage({
pageParam: {
pageNumber: pagination.page,
pageSize: pagination.size
},
filter: searchForm
});
if (result.success) {
messageList.value = result.dataList || [];
pagination.total = result.pageDomain?.pageParam.totalElements || 0;
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息列表失败:', error);
ElMessage.error('加载失败');
} finally {
loading.value = false;
}
}
/** 搜索 */
function handleSearch() {
pagination.page = 1;
loadMessages();
}
/** 重置搜索 */
function handleReset() {
Object.assign(searchForm, {
title: '',
messageType: undefined,
status: undefined,
priority: undefined
});
handleSearch();
}
/** 创建消息 */
function handleCreate() {
isEdit.value = false;
currentMessage.value = {
title: '',
content: '',
messageType: 'notice',
priority: 'normal',
sendMode: 'immediate',
scheduledTime: '',
maxRetryCount: 3,
sendMethods: ['system'],
targets: []
};
createDialogVisible.value = true;
}
/** 编辑消息 */
async function handleEdit(row: MessageVO) {
try {
const result = await messageApi.getMessageDetail(row.messageID!);
if (result.success && result.data) {
isEdit.value = true;
currentMessage.value = {
messageID: result.data.messageID,
title: result.data.title ?? '',
content: result.data.content ?? '',
messageType: result.data.messageType ?? 'notice',
priority: result.data.priority ?? 'normal',
sendMode: result.data.sendMode ?? 'immediate',
scheduledTime: result.data.scheduledTime,
maxRetryCount: result.data.maxRetryCount,
sendMethods: result.data.sendMethods ?? ['system'],
targets: result.data.targets || []
};
createDialogVisible.value = true;
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息详情失败:', error);
ElMessage.error('加载失败');
}
}
/** 保存草稿 */
async function handleSaveDraft() {
const valid = await messageAddRef.value?.validate();
if (!valid) return;
const formData = messageAddRef.value?.getFormData();
if (!formData) return;
const draftData = { ...formData, status: 'draft' };
try {
// 如果 currentMessage 有 messageID说明是编辑模式需要带上 messageID
if (isEdit.value && currentMessage.value.messageID) {
draftData.messageID = currentMessage.value.messageID;
}
// 根据是否有 messageID 判断是创建还是更新
const result = draftData.messageID
? await messageApi.updateMessage(draftData)
: await messageApi.createMessage(draftData);
if (result.success) {
ElMessage.success('保存成功');
createDialogVisible.value = false;
loadMessages();
} else {
ElMessage.error(result.message || '保存失败');
}
} catch (error) {
console.error('保存草稿失败:', error);
ElMessage.error('保存失败');
}
}
/** 提交(创建并发送或更新) */
async function handleSubmit() {
const valid = await messageAddRef.value?.validate();
if (!valid) return;
const formData = messageAddRef.value?.getFormData();
if (!formData) return;
try {
// 如果 currentMessage 有 messageID说明是编辑模式需要带上 messageID
if (isEdit.value && currentMessage.value.messageID) {
formData.messageID = currentMessage.value.messageID;
}
// 根据是否有 messageID 判断是创建还是更新
const result = formData.messageID
? await messageApi.updateMessage(formData)
: await messageApi.createMessage(formData);
if (result.success) {
ElMessage.success(formData.messageID ? '更新成功' : '创建成功');
createDialogVisible.value = false;
loadMessages();
} else {
ElMessage.error(result.message || (formData.messageID ? '更新失败' : '创建失败'));
}
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('操作失败');
}
}
/** 查看详情 */
async function handleViewDetail(row: MessageVO) {
try {
const result = await messageApi.getMessage(row.messageID!);
if (result.success && result.data) {
currentDetail.value = result.data;
detailDialogVisible.value = true;
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息详情失败:', error);
ElMessage.error('加载失败');
}
}
/** 立即发送 */
async function handleSend(row: MessageVO) {
try {
await ElMessageBox.confirm('确定要立即发送该消息吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
const result = await messageApi.sendMessage(row.messageID!);
if (result.success) {
ElMessage.success('发送成功');
loadMessages();
} else {
ElMessage.error(result.message || '发送失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('发送失败:', error);
ElMessage.error('发送失败');
}
}
}
/** 重试 */
async function handleRetry(row: MessageVO) {
try {
await ElMessageBox.confirm('确定要重试发送该消息吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
const result = await messageApi.retryMessage(row.messageID!);
if (result.success) {
ElMessage.success('已提交重试');
loadMessages();
} else {
ElMessage.error(result.message || '重试失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('重试失败:', error);
ElMessage.error('重试失败');
}
}
}
/** 改期 */
function handleReschedule(row: MessageVO) {
currentRescheduleId.value = row.messageID!;
newScheduledTime.value = row.scheduledTime || '';
rescheduleDialogVisible.value = true;
}
/** 确认改期 */
async function confirmReschedule() {
if (!newScheduledTime.value) {
ElMessage.warning('请选择新的发送时间');
return;
}
try {
const result = await messageApi.rescheduleMessage(
currentRescheduleId.value,
newScheduledTime.value
);
if (result.success) {
ElMessage.success('改期成功');
rescheduleDialogVisible.value = false;
loadMessages();
} else {
ElMessage.error(result.message || '改期失败');
}
} catch (error) {
console.error('改期失败:', error);
ElMessage.error('改期失败');
}
}
/** 取消 */
async function handleCancel(row: MessageVO) {
try {
await ElMessageBox.confirm('确定要取消该消息发送吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
const result = await messageApi.cancelMessage(row.messageID!);
if (result.success) {
ElMessage.success('已取消');
loadMessages();
} else {
ElMessage.error(result.message || '取消失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('取消失败:', error);
ElMessage.error('取消失败');
}
}
}
/** 删除 */
async function handleDelete(row: MessageVO) {
try {
await ElMessageBox.confirm('确定要删除该消息吗?删除后无法恢复!', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
});
const result = await messageApi.deleteMessage(row.messageID!);
if (result.success) {
ElMessage.success('删除成功');
loadMessages();
} else {
ElMessage.error(result.message || '删除失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error);
ElMessage.error('删除失败');
}
}
}
/** 禁用过去日期 */
function disablePastDate(time: Date): boolean {
return time.getTime() < Date.now();
}
/** 获取消息类型标签 */
function getMessageTypeTag(type: string): string {
const map: Record<string, string> = {
notice: '',
announcement: 'success',
warning: 'warning',
system: 'info'
};
return map[type] || '';
}
/** 获取消息类型文本 */
function getMessageTypeLabel(type: string): string {
const map: Record<string, string> = {
notice: '通知',
announcement: '公告',
warning: '警告',
system: '系统消息'
};
return map[type] || type;
}
/** 获取发送方式文本 */
function getSendMethodLabel(method: string): string {
const map: Record<string, string> = {
system: '系统消息',
email: '邮件通知',
sms: '短信通知'
};
return map[method] || method;
}
onMounted(() => {
loadMessages();
});
</script>
<style lang="scss" scoped>
.message-manage-view {
padding: 20px;
.search-card,
.operation-card,
.table-card {
margin-bottom: 20px;
}
.detail-content {
max-height: 600px;
overflow-y: auto;
}
}
</style>

View File

@@ -0,0 +1,572 @@
<template>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-form-item label="消息标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入消息标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="消息类型" prop="messageType">
<el-radio-group v-model="formData.messageType">
<el-radio value="notice">通知</el-radio>
<el-radio value="announcement">公告</el-radio>
<el-radio value="warning">警告</el-radio>
<el-radio value="system">系统消息</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="formData.priority">
<el-radio value="urgent">紧急</el-radio>
<el-radio value="important">重要</el-radio>
<el-radio value="normal">普通</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="消息内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="6"
placeholder="请输入消息内容"
maxlength="2000"
show-word-limit
/>
</el-form-item>
<!-- 发送设置 -->
<el-divider content-position="left">发送设置</el-divider>
<el-form-item label="发送模式" prop="sendMode">
<el-radio-group v-model="formData.sendMode">
<el-radio value="immediate">立即发送</el-radio>
<el-radio value="scheduled">定时发送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="formData.sendMode === 'scheduled'"
label="发送时间"
prop="scheduledTime"
>
<el-date-picker
v-model="formData.scheduledTime"
type="datetime"
placeholder="选择发送时间"
:disabled-date="disablePastDate"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</el-form-item>
<el-form-item label="最大重试次数" prop="maxRetryCount">
<el-input-number
v-model="formData.maxRetryCount"
:min="0"
:max="5"
controls-position="right"
/>
<span class="form-tip">发送失败后重试次数0-5</span>
</el-form-item>
<!-- 发送方式 -->
<el-divider content-position="left">发送方式</el-divider>
<el-form-item label="发送渠道" prop="sendMethods">
<el-checkbox-group v-model="formData.sendMethods">
<el-checkbox value="system">系统消息</el-checkbox>
<el-checkbox value="email">邮件通知</el-checkbox>
<el-checkbox value="sms">短信通知</el-checkbox>
</el-checkbox-group>
</el-form-item>
<!-- 接收对象 -->
<el-divider content-position="left">接收对象</el-divider>
<el-form-item label="选择对象">
<el-button type="primary" size="small" @click="deptSelectorVisible = true">
<el-icon><Plus /></el-icon>
选择部门
</el-button>
<el-button type="primary" size="small" @click="deptRoleSelectorVisible = true" style="margin-left: 8px">
<el-icon><Plus /></el-icon>
选择部门角色
</el-button>
<el-button type="primary" size="small" @click="userSelectorVisible = true" style="margin-left: 8px">
<el-icon><Plus /></el-icon>
选择用户
</el-button>
<span class="form-tip">已选择 {{ getTotalTargetCount() }} 个对象</span>
</el-form-item>
<div v-if="formData.targets && formData.targets.length > 0" class="selected-targets">
<el-tag
v-for="(target, index) in formData.targets"
:key="index"
closable
@close="removeTarget(index)"
style="margin: 4px"
>
{{ getTargetDisplayName(target) }}
</el-tag>
</div>
<!-- 部门选择器 -->
<GenericSelector
v-model:visible="deptSelectorVisible"
title="选择部门"
:fetch-available-api="fetchAllDepts"
:fetch-selected-api="fetchSelectedDepts"
:filter-selected="filterDepts"
:item-config="{ id: 'id', label: 'name' }"
unit-name="个部门"
@confirm="handleDeptConfirm"
/>
<!-- 部门角色选择器 -->
<GenericSelector
v-model:visible="deptRoleSelectorVisible"
title="选择部门角色"
:fetch-available-api="fetchAllDeptRoles"
:fetch-selected-api="fetchSelectedDeptRoles"
:filter-selected="filterDeptRoles"
:item-config="{ id: 'combinedId', label: 'displayName' }"
:use-tree="true"
:tree-transform="transformDeptRolesToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'combinedId' }"
:only-leaf-selectable="true"
unit-name="个部门角色"
@confirm="handleDeptRoleConfirm"
/>
<!-- 用户选择器 -->
<GenericSelector
v-model:visible="userSelectorVisible"
title="选择用户"
:fetch-available-api="fetchAllUsers"
:fetch-selected-api="fetchSelectedUsers"
:filter-selected="filterUsers"
:item-config="{ id: 'id', label: 'displayName', sublabel: 'deptName' }"
unit-name="个用户"
@confirm="handleUserConfirm"
/>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import type { TbSysMessage, TbSysMessageTarget } from '@/types';
import { GenericSelector } from '@/components/base';
import { deptApi, roleApi, userApi } from '@/apis/system';
interface Props {
modelValue?: TbSysMessage;
isEdit?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isEdit: false
});
const formRef = ref<FormInstance>();
const formData = ref<TbSysMessage>({
title: '',
content: '',
messageType: 'notice',
priority: 'normal',
sendMode: 'immediate',
scheduledTime: '',
maxRetryCount: 3,
sendMethods: ['system'],
targets: []
});
// 表单验证规则
const formRules: FormRules = {
title: [{ required: true, message: '请输入消息标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入消息内容', trigger: 'blur' }],
messageType: [{ required: true, message: '请选择消息类型', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
sendMode: [{ required: true, message: '请选择发送模式', trigger: 'change' }],
scheduledTime: [{ required: true, message: '请选择发送时间', trigger: 'change' }],
sendMethods: [
{
required: true,
message: '请至少选择一种发送方式',
trigger: 'change',
validator: (rule: any, value: any) => {
return Array.isArray(value) && value.length > 0;
}
}
]
};
// 目标选择相关
interface TargetOption {
id: string;
name: string;
deptName?: string;
}
// 选择器可见性状态
const deptSelectorVisible = ref(false);
const deptRoleSelectorVisible = ref(false);
const userSelectorVisible = ref(false);
/** 禁用过去日期 */
function disablePastDate(time: Date): boolean {
return time.getTime() < Date.now();
}
/** 获取总目标数量 */
function getTotalTargetCount(): number {
return formData.value.targets?.length || 0;
}
/** 获取目标显示名称 */
function getTargetDisplayName(target: TbSysMessageTarget): string {
if (target.targetType === 'dept') {
return `部门: ${target.targetName || target.targetID}`;
} else if (target.targetType === 'role') {
return `部门角色: ${target.targetName || target.targetID}`;
} else if (target.targetType === 'user') {
return `用户: ${target.targetName || target.targetID}`;
}
return target.targetName || target.targetID || '';
}
/** 移除目标 */
function removeTarget(index: number) {
if (formData.value.targets) {
formData.value.targets.splice(index, 1);
}
}
// ==================== 部门选择相关 ====================
/** 获取所有可选部门 */
async function fetchAllDepts() {
const result = await deptApi.getAllDepts();
if (result.success && result.dataList) {
return {
...result,
dataList: result.dataList.map((dept: any) => ({
id: dept.deptID,
name: dept.name
}))
};
}
return result;
}
/** 获取已选择的部门 */
async function fetchSelectedDepts() {
if (!formData.value.targets) {
return { success: true, dataList: [], code: 200, message: '', login: true, auth: true };
}
const selectedDepts = formData.value.targets
.filter(t => t.targetType === 'dept')
.map(t => ({
id: t.targetID,
name: t.targetName || t.targetID
}));
return {
success: true,
dataList: selectedDepts,
code: 200,
message: '',
login: true,
auth: true
};
}
/** 过滤已选择的部门 */
function filterDepts(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.id));
return available.filter(item => !selectedIds.has(item.id));
}
/** 部门选择确认 */
function handleDeptConfirm(items: any[]) {
// 移除旧的部门类型目标
if (!formData.value.targets) {
formData.value.targets = [];
}
formData.value.targets = formData.value.targets.filter(t => t.targetType !== 'dept');
// 添加新选择的部门
items.forEach(dept => {
formData.value.targets!.push({
targetType: 'dept',
targetID: dept.id,
targetName: dept.name,
scopeDeptID: dept.id, // 部门的作用域就是自己
sendMethod: formData.value.sendMethods?.[0] || 'system'
});
});
}
// ==================== 部门角色选择相关 ====================
/** 获取所有可选部门角色 */
async function fetchAllDeptRoles() {
const result = await deptApi.getDeptRoleList({} as any);
if (result.success && result.dataList) {
const transformed = result.dataList
.filter((item: any) => item.deptID && item.roleID)
.map((item: any) => ({
...item,
combinedId: `${item.deptID}-${item.roleID}`,
displayName: `${item.deptName || ''} - ${item.roleName || ''}`,
deptDescription: item.deptDescription || ''
}));
return { ...result, dataList: transformed };
}
return result;
}
/** 获取已选择的部门角色 */
async function fetchSelectedDeptRoles() {
if (!formData.value.targets) {
return { success: true, dataList: [], code: 200, message: '', login: true, auth: true };
}
const selectedRoles = formData.value.targets
.filter(t => t.targetType === 'role')
.map(t => ({
deptID: t.scopeDeptID,
roleID: t.targetID,
combinedId: `${t.scopeDeptID}-${t.targetID}`,
displayName: t.targetName || `${t.scopeDeptID}-${t.targetID}`
}));
return {
success: true,
dataList: selectedRoles,
code: 200,
message: '',
login: true,
auth: true
};
}
/** 过滤已选择的部门角色 */
function filterDeptRoles(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.combinedId));
return available.filter(item => !selectedIds.has(item.combinedId));
}
/** 转换部门角色为树形结构 */
function transformDeptRolesToTree(flatData: any[]) {
const deptMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const deptID = item.deptID;
if (!deptMap.has(deptID)) {
deptMap.set(deptID, {
combinedId: deptID,
displayName: item.deptName || deptID,
deptDescription: item.deptDescription,
children: [],
isDept: true
});
}
const deptNode = deptMap.get(deptID);
if (deptNode) {
deptNode.children.push({
...item,
isDept: false
});
}
});
deptMap.forEach(deptNode => {
tree.push(deptNode);
});
return tree;
}
/** 部门角色选择确认 */
function handleDeptRoleConfirm(items: any[]) {
// 移除旧的角色类型目标
if (!formData.value.targets) {
formData.value.targets = [];
}
formData.value.targets = formData.value.targets.filter(t => t.targetType !== 'role');
// 添加新选择的部门角色
items.forEach(role => {
formData.value.targets!.push({
targetType: 'role',
targetID: role.roleID,
targetName: role.displayName,
scopeDeptID: role.deptID,
sendMethod: formData.value.sendMethods?.[0] || 'system'
});
});
}
// ==================== 用户选择相关 ====================
/** 获取所有可选用户 */
async function fetchAllUsers() {
const result = await userApi.getUserList({} as any);
if (result.success && result.dataList) {
return {
...result,
dataList: result.dataList.map((user: any) => ({
id: user.id || user.userID,
displayName: user.realName || user.username,
deptID: user.deptID,
deptName: user.deptName
}))
};
}
return result;
}
/** 获取已选择的用户 */
async function fetchSelectedUsers() {
if (!formData.value.targets) {
return { success: true, dataList: [], code: 200, message: '', login: true, auth: true };
}
const selectedUsers = formData.value.targets
.filter(t => t.targetType === 'user')
.map(t => ({
id: t.targetID,
displayName: t.targetName || t.targetID,
deptID: t.scopeDeptID,
deptName: ''
}));
return {
success: true,
dataList: selectedUsers,
code: 200,
message: '',
login: true,
auth: true
};
}
/** 过滤已选择的用户 */
function filterUsers(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.id));
return available.filter(item => !selectedIds.has(item.id));
}
/** 用户选择确认 */
function handleUserConfirm(items: any[]) {
// 移除旧的用户类型目标
if (!formData.value.targets) {
formData.value.targets = [];
}
formData.value.targets = formData.value.targets.filter(t => t.targetType !== 'user');
// 添加新选择的用户
items.forEach(user => {
formData.value.targets!.push({
targetType: 'user',
targetID: user.id,
targetName: user.displayName,
scopeDeptID: user.deptID,
sendMethod: formData.value.sendMethods?.[0] || 'system'
});
});
}
// ==================== 表单操作 ====================
/** 表单验证 */
async function validate(): Promise<boolean> {
if (!formRef.value) return false;
try {
await formRef.value.validate();
// 验证目标
if (!formData.value.targets || formData.value.targets.length === 0) {
ElMessage.warning('请至少选择一个接收对象');
return false;
}
return true;
} catch {
return false;
}
}
/** 获取表单数据 */
function getFormData(): TbSysMessage {
return formData.value;
}
/** 重置表单 */
function reset() {
formData.value = {
title: '',
content: '',
messageType: 'notice',
priority: 'normal',
sendMode: 'immediate',
scheduledTime: '',
maxRetryCount: 3,
sendMethods: ['system'],
targets: []
};
formRef.value?.clearValidate();
}
// 监听modelValue变化同步到formData
watch(() => props.modelValue, (val) => {
if (val) {
formData.value = { ...val };
}
});
// 暴露方法给父组件
defineExpose({
validate,
getFormData,
reset
});
</script>
<style lang="scss" scoped>
.form-tip {
margin-left: 10px;
font-size: 12px;
color: #999;
}
.selected-targets {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
min-height: 50px;
}
.target-list {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
min-height: 100px;
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="message-statistic">
<!-- 统计卡片 -->
<el-row :gutter="16" class="statistic-cards">
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon users">
<el-icon><User /></el-icon>
</div>
<div class="card-info">
<div class="card-label">目标用户数</div>
<div class="card-value">{{ statistics.targetUserCount || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon sent">
<el-icon><Promotion /></el-icon>
</div>
<div class="card-info">
<div class="card-label">已发送</div>
<div class="card-value">{{ statistics.sentCount || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon success">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="card-info">
<div class="card-label">发送成功</div>
<div class="card-value">{{ statistics.successCount || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="statistic-cards">
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon failed">
<el-icon><CircleClose /></el-icon>
</div>
<div class="card-info">
<div class="card-label">发送失败</div>
<div class="card-value">{{ statistics.failedCount || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon read">
<el-icon><View /></el-icon>
</div>
<div class="card-info">
<div class="card-label">已读人数</div>
<div class="card-value">{{ statistics.readCount || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="card-content">
<div class="card-icon pending">
<el-icon><Clock /></el-icon>
</div>
<div class="card-info">
<div class="card-label">待发送</div>
<div class="card-value">{{ pendingCount }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 进度条 -->
<div class="progress-section">
<div class="progress-item">
<div class="progress-label">
<span>发送进度</span>
<span class="progress-value">{{ sendProgress }}%</span>
</div>
<el-progress
:percentage="sendProgress"
:color="progressColor"
:stroke-width="12"
/>
</div>
<div class="progress-item">
<div class="progress-label">
<span>成功率</span>
<span class="progress-value">{{ successRate }}%</span>
</div>
<el-progress
:percentage="successRate"
:color="successRateColor"
:stroke-width="12"
/>
</div>
<div class="progress-item">
<div class="progress-label">
<span>已读率</span>
<span class="progress-value">{{ readRate }}%</span>
</div>
<el-progress
:percentage="readRate"
color="#409eff"
:stroke-width="12"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
User,
Promotion,
CircleCheck,
CircleClose,
View,
Clock
} from '@element-plus/icons-vue';
import type { MessageVO } from '@/types';
interface Props {
statistics: MessageVO;
}
const props = defineProps<Props>();
/** 待发送数量 */
const pendingCount = computed(() => {
const target = props.statistics.targetUserCount || 0;
const sent = props.statistics.sentCount || 0;
return Math.max(0, target - sent);
});
/** 发送进度 */
const sendProgress = computed(() => {
const target = props.statistics.targetUserCount || 0;
const sent = props.statistics.sentCount || 0;
if (target === 0) return 0;
return Math.round((sent / target) * 100);
});
/** 成功率 */
const successRate = computed(() => {
const sent = props.statistics.sentCount || 0;
const success = props.statistics.successCount || 0;
if (sent === 0) return 0;
return Math.round((success / sent) * 100);
});
/** 已读率 */
const readRate = computed(() => {
const success = props.statistics.successCount || 0;
const read = props.statistics.readCount || 0;
if (success === 0) return 0;
return Math.round((read / success) * 100);
});
/** 发送进度颜色 */
const progressColor = computed(() => {
const progress = sendProgress.value;
if (progress < 30) return '#f56c6c';
if (progress < 70) return '#e6a23c';
if (progress < 100) return '#409eff';
return '#67c23a';
});
/** 成功率颜色 */
const successRateColor = computed(() => {
const rate = successRate.value;
if (rate < 60) return '#f56c6c';
if (rate < 90) return '#e6a23c';
return '#67c23a';
});
</script>
<style lang="scss" scoped>
.message-statistic {
padding: 10px 0;
}
.statistic-cards {
margin-bottom: 20px;
&:last-of-type {
margin-bottom: 30px;
}
}
.card-content {
display: flex;
align-items: center;
gap: 16px;
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #fff;
&.users {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.sent {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.failed {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&.read {
background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);
}
&.pending {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
}
}
.card-info {
flex: 1;
}
.card-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.card-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
.progress-section {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
}
.progress-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.progress-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: #606266;
.progress-value {
font-weight: bold;
color: #303133;
}
}
</style>

View File

@@ -0,0 +1,8 @@
/**
* @description 消息管理组件导出
* @author Claude
* @since 2025-11-13
*/
export { default as MessageAdd } from './MessageAdd.vue';
export { default as MessageStatistic } from './MessageStatistic.vue';

View File

@@ -0,0 +1,8 @@
/**
* @description 消息管理视图导出
* @author Claude
* @since 2025-11-13
*/
export { default as MessageManageView } from './MessageManageView.vue';
export * from './components';

View File

@@ -0,0 +1,326 @@
<template>
<div v-loading="loading" class="message-detail">
<div v-if="messageData" class="detail-container">
<!-- 返回按钮 -->
<el-button
v-if="showBackButton"
type="text"
@click="handleBack"
class="back-button"
>
<el-icon><ArrowLeft /></el-icon>
{{ backButtonText }}
</el-button>
<!-- 消息头部 -->
<div class="message-header">
<div class="header-left">
<h2 class="message-title">{{ messageData.title }}</h2>
<div class="message-meta">
<el-tag :type="getMessageTypeTag(messageData.messageType || '')" size="large">
{{ getMessageTypeLabel(messageData.messageType || '') }}
</el-tag>
<MessagePriorityBadge :priority="messageData.priority || ''" size="large" />
<MessageStatusBadge :status="messageData.status||''" size="large" />
</div>
</div>
</div>
<el-divider />
<!-- 消息信息 -->
<div class="message-info">
<el-descriptions :column="2" border>
<el-descriptions-item label="创建时间">
<el-icon><Clock /></el-icon>
{{ messageData.createTime }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
<el-icon><Clock /></el-icon>
{{ messageData.updateTime }}
</el-descriptions-item>
<el-descriptions-item label="计划发送时间">
<el-icon><Timer /></el-icon>
{{ messageData.scheduledTime || '立即发送' }}
</el-descriptions-item>
<el-descriptions-item label="实际发送时间">
<el-icon><Timer /></el-icon>
{{ messageData.actualSendTime || '未发送' }}
</el-descriptions-item>
<el-descriptions-item label="发送方式" :span="2">
<el-tag
v-for="method in messageData.sendMethods"
:key="method"
style="margin-right: 8px"
>
{{ getSendMethodLabel(method) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最大重试次数">
{{ messageData.maxRetryCount }}
</el-descriptions-item>
<el-descriptions-item label="当前重试次数">
{{ messageData.retryCount || 0 }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-divider content-position="left">消息内容</el-divider>
<!-- 消息内容 -->
<div class="message-content">
<el-card shadow="never" class="content-card">
<div class="content-text">{{ messageData.content }}</div>
</el-card>
</div>
<!-- 发送统计如果有统计数据 -->
<template v-if="showStatistics && hasStatistics">
<el-divider content-position="left">发送统计</el-divider>
<MessageStatistic :statistics="messageData" />
</template>
<!-- 操作按钮如果提供 -->
<div v-if="showActions" class="message-actions">
<slot name="actions" :message="messageData" />
</div>
</div>
<el-empty v-else-if="!loading" description="消息不存在" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { ArrowLeft, Clock, Timer } from '@element-plus/icons-vue';
import { messageApi } from '@/apis/message';
import { MessagePriorityBadge, MessageStatusBadge } from '@/components/message';
import { MessageStatistic } from '@/views/admin/manage/message/components';
import type { MessageVO } from '@/types';
interface Props {
messageId: string;
showBackButton?: boolean;
backButtonText?: string;
showStatistics?: boolean;
showActions?: boolean;
autoMarkRead?: boolean;
}
interface Emits {
(e: 'back'): void;
(e: 'loaded', message: MessageVO): void;
(e: 'error', error: any): void;
}
const props = withDefaults(defineProps<Props>(), {
showBackButton: false,
backButtonText: '返回',
showStatistics: false,
showActions: false,
autoMarkRead: false
});
const emit = defineEmits<Emits>();
const loading = ref(false);
const messageData = ref<MessageVO | null>(null);
/** 是否有统计数据 */
const hasStatistics = computed(() => {
if (!messageData.value) return false;
return (
messageData.value.targetUserCount !== undefined &&
messageData.value.targetUserCount > 0
);
});
/** 加载消息详情 */
async function loadMessage() {
if (!props.messageId) {
ElMessage.warning('消息ID不能为空');
return;
}
loading.value = true;
try {
const result = await messageApi.getMessageDetail(props.messageId);
if (result.success && result.data) {
messageData.value = result.data;
emit('loaded', result.data);
// 自动标记已读(用户端)
if (props.autoMarkRead) {
markAsRead();
}
} else {
ElMessage.error(result.message || '加载失败');
emit('error', new Error(result.message || '加载失败'));
}
} catch (error) {
console.error('加载消息详情失败:', error);
ElMessage.error('加载失败');
emit('error', error);
} finally {
loading.value = false;
}
}
/** 标记已读 */
async function markAsRead() {
if (!props.messageId) return;
try {
await messageApi.markAsRead(props.messageId);
} catch (error) {
console.error('标记已读失败:', error);
}
}
/** 返回 */
function handleBack() {
emit('back');
}
/** 获取消息类型标签 */
function getMessageTypeTag(type: string): string {
const map: Record<string, string> = {
notice: '',
announcement: 'success',
warning: 'warning',
system: 'info'
};
return map[type] || '';
}
/** 获取消息类型文本 */
function getMessageTypeLabel(type: string): string {
const map: Record<string, string> = {
notice: '通知',
announcement: '公告',
warning: '警告',
system: '系统消息'
};
return map[type] || type;
}
/** 获取发送方式文本 */
function getSendMethodLabel(method: string): string {
const map: Record<string, string> = {
system: '系统消息',
email: '邮件通知',
sms: '短信通知'
};
return map[method] || method;
}
/** 刷新数据 */
function refresh() {
loadMessage();
}
/** 监听messageId变化 */
watch(() => props.messageId, () => {
loadMessage();
}, { immediate: false });
onMounted(() => {
loadMessage();
});
// 暴露方法给父组件
defineExpose({
refresh,
messageData
});
</script>
<style lang="scss" scoped>
.message-detail {
.detail-container {
background: #fff;
border-radius: 8px;
padding: 24px;
}
.back-button {
margin-bottom: 16px;
font-size: 14px;
color: #409eff;
padding: 0;
&:hover {
color: #66b1ff;
}
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.header-left {
flex: 1;
}
.message-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0 0 12px 0;
line-height: 1.4;
}
.message-meta {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
}
.message-info {
margin-bottom: 24px;
:deep(.el-descriptions__label) {
width: 140px;
font-weight: 500;
}
.el-icon {
margin-right: 6px;
}
}
.message-content {
margin-bottom: 24px;
.content-card {
background: #f9f9f9;
:deep(.el-card__body) {
padding: 20px;
}
}
.content-text {
font-size: 15px;
line-height: 1.8;
color: #606266;
white-space: pre-wrap;
word-wrap: break-word;
}
}
.message-actions {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #ebeef5;
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,7 @@
/**
* @description 公共消息组件导出
* @author Claude
* @since 2025-11-13
*/
export { default as MessageDetail } from './MessageDetail.vue';

View File

@@ -0,0 +1,473 @@
<template>
<div class="my-message-detail" v-loading="loading">
<div class="header">
<el-button @click="handleBack" text>
<el-icon><ArrowLeft /></el-icon>
返回消息列表
</el-button>
</div>
<div v-if="message" class="detail-container">
<!-- 消息卡片 -->
<el-card class="message-card" shadow="never">
<!-- 标题区域 -->
<div class="message-header">
<div class="title-area">
<h1 class="message-title">{{ message.title }}</h1>
<div class="title-badges">
<el-tag :type="getMessageTypeTag(message.messageType)">
{{ getMessageTypeText(message.messageType) }}
</el-tag>
<MessagePriorityBadge :priority="message.priority || ''" />
</div>
</div>
<div class="message-meta">
<div class="meta-item">
<el-icon><User /></el-icon>
<span>发送人{{ message.senderName }}</span>
</div>
<div class="meta-item">
<el-icon><OfficeBuilding /></el-icon>
<span>部门{{ message.senderDeptName }}</span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span>发送时间{{ formatDateTime(message.actualSendTime) }}</span>
</div>
<div v-if="message.isRead" class="meta-item read-status">
<el-icon><Check /></el-icon>
<span>已读于{{ formatDateTime(message.readTime) }}</span>
</div>
<div v-else class="meta-item unread-status">
<el-icon><View /></el-icon>
<span>未读</span>
</div>
</div>
</div>
<!-- 消息内容 -->
<el-divider />
<div class="message-content">
<div class="content-body">
{{ message.content }}
</div>
</div>
<!-- 发送方式 -->
<el-divider />
<div class="send-info">
<div class="info-label">
<el-icon><Message /></el-icon>
<span>发送方式</span>
</div>
<div class="send-methods">
<el-tag
v-if="message.sendMethod === 'system' || !message.sendMethod"
type="primary"
size="small"
>
系统消息
</el-tag>
<el-tag
v-if="message.sendMethod === 'email'"
type="success"
size="small"
>
邮件通知
</el-tag>
<el-tag
v-if="message.sendMethod === 'sms'"
type="warning"
size="small"
>
短信通知
</el-tag>
</div>
</div>
<!-- 发送状态 -->
<div class="send-status">
<div class="info-label">
<el-icon><CircleCheck /></el-icon>
<span>发送状态</span>
</div>
<el-tag
:type="getSendStatusType(message.sendStatus)"
size="small"
>
{{ getSendStatusText(message.sendStatus) }}
</el-tag>
<span v-if="message.failReason" class="fail-reason">
{{ message.failReason }}
</span>
</div>
<!-- 操作按钮 -->
<el-divider />
<div class="message-actions">
<el-button
v-if="!message.isRead"
type="primary"
@click="handleMarkAsRead"
:loading="marking"
>
<el-icon><Check /></el-icon>
标记为已读
</el-button>
</div>
</el-card>
<!-- 相关消息推荐可选功能 -->
<el-card v-if="relatedMessages.length > 0" class="related-messages" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">相关消息</span>
</div>
</template>
<div class="related-list">
<div
v-for="relMsg in relatedMessages"
:key="relMsg.messageID"
class="related-item"
@click="handleViewRelated(relMsg.messageID)"
>
<div class="related-title">
<span v-if="!relMsg.isRead" class="unread-dot"></span>
{{ relMsg.title }}
</div>
<div class="related-meta">
<span>{{ formatDateTime(relMsg.actualSendTime) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
ArrowLeft,
User,
OfficeBuilding,
Clock,
Check,
View,
Message,
CircleCheck
} from '@element-plus/icons-vue';
import { messageApi } from '@/apis/message';
import type { MessageUserVO } from '@/types';
import { MessagePriorityBadge } from '@/components/message';
defineOptions({
name: 'MyMessageDetailView'
});
const router = useRouter();
const route = useRoute();
const messageID = route.params.messageID as string;
const loading = ref(false);
const marking = ref(false);
const message = ref<MessageUserVO | null>(null);
const relatedMessages = ref<MessageUserVO[]>([]);
/** 获取消息类型标签 */
function getMessageTypeTag(type?: string): string {
const map: Record<string, string> = {
notice: 'info',
announcement: 'warning',
warning: 'danger',
system: 'success'
};
return map[type || ''] || '';
}
/** 获取消息类型文本 */
function getMessageTypeText(type?: string): string {
const map: Record<string, string> = {
notice: '通知',
announcement: '公告',
warning: '预警',
system: '系统消息'
};
return map[type || ''] || type || '';
}
/** 获取发送状态类型 */
function getSendStatusType(status?: string): string {
const map: Record<string, string> = {
pending: 'info',
success: 'success',
failed: 'danger'
};
return map[status || ''] || '';
}
/** 获取发送状态文本 */
function getSendStatusText(status?: string): string {
const map: Record<string, string> = {
pending: '待发送',
success: '发送成功',
failed: '发送失败'
};
return map[status || ''] || status || '';
}
/** 格式化日期时间 */
function formatDateTime(dateStr?: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
/** 加载消息详情 */
async function loadMessageDetail() {
loading.value = true;
try {
const result = await messageApi.getMyMessageDetail(messageID);
if (result.success) {
message.value = result.data || null;
// 如果是未读消息,自动标记为已读
if (message.value && !message.value.isRead) {
await autoMarkAsRead();
}
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息详情失败:', error);
ElMessage.error('加载失败');
} finally {
loading.value = false;
}
}
/** 自动标记为已读 */
async function autoMarkAsRead() {
try {
await messageApi.markAsRead(messageID);
// 不显示成功消息,静默标记
} catch (error) {
console.error('自动标记已读失败:', error);
}
}
/** 手动标记为已读 */
async function handleMarkAsRead() {
marking.value = true;
try {
const result = await messageApi.markAsRead(messageID);
if (result.success) {
ElMessage.success('已标记为已读');
await loadMessageDetail();
} else {
ElMessage.error(result.message || '操作失败');
}
} catch (error) {
console.error('标记已读失败:', error);
ElMessage.error('操作失败');
} finally {
marking.value = false;
}
}
/** 查看相关消息 */
function handleViewRelated(relatedMessageID?: string) {
if (!relatedMessageID) return;
router.push(`/user/message/detail/${relatedMessageID}`);
// 重新加载当前页面
loadMessageDetail();
}
/** 返回 */
function handleBack() {
router.back()
}
onMounted(() => {
loadMessageDetail();
});
</script>
<style lang="scss" scoped>
.my-message-detail {
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
.header {
max-width: 900px;
margin: 0 auto 20px;
}
.detail-container {
max-width: 900px;
margin: 0 auto;
.message-card {
margin-bottom: 20px;
.message-header {
.title-area {
margin-bottom: 20px;
.message-title {
margin: 0 0 12px 0;
font-size: 28px;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.title-badges {
display: flex;
gap: 8px;
}
}
.message-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
padding: 16px;
background-color: #f9f9f9;
border-radius: 8px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
&.read-status {
color: #67c23a;
}
&.unread-status {
color: #409eff;
}
.el-icon {
font-size: 16px;
}
}
}
}
.message-content {
.content-body {
font-size: 16px;
line-height: 1.8;
color: #333;
white-space: pre-wrap;
word-wrap: break-word;
min-height: 200px;
padding: 20px 0;
}
}
.send-info,
.send-status {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.info-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
font-weight: 500;
.el-icon {
font-size: 16px;
}
}
.send-methods {
display: flex;
gap: 8px;
}
.fail-reason {
font-size: 12px;
color: #f56c6c;
}
}
.message-actions {
display: flex;
gap: 12px;
padding-top: 8px;
}
}
.related-messages {
.card-header {
.header-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
.related-list {
.related-item {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f9f9f9;
}
.related-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #333;
margin-bottom: 6px;
.unread-dot {
width: 6px;
height: 6px;
background-color: #f56c6c;
border-radius: 50%;
flex-shrink: 0;
}
}
.related-meta {
font-size: 12px;
color: #999;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,507 @@
<template>
<div class="my-message-list">
<div class="header">
<h2>我的消息</h2>
<div class="header-actions">
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-button @click="loadMessages">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</el-badge>
<el-button @click="handleMarkAllRead" :disabled="selectedMessages.length === 0">
<el-icon><Check /></el-icon>
标记已读
</el-button>
</div>
</div>
<!-- 筛选条件 -->
<el-form :model="filterForm" inline class="filter-form">
<el-form-item label="消息类型">
<el-select class="w-full" v-model="filterForm.messageType" placeholder="全部" clearable>
<el-option label="通知" value="notice" />
<el-option label="公告" value="announcement" />
<el-option label="预警" value="warning" />
<el-option label="系统消息" value="system" />
</el-select>
</el-form-item>
<el-form-item label="阅读状态">
<el-select class="w-full" v-model="filterForm.isRead" placeholder="全部" clearable>
<el-option label="未读" :value="false" />
<el-option label="已读" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select class="w-full" v-model="filterForm.priority" placeholder="全部" clearable>
<el-option label="紧急" value="urgent" />
<el-option label="重要" value="important" />
<el-option label="普通" value="normal" />
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 消息列表 -->
<div class="message-cards" v-loading="loading">
<el-checkbox-group v-model="selectedMessages" class="message-group">
<div
v-for="msg in messageList"
:key="msg.messageID"
class="message-card"
:class="{ unread: !msg.isRead, urgent: msg.priority === 'urgent' }"
>
<div class="card-header">
<el-checkbox :value="msg.messageID" class="message-checkbox" />
<div class="card-title-area" @click="handleViewDetail(msg)">
<span v-if="!msg.isRead" class="unread-dot"></span>
<h3 class="card-title">{{ msg.title }}</h3>
<MessagePriorityBadge :priority="msg.priority || ''" />
</div>
<div class="card-actions">
<el-button
v-if="!msg.isRead"
type="primary"
size="small"
link
@click.stop="handleMarkAsRead(msg.messageID)"
>
标记已读
</el-button>
<el-button
type="primary"
size="small"
link
@click="handleViewDetail(msg)"
>
查看详情
</el-button>
</div>
</div>
<div class="card-body" @click="handleViewDetail(msg)">
<div class="message-meta">
<el-tag :type="getMessageTypeTag(msg.messageType)" size="small">
{{ getMessageTypeText(msg.messageType) }}
</el-tag>
<span class="sender-info">
<el-icon><User /></el-icon>
{{ msg.senderName }} · {{ msg.senderDeptName }}
</span>
<span class="send-time">
<el-icon><Clock /></el-icon>
{{ formatDateTime(msg.actualSendTime) }}
</span>
<el-tag
v-if="msg.isRead"
type="success"
size="small"
>
已读于 {{ formatDateTime(msg.readTime) }}
</el-tag>
</div>
<div class="message-preview">
{{ getPreviewText(msg.content) }}
</div>
</div>
</div>
</el-checkbox-group>
<el-empty
v-if="messageList.length === 0 && !loading"
description="暂无消息"
/>
</div>
<!-- 分页组件 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { Refresh, Check, User, Clock } from '@element-plus/icons-vue';
import { messageApi } from '@/apis/message';
import type { MessageUserVO, PageParam } from '@/types';
import { MessagePriorityBadge } from '@/components/message';
defineOptions({
name: 'MyMessageListView'
});
const router = useRouter();
const loading = ref(false);
const messageList = ref<MessageUserVO[]>([]);
const selectedMessages = ref<string[]>([]);
const unreadCount = ref(0);
// 分页参数
const pageParam = ref<PageParam>({
pageNumber: 1,
pageSize: 10
});
const total = ref(0);
// 筛选表单
const filterForm = ref<Partial<MessageUserVO>>({
messageType: undefined,
priority: undefined,
isRead: undefined
});
/** 获取消息类型标签 */
function getMessageTypeTag(type?: string): string {
const map: Record<string, string> = {
notice: 'info',
announcement: 'warning',
warning: 'danger',
system: 'success'
};
return map[type || ''] || '';
}
/** 获取消息类型文本 */
function getMessageTypeText(type?: string): string {
const map: Record<string, string> = {
notice: '通知',
announcement: '公告',
warning: '预警',
system: '系统'
};
return map[type || ''] || type || '';
}
/** 格式化日期时间 */
function formatDateTime(dateStr?: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
// 1分钟内
if (diff < 60000) {
return '刚刚';
}
// 1小时内
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`;
}
// 24小时内
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`;
}
// 7天内
if (diff < 604800000) {
return `${Math.floor(diff / 86400000)}天前`;
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/** 获取预览文本 */
function getPreviewText(content?: string): string {
if (!content) return '';
return content.length > 100 ? content.substring(0, 100) + '...' : content;
}
/** 加载消息列表 */
async function loadMessages() {
loading.value = true;
try {
const result = await messageApi.getMyMessages(pageParam.value, filterForm.value);
if (result.success) {
messageList.value = result.dataList || [];
total.value = result.pageParam?.totalElements || 0;
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息列表失败:', error);
ElMessage.error('加载失败');
} finally {
loading.value = false;
}
}
/** 加载未读数量 */
async function loadUnreadCount() {
try {
const result = await messageApi.getUnreadCount();
if (result.success) {
unreadCount.value = result.data || 0;
}
} catch (error) {
console.error('加载未读数量失败:', error);
}
}
/** 搜索 */
function handleSearch() {
pageParam.value.pageNumber = 1;
loadMessages();
}
/** 重置 */
function handleReset() {
filterForm.value = {
isRead: undefined
};
handleSearch();
}
/** 分页大小变更 */
function handleSizeChange() {
pageParam.value.pageNumber = 1;
loadMessages();
}
/** 页码变更 */
function handlePageChange() {
loadMessages();
}
/** 查看详情 */
async function handleViewDetail(msg: MessageUserVO) {
// 如果是未读消息,先标记为已读
if (!msg.isRead && msg.messageID) {
await handleMarkAsRead(msg.messageID, false);
}
router.push(`/user/message/detail/${msg.messageID}`);
}
/** 标记单条消息为已读 */
async function handleMarkAsRead(messageID?: string, showMessage = true) {
if (!messageID) return;
try {
const result = await messageApi.markAsRead(messageID);
if (result.success) {
if (showMessage) {
ElMessage.success('已标记为已读');
}
await loadMessages();
await loadUnreadCount();
} else {
ElMessage.error(result.message || '操作失败');
}
} catch (error) {
console.error('标记已读失败:', error);
ElMessage.error('操作失败');
}
}
/** 批量标记为已读 */
async function handleMarkAllRead() {
if (selectedMessages.value.length === 0) {
ElMessage.warning('请选择要标记的消息');
return;
}
try {
const result = await messageApi.batchMarkAsRead(selectedMessages.value);
if (result.success) {
ElMessage.success('已标记为已读');
selectedMessages.value = [];
await loadMessages();
await loadUnreadCount();
} else {
ElMessage.error(result.message || '操作失败');
}
} catch (error) {
console.error('批量标记已读失败:', error);
ElMessage.error('操作失败');
}
}
onMounted(() => {
loadMessages();
loadUnreadCount();
});
</script>
<style lang="scss" scoped>
.my-message-list {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.filter-form {
margin-bottom: 20px;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 24px;
::v-deep(.el-form-item) {
margin-bottom: 0;
}
::v-deep(.el-select) {
width: 180px;
}
.filter-actions {
display: flex;
gap: 12px;
.el-button {
min-width: 88px;
}
}
}
.message-cards {
.message-group {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.message-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
transition: all 0.3s;
cursor: pointer;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #c8232c;
}
&.unread {
border-left: 4px solid #409eff;
background-color: #f0f9ff;
}
&.urgent {
border-left: 4px solid #f56c6c;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.message-checkbox {
flex-shrink: 0;
}
.card-title-area {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-height: 32px;
padding: 4px 0;
flex-wrap: wrap;
.unread-dot {
width: 8px;
height: 8px;
background-color: #f56c6c;
border-radius: 50%;
flex-shrink: 0;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1 1 auto;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
line-height: 1.4;
}
}
.card-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.card-body {
.message-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
font-size: 14px;
color: #666;
.sender-info,
.send-time {
display: flex;
align-items: center;
gap: 4px;
}
}
.message-preview {
font-size: 14px;
color: #666;
line-height: 1.6;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,8 @@
/**
* @description 用户消息视图导出
* @author Claude
* @since 2025-11-13
*/
export { default as MyMessageListView } from './MyMessageListView.vue';
export { default as MyMessageDetailView } from './MyMessageDetailView.vue';

View File

@@ -0,0 +1,188 @@
# 消息通知模块 - 集成说明
## 模块概述
消息通知模块已经完成开发,包含完整的前后端功能。该模块支持系统消息、邮件和短信通知,具有定时发送、重试机制、权限控制等特性。
## 已完成的文件
### 后端Java
- 24个Java文件DTO、Mapper、Service、Controller、Scheduler、配置类
- 位置:`schoolNewsServ/message/``schoolNewsServ/api/api-message/`
- SQL文件`schoolNewsServ/.bin/mysql/sql/createTableMessage.sql``initMenuData.sql`
### 前端Vue
1. **类型定义**
- `schoolNewsWeb/src/types/message/index.ts`
2. **API接口**
- `schoolNewsWeb/src/apis/message/message.ts`
- `schoolNewsWeb/src/apis/message/index.ts`
3. **通用组件**4个
- `schoolNewsWeb/src/components/message/MessagePriorityBadge.vue`
- `schoolNewsWeb/src/components/message/MessageStatusBadge.vue`
- `schoolNewsWeb/src/components/message/MessageSendMethodSelector.vue`
- `schoolNewsWeb/src/components/message/MessageTargetSelector.vue` (已删除,功能集成到列表页)
- `schoolNewsWeb/src/components/message/index.ts`
4. **管理端页面**2个
- `schoolNewsWeb/src/views/admin/manage/message/MessageListView.vue` (集成创建功能)
- `schoolNewsWeb/src/views/admin/manage/message/MessageDetailView.vue`
- `schoolNewsWeb/src/views/admin/manage/message/index.ts`
5. **用户端页面**2个
- `schoolNewsWeb/src/views/user/message/MyMessageListView.vue`
- `schoolNewsWeb/src/views/user/message/MyMessageDetailView.vue`
- `schoolNewsWeb/src/views/user/message/index.ts`
## 需要配置的内容
### 1. 前端路由配置
在 Vue Router 配置文件中添加以下路由:
```typescript
// 管理端路由
{
path: '/admin/manage/message',
component: AdminLayout, // 或您的布局组件
children: [
{
path: 'list',
name: 'MessageManagement',
component: () => import('@/views/admin/manage/message/MessageListView.vue'),
meta: { title: '消息管理', requiresAuth: true, permission: 'message:manage' }
},
{
path: 'detail/:id',
name: 'MessageDetail',
component: () => import('@/views/admin/manage/message/MessageDetailView.vue'),
meta: { title: '消息详情', requiresAuth: true, permission: 'message:manage' }
}
]
}
// 用户端路由
{
path: '/user/message',
component: UserLayout, // 或您的用户布局组件
children: [
{
path: 'list',
name: 'MyMessages',
component: () => import('@/views/user/message/MyMessageListView.vue'),
meta: { title: '我的消息', requiresAuth: true }
},
{
path: 'detail/:id',
name: 'MyMessageDetail',
component: () => import('@/views/user/message/MyMessageDetailView.vue'),
meta: { title: '消息详情', requiresAuth: true }
}
]
}
```
### 2. 后端配置
确保以下配置已添加到 `application.yml`
```yaml
spring:
task:
scheduling:
enabled: true
execution:
pool:
core-size: 10
max-size: 20
```
### 3. 数据库初始化
执行以下SQL文件
1. `schoolNewsServ/.bin/mysql/sql/createTableMessage.sql` - 创建表结构
2. `schoolNewsServ/.bin/mysql/sql/initMenuData.sql` - 初始化菜单和权限数据
### 4. Maven依赖
确保父pom.xml中的modules包含
```xml
<modules>
<module>message</module>
</modules>
```
确保api/pom.xml中的modules包含
```xml
<modules>
<module>api-message</module>
</modules>
```
## 功能说明
### 管理端功能
1. **消息列表**
- 筛选(标题、类型、状态、优先级)
- 分页展示
- 创建消息(对话框模式)
- 立即发送、重试、改期、取消、删除
2. **消息详情**
- 完整信息展示
- 发送统计(进度、成功率、已读率)
- 接收对象列表
- 用户接收详情
### 用户端功能
1. **我的消息**
- 卡片式列表
- 未读标识
- 批量标记已读
- 筛选功能
2. **消息详情**
- 自动标记已读
- 精美展示
- 响应式设计
## 权限说明
数据库中已配置以下权限:
- `message:manage` - 消息管理权限(管理端)
- `message:send` - 消息发送权限
- `message:view` - 消息查看权限(用户端)
默认绑定到:
- superadmin - 所有权限
- admin - 管理和发送权限
- freedom - 查看权限
## 注意事项
1. **EmailUtils和SmsUtils**确保这两个工具类在common模块中存在且可用
2. **定时任务**:消息定时发送由`MessageScheduler`处理,每分钟扫描一次
3. **权限控制**:用户只能向当前部门及子部门发送消息
4. **重试机制**发送失败后会自动重试默认最多3次间隔5分钟
## 测试建议
1. 先测试立即发送功能
2. 测试定时发送功能设置1-2分钟后的时间
3. 测试权限控制(不同角色的访问)
4. 测试用户端的消息查看和已读标记
## 已知问题
1. MessageDetailView中的用户接收详情列表需要后端补充接口`GET /message/users/{messageID}`
2. 如果EmailUtils或SmsUtils不存在需要先实现这两个工具类
## 后续优化建议
1. 添加富文本编辑器支持
2. 添加消息模板功能
3. 添加消息统计报表
4. 添加消息分类功能
5. 支持附件上传