diff --git a/Dockerfile b/Dockerfile index d463c447..dd2a6c20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,7 +93,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" && \ test -f "/app/prisma/migrations/20260902_add_employee_roster_history/migration.sql" && \ test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql" && \ - test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" + test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" && \ + test -f "/app/prisma/migrations/20260909_expand_brand_knowledge_upload/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -220,6 +221,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_privat COPY --from=builder --chown=root:root /app/prisma/migrations/20260902_add_employee_roster_history ./prisma/migrations/20260902_add_employee_roster_history COPY --from=builder --chown=root:root /app/prisma/migrations/20260903_add_work_review_brand ./prisma/migrations/20260903_add_work_review_brand COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_knowledge ./prisma/migrations/20260908_add_brand_knowledge +COPY --from=builder --chown=root:root /app/prisma/migrations/20260909_expand_brand_knowledge_upload ./prisma/migrations/20260909_expand_brand_knowledge_upload 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/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 976824c0..e29bb45c 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -26,6 +26,7 @@ - `20260902_add_employee_roster_history` - `20260903_add_work_review_brand` - `20260908_add_brand_knowledge` +- `20260909_expand_brand_knowledge_upload` 它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。`20260831_add_private_task_detail` 只新增 `materials.task_detail_demand_type`、`materials.task_detail_private default false`、`material_permission_pack_items.can_access_private default false`,并加上 `materials_private_optimization_check`,确保私密任务详情只能落在“任务-详情 + 优化”语义下。`20260902_add_employee_roster_history` 新增员工完整花名册的时间快照表及查询索引,团队产能按每周结束前最近一份快照统计在职人数;不会用当前花名册回填没有快照的历史周。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。 diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 3bc530bd..fb43d752 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1513,6 +1513,7 @@ const expectedMigrationNames = new Set([ '20260902_add_employee_roster_history', '20260903_add_work_review_brand', '20260908_add_brand_knowledge', + '20260909_expand_brand_knowledge_upload', ]); 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 0f4718e2..df8081ca 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -29,6 +29,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20260902_add_employee_roster_history', '20260903_add_work_review_brand', '20260908_add_brand_knowledge', + '20260909_expand_brand_knowledge_upload', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -314,7 +315,7 @@ async function queryEvidence(prisma, localMigrations) { OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')) OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at')) OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at')) - OR (table_name = 'brand_knowledge' AND column_name IN ('id', 'title', 'knowledge_type', 'brand', 'file_name', 'file_path', 'mime_type', 'file_size', 'uploaded_by', 'created_at', 'updated_at'))) + OR (table_name = 'brand_knowledge' AND column_name IN ('id', 'title', 'knowledge_type', 'brand', 'product_category_name', 'file_name', 'file_path', 'mime_type', 'file_size', 'preview_image_name', 'preview_image_path', 'preview_image_mime_type', 'preview_image_size', 'uploaded_by', 'created_at', 'updated_at'))) ORDER BY table_name, column_name`, ) const indexes = await prisma.$queryRawUnsafe( @@ -434,10 +435,15 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { requireColumn('brand_knowledge', 'title', 'text', 'NO') requireColumn('brand_knowledge', 'knowledge_type', 'text', 'NO') requireColumn('brand_knowledge', 'brand', 'text', 'NO') + requireColumn('brand_knowledge', 'product_category_name', 'text', 'NO') requireColumn('brand_knowledge', 'file_name', 'text', 'NO') requireColumn('brand_knowledge', 'file_path', 'text', 'NO') requireColumn('brand_knowledge', 'mime_type', 'text', 'NO') requireColumn('brand_knowledge', 'file_size', 'bigint', 'NO') + requireColumn('brand_knowledge', 'preview_image_name', 'text', 'YES') + requireColumn('brand_knowledge', 'preview_image_path', 'text', 'YES') + requireColumn('brand_knowledge', 'preview_image_mime_type', 'text', 'YES') + requireColumn('brand_knowledge', 'preview_image_size', 'bigint', 'YES') requireColumn('brand_knowledge', 'uploaded_by', 'text', 'NO') requireColumn('brand_knowledge', 'created_at', 'timestamp without time zone', 'NO') requireColumn('brand_knowledge', 'updated_at', 'timestamp without time zone', 'NO') @@ -558,6 +564,11 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { 'materials', ['is_incomplete_shell'], ) + assertExactBtreeIndex( + indexByName.get('brand_knowledge_product_category_name_idx'), + 'brand_knowledge', + ['product_category_name'], + ) assertExactBtreeIndex( indexByName.get('ding_workflow_snapshots_ongoing_updated_idx'), 'ding_workflow_snapshots', diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 4112b12b..fbfe751a 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -345,6 +345,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20260902_add_employee_roster_history', { checksum: 'r' }], ['20260903_add_work_review_brand', { checksum: 's' }], ['20260908_add_brand_knowledge', { checksum: 't' }], + ['20260909_expand_brand_knowledge_upload', { checksum: 'u' }], ]) const evidence = { migrationRows: [ @@ -368,6 +369,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { migration_name: '20260902_add_employee_roster_history', checksum: 'r', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260903_add_work_review_brand', checksum: 's', finished_at: 'now', rolled_back_at: null }, { migration_name: '20260908_add_brand_knowledge', checksum: 't', finished_at: 'now', rolled_back_at: null }, + { migration_name: '20260909_expand_brand_knowledge_upload', checksum: 'u', finished_at: 'now', rolled_back_at: null }, ], columns: [ { table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' }, @@ -455,10 +457,15 @@ test('target evidence rejects nullable logo columns and structurally wrong index { table_name: 'brand_knowledge', column_name: 'title', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'knowledge_type', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'brand', data_type: 'text', is_nullable: 'NO' }, + { table_name: 'brand_knowledge', column_name: 'product_category_name', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'file_name', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'file_path', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'mime_type', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'file_size', data_type: 'bigint', is_nullable: 'NO' }, + { table_name: 'brand_knowledge', column_name: 'preview_image_name', data_type: 'text', is_nullable: 'YES' }, + { table_name: 'brand_knowledge', column_name: 'preview_image_path', data_type: 'text', is_nullable: 'YES' }, + { table_name: 'brand_knowledge', column_name: 'preview_image_mime_type', data_type: 'text', is_nullable: 'YES' }, + { table_name: 'brand_knowledge', column_name: 'preview_image_size', data_type: 'bigint', is_nullable: 'YES' }, { table_name: 'brand_knowledge', column_name: 'uploaded_by', data_type: 'text', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' }, { table_name: 'brand_knowledge', column_name: 'updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' }, @@ -492,6 +499,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index { indexname: 'task_detail_change_notifications_status_retry_idx', indexdef: 'CREATE INDEX task_detail_change_notifications_status_retry_idx ON public.task_detail_change_notifications USING btree (status, next_retry_at)' }, { indexname: 'api_key_reveal_audits_api_key_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_api_key_id_created_at_idx ON public.api_key_reveal_audits USING btree (api_key_id, created_at)' }, { indexname: 'api_key_reveal_audits_viewer_user_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_viewer_user_id_created_at_idx ON public.api_key_reveal_audits USING btree (viewer_user_id, created_at)' }, + { indexname: 'brand_knowledge_product_category_name_idx', indexdef: 'CREATE INDEX brand_knowledge_product_category_name_idx ON public.brand_knowledge USING btree (product_category_name)' }, ], constraints: [{ tablename: 'materials', @@ -709,6 +717,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co '20260902_add_employee_roster_history', '20260903_add_work_review_brand', '20260908_add_brand_knowledge', + '20260909_expand_brand_knowledge_upload', ]) { assert.match(dockerfile, new RegExp(migrationName)) assert.match(migratorSource, new RegExp(`'${migrationName}'`)) @@ -726,6 +735,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.ok(sop.includes('`20260903_add_work_review_brand`')) assert.match(deploymentSource, /'20260908_add_brand_knowledge'/) assert.ok(sop.includes('`20260908_add_brand_knowledge`')) + assert.match(deploymentSource, /'20260909_expand_brand_knowledge_upload'/) + assert.ok(sop.includes('`20260909_expand_brand_knowledge_upload`')) assert.match(sop, /materials_private_optimization_check/) assert.match(sop, /task_detail_private/) assert.match(sop, /can_access_private/)