diff --git a/apps/server/src/common/rbac/rbac.service.spec.ts b/apps/server/src/common/rbac/rbac.service.spec.ts index ed0f7a8..5afadbb 100644 --- a/apps/server/src/common/rbac/rbac.service.spec.ts +++ b/apps/server/src/common/rbac/rbac.service.spec.ts @@ -29,6 +29,29 @@ describe('RbacService V2.7 adapter contract', () => { expect(prisma.projectMember.findUnique).not.toHaveBeenCalled(); }); + it('allows actors with an explicit global permission', async () => { + const { service } = makeService(); + + await expect(service.assertGlobalPermission({ + actorId: 'm-pm', + permissions: ['management:view'], + requiredPermissions: ['management:view'], + })).resolves.toEqual({ + actorId: 'm-pm', + via: 'permission', + }); + }); + + it('rejects actors without the required global permission', async () => { + const { service } = makeService(); + + await expect(service.assertGlobalPermission({ + actorId: 'm-dev', + permissions: ['project:view'], + requiredPermissions: ['governance:manage'], + })).rejects.toBeInstanceOf(ForbiddenException); + }); + it('allows project owners to perform admin-scoped actions', async () => { const { prisma, service } = makeService(); prisma.projectMember.findUnique.mockResolvedValue({ diff --git a/apps/server/src/common/rbac/rbac.service.ts b/apps/server/src/common/rbac/rbac.service.ts index 151877c..9f91674 100644 --- a/apps/server/src/common/rbac/rbac.service.ts +++ b/apps/server/src/common/rbac/rbac.service.ts @@ -10,6 +10,12 @@ export interface ProjectRoleAssertion { permissions?: string[]; } +export interface GlobalPermissionAssertion { + actorId?: string; + permissions?: string[]; + requiredPermissions: string[]; +} + export interface ProjectRoleDecision { actorId: string; projectId: string; @@ -17,6 +23,11 @@ export interface ProjectRoleDecision { via: 'system' | 'project_member'; } +export interface GlobalPermissionDecision { + actorId: string; + via: 'system' | 'permission'; +} + const ROLE_RANK: Record = { owner: 4, admin: 3, @@ -28,6 +39,20 @@ const ROLE_RANK: Record = { export class RbacService { constructor(private readonly prisma: PrismaService) {} + async assertGlobalPermission(input: GlobalPermissionAssertion): Promise { + const actorId = input.actorId?.trim(); + if (!actorId) { + throw new ForbiddenException('Missing actor scope'); + } + if (input.permissions?.includes('*')) { + return { actorId, via: 'system' }; + } + if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) { + return { actorId, via: 'permission' }; + } + throw new ForbiddenException('Insufficient global permission'); + } + async assertProjectRole(input: ProjectRoleAssertion): Promise { const actorId = input.actorId?.trim(); const projectId = input.projectId?.trim(); @@ -61,3 +86,9 @@ export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: Proje const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole])); return ROLE_RANK[role] >= minimumRank; } + +export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean { + if (requiredPermissions.length === 0) return false; + const granted = new Set(permissions); + return requiredPermissions.some((permission) => granted.has(permission)); +} diff --git a/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts index 9037dfb..5eb9eba 100644 --- a/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts +++ b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts @@ -23,17 +23,32 @@ export class GovernanceDictionaryDto { @IsOptional() @IsString() scope?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; } export class RemoveGovernanceDictionaryDto { @IsString() actorId!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; } export class ImportGovernanceDictionariesDto { @IsString() actorId!: string; + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; + @IsArray() @ValidateNested({ each: true }) @Type(() => GovernanceDictionaryDto) diff --git a/apps/server/src/modules/governance/governance.controller.ts b/apps/server/src/modules/governance/governance.controller.ts index a3f7eea..13c65a2 100644 --- a/apps/server/src/modules/governance/governance.controller.ts +++ b/apps/server/src/modules/governance/governance.controller.ts @@ -31,7 +31,7 @@ export class GovernanceController { @Param('id') id: string, @Body() dto: RemoveGovernanceDictionaryDto, ) { - return this.governanceService.remove({ actorId: dto.actorId, kind, id }); + return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id }); } @Get('export') @@ -41,6 +41,6 @@ export class GovernanceController { @Post('import') importAll(@Body() dto: ImportGovernanceDictionariesDto) { - return this.governanceService.importAll(dto.actorId, dto.items); + return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions); } } diff --git a/apps/server/src/modules/governance/governance.service.spec.ts b/apps/server/src/modules/governance/governance.service.spec.ts index a73f236..2f44aa2 100644 --- a/apps/server/src/modules/governance/governance.service.spec.ts +++ b/apps/server/src/modules/governance/governance.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; import { GovernanceService } from './governance.service'; describe('GovernanceService', () => { @@ -21,20 +22,43 @@ describe('GovernanceService', () => { }, }; const audit = { record: jest.fn() } as unknown as AuditService; - return { prisma, audit, service: new GovernanceService(prisma as any, audit) }; + const rbac = { + assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-admin', via: 'system' }), + } as unknown as RbacService; + return { prisma, audit, rbac, service: new GovernanceService(prisma as any, audit, rbac) }; }; + it('requires governance manage permission through the RBAC adapter', async () => { + const { prisma, rbac, service } = makeService(); + (rbac.assertGlobalPermission as jest.Mock).mockRejectedValue(new Error('forbidden')); + + await expect(service.create({ + actorId: 'm-dev', + permissions: ['project:view'], + kind: 'requirement_type', + name: '新功能', + })).rejects.toThrow('forbidden'); + + expect(prisma.governanceDictionary.create).not.toHaveBeenCalled(); + }); + it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => { - const { prisma, service } = makeService(); + const { prisma, rbac, service } = makeService(); prisma.devTask.count.mockResolvedValue(1); prisma.testCase.count.mockResolvedValue(0); await expect(service.remove({ actorId: 'm-admin', + permissions: ['governance:manage'], kind: 'task_category', id: 'cat-1', })).rejects.toBeInstanceOf(BadRequestException); + expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({ + actorId: 'm-admin', + permissions: ['governance:manage'], + requiredPermissions: ['governance:manage'], + }); expect(prisma.taskCategory.delete).not.toHaveBeenCalled(); }); @@ -42,7 +66,7 @@ describe('GovernanceService', () => { const { prisma, audit, service } = makeService(); prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') }); - await service.remove({ actorId: 'm-admin', kind: 'requirement_type', id: 'dict-1' }); + await service.remove({ actorId: 'm-admin', permissions: ['governance:manage'], kind: 'requirement_type', id: 'dict-1' }); expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({ where: { id: 'dict-1' }, diff --git a/apps/server/src/modules/governance/governance.service.ts b/apps/server/src/modules/governance/governance.service.ts index 2d4e7a7..0512e4c 100644 --- a/apps/server/src/modules/governance/governance.service.ts +++ b/apps/server/src/modules/governance/governance.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { AuditService } from '../../common/audit/audit.service'; +import { RbacService } from '../../common/rbac/rbac.service'; import { PrismaService } from '../../prisma/prisma.service'; export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const; @@ -14,12 +15,14 @@ export interface GovernanceDictionaryInput { group?: string | null; scope?: string; value?: unknown; + permissions?: string[]; } export interface GovernanceRemoveInput { actorId: string; kind: GovernanceDictionaryKind; id: string; + permissions?: string[]; } @Injectable() @@ -27,6 +30,7 @@ export class GovernanceService { constructor( private readonly prisma: PrismaService, private readonly auditService: AuditService, + private readonly rbacService: RbacService, ) {} list(kind: GovernanceDictionaryKind) { @@ -43,6 +47,7 @@ export class GovernanceService { async create(input: GovernanceDictionaryInput) { assertDictionaryKind(input.kind); const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); const created = input.kind === 'task_category' ? await this.prisma.taskCategory.create({ data: { @@ -75,6 +80,7 @@ export class GovernanceService { async update(input: GovernanceDictionaryInput & { id: string }) { assertDictionaryKind(input.kind); const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); const id = requireText(input.id, 'id'); const updated = input.kind === 'task_category' ? await this.prisma.taskCategory.update({ @@ -108,6 +114,7 @@ export class GovernanceService { async remove(input: GovernanceRemoveInput) { assertDictionaryKind(input.kind); const actorId = requireText(input.actorId, 'actorId'); + await this.assertManagePermission(actorId, input.permissions); const id = requireText(input.id, 'id'); if (input.kind === 'task_category') { const [devTaskCount, testCaseCount] = await Promise.all([ @@ -150,12 +157,13 @@ export class GovernanceService { return { taskCategories, dictionaries }; } - async importAll(actorId: string, items: GovernanceDictionaryInput[]) { + async importAll(actorId: string, items: GovernanceDictionaryInput[], permissions: string[] = []) { + await this.assertManagePermission(actorId, permissions); const results = []; for (const item of items) { assertDictionaryKind(item.kind); if (item.kind === 'task_category') { - results.push(await this.create({ ...item, actorId })); + results.push(await this.create({ ...item, actorId, permissions })); } else { const saved = await this.prisma.governanceDictionary.upsert({ where: { @@ -196,6 +204,14 @@ export class GovernanceService { private resourceType(kind: GovernanceDictionaryKind) { return kind === 'task_category' ? 'task_category' : 'governance_dictionary'; } + + private assertManagePermission(actorId: string, permissions: string[] = []) { + return this.rbacService.assertGlobalPermission({ + actorId, + permissions, + requiredPermissions: ['governance:manage'], + }); + } } function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind { diff --git a/apps/server/src/modules/management/management.module.ts b/apps/server/src/modules/management/management.module.ts index 8a7f281..b79a32e 100644 --- a/apps/server/src/modules/management/management.module.ts +++ b/apps/server/src/modules/management/management.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { CommonDomainModule } from '../../common/common-domain.module'; import { ManagementController } from './management.controller'; import { ManagementService } from './management.service'; @Module({ + imports: [CommonDomainModule], controllers: [ManagementController], providers: [ManagementService], }) diff --git a/apps/server/src/modules/management/management.service.spec.ts b/apps/server/src/modules/management/management.service.spec.ts index f22b81e..df1d537 100644 --- a/apps/server/src/modules/management/management.service.spec.ts +++ b/apps/server/src/modules/management/management.service.spec.ts @@ -12,14 +12,24 @@ describe('ManagementService', () => { bug: { findMany: jest.fn() }, xiaobaoRiskSummary: { findMany: jest.fn() }, }; - return { prisma, service: new ManagementService(prisma as any) }; + const rbac = { + assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }), + }; + return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) }; }; + it('requires management view permission through the RBAC adapter', async () => { + const { rbac, service } = makeService(); + rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden')); + + await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden'); + }); + it('returns an empty dashboard when actor has no managed projects', async () => { - const { prisma, service } = makeService(); + const { prisma, rbac, service } = makeService(); prisma.projectMember.findMany.mockResolvedValue([]); - await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).resolves.toEqual({ + await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({ activeVersionCount: 0, overdueItemCount: 0, blockedItemCount: 0, @@ -30,6 +40,11 @@ describe('ManagementService', () => { blockedItems: [], highRiskVersions: [], }); + expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({ + actorId: 'm-dev', + permissions: ['management:view'], + requiredPermissions: ['management:view'], + }); expect(prisma.appData.findMany).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/management/management.service.ts b/apps/server/src/modules/management/management.service.ts index eea6be2..2756099 100644 --- a/apps/server/src/modules/management/management.service.ts +++ b/apps/server/src/modules/management/management.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { RbacService } from '../../common/rbac/rbac.service'; import { PrismaService } from '../../prisma/prisma.service'; export interface ManagementOverviewQuery { @@ -22,13 +23,21 @@ type WorkItem = { @Injectable() export class ManagementService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly rbacService: RbacService, + ) {} async getOverview(query: ManagementOverviewQuery) { const actorId = query.actorId?.trim(); if (!actorId) throw new BadRequestException('actorId is required'); + const permission = await this.rbacService.assertGlobalPermission({ + actorId, + permissions: query.permissions ?? [], + requiredPermissions: ['management:view'], + }); const now = query.now ?? new Date(); - const allProjects = query.permissions?.includes('*') === true; + const allProjects = permission.via === 'system'; const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId); if (!allProjects && projectIds?.length === 0) return emptyOverview(); diff --git a/apps/web/app/admin/governance/page.tsx b/apps/web/app/admin/governance/page.tsx index 887421c..e368e15 100644 --- a/apps/web/app/admin/governance/page.tsx +++ b/apps/web/app/admin/governance/page.tsx @@ -5,6 +5,7 @@ import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react'; import { RouteGuard } from '@/components/auth/Guard'; import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source'; @@ -26,12 +27,14 @@ const KIND_LABEL: Record = { function GovernancePageInner() { const user = useAuthStore((s) => s.user); + const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId)); const [kind, setKind] = useState('task_category'); const [items, setItems] = useState([]); const [name, setName] = useState(''); const [group, setGroup] = useState('other'); const [exportText, setExportText] = useState(''); const actorId = user?.id ?? ''; + const permissions = role?.permissions ?? []; const reload = async () => { const rows = await api.get(`/governance/dictionaries?kind=${kind}`); @@ -44,14 +47,14 @@ function GovernancePageInner() { const create = async () => { if (!actorId || !name.trim()) return; - await api.post('/governance/dictionaries', { actorId, kind, name: name.trim(), group }); + await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group }); setName(''); await reload(); }; const remove = async (item: GovernanceItem) => { if (!actorId) return; - await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId }); + await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId, permissions }); await reload(); }; @@ -66,7 +69,7 @@ function GovernancePageInner() { const sourceItems = Array.isArray(parsed.items) ? parsed.items : [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))]; - await api.post('/governance/import', { actorId, items: sourceItems }); + await api.post('/governance/import', { actorId, permissions, items: sourceItems }); await reload(); }; diff --git a/docs/architecture.md b/docs/architecture.md index 2b4b19d..b38dff9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -307,3 +307,22 @@ Current source-of-truth boundary: - `products-overview` remains the primary document for the product/project/version tree until Project and Version write APIs replace it. - V2.2 read APIs and V2.3 relation sync are compatibility infrastructure, not proof that every relation model already has a public CRUD API. - `packages/shared` still contains early Requirement/Task status enums. Before switching frontend writes to domain APIs, align shared enums with the current workflow statuses in this document. + +## V2.7 Enterprise Collaboration And Governance Layer (2026-07-08) + +V2.7 adds enterprise collaboration capabilities on top of the relational source-of-truth direction. New collaboration data does not add AppData keys: + +- `notifications`: per-recipient notification records with stable event types `assignment / mention / risk_alert / overdue_item`. +- `comments`: polymorphic comments for `dev_task / test_case / bug / requirement / version_plan`, with mention metadata and soft deletion. +- `project_members`: project-level Owner/Admin/Member/Viewer governance, now exposed through server-enforced APIs. +- `audit_logs`: append-only governance and collaboration audit events. +- `governance_dictionaries`: centralized requirement type/platform/source dictionaries; task categories continue to use `task_categories`. + +Because the full V2.5 RBAC/audit contract is not fully materialized as a standalone backend framework yet, V2.7 uses stable server adapters: + +- `RbacService`: project role and global permission assertion adapter. Feature modules call this instead of hard-coding permission checks. +- `AuditService`: append-only audit adapter. Feature modules call this instead of writing ad-hoc audit records. + +When V2.5 materializes a trusted auth context, global permission sourcing should be swapped behind `RbacService`; feature modules should keep depending on the adapter boundary. + +Management overview reads only relation tables and summaries. It intentionally avoids AppData so it reflects the target backend boundary rather than the compatibility document store. diff --git a/docs/decisions.md b/docs/decisions.md index f375bd5..f767629 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -587,3 +587,17 @@ - V2.8:生产硬化稳定版 + 运维闭环。生产部署基线已存在,V2.8 聚焦备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。 **理由**:这条链路保持了从低风险兼容到强一致主源的顺序。权限/审计必须随领域 CRUD 进入代码路径,否则后补会重写接口边界;AppData 退场必须有闸门和回滚价值,不能直接删除;性能基础要从 V2.4 的 API 设计开始,V2.6 只做规模化增强和后台化能力。这样每个阶段都有清晰验收物,也能避免长期双主源、无审计写入和大数据查询返工。 + +## 46. V2.7 协作治理先落稳定适配器,不硬编码临时权限 + +**问题**:V2.7 需要通知、评论、项目成员治理、管理驾驶舱和治理字典。但 V2.5 的完整后端 RBAC / audit 合同尚未以统一模块形式沉淀。如果各 V2.7 模块直接写临时权限判断和审计插入,后续 V2.5 收口会再次返工。 + +**决策**: +- 新增 `RbacService` 作为项目角色与全局权限断言适配器,Owner/Admin/Member/Viewer 的层级判断和 `management:view` / `governance:manage` 等全局权限入口集中在此处。 +- 新增 `AuditService` 作为审计写入适配器,业务模块只提交 `actorId/action/resource/before/after`。 +- 通知事件类型固定为 `assignment / mention / risk_alert / overdue_item`,跨模块通过这些稳定语义发通知。 +- 通用评论使用 `entityType + entityId + entityVersionId` 的多态引用,不给每个业务表单独建评论表。 +- 管理驾驶舱只读关系表和 `xiaobao_risk_summaries`,不回读 AppData。 +- 治理字典使用软删除或使用中禁止硬删,变更必须写审计。 + +**理由**:适配器把“当前合同未完全落地”的不确定性隔离在一层,V2.7 能先交付企业协作能力,同时给 V2.5 后续权限/审计收口留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。 diff --git a/docs/roadmap.md b/docs/roadmap.md index be4761c..74e3f9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,18 @@ # 开发路线图 -## 当前阶段:V2.4 — 领域 CRUD 主写迁移 +## 当前阶段:V2.7 — 企业级协作能力 + 管理治理 + +V2.7 的目标是在关系表主源方向上补齐企业协作和治理能力:通知、评论与提及、项目成员治理、管理驾驶舱、治理字典、以及统一 RBAC/audit 适配器。当前执行前提为 V2.4 已由用户确认完成;V2.7 不再新增 AppData 主存储。 + +### 当前重点 + +1. **协作通知**:通知记录、已读状态、NotificationBell,并覆盖 assignment / mention / risk_alert / overdue_item 稳定事件类型。 +2. **通用评论**:DevTask/TestCase/Bug/Requirement/VersionPlan 统一评论面板,支持 `@成员名` 和显式成员选择,创建/删除写 audit。 +3. **项目成员治理**:Owner/Admin/Member/Viewer 服务端强校验,禁止移除最后 Owner,角色变更写 audit。 +4. **管理驾驶舱**:只读关系表和 summary,聚合活跃版本、逾期、阻塞、风险和成员负载。 +5. **治理设置**:集中维护 task category、requirement type/platform/source,使用中的字典不可硬删,支持导入导出。 + +## 历史阶段:V2.4 — 领域 CRUD 主写迁移 V2.4 的目标是把业务主数据源从 AppData JSONB 文档切换到 PostgreSQL 领域关系表。AppData 继续保留为迁移、回填、兼容读取和排查入口,但不再作为长期主写入源;新增业务能力必须优先设计关系表、领域 CRUD API、索引/分区键和权限边界。V2.4 做逐领域主写迁移,并随 CRUD 入口埋好基础权限、`actorId` 和审计事件骨架;完整 RBAC、审计覆盖和 AppData 退场收口放到 V2.5。 diff --git a/docs/workflow.md b/docs/workflow.md index 293647f..d2ac59a 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -288,6 +288,21 @@ AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自 静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。 +## V2.7 协作治理工作流 + +通知统一进入 `notifications` 关系表,事件类型固定为: + +- `assignment`:负责人或处理人被分配工作。 +- `mention`:评论中 `@成员名` 或显式选择成员。 +- `risk_alert`:小宝预警保存高风险快照后提醒管理者。 +- `overdue_item`:逾期事项提醒。 + +评论统一使用 `CommentPanel`,支持 DevTask、TestCase、Bug、Requirement 和 VersionPlan。创建/删除评论必须写 audit;提及成员必须生成 mention 通知。 + +项目成员治理走 `/projects/:projectId/members` 服务端接口。角色为 Owner/Admin/Member/Viewer,Owner/Admin 可管理成员;服务端禁止移除或降级最后一个 Owner。版本成员可见性继续兼容旧 `version.members` 展示,但治理来源应逐步收敛到 ProjectMember。 + +管理驾驶舱 `/admin/management` 只查关系表和小宝 summary,不读取 AppData,并通过 RBAC adapter 校验 `management:view`。治理设置 `/admin/governance` 集中维护任务类型与需求字典;使用中的字典不可硬删,字典变更必须写 audit,并通过 RBAC adapter 校验 `governance:manage`。 + ## 日期选择与计划时间 - 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。