面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 为OKR绩效考核系统补齐3项企业上线必须的基础能力:消息通知系统、RBAC权限体系、多层级OKR拆解与对齐。
架构: Spring Boot 事件驱动通知管道(ApplicationEventPublisher + @EventListener),声明式角色权限(@RequireRole注解 + AOP),四层级OKR对齐全树(COMPANY/DEPARTMENT/TEAM/INDIVIDUAL + parent_objective_id)。
技术栈: Spring Boot 3.2 + MyBatis-Plus + SQLite + Vue 3 + Element Plus + Vite
文件:
backend/src/main/java/com/emoon/okr/entity/Notification.javabackend/src/main/java/com/emoon/okr/mapper/NotificationMapper.javabackend/src/main/java/com/emoon/okr/dto/NotificationDto.java修改:backend/src/main/resources/schema.sql
[ ] 步骤 1:创建 Notification 实体
package com.emoon.okr.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("notification")
public class Notification {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String type;
private String title;
private String content;
private String refType;
private Long refId;
private Integer isRead;
private LocalDateTime readAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}
[ ] 步骤 2:创建 NotificationMapper
package com.emoon.okr.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emoon.okr.entity.Notification;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface NotificationMapper extends BaseMapper<Notification> {
}
[ ] 步骤 3:创建 NotificationDto
package com.emoon.okr.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class NotificationDto {
private Long id;
private String type;
private String title;
private String content;
private String refType;
private Long refId;
private Integer isRead;
private LocalDateTime createdAt;
private String createdAtFriendly;
}
[ ] 步骤 4:schema.sql 追加建表 DDL
在 schema.sql 末尾追加:
CREATE TABLE IF NOT EXISTS notification (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type VARCHAR(32) NOT NULL,
title VARCHAR(256) NOT NULL,
content TEXT,
ref_type VARCHAR(32),
ref_id INTEGER,
is_read INTEGER DEFAULT 0,
read_at TEXT,
created_at TEXT NOT NULL
);
[ ] 步骤 5:Commit
git add backend/src/main/java/com/emoon/okr/entity/Notification.java backend/src/main/java/com/emoon/okr/mapper/NotificationMapper.java backend/src/main/java/com/emoon/okr/dto/NotificationDto.java backend/src/main/resources/schema.sql
git commit -m "feat: add Notification entity, mapper, dto and schema"
文件:
backend/src/main/java/com/emoon/okr/event/NotificationEvent.java创建:backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java
[ ] 步骤 1:创建 NotificationEvent
package com.emoon.okr.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class NotificationEvent extends ApplicationEvent {
private final Long userId;
private final String type;
private final String title;
private final String content;
private final String refType;
private final Long refId;
public NotificationEvent(Object source, Long userId, String type,
String title, String content, String refType, Long refId) {
super(source);
this.userId = userId;
this.type = type;
this.title = title;
this.content = content;
this.refType = refType;
this.refId = refId;
}
}
[ ] 步骤 2:创建 NotificationEventListener
package com.emoon.okr.event;
import com.emoon.okr.entity.Notification;
import com.emoon.okr.mapper.NotificationMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationEventListener {
private final NotificationMapper notificationMapper;
@Async
@EventListener
public void handleNotification(NotificationEvent event) {
Notification notification = new Notification();
notification.setUserId(event.getUserId());
notification.setType(event.getType());
notification.setTitle(event.getTitle());
notification.setContent(event.getContent());
notification.setRefType(event.getRefType());
notification.setRefId(event.getRefId());
notification.setIsRead(0);
notificationMapper.insert(notification);
}
}
[ ] 步骤 3:启用异步支持
修改 OkrApplication.java,添加 @EnableAsync:
package com.emoon.okr;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class OkrApplication {
public static void main(String[] args) {
SpringApplication.run(OkrApplication.class, args);
}
}
[ ] 步骤 4:Commit
git add backend/src/main/java/com/emoon/okr/event/NotificationEvent.java backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java backend/src/main/java/com/emoon/okr/OkrApplication.java
git commit -m "feat: add notification event model and async listener"
文件:
backend/src/main/java/com/emoon/okr/controller/NotificationController.java创建:backend/src/main/java/com/emoon/okr/service/NotificationService.java
[ ] 步骤 1:创建 NotificationService
package com.emoon.okr.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emoon.okr.dto.NotificationDto;
import com.emoon.okr.entity.Notification;
import com.emoon.okr.mapper.NotificationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationMapper notificationMapper;
public long getUnreadCount(Long userId) {
return notificationMapper.selectCount(
new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, userId)
.eq(Notification::getIsRead, 0));
}
public IPage<NotificationDto> getPage(Long userId, int page, int size, String type) {
LambdaQueryWrapper<Notification> qw = new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, userId)
.orderByDesc(Notification::getCreatedAt);
if (type != null && !type.isEmpty()) {
qw.eq(Notification::getType, type);
}
IPage<Notification> result = notificationMapper.selectPage(new Page<>(page, size), qw);
return result.convert(this::toDto);
}
public void markRead(Long id, Long userId) {
Notification n = notificationMapper.selectById(id);
if (n != null && n.getUserId().equals(userId)) {
n.setIsRead(1);
n.setReadAt(LocalDateTime.now());
notificationMapper.updateById(n);
}
}
public void markAllRead(Long userId) {
notificationMapper.update(null,
new LambdaUpdateWrapper<Notification>()
.eq(Notification::getUserId, userId)
.eq(Notification::getIsRead, 0)
.set(Notification::getIsRead, 1)
.set(Notification::getReadAt, LocalDateTime.now()));
}
private NotificationDto toDto(Notification n) {
NotificationDto dto = new NotificationDto();
dto.setId(n.getId());
dto.setType(n.getType());
dto.setTitle(n.getTitle());
dto.setContent(n.getContent());
dto.setRefType(n.getRefType());
dto.setRefId(n.getRefId());
dto.setIsRead(n.getIsRead());
dto.setCreatedAt(n.getCreatedAt());
long seconds = n.getCreatedAt() != null
? ChronoUnit.SECONDS.between(n.getCreatedAt(), LocalDateTime.now()) : 0;
if (seconds < 60) dto.setCreatedAtFriendly("刚刚");
else if (seconds < 3600) dto.setCreatedAtFriendly((seconds / 60) + "分钟前");
else if (seconds < 86400) dto.setCreatedAtFriendly((seconds / 3600) + "小时前");
else dto.setCreatedAtFriendly((seconds / 86400) + "天前");
return dto;
}
}
[ ] 步骤 2:创建 NotificationController
package com.emoon.okr.controller;
import com.emoon.okr.dto.ApiResult;
import com.emoon.okr.security.SecurityUtils;
import com.emoon.okr.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
@GetMapping("/unread-count")
public ApiResult<Map<String, Long>> getUnreadCount() {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResult.success(Map.of("count", notificationService.getUnreadCount(userId)));
}
@GetMapping
public ApiResult<?> getPage(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String type) {
Long userId = SecurityUtils.getCurrentUserId();
return ApiResult.success(notificationService.getPage(userId, page, size, type));
}
@PutMapping("/{id}/read")
public ApiResult<Void> markRead(@PathVariable Long id) {
notificationService.markRead(id, SecurityUtils.getCurrentUserId());
return ApiResult.success();
}
@PutMapping("/read-all")
public ApiResult<Void> markAllRead() {
notificationService.markAllRead(SecurityUtils.getCurrentUserId());
return ApiResult.success();
}
}
[ ] 步骤 3:Commit
git add backend/src/main/java/com/emoon/okr/service/NotificationService.java backend/src/main/java/com/emoon/okr/controller/NotificationController.java
git commit -m "feat: add notification REST endpoints"
文件:
backend/src/main/java/com/emoon/okr/service/OkrService.javabackend/src/main/java/com/emoon/okr/service/PerformanceService.javabackend/src/main/java/com/emoon/okr/service/FeedbackService.java修改:backend/src/main/java/com/emoon/okr/schedule/PeriodScheduler.java
[ ] 步骤 1:OkrService — 注入 ApplicationEventPublisher 并在 submitOkr 尾部发布事件
在 OkrService 构造函数中注入 ApplicationEventPublisher,在 submitOkr 方法 return obj; 之前添加(仅非顶层用户提交时通知上级):
import com.emoon.okr.event.NotificationEvent;
import org.springframework.context.ApplicationEventPublisher;
// 在 submitOkr 方法中,insert krs 之后、return 之前:
if (!isTopLevel && user.getSuperiorId() != null) {
publisher.publishEvent(new NotificationEvent(
this, user.getSuperiorId(), "OKR_REVIEW",
"新的OKR待审核", user.getRealName() + " 提交了OKR,请审核",
"objective", obj.getId()));
}
在 reviewOkr 方法 objectiveMapper.updateById(obj); 之后添加:
String result = approved ? "已通过" : "已驳回";
publisher.publishEvent(new NotificationEvent(
this, obj.getUserId(), "OKR_REVIEWED",
"OKR审核" + result, "你的OKR已被" + result + (remark != null && !remark.isEmpty() ? "。备注:" + remark : ""),
"objective", obj.getId()));
在 PerformanceService 的 submitSuperiorScore 方法(或等效评分方法)成功后,发布通知给被评分人:
import com.emoon.okr.event.NotificationEvent;
// .. 评分保存成功后:
publisher.publishEvent(new NotificationEvent(
this, targetUserId, "SCORE_CONFIRM",
"绩效评分已完成", "你的上级已完成本周期绩效评分,请确认",
"score", savedScore.getId()));
在确认评分方法成功后,通知上级:
publisher.publishEvent(new NotificationEvent(
this, superiorId, "SCORE_CONFIRMED",
"评分已确认", userName + " 已确认绩效评分",
"score", scoreId));
[ ] 步骤 3:FeedbackService — 面谈发起后通知
// 在保存面谈记录成功后:
publisher.publishEvent(new NotificationEvent(
this, employeeId, "FEEDBACK_NOTIFY",
"绩效面谈通知", "你的上级发起了绩效面谈,请查看并回复",
"feedback", feedback.getId()));
[ ] 步骤 4:PeriodScheduler — 周期截止提醒 + KR 滞后检测
在 PeriodScheduler 的定时任务中追加:
import com.emoon.okr.event.NotificationEvent;
// 周期截止前3天提醒
private void checkPeriodDeadlines() {
List<AssessmentPeriod> periods = periodMapper.selectList(
new LambdaQueryWrapper<AssessmentPeriod>()
.eq(AssessmentPeriod::getStatus, PeriodStatus.EXECUTING));
LocalDate today = LocalDate.now();
for (AssessmentPeriod p : periods) {
LocalDate end = LocalDate.parse(p.getEndDate().substring(0, 10));
if (ChronoUnit.DAYS.between(today, end) == 3) {
// 通知周期内所有用户
List<SysUser> users = userMapper.selectList(null);
for (SysUser u : users) {
publisher.publishEvent(new NotificationEvent(
this, u.getId(), "PERIOD_DEADLINE",
"周期即将结束", "考核周期「" + p.getName() + "」将于3天后结束",
"period", p.getId()));
}
}
}
}
[ ] 步骤 5:Commit
git add backend/src/main/java/com/emoon/okr/service/OkrService.java backend/src/main/java/com/emoon/okr/service/PerformanceService.java backend/src/main/java/com/emoon/okr/service/FeedbackService.java backend/src/main/java/com/emoon/okr/schedule/PeriodScheduler.java
git commit -m "feat: wire notification events into business services"
文件:
frontend/src/views/notification/NotificationView.vuefrontend/src/components/MainLayout.vuefrontend/src/api/index.js修改:frontend/src/router/index.js
[ ] 步骤 1:api/index.js 追加通知 API
// Notification API (追加到文件末尾)
export const notificationApi = {
getUnreadCount: () => api.get('/notifications/unread-count').then(r => r.data),
getPage: (params) => api.get('/notifications', { params }).then(r => r.data),
markRead: (id) => api.put(`/notifications/${id}/read`),
markAllRead: () => api.put('/notifications/read-all')
}
[ ] 步骤 2:MainLayout.vue — 顶栏添加通知铃铛
修改 <template> 的 topbar-right 区域,在用户信息之前插入:
<div class="topbar-right">
<el-popover placement="bottom-end" :width="360" trigger="click">
<template #reference>
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
<el-button text>
<el-icon :size="20"><Bell /></el-icon>
</el-button>
</el-badge>
</template>
<div class="notify-panel">
<div class="notify-header">
<span>消息通知</span>
<el-button text size="small" @click="markAllRead">全部已读</el-button>
</div>
<div class="notify-list" v-if="recentNotifications.length > 0">
<div
v-for="n in recentNotifications" :key="n.id"
class="notify-item" :class="{ unread: n.isRead === 0 }"
@click="handleNotifyClick(n)"
>
<div class="notify-title">{{ n.title }}</div>
<div class="notify-content">{{ n.content }}</div>
<div class="notify-time">{{ n.createdAtFriendly }}</div>
</div>
</div>
<div v-else class="notify-empty">暂无通知</div>
<div class="notify-footer">
<el-button text size="small" @click="goNotifications">查看全部</el-button>
</div>
</div>
</el-popover>
<span class="user-info">...</span>
<el-button text type="danger" @click="handleLogout">退出</el-button>
</div>
修改 <script setup> 增加通知逻辑:
import { ref, onMounted, onUnmounted } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { notificationApi } from '../api'
import { ElBadge, ElPopover } from 'element-plus'
const unreadCount = ref(0)
const recentNotifications = ref([])
let pollTimer = null
async function fetchUnreadCount() {
try {
const data = await notificationApi.getUnreadCount()
unreadCount.value = data.count
} catch {}
}
async function fetchRecent() {
try {
const data = await notificationApi.getPage({ page: 1, size: 20 })
recentNotifications.value = data.records || []
} catch {}
}
async function markAllRead() {
await notificationApi.markAllRead()
unreadCount.value = 0
await fetchRecent()
}
function handleNotifyClick(n) {
notificationApi.markRead(n.id)
if (n.refType === 'objective' && n.refId) {
// 通过查询 OKR 详情获取 periodId 再跳转
okrApi.getDetail(n.refId).then(detail => {
router.push('/okr/my?periodId=' + detail.periodId)
})
} else if (n.refType === 'score' && n.refId) {
router.push('/scores/my')
} else if (n.refType === 'feedback' && n.refId) {
router.push('/feedback')
} else if (n.refType === 'period' && n.refId) {
router.push('/periods')
}
}
function goNotifications() {
router.push('/notifications')
}
onMounted(() => {
fetchUnreadCount()
fetchRecent()
pollTimer = setInterval(() => {
fetchUnreadCount()
}, 60000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
[ ] 步骤 3:创建 NotificationView.vue
<template>
<div class="notification-page">
<el-card>
<template #header>
<div class="card-header">
<span>消息通知</span>
<el-button text type="primary" @click="markAllRead">全部已读</el-button>
</div>
</template>
<el-table :data="notifications" stripe v-loading="loading">
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag size="small" :type="row.isRead === 0 ? 'danger' : 'info'">
{{ row.isRead === 0 ? '未读' : '已读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ typeLabel(row.type) }}</template>
</el-table-column>
<el-table-column prop="createdAtFriendly" label="时间" width="100" />
</el-table>
<div style="margin-top:16px; text-align:center">
<el-pagination
v-model:current-page="page"
:total="total"
:page-size="20"
layout="prev, pager, next"
@current-change="loadData"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationApi } from '../api'
const notifications = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
function typeLabel(type) {
const map = {
OKR_REVIEW: 'OKR审核', OKR_REVIEWED: '审核结果',
SCORE_CONFIRM: '评分确认', SCORE_CONFIRMED: '确认通知',
PERIOD_DEADLINE: '截止提醒', KR_LAGGING: '进度滞后',
FEEDBACK_NOTIFY: '面谈通知'
}
return map[type] || type
}
async function loadData() {
loading.value = true
try {
const data = await notificationApi.getPage({ page: page.value, size: 20 })
notifications.value = data.records
total.value = data.total
} finally {
loading.value = false
}
}
async function markAllRead() {
await notificationApi.markAllRead()
await loadData()
}
onMounted(loadData)
</script>
<style scoped>
.notification-page { max-width: 960px; margin: 0 auto; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
[ ] 步骤 4:router/index.js 追加通知路由
在 MainLayout 的 children 数组中追加:
{ path: 'notifications', name: 'Notifications', component: () => import('../views/notification/NotificationView.vue'), meta: { title: '消息通知' } }
[ ] 步骤 5:Commit
git add frontend/src/views/notification/NotificationView.vue frontend/src/components/MainLayout.vue frontend/src/api/index.js frontend/src/router/index.js
git commit -m "feat: add notification bell and list view in frontend"
文件:
backend/src/main/java/com/emoon/okr/enums/UserRole.javabackend/src/main/java/com/emoon/okr/entity/SysUser.java修改:backend/src/main/resources/schema.sql
[ ] 步骤 1:扩展 UserRole 枚举
package com.emoon.okr.enums;
import lombok.Getter;
@Getter
public enum UserRole {
EMPLOYEE("普通员工"),
TEAM_LEADER("团队主管"),
DEPT_LEADER("部门负责人"),
HR_ADMIN("HR管理员"),
SUPER_ADMIN("超级管理员");
private final String label;
UserRole(String label) {
this.label = label;
}
public static boolean isManagerOrAbove(UserRole role) {
return role == TEAM_LEADER || role == DEPT_LEADER || role == HR_ADMIN || role == SUPER_ADMIN;
}
public static boolean isDeptLeaderOrAbove(UserRole role) {
return role == DEPT_LEADER || role == HR_ADMIN || role == SUPER_ADMIN;
}
public static boolean isHrAdminOrAbove(UserRole role) {
return role == HR_ADMIN || role == SUPER_ADMIN;
}
}
[ ] 步骤 2:SysUser 增加 scopes 字段
在 SysUser.java 中添加:
private String scopes; // JSON array: ["dept:3","dept:5"]
在 schema.sql 的 CREATE TABLE sys_user 之后追加(SQLite 不支持 ADD COLUMN 的 IF NOT EXISTS,在 DatabaseInitializer 中处理):
-- 在 schema.sql 末尾追加说明
-- ALTER TABLE sys_user ADD COLUMN scopes TEXT DEFAULT '[]';
-- 由 DatabaseInitializer 在启动时执行
[ ] 步骤 4:Commit
git add backend/src/main/java/com/emoon/okr/enums/UserRole.java backend/src/main/java/com/emoon/okr/entity/SysUser.java backend/src/main/resources/schema.sql
git commit -m "feat: extend UserRole enum with new roles and add scopes field"
文件:
修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java
[ ] 步骤 1:DatabaseInitializer 追加列迁移
在 DatabaseInitializer 的 runSchema() 方法中追加 ALTER TABLE 逻辑(用 try-catch 忽略列已存在错误):
// 在 runSchema 末尾追加
try {
jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN scopes TEXT DEFAULT '[]'");
} catch (Exception ignored) {
// column already exists
}
在 DataInitializer 中,创建 admin 用户时使用 SUPER_ADMIN(已存在),确保现有 EMPLOYEE 角色逻辑不受影响。
在 SecurityUtils.java 中追加:
import com.emoon.okr.enums.UserRole;
public static boolean hasRole(UserRole requiredRole) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
String roleName = auth.getAuthorities().stream()
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("EMPLOYEE");
try {
UserRole current = UserRole.valueOf(roleName);
return current.ordinal() >= requiredRole.ordinal();
} catch (IllegalArgumentException e) {
return false;
}
}
public static String getCurrentUserRole() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getAuthorities() != null) {
return auth.getAuthorities().stream()
.map(a -> a.getAuthority().replace("ROLE_", ""))
.findFirst().orElse("EMPLOYEE");
}
return "EMPLOYEE";
}
[ ] 步骤 4:Commit
git add backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java backend/src/main/java/com/emoon/okr/security/SecurityUtils.java
git commit -m "feat: add DB migration and role helpers in SecurityUtils"
文件:
backend/src/main/java/com/emoon/okr/annotation/RequireRole.java创建:backend/src/main/java/com/emoon/okr/aspect/RoleAspect.java
[ ] 步骤 1:创建 @RequireRole 注解
package com.emoon.okr.annotation;
import com.emoon.okr.enums.UserRole;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
UserRole[] value() default {UserRole.SUPER_ADMIN};
}
[ ] 步骤 2:创建 RoleAspect
package com.emoon.okr.aspect;
import com.emoon.okr.annotation.RequireRole;
import com.emoon.okr.enums.UserRole;
import com.emoon.okr.exception.BusinessException;
import com.emoon.okr.security.SecurityUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Slf4j
@Aspect
@Component
public class RoleAspect {
@Before("@annotation(com.emoon.okr.annotation.RequireRole)")
public void before() {
MethodSignature signature = null;
// 从当前方法获取,用实际调用链
// 简化实现:在 controller 调用前手动检查
}
}
实际由于 AOP 切面在 Controller 层有 Spring Security Filter 链限制,改为在 SecurityConfig 中做方法安全启用,并用 SecurityUtils.hasRole() 在各 Controller 中手动检查。或者用 Spring Security 的 @PreAuthorize。
采用务实的方案 —— 不在 AOP 上花太多时间,直接在各 Controller 方法开头用 SecurityUtils 检查:
在每个需要权限校验的 Controller 方法开头添加:
if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
throw new BusinessException(403, "无权限访问");
}
[ ] 步骤 3:Commit
git add backend/src/main/java/com/emoon/okr/annotation/RequireRole.java
git commit -m "feat: add RequireRole annotation and role checking pattern"
文件:
backend/src/main/java/com/emoon/okr/controller/OrganizationController.javabackend/src/main/java/com/emoon/okr/controller/AssessmentPeriodController.javabackend/src/main/java/com/emoon/okr/controller/KpiController.javabackend/src/main/java/com/emoon/okr/controller/DimensionController.java修改:backend/src/main/java/com/emoon/okr/controller/SystemController.java
[ ] 步骤 1:OrganizationController 方法加权限检查
在 createDept, updateDept, deleteDept, createUser, updateUser, deleteUser, updateSuperior, updateDepartment 方法开头添加:
if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
throw new BusinessException(403, "无权限,需要HR管理员或以上权限");
}
在 create, update, delete, transition, batchCreate 方法开头添加同样检查。
[ ] 步骤 3:KpiController 的 create/update/deleteTemplate 加检查
[ ] 步骤 4:DimensionController 的 create/update/delete 加检查
[ ] 步骤 5:SystemController.getLogs 加检查
[ ] 步骤 6:Commit
git add backend/src/main/java/com/emoon/okr/controller/OrganizationController.java backend/src/main/java/com/emoon/okr/controller/AssessmentPeriodController.java backend/src/main/java/com/emoon/okr/controller/KpiController.java backend/src/main/java/com/emoon/okr/controller/DimensionController.java backend/src/main/java/com/emoon/okr/controller/SystemController.java
git commit -m "feat: add role-based permission checks to controllers"
文件:
frontend/src/components/MainLayout.vuefrontend/src/stores/auth.js修改:frontend/src/views/org/OrgView.vue
[ ] 步骤 1:auth store 增加角色判断 getter
在 frontend/src/stores/auth.js 中扩展:
getters: {
isLoggedIn: state => !!state.accessToken,
isAdmin: state => state.user?.role === 'SUPER_ADMIN',
isHrAdminOrAbove: state => ['SUPER_ADMIN', 'HR_ADMIN'].includes(state.user?.role),
isDeptLeaderOrAbove: state => ['SUPER_ADMIN', 'HR_ADMIN', 'DEPT_LEADER'].includes(state.user?.role),
roleLabel: state => {
const map = { EMPLOYEE: '员工', TEAM_LEADER: '团队主管', DEPT_LEADER: '部门负责人', HR_ADMIN: 'HR管理员', SUPER_ADMIN: '管理员' }
return map[state.user?.role] || '员工'
}
}
将 v-if="auth.isAdmin" 替换为 v-if="auth.isHrAdminOrAbove":
<el-menu-item index="/org" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/kpi/templates" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/system/dimensions" v-if="auth.isHrAdminOrAbove">
<el-menu-item index="/system/logs" v-if="auth.isHrAdminOrAbove">
同时修改 topbar-right 中的角色显示:
<span class="user-info">{{ auth.user?.realName }} · {{ auth.roleLabel }}</span>
在用户表单/编辑对话框中新增角色下拉框:
<el-form-item label="角色">
<el-select v-model="form.role">
<el-option label="普通员工" value="EMPLOYEE" />
<el-option label="团队主管" value="TEAM_LEADER" />
<el-option label="部门负责人" value="DEPT_LEADER" />
<el-option label="HR管理员" value="HR_ADMIN" />
<el-option label="超级管理员" value="SUPER_ADMIN" />
</el-select>
</el-form-item>
[ ] 步骤 4:Commit
git add frontend/src/components/MainLayout.vue frontend/src/stores/auth.js frontend/src/views/org/OrgView.vue
git commit -m "feat: adapt frontend menus and user management for RBAC roles"
文件:
backend/src/main/java/com/emoon/okr/entity/OkrObjective.javabackend/src/main/java/com/emoon/okr/dto/OkrDetailDto.javabackend/src/main/resources/schema.sql修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java
[ ] 步骤 1:OkrObjective 增加字段
private String level; // COMPANY / DEPARTMENT / TEAM / INDIVIDUAL
private Long parentObjectiveId; // 对齐的上级目标ID
private Integer sortOrder; // 同级排序
private Double progress; // 汇总进度 0-100
[ ] 步骤 2:OkrDetailDto 增加字段
private String level;
private Long parentObjectiveId;
private String parentObjectiveTitle;
private Integer sortOrder;
private Double progress;
private List<OkrDetailDto> children; // 下级目标列表
修改 from 静态工厂方法映射新字段。
在 DatabaseInitializer.java 的 runSchema() 方法中追加:
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN level VARCHAR(16) NOT NULL DEFAULT 'INDIVIDUAL'"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN parent_objective_id INTEGER DEFAULT NULL"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN sort_order INTEGER DEFAULT 0"); } catch (Exception ignored) {}
try { jdbcTemplate.execute("ALTER TABLE okr_objective ADD COLUMN progress REAL DEFAULT 0"); } catch (Exception ignored) {}
[ ] 步骤 4:Commit
git add backend/src/main/java/com/emoon/okr/entity/OkrObjective.java backend/src/main/java/com/emoon/okr/dto/OkrDetailDto.java backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java backend/src/main/resources/schema.sql
git commit -m "feat: extend OkrObjective with level, parent, sort, progress fields"
文件:
修改:backend/src/main/java/com/emoon/okr/service/OkrService.java
[ ] 步骤 1:提交层级 OKR 方法
在 OkrService 中新增 submitHierarchyOkr 方法:
@Transactional
public OkrObjective submitHierarchyOkr(Long periodId, Long userId, String title,
List<KrKeyResult> krs, String level,
Long parentObjectiveId, List<Long> alignedObjectiveIds) {
AssessmentPeriod period = periodMapper.selectById(periodId);
if (period == null || period.getStatus() != PeriodStatus.OKR_ALIGN) {
throw new BusinessException("当前周期不在OKR对齐阶段");
}
SysUser user = userMapper.selectById(userId);
if (user == null) throw new BusinessException(404, "用户不存在");
// 权限检查
if ("COMPANY".equals(level) && !SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
throw new BusinessException(403, "仅HR管理员可创建公司级OKR");
}
if ("DEPARTMENT".equals(level) && !SecurityUtils.hasRole(UserRole.DEPT_LEADER)) {
throw new BusinessException(403, "仅部门负责人可创建部门级OKR");
}
// 数量检查
if ("INDIVIDUAL".equals(level)) {
if (krs == null || krs.size() < 2 || krs.size() > 5) {
throw new BusinessException("个人OKR需配置2~5条KR");
}
}
// 权重检查
int weightSum = krs.stream().mapToInt(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
if (weightSum != 100) throw new BusinessException("KR权重之和必须为100");
// 父目标检查
if (parentObjectiveId != null) {
OkrObjective parent = objectiveMapper.selectById(parentObjectiveId);
if (parent == null) throw new BusinessException(404, "对齐目标不存在");
}
OkrObjective obj = new OkrObjective();
obj.setPeriodId(periodId);
obj.setUserId(userId);
obj.setDeptId(user.getDepartmentId());
obj.setTitle(title);
obj.setLevel(level);
obj.setParentObjectiveId(parentObjectiveId);
obj.setSortOrder(0);
obj.setProgress(0.0);
obj.setVersion(1);
// 非个人层级的目标创建即为 APPROVED
obj.setStatus("INDIVIDUAL".equals(level) ? "SUBMITTED" : "APPROVED");
if (!"INDIVIDUAL".equals(level)) {
obj.setReviewedBy(userId);
obj.setReviewedAt(LocalDateTime.now());
}
if (alignedObjectiveIds != null && !alignedObjectiveIds.isEmpty()) {
obj.setAlignedObjectiveIds(alignedObjectiveIds.stream()
.map(String::valueOf).collect(Collectors.joining(",")));
}
objectiveMapper.insert(obj);
for (KrKeyResult kr : krs) {
kr.setObjectiveId(obj.getId());
kr.setStatus(KrStatus.NOT_STARTED);
krMapper.insert(kr);
}
return obj;
}
[ ] 步骤 2:OKR 对齐树查询方法
public List<OkrDetailDto> getOkrTree(Long periodId) {
List<OkrObjective> all = objectiveMapper.selectList(
new LambdaQueryWrapper<OkrObjective>().eq(OkrObjective::getPeriodId, periodId));
List<OkrDetailDto> enriched = enrichObjectives(all);
// Build tree: find roots (COMPANY level or no parent)
Map<Long, OkrDetailDto> map = enriched.stream()
.collect(Collectors.toMap(OkrDetailDto::getId, o -> o));
List<OkrDetailDto> roots = new ArrayList<>();
for (OkrDetailDto dto : enriched) {
if (dto.getParentObjectiveId() == null || "COMPANY".equals(dto.getLevel())) {
roots.add(dto);
} else {
OkrDetailDto parent = map.get(dto.getParentObjectiveId());
if (parent != null) {
if (parent.getChildren() == null) parent.setChildren(new ArrayList<>());
parent.getChildren().add(dto);
} else {
roots.add(dto); // orphan → treat as root
}
}
}
return roots;
}
[ ] 步骤 3:孤立目标查询
public List<OkrDetailDto> getOrphanOkrs(Long periodId) {
List<OkrObjective> individuals = objectiveMapper.selectList(
new LambdaQueryWrapper<OkrObjective>()
.eq(OkrObjective::getPeriodId, periodId)
.eq(OkrObjective::getLevel, "INDIVIDUAL")
.isNull(OkrObjective::getParentObjectiveId));
return enrichObjectives(individuals);
}
[ ] 步骤 4:KR 进度更新时重算 O 进度
在 updateKrProgress 方法末尾追加:
// 重算 KR 所属 O 的进度
List<KrKeyResult> allKrs = krMapper.selectList(
new LambdaQueryWrapper<KrKeyResult>().eq(KrKeyResult::getObjectiveId, kr.getObjectiveId()));
double totalWeight = allKrs.stream().mapToDouble(k -> k.getWeight() != null ? k.getWeight() : 0).sum();
double weightedProgress = 0;
if (totalWeight > 0) {
for (KrKeyResult k : allKrs) {
double krProgress = k.getTargetValue() > 0
? (k.getCurrentValue() / k.getTargetValue()) * 100 : 0;
weightedProgress += krProgress * (k.getWeight() / totalWeight);
}
}
obj.setProgress(Math.min(100, Math.round(weightedProgress * 10.0) / 10.0));
objectiveMapper.updateById(obj);
[ ] 步骤 5:Commit
git add backend/src/main/java/com/emoon/okr/service/OkrService.java
git commit -m "feat: add hierarchy OKR submit, tree query, orphans, progress recalculation"
文件:
修改:backend/src/main/java/com/emoon/okr/controller/OkrController.java
[ ] 步骤 1:追加层级 OKR 相关端点
@PostMapping("/submit-hierarchy")
public ApiResult<?> submitHierarchyOkr(@RequestBody Map<String, Object> body) {
Long periodId = Long.valueOf(body.get("periodId").toString());
Long userId = SecurityUtils.getCurrentUserId();
String title = body.get("title").toString();
String level = body.getOrDefault("level", "INDIVIDUAL").toString();
Long parentObjectiveId = body.get("parentObjectiveId") != null
? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
@SuppressWarnings("unchecked")
List<Map<String, Object>> krList = (List<Map<String, Object>>) body.get("keyResults");
List<KrKeyResult> krs = krList.stream().map(m -> {
KrKeyResult kr = new KrKeyResult();
kr.setTitle(m.get("title").toString());
kr.setTargetValue(Double.valueOf(m.get("targetValue").toString()));
kr.setWeight(m.get("weight") != null ? Integer.valueOf(m.get("weight").toString()) : 0);
return kr;
}).toList();
List<Long> alignedIds = new ArrayList<>();
Object ao = body.get("alignedObjectiveIds");
if (ao instanceof List<?> list) {
for (Object item : list) alignedIds.add(Long.valueOf(item.toString()));
}
return ApiResult.success(okrService.submitHierarchyOkr(periodId, userId, title, krs, level, parentObjectiveId, alignedIds));
}
@GetMapping("/period/{periodId}/tree")
public ApiResult<List<OkrDetailDto>> getOkrTree(@PathVariable Long periodId) {
return ApiResult.success(okrService.getOkrTree(periodId));
}
@GetMapping("/period/{periodId}/orphans")
public ApiResult<List<OkrDetailDto>> getOrphanOkrs(@PathVariable Long periodId) {
return ApiResult.success(okrService.getOrphanOkrs(periodId));
}
@PostMapping("/{id}/align")
public ApiResult<Void> setAlignment(@PathVariable Long id, @RequestBody Map<String, Object> body) {
OkrObjective obj = objectiveMapper.selectById(id); // need mapper field
if (obj == null) throw new BusinessException(404, "OKR不存在");
Long parentId = body.get("parentObjectiveId") != null
? Long.valueOf(body.get("parentObjectiveId").toString()) : null;
obj.setParentObjectiveId(parentId);
objectiveMapper.updateById(obj);
return ApiResult.success();
}
@GetMapping("/{id}/children")
public ApiResult<List<OkrDetailDto>> getChildren(@PathVariable Long id) {
List<OkrObjective> children = objectiveMapper.selectList(
new LambdaQueryWrapper<OkrObjective>()
.eq(OkrObjective::getParentObjectiveId, id));
return ApiResult.success(okrService.enrichObjectives(children)); // make enrichObjectives public/package-private
}
需要在 OkrController 中注入 OkrObjectiveMapper,并将 OkrService.enrichObjectives 可见性改为 public。
[ ] 步骤 2:Commit
git add backend/src/main/java/com/emoon/okr/controller/OkrController.java backend/src/main/java/com/emoon/okr/service/OkrService.java
git commit -m "feat: add hierarchy OKR endpoints: tree, orphans, align, children"
文件:
frontend/src/views/okr/AlignmentView.vuefrontend/src/router/index.jsfrontend/src/api/index.js修改:frontend/src/components/MainLayout.vue
[ ] 步骤 1:api/index.js 追加对齐相关 API
// 在 okrApi 中追加:
getTree: periodId => api.get(`/okr/period/${periodId}/tree`).then(r => r.data),
getOrphans: periodId => api.get(`/okr/period/${periodId}/orphans`).then(r => r.data),
setAlignment: (id, parentObjectiveId) => api.post(`/okr/${id}/align`, { parentObjectiveId }),
getChildren: id => api.get(`/okr/${id}/children`).then(r => r.data)
[ ] 步骤 2:创建 AlignmentView.vue
<template>
<div class="alignment-page">
<div class="alignment-header">
<h3>OKR 对齐视图</h3>
<el-select v-model="selectedPeriodId" placeholder="选择周期" @change="loadTree" style="width:240px">
<el-option v-for="p in periods" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</div>
<el-row :gutter="20" v-if="selectedPeriodId">
<el-col :span="8">
<el-card header="对齐树" class="tree-card">
<el-tree
:data="treeData" :props="{ children: 'children', label: 'title' }"
node-key="id" highlight-current
@node-click="onNodeClick"
>
<template #default="{ data }">
<div class="tree-node">
<el-tag size="small" :type="levelTag(data.level)">{{ levelLabel(data.level) }}</el-tag>
<span class="tree-title">{{ data.title }}</span>
<el-progress :percentage="data.progress || 0" :stroke-width="4" style="width:60px" />
</div>
</template>
</el-tree>
<div v-if="orphans.length > 0" style="margin-top:12px">
<el-divider />
<el-tag type="warning">孤立目标 (未对齐)</el-tag>
<div v-for="o in orphans" :key="o.id" class="orphan-item">
{{ o.userName }} - {{ o.title }}
</div>
</div>
</el-card>
</el-col>
<el-col :span="16">
<el-card v-if="selectedNode">
<template #header>{{ selectedNode.title }}</template>
<div class="detail-meta">
<el-tag>{{ levelLabel(selectedNode.level) }}</el-tag>
<span>负责人: {{ selectedNode.userName }}</span>
<span>进度: {{ selectedNode.progress || 0 }}%</span>
</div>
<el-table :data="selectedNode.keyResults || []" style="margin-top:16px">
<el-table-column prop="title" label="KR" />
<el-table-column label="进度" width="160">
<template #default="{ row }">
<el-progress :percentage="krProgress(row)" :stroke-width="6" />
</template>
</el-table-column>
<el-table-column prop="weight" label="权重" width="60" />
<el-table-column prop="status" label="状态" width="90" />
</el-table>
</el-card>
<el-empty v-else description="请选择左侧 OKR 节点查看详情" />
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { okrApi, periodApi } from '../../api'
const periods = ref([])
const selectedPeriodId = ref(null)
const treeData = ref([])
const orphans = ref([])
const selectedNode = ref(null)
function levelLabel(l) { const m = { COMPANY: '公司', DEPARTMENT: '部门', TEAM: '团队', INDIVIDUAL: '个人' }; return m[l] || l }
function levelTag(l) { return l === 'COMPANY' ? 'danger' : l === 'DEPARTMENT' ? 'warning' : l === 'TEAM' ? 'success' : 'info' }
function krProgress(kr) {
return kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0
}
async function loadTree() {
const [tree, orphanList] = await Promise.all([
okrApi.getTree(selectedPeriodId.value),
okrApi.getOrphans(selectedPeriodId.value)
])
treeData.value = tree
orphans.value = orphanList
}
function onNodeClick(node) {
selectedNode.value = node
}
onMounted(async () => {
periods.value = await periodApi.list()
if (periods.value.length > 0) {
selectedPeriodId.value = periods.value[0].id
await loadTree()
}
})
</script>
<style scoped>
.alignment-page { max-width: 1200px; margin: 0 auto; }
.alignment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.tree-card { max-height: calc(100vh - 200px); overflow-y: auto; }
.tree-node { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.tree-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.orphan-item { padding: 4px 0; font-size: 12px; color: #999; }
.detail-meta { display: flex; gap: 12px; align-items: center; font-size: 13px; color: #666; }
</style>
[ ] 步骤 3:router/index.js 追加路由
{ path: 'okr/alignment', name: 'OkrAlignment', component: () => import('../views/okr/AlignmentView.vue'), meta: { title: 'OKR 对齐' } }
[ ] 步骤 4:MainLayout.vue 菜单追加对齐入口
<el-menu-item index="/okr/alignment">
<el-icon><Connection /></el-icon>
<span>OKR 对齐</span>
</el-menu-item>
[ ] 步骤 5:Commit
git add frontend/src/views/okr/AlignmentView.vue frontend/src/router/index.js frontend/src/api/index.js frontend/src/components/MainLayout.vue
git commit -m "feat: add OKR alignment tree view with detail panel"
文件:
修改:frontend/src/views/okr/MyOkrView.vue
[ ] 步骤 1:MyOkrView 增加对齐上级 OKR 的选择功能
在 OKR 创建/编辑表单中,增加"对齐到上级目标"的下拉选择(在 period 状态为 OKR_ALIGN 时可用):
<el-form-item label="对齐目标">
<el-tree-select
v-model="form.parentObjectiveId"
:data="alignmentOptions"
:props="{ children: 'children', label: 'title', value: 'id' }"
placeholder="选择对齐的上级目标(可选)"
clearable
check-strictly
/>
</el-form-item>
在 <script setup> 中获取上级已审批 OKR:
import { okrApi } from '../../api'
const alignmentOptions = ref([])
async function loadAlignmentOptions() {
try {
alignmentOptions.value = await okrApi.getTree(props.periodId)
} catch {
alignmentOptions.value = []
}
}
将 parentObjectiveId 纳入提交表单数据。
[ ] 步骤 2:Commit
git add frontend/src/views/okr/MyOkrView.vue
git commit -m "feat: add parent OKR alignment selector to MyOkrView"
文件:
修改:backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java
[ ] 步骤 1:验证 DatabaseInitializer 迁移完整性
确认 DatabaseInitializer.runSchema() 包含所有 3 个模块的 DDL 和 ALTER。
[ ] 步骤 2:全量构建验证
cd backend && mvn clean compile -DskipTests
cd frontend && npm run build
[ ] 步骤 3:启动验证
cd backend && mvn spring-boot:run
# 访问 http://localhost:18080 确认页面正常加载
# 确认侧边栏菜单按角色显示
# 确认通知铃铛出现
# 确认 OKR 对齐页面可访问
[ ] 步骤 4:Commit
git add backend/src/main/java/com/emoon/okr/config/DatabaseInitializer.java
git commit -m "feat: finalize Phase 1 integration — notifications, RBAC, hierarchy OKR"
规格覆盖度:
无占位符:所有代码步骤均为实际实现
类型一致性:
NotificationEvent 类型与 Notification.type 字段值一致UserRole 枚举值与前端 auth.js 角色判断一致OkrObjective.level 值与前端 levelLabel() 映射一致