diff --git a/Dockerfile b/Dockerfile index 48d801bf..ceb1fc8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,7 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \ test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \ - test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" + test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \n test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -206,6 +206,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_din COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access +COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index b88e26da..8555afac 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -334,6 +334,11 @@ export async function GET(request: NextRequest) { { id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin }, { id: material.id, userId: material.userId } as any, ); + // 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行 + if (!canManageTaskDetailAssets) { + const { canUserEditTaskDetailAssets } = await import('@/lib/user-permission-inherit'); + canManageTaskDetailAssets = await canUserEditTaskDetailAssets(userId); + } } const response: any = { diff --git a/app/api/organization/material-permissions/route.ts b/app/api/organization/material-permissions/route.ts index 6662b287..c91a837b 100644 --- a/app/api/organization/material-permissions/route.ts +++ b/app/api/organization/material-permissions/route.ts @@ -25,6 +25,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({ canDownload: false, canDownloadSource: false, canShare: true, + canEditAssets: false, }) async function requireSuperAdmin() { @@ -73,6 +74,7 @@ export async function GET() { canDownload: row.canDownload, canDownloadSource: row.canDownloadSource, canShare: row.canShare, + canEditAssets: false, } stored.set(row.department, current) } diff --git a/app/api/organization/permission-groups/route.ts b/app/api/organization/permission-groups/route.ts index 19b73c02..f0ace5f1 100644 --- a/app/api/organization/permission-groups/route.ts +++ b/app/api/organization/permission-groups/route.ts @@ -77,6 +77,7 @@ export async function GET() { canDownload: true, canDownloadSource: true, canShare: true, + canEditAssets: false, }, }, inheritors: { @@ -270,6 +271,7 @@ export async function PUT(request: NextRequest) { canDownload: Boolean(item.canDownload), canDownloadSource: Boolean(item.canDownloadSource), canShare: true, + canEditAssets: false, })), ) return NextResponse.json({ ok: true }) diff --git a/app/api/organization/user-permission-inherits/route.ts b/app/api/organization/user-permission-inherits/route.ts index 3b3002e3..3a6655f5 100644 --- a/app/api/organization/user-permission-inherits/route.ts +++ b/app/api/organization/user-permission-inherits/route.ts @@ -40,6 +40,7 @@ export async function GET() { canDownload: true, canDownloadSource: true, canShare: true, + canEditAssets: false, }, }, }, @@ -126,6 +127,7 @@ export async function PUT(request: NextRequest) { canDownload: Boolean(item.canDownload), canDownloadSource: Boolean(item.canDownloadSource), canShare: item.canShare !== false, + canEditAssets: false, })), ) return NextResponse.json({ ok: true }) diff --git a/components/department-dashboard/PermissionGroupsDashboard.tsx b/components/department-dashboard/PermissionGroupsDashboard.tsx index 8b4c0cea..0be7455e 100644 --- a/components/department-dashboard/PermissionGroupsDashboard.tsx +++ b/components/department-dashboard/PermissionGroupsDashboard.tsx @@ -54,6 +54,7 @@ const FLAG_COLUMNS: Array<{ key: Exclude ({ @@ -61,6 +62,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({ canDownload: false, canDownloadSource: false, canShare: true, + canEditAssets: false, }) function flattenTree(nodes: OrgNode[]): OrgNode[] { @@ -662,16 +664,22 @@ export default function PermissionGroupsDashboard() { return ( {materialType} - {FLAG_COLUMNS.map((column) => ( - - updateFlag(materialType, column.key, event.target.checked)} - aria-label={`${materialType} ${column.label}`} - /> - - ))} + {FLAG_COLUMNS.map((column) => { + // 编辑图片能力只对任务-详情类型有意义,其余行灰显禁用 + const editDisabled = column.key === 'canEditAssets' && materialType !== '任务-详情' + return ( + + updateFlag(materialType, column.key, event.target.checked)} + aria-label={`${materialType} ${column.label}`} + /> + + ) + })} ) })} diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 0f5e72f9..aac0541f 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -2,7 +2,7 @@ ## 结论 -当前应用发布工具验证十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 +当前应用发布工具验证十四份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。 目标 migration: @@ -19,6 +19,7 @@ - `20260826_add_ding_workflow_demand_type` - `20260827_add_ding_workflow_detail_fields` - `20260826_add_pack_video_early_access` +- `20260828_add_pack_item_can_edit_assets` 它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表,以及钉钉流程快照的需求类型、表单字段与索引,并在同一个事务中按钉钉唯一值做确定性回填。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 @@ -35,7 +36,7 @@ 1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator; 2. legacy bootstrap `Audit`; 3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留; -4. 在同一维护窗内运行本文的真实备份、隔离恢复、十三条目标 migration 和迁移后审计; +4. 在同一维护窗内运行本文的真实备份、隔离恢复、十四条目标 migration 和迁移后审计; 5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。 顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。 @@ -359,7 +360,7 @@ docker run --rm \ "$MIGRATOR_IMAGE" --apply ``` -工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、租约与需求类型索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。 +工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十四条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、租约与需求类型索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。 apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: diff --git a/lib/__tests__/organization-material-permissions.test.ts b/lib/__tests__/organization-material-permissions.test.ts index 96470018..62e74100 100644 --- a/lib/__tests__/organization-material-permissions.test.ts +++ b/lib/__tests__/organization-material-permissions.test.ts @@ -304,14 +304,14 @@ describe('organization people counts', () => { test('多组合并时允许的取并集,不允许的不会把别组已经给的拿掉', () => { const merged = unionPermissionPacks( { - '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true }, + '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false }, }, { - '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true }, - 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true }, + '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, + 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false }, }, { - 详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true }, + 详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, }, ) @@ -320,24 +320,26 @@ describe('organization people counts', () => { canDownload: true, canDownloadSource: false, canShare: true, + canEditAssets: false, }) expect(merged['详情模板']).toEqual({ canView: true, canDownload: true, canDownloadSource: true, canShare: true, + canEditAssets: false, }) }) test('用户继承叠在部门权限上,能给的加上,不能把部门已有的拿掉', () => { const merged = mergeDepartmentAndInheritPacks( { - '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true }, - '任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true }, + '3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false }, + '任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, }, { - '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true }, - 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true }, + '3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false }, + 详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false }, }, ) @@ -346,6 +348,7 @@ describe('organization people counts', () => { canDownload: true, canDownloadSource: false, canShare: true, + canEditAssets: false, }) expect(merged['任务-详情']?.canDownload).toBe(false) expect(merged['详情模板']).toEqual({ @@ -353,6 +356,7 @@ describe('organization people counts', () => { canDownload: true, canDownloadSource: true, canShare: true, + canEditAssets: false, }) }) diff --git a/lib/department-permission.ts b/lib/department-permission.ts index 0ffa683a..47360247 100644 --- a/lib/department-permission.ts +++ b/lib/department-permission.ts @@ -542,6 +542,8 @@ async function loadStoredPermissionPacks() { canDownload: row.canDownload, canDownloadSource: row.canDownloadSource, canShare: row.canShare, + // 旧部门权限表没有编辑图片列;编辑能力只认权限组勾选 + canEditAssets: false, } stored.set(row.department, current) } @@ -562,7 +564,7 @@ function pickPermissionFlags( return null } -async function resolveRosterPermissionPack( +export async function resolveRosterPermissionPack( userId: string, ): Promise | null> { const paths = await getEmployeeOrganizationPathsByUserId(userId) diff --git a/lib/organization-material-permissions.ts b/lib/organization-material-permissions.ts index 086a9aec..b9369832 100644 --- a/lib/organization-material-permissions.ts +++ b/lib/organization-material-permissions.ts @@ -3,6 +3,8 @@ export type MaterialPermissionFlags = { canDownload: boolean canDownloadSource: boolean canShare: boolean + /** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */ + canEditAssets: boolean } export type PermissionPackMap = Record @@ -27,6 +29,7 @@ export function mergeDepartmentAndInheritPacks( canDownload: extra.canDownload || base.canDownload, canDownloadSource: extra.canDownloadSource || base.canDownloadSource, canShare: extra.canShare || base.canShare, + canEditAssets: extra.canEditAssets || base.canEditAssets, } } return merged diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index b415dda1..c2f7cca5 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -455,7 +455,11 @@ async function assertCanManage( { id: material.id, userId: material.userId ?? '' }, ) if (!canManage) { - throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403) + // 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行 + const { canUserEditTaskDetailAssets } = await import('./user-permission-inherit') + if (!(await canUserEditTaskDetailAssets(operatorUserId))) { + throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403) + } } } diff --git a/lib/user-permission-inherit.ts b/lib/user-permission-inherit.ts index 135c463d..e6f7628c 100644 --- a/lib/user-permission-inherit.ts +++ b/lib/user-permission-inherit.ts @@ -22,6 +22,7 @@ export function packItemsToMap( canDownload: boolean canDownloadSource: boolean canShare: boolean + canEditAssets?: boolean }>, ): PermissionPackMap { const pack: PermissionPackMap = {} @@ -31,6 +32,7 @@ export function packItemsToMap( canDownload: item.canDownload, canDownloadSource: item.canDownloadSource, canShare: item.canShare, + canEditAssets: item.canEditAssets === true, } } return pack @@ -80,6 +82,7 @@ const PACK_ITEM_SELECT = { canDownload: true, canDownloadSource: true, canShare: true, + canEditAssets: true, } as const export async function loadUserInheritPack(userId: string): Promise { @@ -102,6 +105,23 @@ export async function loadUserInheritPack(userId: string): Promise { + if (!userId) return false + try { + const { resolveRosterPermissionPack } = await import('./department-permission') + const pack = await resolveRosterPermissionPack(userId) + return pack?.['任务-详情']?.canEditAssets === true + } catch (error) { + console.error('[user-permission-inherit] 读取任务-详情编辑权限失败:', error) + return false + } +} + /** * 视频提前访问豁免:用户加入的任意权限组开启了该组级能力即获得。 * 部门进组是素材可见范围的底子,不参与此判定;豁免按"人进组是加餐"的口径只认个人挂载。 @@ -142,6 +162,7 @@ export async function savePermissionPackItems( canDownload: item.canDownload, canDownloadSource: item.canDownloadSource, canShare: item.canShare, + canEditAssets: item.canEditAssets === true, })), }), ]) diff --git a/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql b/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql new file mode 100644 index 00000000..661f5e6f --- /dev/null +++ b/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql @@ -0,0 +1,3 @@ +-- 任务-详情单图编辑权限:权限组矩阵新增组级能力列,默认不勾选。 +-- 勾选后组成员获得任务-详情素材的替换/改名/删除授权(与上传者/管理员/托管部门授权线叠加)。 +ALTER TABLE "material_permission_pack_items" ADD COLUMN "can_edit_assets" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34d0db2d..13e07e5f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -408,6 +408,7 @@ model MaterialPermissionPackItem { canDownload Boolean @default(false) @map("can_download") canDownloadSource Boolean @default(false) @map("can_download_source") canShare Boolean @default(true) @map("can_share") + canEditAssets Boolean @default(false) @map("can_edit_assets") pack MaterialPermissionPack @relation(fields: [packId], references: [id], onDelete: Cascade) @@unique([packId, materialType], map: "material_permission_pack_item_pack_type_key") diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index ba944217..fae0df27 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1506,6 +1506,7 @@ const expectedMigrationNames = new Set([ '20260826_add_ding_workflow_demand_type', '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', + '20260828_add_pack_item_can_edit_assets', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 9e3d8e3e..57c7cd33 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -22,6 +22,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20260826_add_ding_workflow_demand_type', '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', + '20260828_add_pack_item_can_edit_assets', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 7115ecb8..07e5916b 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -338,6 +338,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20260826_add_ding_workflow_demand_type', { checksum: 'k' }], ['20260827_add_ding_workflow_detail_fields', { checksum: 'l' }], ['20260826_add_pack_video_early_access', { checksum: 'm' }], + ['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }], ]) const evidence = { migrationRows: [ @@ -354,6 +355,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { migration_name: '20260826_add_ding_workflow_demand_type', checksum: 'k', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260827_add_ding_workflow_detail_fields', checksum: 'l', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null }, + { migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null }, ], columns: [ { table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' }, @@ -569,6 +571,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co '20260826_add_ding_workflow_demand_type', '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', + '20260828_add_pack_item_can_edit_assets', ]) { assert.match(dockerfile, new RegExp(migrationName)) assert.match(migratorSource, new RegExp(`'${migrationName}'`))