From 4283824533d15512674c8ac0ceb93651840bed9f Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 14 Apr 2026 10:33:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9D=83=E9=99=90):=20=E8=A7=82=E5=AF=9F?= =?UTF-8?q?=E8=80=85=E8=A7=92=E8=89=B2=E6=94=AF=E6=8C=81=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BA=A7=E6=9F=A5=E7=9C=8B=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 user_project_permissions 表,管理观察者可查看的项目 - 管理员可在用户管理页面为观察者分配项目权限 - 所有数据接口(总览、项目、OKR、Git活动)按分配的项目过滤 - 未分配项目的观察者看到空数据 - 同步日志按时间倒序排列 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../0001_add_user_project_permissions.sql | 14 +++ backend/src/db/schema.ts | 12 +++ backend/src/routes/admin.ts | 56 +++++++++++- backend/src/routes/git.ts | 36 ++++++-- backend/src/routes/okr.ts | 11 +++ backend/src/routes/overview.ts | 77 +++++++++++++--- backend/src/routes/projects.ts | 17 +++- backend/src/services/okr.ts | 1 + backend/src/services/permissions.ts | 21 +++++ frontend/src/api/admin.ts | 9 ++ frontend/src/router/index.ts | 2 +- frontend/src/views/Admin.vue | 88 +++++++++++++++++-- 12 files changed, 316 insertions(+), 28 deletions(-) create mode 100644 backend/drizzle/0001_add_user_project_permissions.sql create mode 100644 backend/src/services/permissions.ts diff --git a/backend/drizzle/0001_add_user_project_permissions.sql b/backend/drizzle/0001_add_user_project_permissions.sql new file mode 100644 index 0000000..9b6b18e --- /dev/null +++ b/backend/drizzle/0001_add_user_project_permissions.sql @@ -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`); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 04c3483..c4e9fdf 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 63e8a47..3d0c9f8 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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(); + 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); diff --git a/backend/src/routes/git.ts b/backend/src/routes/git.ts index 56e7b20..35d1c1b 100644 --- a/backend/src/routes/git.ts +++ b/backend/src/routes/git.ts @@ -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 | 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 = {}; @@ -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; diff --git a/backend/src/routes/okr.ts b/backend/src/routes/okr.ts index a085f00..c5d0aea 100644 --- a/backend/src/routes/okr.ts +++ b/backend/src/routes/okr.ts @@ -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' }); }); diff --git a/backend/src/routes/overview.ts b/backend/src/routes/overview.ts index d2aa780..3b9b3c5 100644 --- a/backend/src/routes/overview.ts +++ b/backend/src/routes/overview.ts @@ -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> { + 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) { + 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 | 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> = {}; @@ -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', }); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 993be96..b411098 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -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), diff --git a/backend/src/services/okr.ts b/backend/src/services/okr.ts index 261f810..565194a 100644 --- a/backend/src/services/okr.ts +++ b/backend/src/services/okr.ts @@ -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, diff --git a/backend/src/services/permissions.ts b/backend/src/services/permissions.ts new file mode 100644 index 0000000..e7f5925 --- /dev/null +++ b/backend/src/services/permissions.ts @@ -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 { + 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); +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 257c57e..d667b34 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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 }); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a0b5cd2..11b0f69 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 6b77219..8cd9c02 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -1,7 +1,7 @@