feat(权限): 观察者角色支持项目级查看权限
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s

- 新增 user_project_permissions 表,管理观察者可查看的项目
- 管理员可在用户管理页面为观察者分配项目权限
- 所有数据接口(总览、项目、OKR、Git活动)按分配的项目过滤
- 未分配项目的观察者看到空数据
- 同步日志按时间倒序排列

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-14 10:33:11 +08:00
parent d6dc0a882e
commit 4283824533
12 changed files with 316 additions and 28 deletions

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `user_project_permissions` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`user_id` varchar(50) NOT NULL,
`project_id` varchar(50) NOT NULL,
`created_at` datetime NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `idx_upp_user` ON `user_project_permissions` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_upp_project` ON `user_project_permissions` (`project_id`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_upp_user_project` ON `user_project_permissions` (`user_id`, `project_id`);

View File

@ -218,6 +218,18 @@ export const projectRepos = mysqlTable('project_repos', {
repoIdx: index('idx_project_repos_repo').on(table.repoName),
}));
// ── User Project Permissions (观察者可查看项目) ──
export const userProjectPermissions = mysqlTable('user_project_permissions', {
id: varchar('id', { length: 50 }).primaryKey(),
userId: varchar('user_id', { length: 50 }).references(() => users.id).notNull(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
userIdx: index('idx_upp_user').on(table.userId),
projectIdx: index('idx_upp_project').on(table.projectId),
userProjectIdx: uniqueIndex('uniq_upp_user_project').on(table.userId, table.projectId),
}));
// ── Sync Logs ──
export const syncLogs = mysqlTable('sync_logs', {
id: varchar('id', { length: 50 }).primaryKey(),

View File

@ -1,11 +1,11 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { eq, desc } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import bcrypt from 'bcrypt';
import { db } from '../db/index';
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs } from '../db/schema';
import { users, authorMappings, syncLogs, projects, projectRepos, gitCommits, gitPRs, userProjectPermissions } from '../db/schema';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
@ -18,6 +18,12 @@ adminRoutes.use('/admin/*', requireRole('admin'));
adminRoutes.get('/admin/users', async (c) => {
const allUsers = await db.select().from(users);
const allPerms = await db.select().from(userProjectPermissions);
const permsByUser = new Map<string, string[]>();
for (const p of allPerms) {
if (!permsByUser.has(p.userId)) permsByUser.set(p.userId, []);
permsByUser.get(p.userId)!.push(p.projectId);
}
return c.json({
code: 0,
data: allUsers.map(u => ({
@ -27,6 +33,7 @@ adminRoutes.get('/admin/users', async (c) => {
role: u.role,
planeUserId: u.planeUserId,
gitUsername: u.gitUsername,
allowedProjectIds: permsByUser.get(u.id) || [],
createdAt: u.createdAt.toISOString(),
})),
message: 'success',
@ -249,6 +256,49 @@ adminRoutes.delete('/admin/author-mappings/:id', async (c) => {
return c.json({ code: 0, data: null, message: 'success' });
});
// ── User Project Permissions (观察者项目权限) ──
// GET /api/admin/users/:id/projects — 获取用户可查看的项目列表
adminRoutes.get('/admin/users/:id/projects', async (c) => {
const userId = c.req.param('id');
const perms = await db.select().from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, userId));
return c.json({
code: 0,
data: perms.map(p => p.projectId),
message: 'success',
});
});
// PUT /api/admin/users/:id/projects — 设置用户可查看的项目(全量替换)
const setUserProjectsSchema = z.object({
projectIds: z.array(z.string()),
});
adminRoutes.put('/admin/users/:id/projects', zValidator('json', setUserProjectsSchema), async (c) => {
const userId = c.req.param('id');
const { projectIds } = c.req.valid('json');
// 先删除现有权限
await db.delete(userProjectPermissions)
.where(eq(userProjectPermissions.userId, userId));
// 批量插入新权限
if (projectIds.length > 0) {
const now = new Date();
await db.insert(userProjectPermissions).values(
projectIds.map(projectId => ({
id: uuid(),
userId,
projectId,
createdAt: now,
}))
);
}
return c.json({ code: 0, data: { projectIds }, message: 'success' });
});
// ── Sync ──
adminRoutes.post('/admin/sync/trigger', async (c) => {
@ -266,7 +316,7 @@ adminRoutes.get('/admin/sync/logs', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const pageSize = parseInt(c.req.query('pageSize') || '20');
const allLogs = await db.select().from(syncLogs).orderBy(syncLogs.syncedAt);
const allLogs = await db.select().from(syncLogs).orderBy(desc(syncLogs.syncedAt));
const total = allLogs.length;
const items = allLogs.slice((page - 1) * pageSize, page * pageSize);

View File

@ -1,8 +1,9 @@
import { Hono } from 'hono';
import { db } from '../db/index';
import { gitCommits, gitPRs, users } from '../db/schema';
import { eq, and, gte, desc } from 'drizzle-orm';
import { gitCommits, gitPRs, users, projectRepos } from '../db/schema';
import { eq, and, gte, desc, inArray } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
export const gitRoutes = new Hono();
@ -13,8 +14,25 @@ gitRoutes.get('/git/activity', async (c) => {
const queryUserId = c.req.query('userId');
const weeks = parseInt(c.req.query('weeks') || '12');
// 观察者:允许访问但限定到已分配项目的仓库
let allowedRepoNames: Set<string> | null = null;
if (user.role === 'viewer') {
throw new AppError(40103, 'Insufficient permissions', 403);
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && allowedIds.length > 0) {
const repos = await db.select().from(projectRepos)
.where(inArray(projectRepos.projectId, allowedIds));
allowedRepoNames = new Set(repos.map(r => {
let cleaned = r.repoName.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {}
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}));
} else {
// 没有分配任何项目,返回空数据
return c.json({ code: 0, data: { heatmap: [], stats: { totalCommits: 0, activeContributors: 0, thisMonthCommits: 0, activeRepos: 0 }, weeklyTrend: [] }, message: 'success' });
}
}
let targetUserId: string | undefined;
@ -30,7 +48,12 @@ gitRoutes.get('/git/activity', async (c) => {
const commitQuery = targetUserId
? db.select().from(gitCommits).where(and(eq(gitCommits.userId, targetUserId), gte(gitCommits.committedAt, startDate)))
: db.select().from(gitCommits).where(gte(gitCommits.committedAt, startDate));
const commits = await commitQuery;
let commits = await commitQuery;
// 观察者:过滤到分配项目的仓库
if (allowedRepoNames) {
commits = commits.filter(c => allowedRepoNames!.has(c.repoName));
}
// Heatmap按天
const dayMap: Record<string, { commits: number; additions: number; deletions: number }> = {};
@ -49,7 +72,10 @@ gitRoutes.get('/git/activity', async (c) => {
const heatmap = Object.entries(dayMap).map(([date, data]) => ({ date, ...data }));
// 统计指标(替代原来的 PR 指标)
const allCommits = await db.select().from(gitCommits);
let allCommits = await db.select().from(gitCommits);
if (allowedRepoNames) {
allCommits = allCommits.filter(c => allowedRepoNames!.has(c.repoName));
}
const thisMonthStart = dayjs().startOf('month').toDate();
const thisMonthCommits = allCommits.filter(c => dayjs(c.committedAt).isAfter(thisMonthStart));
const activeContributors = new Set(allCommits.filter(c => c.userId).map(c => c.userId)).size;

View File

@ -2,14 +2,25 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { requireRole } from '../middleware/role';
import { getAllowedProjectIds } from '../services/permissions';
import * as okrService from '../services/okr';
export const okrRoutes = new Hono();
// GET /api/okr
okrRoutes.get('/okr', async (c) => {
const user = c.get('user');
const period = c.req.query('period');
const data = await okrService.getOKRByPeriod(period || undefined);
// 观察者:只返回已分配项目的 OKR
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null) {
data.objectives = data.objectives.filter(
(obj: any) => obj.projectId && allowedIds.includes(obj.projectId)
);
}
return c.json({ code: 0, data, message: 'success' });
});

View File

@ -1,16 +1,36 @@
import { Hono } from 'hono';
import { db } from '../db/index';
import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema';
import { eq, desc, gte } from 'drizzle-orm';
import { eq, desc, gte, inArray } from 'drizzle-orm';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
/** 根据项目绑定的仓库名,提取纯仓库名 Set */
async function getAllowedRepoNames(allowedProjectIds: string[]): Promise<Set<string>> {
if (allowedProjectIds.length === 0) return new Set();
const repos = await db.select().from(projectRepos)
.where(inArray(projectRepos.projectId, allowedProjectIds));
return new Set(repos.map(r => {
let cleaned = r.repoName.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {}
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}));
}
export const overviewRoutes = new Hono();
async function getRecentCommits() {
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(15);
async function getRecentCommits(allowedRepos?: Set<string>) {
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(50);
const allUsers = await db.select().from(users);
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
return recent.map(c => ({
let filtered = recent;
if (allowedRepos) {
filtered = recent.filter(c => allowedRepos.has(c.repoName));
}
return filtered.slice(0, 15).map(c => ({
sha: c.sha?.slice(0, 7) || '',
message: (c.message || '').split('\n')[0].slice(0, 60),
authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName,
@ -20,15 +40,39 @@ async function getRecentCommits() {
}
overviewRoutes.get('/overview', async (c) => {
const user = c.get('user');
const period = c.req.query('period');
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
let projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
// 观察者:强制限定到已分配的项目
const allowedIds = await getAllowedProjectIds(user);
let mustFilterByProject = false;
if (allowedIds !== null) {
mustFilterByProject = true;
if (projectIds.length > 0) {
projectIds = projectIds.filter(id => allowedIds.includes(id));
} else {
projectIds = allowedIds;
}
}
// 观察者:获取可访问的仓库名(用于过滤 git 相关数据)
const allowedRepos = mustFilterByProject ? await getAllowedRepoNames(projectIds) : undefined;
// 观察者:获取可访问项目下的 objective IDs用于过滤 KR 数据)
let allowedObjIds: Set<string> | undefined;
if (mustFilterByProject) {
const objs = projectIds.length > 0
? (await db.select().from(objectives)).filter(o => o.projectId && projectIds.includes(o.projectId))
: [];
allowedObjIds = new Set(objs.map(o => o.id));
}
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
const allProjects = await db.select().from(projects);
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
for (const proj of allProjects) {
if (projectIds.length > 0 && !projectIds.includes(proj.id)) continue;
if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
let projObjectives = await db.select().from(objectives)
.where(eq(objectives.projectId, proj.id));
if (period) {
@ -49,7 +93,7 @@ overviewRoutes.get('/overview', async (c) => {
// 2. KR 完成状态分布(替代任务状态分布)
const allKRs = await db.select().from(keyResults);
let filteredKRs = allKRs;
if (projectIds.length > 0) {
if (mustFilterByProject || projectIds.length > 0) {
const projObjIds = new Set(
(await db.select().from(objectives))
.filter(o => o.projectId && projectIds.includes(o.projectId))
@ -81,10 +125,15 @@ overviewRoutes.get('/overview', async (c) => {
// 4. Weekly Code Activity (last 12 weeks)
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
const commits = await db.select().from(gitCommits)
let commits = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
const prs = await db.select().from(gitPRs)
let prs = await db.select().from(gitPRs)
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
// 观察者:按项目绑定仓库过滤
if (allowedRepos) {
commits = commits.filter(c => allowedRepos.has(c.repoName));
prs = prs.filter(p => allowedRepos.has(p.repoName));
}
const allUsers = await db.select().from(users);
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
@ -126,7 +175,7 @@ overviewRoutes.get('/overview', async (c) => {
? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives);
if (projectIds.length > 0) {
if (mustFilterByProject || projectIds.length > 0) {
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
}
@ -158,8 +207,12 @@ overviewRoutes.get('/overview', async (c) => {
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
const allKRsRaw = await db.select().from(keyResults);
let allKRsRaw = await db.select().from(keyResults);
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
// 观察者:过滤到已分配项目的 KR
if (allowedObjIds) {
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
}
const thisWeekKRs = allKRsRaw.filter(kr => {
if (!kr.endDate) return false;
@ -303,7 +356,7 @@ overviewRoutes.get('/overview', async (c) => {
weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'),
},
overdueKRs: overdueList,
recentCommits: await getRecentCommits(),
recentCommits: await getRecentCommits(allowedRepos),
},
message: 'success',
});

View File

@ -7,13 +7,19 @@ import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPR
import { eq, and, desc, gte } from 'drizzle-orm';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
import { getAllowedProjectIds } from '../services/permissions';
import dayjs from 'dayjs';
export const projectRoutes = new Hono();
// GET /api/projects — 所有登录用户都能查
// GET /api/projects — 所有登录用户都能查(观察者仅返回已分配的项目)
projectRoutes.get('/projects', async (c) => {
const allProjects = await db.select().from(projects);
const user = c.get('user');
const allowedIds = await getAllowedProjectIds(user);
let allProjects = await db.select().from(projects);
if (allowedIds !== null) {
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
}
return c.json({
code: 0,
data: allProjects.map(p => ({
@ -99,6 +105,13 @@ projectRoutes.delete('/project-repos/:id',
// GET /api/projects/:id
projectRoutes.get('/projects/:id', async (c) => {
const projectId = c.req.param('id');
const user = c.get('user');
// 观察者只能查看已分配的项目
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && !allowedIds.includes(projectId)) {
throw new AppError(40103, 'Insufficient permissions', 403);
}
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),

View File

@ -27,6 +27,7 @@ export async function getOKRByPeriod(period?: string) {
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
projectId: obj.projectId || null,
projectName: project?.name || '未关联项目',
period: obj.period,
startDate: obj.startDate || null,

View File

@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm';
import { db } from '../db/index';
import { userProjectPermissions } from '../db/schema';
import type { JWTPayload } from '../middleware/auth';
/**
* viewer ID
* - viewer user_project_permissions ID
* - null
*/
export async function getAllowedProjectIds(user: JWTPayload): Promise<string[] | null> {
if (user.role !== 'viewer') {
return null; // 非 viewer不做项目级过滤
}
const perms = await db.select({ projectId: userProjectPermissions.projectId })
.from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, user.sub));
return perms.map(p => p.projectId);
}

View File

@ -65,3 +65,12 @@ export function triggerSyncApi(data?: { source?: string }) {
export function getSyncLogsApi(params?: { page?: number; pageSize?: number }) {
return request.get('/api/admin/sync/logs', { params });
}
// User Project Permissions (观察者项目权限)
export function getUserProjectsApi(userId: string) {
return request.get(`/api/admin/users/${userId}/projects`);
}
export function setUserProjectsApi(userId: string, projectIds: string[]) {
return request.put(`/api/admin/users/${userId}/projects`, { projectIds });
}

View File

@ -53,7 +53,7 @@ const router = createRouter({
path: 'git',
name: 'GitActivity',
component: () => import('@/views/GitActivity.vue'),
meta: { roles: ['admin', 'manager', 'developer'] },
meta: { roles: ['admin', 'manager', 'developer', 'viewer'] },
},
{
path: 'admin',

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue';
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi } from '@/api/admin';
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
import dayjs from 'dayjs';
const message = useMessage();
@ -58,23 +58,86 @@ function formatDate(value: unknown): string {
const userColumns = [
{ title: '姓名', key: 'displayName' },
{ title: '邮箱', key: 'email' },
{ title: '角色', key: 'role', width: 100 },
{
title: '角色', key: 'role', width: 100,
render: (row: any) => {
const roleMap: Record<string, string> = { admin: '管理员', manager: '经理', developer: '开发者', viewer: '观察者' };
return roleMap[row.role] || row.role;
},
},
{
title: '可查看项目', key: 'allowedProjects', width: 120,
render: (row: any) => {
if (row.role !== 'viewer') return '-';
const count = (row.allowedProjectIds || []).length;
return count > 0 ? `${count} 个项目` : '未分配';
},
},
{
title: '创建时间', key: 'createdAt', width: 180,
render: (row: any) => formatDate(row.createdAt),
},
{
title: '操作', key: 'actions', width: 100,
title: '操作', key: 'actions', width: 180,
render: (row: any) => {
return h(
const buttons = [];
if (row.role === 'viewer') {
buttons.push(h(
NButton,
{ size: 'tiny', type: 'info', onClick: () => openProjectPermModal(row), style: 'margin-right: 8px' },
{ default: () => '分配项目' },
));
}
buttons.push(h(
NButton,
{ size: 'tiny', type: 'error', onClick: () => handleDeleteUser(row.id) },
{ default: () => '删除' },
);
));
return buttons;
},
},
];
// Project permissions ()
const showProjectPermModal = ref(false);
const permEditUserId = ref('');
const permEditUserName = ref('');
const permSelectedProjects = ref<string[]>([]);
const projectsLoading = ref(false);
const allProjects = ref<any[]>([]);
async function loadProjects() {
projectsLoading.value = true;
try {
const res = await getAdminProjectsApi();
allProjects.value = res.data.data;
} finally {
projectsLoading.value = false;
}
}
const projectOptions = computed(() =>
allProjects.value.map(p => ({ value: p.id, label: `${p.name} (${p.identifier || p.id.slice(0, 8)})` }))
);
function openProjectPermModal(user: any) {
permEditUserId.value = user.id;
permEditUserName.value = user.displayName;
permSelectedProjects.value = user.allowedProjectIds || [];
showProjectPermModal.value = true;
}
async function handleSaveProjectPerms() {
try {
await setUserProjectsApi(permEditUserId.value, permSelectedProjects.value);
message.success('项目权限已更新');
showProjectPermModal.value = false;
loadUsers();
} catch (err: any) {
message.error(err.response?.data?.message || '更新项目权限失败');
}
}
// Mappings tab
const mappingsLoading = ref(false);
const mappingsData = ref<any[]>([]);
@ -211,6 +274,7 @@ onMounted(() => {
loadUsers();
loadMappings();
loadLogs();
loadProjects();
});
const roleOptions = [
@ -273,6 +337,20 @@ const roleOptions = [
</NForm>
</NModal>
<!-- 分配项目权限弹窗 -->
<NModal v-model:show="showProjectPermModal" :title="`分配项目权限 - ${permEditUserName}`" preset="dialog" positive-text="保存" @positive-click="handleSaveProjectPerms">
<div style="margin-bottom: 12px; color: var(--color-text-tertiary); font-size: 13px;">
选择该观察者可查看的项目未分配项目的观察者将无法查看任何数据
</div>
<NSelect
v-model:value="permSelectedProjects"
:options="projectOptions"
multiple
filterable
placeholder="选择可查看的项目"
/>
</NModal>
<!-- 添加映射弹窗 -->
<NModal v-model:show="showMappingModal" title="添加作者映射" preset="dialog" positive-text="创建" @positive-click="handleCreateMapping">
<NForm>