权限组矩阵新增任务-详情编辑图片能力,默认关闭,勾选组成员可单图编辑

This commit is contained in:
chenpei 2026-08-28 16:06:20 +08:00
parent 4e58fde4d6
commit fd2dec176a
17 changed files with 88 additions and 24 deletions

View File

@ -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

View File

@ -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 = {

View File

@ -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)
}

View File

@ -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 })

View File

@ -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 })

View File

@ -54,6 +54,7 @@ const FLAG_COLUMNS: Array<{ key: Exclude<keyof MaterialPermissionFlags, 'canShar
{ key: 'canView', label: '可见' },
{ key: 'canDownload', label: '下载' },
{ key: 'canDownloadSource', label: '源文件' },
{ key: 'canEditAssets', label: '编辑图片' },
]
const emptyFlags = (): MaterialPermissionFlags => ({
@ -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 (
<tr key={materialType} className="border-t border-[#eef2f6]">
<td className="px-5 py-2.5 font-medium text-[#101828]">{materialType}</td>
{FLAG_COLUMNS.map((column) => (
<td key={column.key} className="px-3 py-2.5">
<input
type="checkbox"
checked={Boolean(flags[column.key])}
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
aria-label={`${materialType} ${column.label}`}
/>
</td>
))}
{FLAG_COLUMNS.map((column) => {
// 编辑图片能力只对任务-详情类型有意义,其余行灰显禁用
const editDisabled = column.key === 'canEditAssets' && materialType !== '任务-详情'
return (
<td key={column.key} className="px-3 py-2.5">
<input
type="checkbox"
checked={Boolean(flags[column.key])}
disabled={editDisabled}
title={editDisabled ? '仅任务-详情支持单图编辑' : undefined}
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
aria-label={`${materialType} ${column.label}`}
/>
</td>
)
})}
</tr>
)
})}

View File

@ -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 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:

View File

@ -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,
})
})

View File

@ -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<Record<string, MaterialPermissionFlags> | null> {
const paths = await getEmployeeOrganizationPathsByUserId(userId)

View File

@ -3,6 +3,8 @@ export type MaterialPermissionFlags = {
canDownload: boolean
canDownloadSource: boolean
canShare: boolean
/** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */
canEditAssets: boolean
}
export type PermissionPackMap = Record<string, MaterialPermissionFlags>
@ -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

View File

@ -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)
}
}
}

View File

@ -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<PermissionPackMap | null> {
@ -102,6 +105,23 @@ export async function loadUserInheritPack(userId: string): Promise<PermissionPac
}
}
/**
* 任务-详情单图编辑授权线:用户的花名册路径权限组(部门组+个人组并集)
* 对「任务-详情」勾选了编辑图片即获得。与上传者/管理员/托管部门的既有
* 授权线叠加,任何一边允许即可编辑。
*/
export async function canUserEditTaskDetailAssets(userId: string): Promise<boolean> {
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,
})),
}),
])

View File

@ -0,0 +1,3 @@
-- 任务-详情单图编辑权限:权限组矩阵新增组级能力列,默认不勾选。
-- 勾选后组成员获得任务-详情素材的替换/改名/删除授权(与上传者/管理员/托管部门授权线叠加)。
ALTER TABLE "material_permission_pack_items" ADD COLUMN "can_edit_assets" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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")

View File

@ -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)

View File

@ -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'

View File

@ -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}'`))