停止跟踪本地工具和日志并完善生产迁移交付

This commit is contained in:
焦龙言
2026-10-08 17:40:30 +08:00
parent 56e646bcc1
commit f3393d78fb
73 changed files with 738 additions and 555163 deletions
+48
View File
@@ -0,0 +1,48 @@
# MySQL 初始化与升级
## 新建空库
使用 [shierfeng-fastapi.sql](./shierfeng-fastapi.sql)。建表与初始化菜单已经包含截至 2026-10-08 的最新结构:
- 测试数据批次、隧道主数据及隧道菜单。
- 库存 `tunnel_id`、`trace_no`,使用、退回、事件的隧道关联与批次快照;使用记录 `line_id` 可空。
- 装填记录的 `production_date`、`expiry_date` 及日期约束。
- 发货收货状态、人员、时间、幂等字段,库存 `site_arrived_at`,在途状态和收货事件及菜单。
- 使用记录和托盘事件的 `fifo_skip_reason`、`fifo_recommendation`。
**全量脚本含 `DROP TABLE`,仅用于新空库,禁止用于已有业务库升级。** 新库初始化后不要再执行对应的历史增量补丁,否则可能遇到重复字段、表或菜单冲突。
Docker Compose 仅在 MySQL 数据卷首次为空时自动执行初始化 SQL。更新代码、替换 SQL 文件或重启容器不会自动升级已有库;不要删除数据卷来触发初始化。
## 已有生产库:已存在隧道表和物料日期,缺少关联字段
适用于已核对的 `shierfeng-fastapi` / MySQL 8.0.46 生产结构。完整的停写、备份、独立恢复演练、逐份执行和验收流程见 [生产 Compose 物料流转增量迁移](./production-compose-material-flow-migration-20261008.md)。
在该文档全部门禁满足后,按顺序使用:
1. [生产隧道关联补齐](./patches/20261008_prod_existing_tunnel_repair_mysql.sql):保留已有隧道表和日期,补齐库存等表的关联列、外键及菜单。
2. [收货补丁](./patches/20261008_shipment_receipt_mysql.sql)。
3. [FIFO 补丁](./patches/20261008_usage_fifo_mysql.sql)。
配套只读检查:
- [原字段基线](./checks/20261008_prod_material_flow_baseline.sql):停写后在迁移前、后各执行一次,逐行比较原数据。
- [迁移验收](./checks/20261008_prod_material_flow_verify.sql):检查字段、约束、菜单及历史数据状态;查询退出码为 0 不代表每项均通过,仍须核对结果。
这是特定起点的一次性升级路线,不是幂等脚本。其他结构、部分迁移或已经升级的库必须重新核对,不能盲目重复执行;MySQL DDL 隐式提交,失败时不能依赖 `ROLLBACK` 撤销已完成的结构变更。
本次仅交付文件,没有执行本地或生产数据库操作,也没有启停服务或修改 Compose 配置。生产执行仍须单独审批;不要在本机开发库运行该生产路线。
## 业务说明与离线验证
- [托盘流转业务部署说明](./pallet-workflow-deployment.md):业务规则与历史执行记录;历史记录不能替代当前环境的迁移证明。
- [测试数据批次操作说明](./test-data-batch-operations.md):权限、纳入与批次清理流程。
使用已安装后端依赖的 Python 环境,从项目根目录运行下列离线契约检查;不导入应用、不连接数据库:
```bash
python -B shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py -q
python -B shierfeng-fastapi-backend/tests/module_business/test_prod_tunnel_repair_sql.py -q
```
离线检查不能替代真实 MySQL 的恢复演练和迁移验收。
@@ -0,0 +1,36 @@
-- 只读:在持续停写期间,于迁移前/后各执行一次,保存到权限600的文件后 cmp。
-- 只选择用户提供结构中的原字段,新增列不参与比较。覆盖正式及测试数据,不输出密码/Token。
-- JSON_ARRAY 保持 NULL 与空字符串差异并转义换行;按主键排序,避免行序造成假差异。
-- 文件包含业务信息,只留在受控生产备份位置,不上传聊天、不提交仓库。
SELECT 'biz_tunnel', JSON_ARRAY(tunnel_id, test_batch_id, site_id, tunnel_code, tunnel_name,
current_trace_no, current_trace_started_at, status, create_by, create_time, update_by, update_time, remark)
FROM biz_tunnel ORDER BY tunnel_id;
SELECT 'biz_pallet_inventory', JSON_ARRAY(pallet_id, test_batch_id, active_filling_id, warehouse_id,
site_id, current_line_id, flow_state, remaining_packages, last_event_id, version, updated_at)
FROM biz_pallet_inventory ORDER BY pallet_id;
SELECT 'biz_usage_record', JSON_ARRAY(usage_id, test_batch_id, usage_no, pallet_id, active_filling_id,
site_id, line_id, used_packages, remaining_packages_after, idempotency_key, request_hash, operator_id, occurred_at)
FROM biz_usage_record ORDER BY usage_id;
SELECT 'biz_return_item', JSON_ARRAY(return_item_id, test_batch_id, return_id, pallet_id,
active_filling_id_snapshot, remaining_packages_snapshot, create_by, create_time)
FROM biz_return_item ORDER BY return_item_id;
SELECT 'biz_pallet_event', JSON_ARRAY(event_id, test_batch_id, pallet_id, event_seq, event_type,
source_type, source_id, before_state, after_state, before_warehouse_id, before_site_id,
before_line_id, after_warehouse_id, after_site_id, after_line_id, before_remaining_packages,
after_remaining_packages, operator_id, occurred_at, idempotency_key, request_hash)
FROM biz_pallet_event ORDER BY event_id;
SELECT 'biz_filling_record', JSON_ARRAY(filling_id, test_batch_id, filling_no, pallet_id, warehouse_id,
strain_id, strain_code_snapshot, strain_name_snapshot, batch_no, production_date, expiry_date,
package_spec, initial_packages, active_slot, closed_at, idempotency_key, request_hash, operator_id,
operated_at, create_by, create_time, update_by, update_time, remark)
FROM biz_filling_record ORDER BY filling_id;
SELECT 'biz_shipment_order', JSON_ARRAY(shipment_id, test_batch_id, shipment_no, source_warehouse_id,
target_site_id, status, confirm_idempotency_key, confirm_request_hash, confirmed_by, confirmed_at,
create_by, create_time, update_by, update_time, remark)
FROM biz_shipment_order ORDER BY shipment_id;
@@ -0,0 +1,90 @@
-- 只读:三份补丁成功后、仍停写时执行。所有 status=OK、异常计数=0;还需原字段快照一致。
SELECT DATABASE() AS db_name, VERSION() AS mysql_version;
WITH expected AS (
SELECT 'biz_pallet_inventory' AS tab, 'tunnel_id' AS col, 'bigint' AS typ, 'YES' AS nullable
UNION ALL SELECT 'biz_pallet_inventory', 'trace_no', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_pallet_inventory', 'site_arrived_at', 'datetime', 'YES'
UNION ALL SELECT 'biz_usage_record', 'tunnel_id', 'bigint', 'YES'
UNION ALL SELECT 'biz_usage_record', 'trace_no', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_usage_record', 'batch_no_snapshot', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_usage_record', 'fifo_skip_reason', 'varchar(500)', 'YES'
UNION ALL SELECT 'biz_usage_record', 'fifo_recommendation', 'json', 'YES'
UNION ALL SELECT 'biz_usage_record', 'line_id', 'bigint', 'YES'
UNION ALL SELECT 'biz_return_item', 'tunnel_id_snapshot', 'bigint', 'YES'
UNION ALL SELECT 'biz_return_item', 'trace_no_snapshot', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_return_item', 'batch_no_snapshot', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'before_tunnel_id', 'bigint', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'after_tunnel_id', 'bigint', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'trace_no', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'batch_no_snapshot', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'fifo_skip_reason', 'varchar(500)', 'YES'
UNION ALL SELECT 'biz_pallet_event', 'fifo_recommendation', 'json', 'YES'
UNION ALL SELECT 'biz_filling_record', 'production_date', 'date', 'YES'
UNION ALL SELECT 'biz_filling_record', 'expiry_date', 'date', 'YES'
UNION ALL SELECT 'biz_shipment_order', 'receipt_state', 'varchar(16)', 'NO'
UNION ALL SELECT 'biz_shipment_order', 'received_by', 'bigint', 'YES'
UNION ALL SELECT 'biz_shipment_order', 'received_at', 'datetime', 'YES'
UNION ALL SELECT 'biz_shipment_order', 'receipt_idempotency_key', 'varchar(64)', 'YES'
UNION ALL SELECT 'biz_shipment_order', 'receipt_request_hash', 'varchar(64)', 'YES'
)
SELECT e.tab, e.col, c.column_type, c.is_nullable,
CASE WHEN c.column_name IS NULL THEN 'MISSING'
WHEN c.column_type <> e.typ OR c.is_nullable <> e.nullable THEN 'MISMATCH'
ELSE 'OK' END AS status
FROM expected e LEFT JOIN information_schema.columns c
ON c.table_schema = DATABASE() AND c.table_name = e.tab AND c.column_name = e.col
ORDER BY e.tab, e.col;
WITH expected AS (
SELECT 'biz_usage_record' AS tab, 'tunnel_id' AS col, 'biz_tunnel' AS ref_tab, 'tunnel_id' AS ref_col
UNION ALL SELECT 'biz_return_item', 'tunnel_id_snapshot', 'biz_tunnel', 'tunnel_id'
UNION ALL SELECT 'biz_pallet_inventory', 'tunnel_id', 'biz_tunnel', 'tunnel_id'
UNION ALL SELECT 'biz_pallet_event', 'before_tunnel_id', 'biz_tunnel', 'tunnel_id'
UNION ALL SELECT 'biz_pallet_event', 'after_tunnel_id', 'biz_tunnel', 'tunnel_id'
UNION ALL SELECT 'biz_shipment_order', 'received_by', 'sys_user', 'user_id'
)
SELECT e.tab, e.col,
CASE WHEN EXISTS (SELECT 1 FROM information_schema.key_column_usage k
WHERE k.table_schema = DATABASE() AND k.table_name = e.tab AND k.column_name = e.col
AND k.referenced_table_schema = DATABASE()
AND k.referenced_table_name = e.ref_tab AND k.referenced_column_name = e.ref_col)
THEN 'OK' ELSE 'MISSING' END AS status
FROM expected e;
-- 三个 CHECK 必须存在且 ENFORCED=YES;人工核对枚举含 IN_TRANSIT、TUNNEL_ENTERED、SHIPMENT_RECEIVED。
SELECT t.table_name, t.constraint_name, t.enforced, c.check_clause
FROM information_schema.table_constraints t JOIN information_schema.check_constraints c
ON c.constraint_schema = t.constraint_schema AND c.constraint_name = t.constraint_name
WHERE t.constraint_schema = DATABASE()
AND t.constraint_name IN ('ck_biz_pallet_inventory_flow_state', 'ck_biz_pallet_event_event_type',
'ck_biz_shipment_order_receipt_state');
SELECT index_name, non_unique, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_in_order
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'biz_shipment_order'
AND index_name = 'uq_biz_shipment_receipt_site_key'
GROUP BY index_name, non_unique;
-- 必须恰有5行且编号/父菜单/权限与操作说明一致;不应自动出现任何普通角色映射。
SELECT menu_id, parent_id, menu_type, perms FROM sys_menu
WHERE menu_id IN (520, 1116, 1117, 1118, 1119) ORDER BY menu_id;
SELECT COUNT(*) AS unexpected_role_grants FROM sys_role_menu WHERE menu_id IN (520, 1116, 1117, 1118, 1119);
SELECT COUNT(*) AS unexpected_receipt_history FROM biz_shipment_order
WHERE (status = 'CONFIRMED' AND receipt_state <> 'LEGACY')
OR (status = 'DRAFT' AND receipt_state <> 'PENDING')
OR received_by IS NOT NULL OR received_at IS NOT NULL
OR receipt_idempotency_key IS NOT NULL OR receipt_request_hash IS NOT NULL;
SELECT COUNT(*) AS unexpected_inventory_history FROM biz_pallet_inventory
WHERE tunnel_id IS NOT NULL OR trace_no IS NOT NULL OR site_arrived_at IS NOT NULL;
SELECT COUNT(*) AS unexpected_usage_history FROM biz_usage_record
WHERE tunnel_id IS NOT NULL OR trace_no IS NOT NULL OR batch_no_snapshot IS NOT NULL
OR fifo_skip_reason IS NOT NULL OR fifo_recommendation IS NOT NULL;
SELECT COUNT(*) AS unexpected_return_history FROM biz_return_item
WHERE tunnel_id_snapshot IS NOT NULL OR trace_no_snapshot IS NOT NULL OR batch_no_snapshot IS NOT NULL;
SELECT COUNT(*) AS unexpected_event_history FROM biz_pallet_event
WHERE before_tunnel_id IS NOT NULL OR after_tunnel_id IS NOT NULL OR trace_no IS NOT NULL
OR batch_no_snapshot IS NOT NULL OR fifo_skip_reason IS NOT NULL OR fifo_recommendation IS NOT NULL;
SELECT COUNT(*) AS remaining_migration_routines FROM information_schema.routines
WHERE routine_schema = DATABASE() AND routine_name IN ('repair_prod_existing_tunnel_20261008',
'migrate_shipment_receipt_20261008', 'migrate_usage_fifo_20261008');
@@ -0,0 +1,146 @@
# 托盘流转业务部署说明
## 2026-10-08 会议流程补齐(本机开发库已执行)
本轮源码增加物料日期、原发货标签、收货核对、隧道报表,以及“允许跳过先到托盘但必须填写原因”。用户明确批准开发库迁移和该 FIFO 规则后,已在本机开发库执行下列三份增量补丁并恢复9099后端。其他环境未执行,不能沿用本记录当作已迁移证明。
### 本机开发库执行与恢复验证记录
- 目标:`.env.dev` 对应 `127.0.0.1:3306/shierfeng-fastapi`,MySQL 8.0.36;43张业务及系统表均为 InnoDB。
- 已核实并停止本项目 `app.py --env=dev` 服务;迁移前无其他活跃目标库查询、无启用的 MySQL Event。
- 完整备份:`/private/tmp/shierfeng-dev-migration-20261008-6ce_sy6c/before.sql`,262663字节,文件权限600、父目录700。SHA-256:`c70393e09574f24820e252c9cfb084e9e15d86b185c8a7aa981a8088cd21a15b`。备份包括全部表、触发器、存储过程和事件;口令不进入命令行,临时口令文件已删除。
- 备份已实际恢复到隔离库 `shierfeng_rehearsal_20261008_6ce_sy6c`;43张表逐行摘要及数量与源库一致。三份补丁先在隔离库试跑通过,然后核对源库未变化、脚本摘要一致,再执行开发库迁移。
- 开发库执行顺序:`20261008_material_dates_mysql.sql` → `20261008_shipment_receipt_mysql.sql` → `20261008_usage_fifo_mysql.sql`,均成功。没有重跑初始化或旧隧道脚本。
- 迁移后按迁移前列集比对43张表的全部原数据摘要,结果一致;仅新增收货菜单1119。原库存、包数、编号、批号及历史事件未变;旧确认发货单收货状态为 `LEGACY`,其他为 `PENDING`;新增日期、收货人/时间和 FIFO 历史列均为空。新 CHECK 约束已核对;未自动授予任何普通角色。
- 后端已按原 Python 环境与 `--env=dev` 恢复,MySQL、Redis连接正常,`/docs` HTTP200;未登录访问收货接口返回业务401。没有修改 `.env.dev`。
- 实际恢复的隔离 MySQL 库还运行了完整业务服务验收:两托100包、原标签/日期、在途禁止入隧、无现场权限拒绝、确认收货、跳过原因校验、同隧道跨批使用、20包使用/80包原托退回、原标签快照不变、收货幂等不恢复已消耗数量,以及三条独立连接同时重放同键只扣减一次。数据仅新建于隔离库;另有一组新建夹具使用正式scope验证两托并发,排除测试批次总锁掩盖冲突,未向实际开发库灌入测试业务。
- 真实 MySQL 验收同时发现并修复入隧事务结束后访问过期 ORM 属性导致的 `MissingGreenlet`;ID及返回模型现在在事务结束前取值。验收使用与应用一致的提交后属性过期行为。
备份与验收产物位于系统临时目录,可能被系统清理;需长期留存时应转存到受控备份位置,不提交仓库。隔离演练库暂保留用于复核,不接业务服务。恢复到业务库将覆盖迁移后变化,必须另行确认停写及恢复目标;失败时不得直接重跑增量补丁。
### 开发库只读预检记录(2026-10-08)
使用 `.env.dev` 指向的 `127.0.0.1:3306/shierfeng-fastapi`,在只读事务中查询版本、结构元数据和收货菜单冲突,随后回滚并关闭连接;未执行备份、DDL、业务数据写入、角色授权或服务重启。
- 实际版本:MySQL 8.0.36;相关业务表均为 InnoDB。
- `biz_test_batch`、`biz_tunnel`、库存隧道/溯源列和事件隧道/溯源快照列已存在。
- `biz_filling_record.production_date/expiry_date` 尚不存在。
- 发货单5个收货字段以及 `biz_pallet_inventory.site_arrived_at` 尚不存在。
- 父菜单510为预期的 `business:shipment:list` 页面;菜单1119、`business:shipment:receive` 权限及1119的孤立角色授权均不存在。
- 后端9099端口仍有服务监听。执行迁移前仍须获准停写、完成备份与恢复方案核对;上述检查不代表已进入维护窗口,也不替代脚本内完整前置约束检查。
上述为执行前的只读预检历史记录;实际执行结果见上节。不要直接重跑已有补丁或初始化脚本。其他环境必须重新核对现状。
### 迁移顺序与风险
1. 只读核对目标库、MySQL版本、既有补丁和约束;不要仅凭文件存在判断已迁移。旧库需先具备 `20260922_test_data_batch_mysql.sql` 及 `20261005_tunnel_material_flow_mysql.sql` 的结构,不可重复执行已落库补丁。
2. 确认维护窗口、停写,完整备份结构、业务数据、菜单及角色关联,并验证恢复方案。
3. 执行 `sql/patches/20261008_material_dates_mysql.sql`:新增生产日期、有效期及日期关系约束;历史记录保持空值,不按装填时间补造日期。
4. 执行 `sql/patches/20261008_shipment_receipt_mysql.sql`:新增收货状态、人员、时间和幂等字段,库存实际收货时间,以及 `IN_TRANSIT` 状态和 `SHIPMENT_RECEIVED` 事件。旧已确认发货单标记 `LEGACY`;原库存位置、状态、包数和历史事件不变。
5. 执行 `sql/patches/20261008_usage_fifo_mysql.sql`,为使用记录和托盘事件新增可空原因及推荐快照列;旧记录保持空值。三份脚本均应在代码启用前完成。
6. 新权限 `business:shipment:receive` 不自动授予普通角色。先核对菜单 ID 1119 未被其他菜单占用;按职责授权发货页面入口、收货核对按钮及目标现场数据范围。接收方不需要来源仓库权限,发货方原双范围校验不放宽。
7. 逐项核对表结构、约束、历史保留结果后再发布三端代码。旧数据库直接运行新后端会因缺少列而报错,不能仅重启服务解决。MySQL DDL 隐式提交,出错必须停止;不可依赖 `ROLLBACK` 恢复整个迁移。
新建空库可使用已同步的 `sql/shierfeng-fastapi.sql`;禁止将包含重建语句的初始化脚本用于现有业务库升级。
### 新流程与历史兼容
- 新发货:仓库有料 → 出库在途 → 接收方逐托核对、整单确认 → 现场未使用 → 扫码入隧 → 实际使用 → 原托盘余料退回。未收货不可入隧;未全部核对不能确认,不支持部分收货。
- 收货不新增托盘号、生产批号或装填记录,不增加/扣减发货快照数量;重复确认采用独立幂等键,不把后续已消耗的余量恢复。
- 标签从原发货装填快照读取品种、生产批号、日期、数量;可补打,历史日期显示“未登记”。印刷托盘号可作为扫码入口,写操作统一使用返回的规范二维码标识。
- 混批与隧道溯源保留在后台及报表;普通操作页面不以内部溯源号代替生产批号。
- 先进先出采用已批准的“允许跳过但必须记录原因”:入隧和实际使用均在后端锁定原业务记录后用当前读复核同现场、同品种的可用候选;其他隧道或旧溯源周期物料不推荐。存在严格更早的实际到货候选时,必须填写不超过500字的原因,原因和推荐托盘/批号/数量/到货时间快照随原事件及使用记录提交。相同到货时间不强排ID;当前历史到货时间未知时不伪造先后,保留人工核对提示。
- FIFO 原因参与幂等摘要;无原因时保持旧摘要兼容。已成功请求重试直接返回原结果,不因候选变化再次扣减。仅服务端明确返回 `code=500, data=FIFO_SKIP_REASON_REQUIRED` 的已回滚请求可解除本机对应凭据以便补原因;网络结果未知和其他失败继续沿用原payload/key。
- 候选当前读使用 `FOR UPDATE NOWAIT`,锁冲突不会跳过早到候选或持有现场/隧道锁等待候选锁。仅 MySQL 3572/1213/1205 触发最多3次整事务原键重试,每次先完成回滚;断连等提交结果未知错误不会自动重放。持续繁忙时仍需客户端按原键恢复核对。
- APP 的 HTTP 非2xx或不完整业务结果不视为成功,入隧、使用及恢复重试均在确认结果后才解除原凭据。PC 成功回调仅清理与该请求完全一致的持久化记录,避免旧页面响应删除新操作;恢复时已确认的 FIFO 必填要求,不会因推荐刷新失败或延迟而丢失。
### 运行态验收门禁
迁移获批且完成后,用获准测试批次验证:两托各100包 → 标签与收货核对 → 同隧道跨批使用 → 20包使用/80包原托退回 → 原批号不变;同时验证重复点击、断网结果未知后重试、无现场权限、收货前使用、漏扫与错扫均被正确处理。核对四类隧道报表和两端日期展示。
本机隔离 MySQL 已验证完整流程、同键并发,以及正式scope两托交错入隧/使用的锁冲突回滚重试。普通角色菜单配置、打印机出纸和东集 CRUISE Gep/D737P_V1.0.5 实机 HID/回车行为仍需现场验收;构建成功不能替代设备验证,未接入设备原生广播 SDK。
## 适用范围与当前状态
本文适用于 MySQL 增量补丁:
`shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql`
2026-07-27 已在本地主工作区 `.env.dev` 指向的 MySQL 8.0.36 执行该补丁,且完成下述本地核对。其他环境尚未执行,Redis 缓存也未清理;本记录不能替代其他环境的上线审批、备份及恢复演练。
## 上线前门禁
执行补丁前必须完成以下准备:
1. 对目标 MySQL 实例进行完整备份,至少覆盖库结构、业务数据、菜单和角色关联数据,并验证备份可读。
2. 在隔离环境完成恢复演练,确认恢复步骤、耗时和责任人,不能只以“备份命令成功”代替可恢复性验证。
3. 由 DBA 人工审查补丁、目标库现状、已有同名表结构、固定菜单 ID 和回退方案。
4. 申请维护窗口,在补丁执行和缓存清理完成前保持后端停止,不接入业务流量。
任一门禁未满足时,不得执行补丁。
## MySQL 补丁执行与核对
MySQL DDL 会隐式提交,因此必须将补丁的 DDL、DML 分节执行,并在每一节后逐项核对结果。执行失败时不能假设整个补丁已自动回滚,应立即停止后续步骤,由 DBA 根据实际已提交状态决定人工修复或从已验证备份恢复。
### 1. DDL 节
1. 先执行补丁中的 DDL 节,逐表核对表结构、索引、外键和约束。
2. `CREATE TABLE IF NOT EXISTS` 只会跳过已经存在的同名表,不会修复已存在但结构发生漂移的表。发现同名表时,必须先人工比对实际结构;存在差异时停止执行并制定独立修复方案。
3. DDL 节核对通过前,不得继续执行 DML 节。
### 2. DML 节
1. DML 节包含固定 64 行菜单 seed。执行后必须核对结果恰为 64 行,并确认稳定字段与补丁定义一致。
2. 任一固定 ID 与未知既有数据冲突时,必须停止并由 DBA、业务负责人共同人工处理;不得覆盖、改写或复用未知 ID,也不得绕过冲突继续执行。
3. DML 节核对失败时,不得清理缓存或启动后端。
### 3. 2026-07-27 本地执行记录
1. 执行前备份:`/private/tmp/shierfeng-fastapi-before-pallet-workflow-20260727-1925.sql`,文件权限 `600`,大小 69,180 字节,SHA-256 为 `a217a7b071c7e3ecda0d4c94dd1010711b6061025148689156405b70a02f6c7e`。已校验文件非空、可读且包含核心系统表;未执行恢复演练。
2. DDL 分节执行成功:新增 18 张 `biz_*` InnoDB 表,业务域幂等键的复合唯一索引列顺序已核对。
3. DML 分节使用标准 `mysql` 批处理执行,未使用 `--force` 或 continue-on-error 模式。64 条新业务菜单已写入,旧 AI/外链菜单、AI 字典及旧角色映射已核对为 0。
4. 新菜单没有自动授予普通角色;后续应通过现有角色菜单机制按实际职责分配。
## 历史 AI/Agno 遗留表
曾运行过历史 AI/Agno 版本的数据库可能残留以下 11 张表:
* `ai_models`
* `ai_chat_config`
* `ai_sessions`
* `ai_memories`
* `ai_metrics`
* `ai_eval_runs`
* `ai_knowledge`
* `ai_culture`
* `ai_traces`
* `ai_spans`
* `ai_schema_versions`
本补丁不会删除或修改这些表。未经另行确认,不得处理这些历史表。后续如需清理,应单独盘点表结构、数据量和外部依赖,完成备份与恢复验证,确认无消费者后,再通过独立变更审批和维护窗口人工执行。
## Redis 缓存清理
只有 MySQL 补丁全部执行成功且核对通过后,才能在目标 Redis 实例定向清理以下缓存:
* `api_cache:login:user:routers:*`
* `api_cache:login:user:info:*`
* `sys_dict:ai_provider_type`
对前两类模式应使用 `SCAN` 定位后逐项删除,只处理明确匹配的键;禁止使用 `FLUSHALL`。本轮未执行任何 Redis 缓存清理,不能把上述清单视为已清理证明。
## 启动与运行态验收
正式环境只有在 MySQL 补丁成功、逐节核对通过并完成上述定向缓存清理后,才可启动后端并开展运行态验收。至少应核对 `/getRouters` 返回的托盘业务菜单、真实角色权限,以及真实用户与仓库/客户现场映射下的业务访问范围,再验证响应式 Web 页面和 Android PDA HID 扫码回车流程。
本地环境已使用独立查询参数验证管理员 `/getRouters`:包含 3 个新业务目录和 17 个业务页面,不包含 AI 管理或“施尔丰仓库”。该结果不等于已完成 Redis 清理,也不代表普通角色已完成授权验收。
## 2026-07-27 当时未验证项(历史记录)
以下事项需要在后续真实环境中验证,本轮没有完成:
* 备份恢复演练。
* 真实角色与仓库/客户现场映射的端到端(E2E)流程。
* MySQL 行锁、并发和事务回滚行为。
* Redis 缓存清理。
@@ -0,0 +1,204 @@
-- 生产专用:修复“biz_tunnel 已存在、日期已升级、关联业务表仍为旧结构”的状态。
-- 依据用户提供的 shierfeng-fastapi / MySQL 8.0.46 SHOW CREATE TABLE;不是通用幂等迁移。
-- 仅生成,未在本地或生产执行。必须先停写、完整备份、在隔离恢复实例试跑,并经 DBA 审核。
-- 保留现有 biz_tunnel(包括 ORM 生成的外键/索引名称)和所有物料日期,不重建表、不补造历史。
-- 本补丁成功后:20261008_shipment_receipt_mysql.sql -> 20261008_usage_fifo_mysql.sql。
-- 不执行原 20261005_tunnel_material_flow_mysql.sql,也不重复执行日期/全量初始化脚本。
-- 在同一连接中显式 SET @shierfeng_prod_migration_approved = 1 后才能改业务表。
-- 该变量是人工确认门,不是环境识别器;同名开发库也必须禁止执行本交付。
-- 使用 mysql 批处理、禁用 --force;任一错误立即停止,不启动应用、不盲目重跑。
-- DDL 隐式提交,异常 handler 的 ROLLBACK 只能回滚菜单 DML,不能回滚之前的 ALTER。
DELIMITER $$
CREATE PROCEDURE repair_prod_existing_tunnel_20261008()
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
IF COALESCE(@shierfeng_prod_migration_approved, 0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Production maintenance approval and verified backup are required';
END IF;
IF DATABASE() IS NULL OR DATABASE() <> 'shierfeng-fastapi' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select the reviewed shierfeng-fastapi database on the production server';
END IF;
IF SUBSTRING_INDEX(VERSION(), '.', 1) <> '8'
OR (CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 2), '.', -1) AS UNSIGNED) = 0
AND CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 3), '.', -1) AS UNSIGNED) < 16) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MySQL 8.0.16 or newer MySQL 8 is required';
END IF;
-- 所有前置检查必须在首个业务表 ALTER 之前;不修改不符合快照的数据库。
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine = 'InnoDB'
AND table_name IN ('biz_tunnel', 'biz_test_batch', 'biz_customer_site', 'biz_usage_record',
'biz_return_item', 'biz_pallet_inventory', 'biz_pallet_event',
'biz_filling_record', 'biz_shipment_order', 'sys_menu', 'sys_role_menu', 'sys_user')) <> 12 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required reviewed InnoDB tables are missing';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'biz_tunnel'
AND column_name IN ('tunnel_id', 'test_batch_id', 'site_id', 'tunnel_code', 'tunnel_name',
'current_trace_no', 'current_trace_started_at', 'status', 'create_by',
'create_time', 'update_by', 'update_time', 'remark')) <> 13
OR NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'biz_tunnel'
AND column_name = 'tunnel_id' AND column_type = 'bigint' AND is_nullable = 'NO') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Existing tunnel table does not match the reviewed structure';
END IF;
-- 只校验外键语义,不要求 ORM 外键名等于原手写脚本的名称。
IF NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
WHERE table_schema = DATABASE() AND table_name = 'biz_tunnel'
AND column_name = 'site_id' AND referenced_table_schema = DATABASE()
AND referenced_table_name = 'biz_customer_site' AND referenced_column_name = 'site_id')
OR NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
WHERE table_schema = DATABASE() AND table_name = 'biz_tunnel'
AND column_name = 'test_batch_id' AND referenced_table_schema = DATABASE()
AND referenced_table_name = 'biz_test_batch' AND referenced_column_name = 'batch_id') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Existing tunnel foreign keys must be reviewed';
END IF;
IF NOT EXISTS (SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'biz_tunnel' AND non_unique = 0
GROUP BY index_name
HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'site_id,tunnel_code') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Existing tunnel site and code unique key is missing';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'biz_filling_record'
AND column_name IN ('production_date', 'expiry_date') AND data_type = 'date' AND is_nullable = 'YES') <> 2
OR NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE() AND table_name = 'biz_filling_record'
AND constraint_name = 'ck_biz_filling_record_material_dates'
AND constraint_type = 'CHECK' AND enforced = 'YES') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'The reviewed material date migration must already be complete';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'biz_usage_record'
AND column_name = 'line_id' AND column_type = 'bigint' AND is_nullable = 'NO') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Usage line_id is not in the reviewed pre-migration state';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'biz_usage_record' AND column_name IN ('tunnel_id', 'trace_no', 'batch_no_snapshot'))
OR (table_name = 'biz_return_item' AND column_name IN ('tunnel_id_snapshot', 'trace_no_snapshot', 'batch_no_snapshot'))
OR (table_name = 'biz_pallet_inventory' AND column_name IN ('tunnel_id', 'trace_no', 'site_arrived_at'))
OR (table_name = 'biz_pallet_event' AND column_name IN ('before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot'))
OR (table_name = 'biz_shipment_order' AND column_name IN ('receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash'))
OR (table_name IN ('biz_usage_record', 'biz_pallet_event') AND column_name IN ('fifo_skip_reason', 'fifo_recommendation')))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Target columns already exist: partial or completed migration; do not rerun';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_event_event_type'
AND UPPER(check_clause) LIKE '%USAGE_RECORDED%'
AND UPPER(check_clause) NOT LIKE '%TUNNEL_ENTERED%'
AND UPPER(check_clause) NOT LIKE '%SHIPMENT_RECEIVED%')
OR NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_inventory_flow_state'
AND UPPER(check_clause) LIKE '%AT_SITE_UNUSED%'
AND UPPER(check_clause) NOT LIKE '%IN_TRANSIT%') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Event or inventory CHECK differs from the reviewed legacy state';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_biz_usage_record_tunnel', 'fk_biz_return_item_tunnel',
'fk_biz_pallet_inventory_tunnel', 'fk_biz_pallet_event_before_tunnel',
'fk_biz_pallet_event_after_tunnel', 'uq_biz_shipment_receipt_site_key',
'fk_biz_shipment_order_received_by', 'ck_biz_shipment_order_receipt_state')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'New constraint names already exist; review schema before migration';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.routines WHERE routine_schema = DATABASE()
AND routine_name IN ('migrate_shipment_receipt_20261008', 'migrate_usage_fifo_20261008')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'A previous receipt or FIFO migration routine remains; inspect before proceeding';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.triggers WHERE trigger_schema = DATABASE()
AND event_object_table IN ('biz_usage_record', 'biz_return_item', 'biz_pallet_inventory',
'biz_pallet_event', 'biz_shipment_order', 'sys_menu'))
OR EXISTS (SELECT 1 FROM information_schema.events WHERE event_schema = DATABASE() AND status = 'ENABLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Custom triggers or enabled events require separate DBA review';
END IF;
IF NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 121 AND parent_id = 0 AND menu_type = 'M')
OR NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 510 AND parent_id = 122
AND menu_type = 'C' AND perms = 'business:shipment:list') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Existing parent menus do not match the reviewed deployment';
END IF;
IF EXISTS (SELECT 1 FROM sys_menu WHERE menu_id IN (520, 1116, 1117, 1118, 1119)
OR perms IN ('master:tunnel:list', 'master:tunnel:query', 'master:tunnel:add',
'master:tunnel:edit', 'business:shipment:receive')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tunnel or receipt menu ID/permission conflict; do not overwrite';
END IF;
IF EXISTS (SELECT 1 FROM sys_role_menu WHERE menu_id IN (520, 1116, 1117, 1118, 1119)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Orphan role grants would become active; review before inserting menus';
END IF;
-- BEGIN DDL
ALTER TABLE biz_usage_record
ADD COLUMN tunnel_id BIGINT NULL COMMENT '隧道ID;历史记录可能为空' AFTER site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '隧道物料内部溯源号;历史记录可能为空' AFTER tunnel_id,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '生产批次快照;历史记录可能为空' AFTER trace_no,
MODIFY COLUMN line_id BIGINT NULL COMMENT '产线ID(可选)',
ADD CONSTRAINT fk_biz_usage_record_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
ALTER TABLE biz_return_item
ADD COLUMN tunnel_id_snapshot BIGINT NULL COMMENT '申请时隧道ID快照' AFTER active_filling_id_snapshot,
ADD COLUMN trace_no_snapshot VARCHAR(64) NULL COMMENT '申请时物料溯源号快照' AFTER tunnel_id_snapshot,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '申请时生产批次快照' AFTER trace_no_snapshot,
ADD CONSTRAINT fk_biz_return_item_tunnel FOREIGN KEY (tunnel_id_snapshot) REFERENCES biz_tunnel (tunnel_id);
ALTER TABLE biz_pallet_inventory
ADD COLUMN tunnel_id BIGINT NULL COMMENT '当前隧道ID' AFTER site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '当前隧道物料溯源号' AFTER tunnel_id,
ADD CONSTRAINT fk_biz_pallet_inventory_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
ALTER TABLE biz_pallet_event
ADD COLUMN before_tunnel_id BIGINT NULL COMMENT '变更前隧道ID' AFTER before_site_id,
ADD COLUMN after_tunnel_id BIGINT NULL COMMENT '变更后隧道ID' AFTER after_site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '事件关联物料溯源号' AFTER after_remaining_packages,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '事件生产批次快照' AFTER trace_no,
ADD CONSTRAINT fk_biz_pallet_event_before_tunnel FOREIGN KEY (before_tunnel_id) REFERENCES biz_tunnel (tunnel_id),
ADD CONSTRAINT fk_biz_pallet_event_after_tunnel FOREIGN KEY (after_tunnel_id) REFERENCES biz_tunnel (tunnel_id);
ALTER TABLE biz_pallet_event
DROP CHECK ck_biz_pallet_event_event_type,
ADD CONSTRAINT ck_biz_pallet_event_event_type CHECK (
event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED',
'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')
);
-- END DDL
-- 菜单只新增已核实空闲的 ID;不修改 sys_role_menu、不自动授予普通角色。
START TRANSACTION;
-- BEGIN MENU
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
) VALUES
(520, '隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', '', '',
1, 0, 'C', '0', '0', 'master:tunnel:list', '#', 'admin', SYSDATE(), '', NULL, '隧道资料菜单'),
(1116, '隧道查询', 520, 1, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', SYSDATE(), '', NULL, ''),
(1117, '隧道新增', 520, 2, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', SYSDATE(), '', NULL, ''),
(1118, '隧道修改', 520, 3, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', SYSDATE(), '', NULL, '');
-- END MENU
COMMIT;
END$$
DELIMITER ;
CALL repair_prod_existing_tunnel_20261008();
DROP PROCEDURE repair_prod_existing_tunnel_20261008;
SET @shierfeng_prod_migration_approved = NULL;
-- 返回12个新增隧道字段、line_id=YES及4行隧道菜单;随后仍须执行收货/FIFO两份补丁。
SELECT table_name, column_name, column_type, is_nullable
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'biz_usage_record' AND column_name IN ('tunnel_id', 'trace_no', 'batch_no_snapshot', 'line_id'))
OR (table_name = 'biz_return_item' AND column_name IN ('tunnel_id_snapshot', 'trace_no_snapshot', 'batch_no_snapshot'))
OR (table_name = 'biz_pallet_inventory' AND column_name IN ('tunnel_id', 'trace_no'))
OR (table_name = 'biz_pallet_event' AND column_name IN ('before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot')))
ORDER BY table_name, ordinal_position;
SELECT menu_id, parent_id, perms FROM sys_menu WHERE menu_id IN (520, 1116, 1117, 1118) ORDER BY menu_id;
@@ -0,0 +1,161 @@
# 生产 Compose 物料流转增量迁移
## 状态、授权与适用范围
本交付**只生成文件**,未连接任何数据库,未执行本地或生产迁移,未重启服务、构建镜像或改动 Compose/环境变量。下列命令供维护人员审核后在**生产服务器**执行,不要在本机开发目录运行。生成脚本的确认不等于已完成生产执行审批。
依据用户提供的生产 `shierfeng-fastapi` / MySQL 8.0.46 结构:
- `biz_test_batch`、`biz_tunnel` 已存在;隧道外键使用 ORM 自动名称,保留不改。
- `biz_filling_record.production_date/expiry_date` 及日期 CHECK 已存在,保留现有数据。
- 库存、使用、退回、事件表的隧道关联未升级,使用记录 `line_id` 仍非空。
- 收货字段、FIFO 原因/快照不存在;隧道及收货菜单尚不存在。
这是一条**特定起点、单次执行、遇错即停**的升级路线,不是通用幂等修复器。生产结构有变化必须重新评估,不能删掉检查后强行执行。
## 文件顺序
以下路径相对生产服务器项目根目录,必须带齐五份 SQL:
1. `shierfeng-fastapi-backend/sql/patches/20261008_prod_existing_tunnel_repair_mysql.sql`:新增专用补丁,保留已存在隧道表,补12个关联字段、5个外键、可空产线、入隧事件和4行菜单。
2. `shierfeng-fastapi-backend/sql/patches/20261008_shipment_receipt_mysql.sql`:复用现有补丁,补收货和在途状态;旧确认单新增 `LEGACY` 标识,不改变旧库存、批号、编号和数量。
3. `shierfeng-fastapi-backend/sql/patches/20261008_usage_fifo_mysql.sql`:复用现有补丁,补可空原因及推荐快照。
4. `shierfeng-fastapi-backend/sql/checks/20261008_prod_material_flow_baseline.sql`:只读,前后原字段快照。
5. `shierfeng-fastapi-backend/sql/checks/20261008_prod_material_flow_verify.sql`:只读,迁移后验收。
**不要执行**原隧道补丁 `20261005_tunnel_material_flow_mysql.sql`(会重复建表),不要重复日期/测试批次补丁,不要执行全量 `shierfeng-fastapi.sql`。不要把这些补丁挂到 MySQL 初始化目录,也不要删除/更换 MySQL 数据卷来修复。
## 1. 审核目标与维护窗口(尚不改库)
进入生产服务器上实际部署的 Compose 目录,使用原部署的 `-f`、`--env-file`、`-p` 参数(如有),下文每条 `docker compose` 都必须保持同一套参数。不要改变项目名或数据卷名称。
```bash
docker compose ps
docker compose exec shierfeng-backend-my python -c "import os; print({k: os.getenv(k) for k in ['APP_ENV', 'DB_HOST', 'DB_PORT', 'DB_DATABASE']})"
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --execute="SELECT DATABASE(), VERSION(), @@hostname, @@port;"'
```
要求后端 `DB_HOST=shierfeng-mysql`、`DB_DATABASE=shierfeng-fastapi`,与 MySQL 查询一致。若使用外部数据库、不同服务名、空输出或其他库名,停止并调整方案,不能把命令照搬到另一个库。
命令利用现有 MySQL 容器环境中的密码,不将密码放进命令参数、终端输出或仓库;不要启用 shell `set -x`,也不要贴完整 `.env`/`docker compose config`。缺少 `MYSQL_ROOT_PASSWORD` 时请 DBA 使用受控凭据,不要把密码改写进文档。
获得维护窗口、停写和生产迁移审批,确认备份保留位置、恢复负责人、可接受恢复时间。先备好一致的新代码版本及上一个可用版本;本操作不执行拉取或构建。
## 2. 停写、备份与原数据基线
以下开始影响生产服务,必须在审批后的维护窗口由维护人员执行。通知用户退出操作,停止所有外部导入、定时任务、独立 worker 和其他写入来源。
```bash
docker compose stop shierfeng-frontend shierfeng-backend-my
docker compose ps
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --execute="SELECT ID, USER, HOST, DB, COMMAND, TIME FROM information_schema.PROCESSLIST WHERE DB=DATABASE() AND ID<>CONNECTION_ID(); SELECT EVENT_NAME, STATUS FROM information_schema.EVENTS WHERE EVENT_SCHEMA=DATABASE() AND STATUS=\"ENABLED\"; SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE=\"BASE TABLE\" AND ENGINE<>\"InnoDB\";"'
```
不得继续:仍有业务连接/事务或启用的事件,或存在非 InnoDB 表。不要擅自 kill 会话、禁用事件或转换表;由 DBA 查明并停写。MySQL/Redis 保持运行,不执行全栈停止或删除卷。
在同一个 Bash 会话保存备份路径,`/var/backups` 需有经批准的写入权限和足够磁盘空间;权限不足时停止,不自动改权限:
```bash
set -euo pipefail
umask 077
prod_backup_dir=$(mktemp -d /var/backups/shierfeng-prod-20261008.XXXXXX)
printf '生产备份目录:%s\n' "$prod_backup_dir"
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --user=root --single-transaction --quick --routines --events --triggers --hex-blob --set-gtid-purged=OFF --no-tablespaces --column-statistics=0 --default-character-set=utf8mb4 "$MYSQL_DATABASE"' > "$prod_backup_dir/before.sql"
test -s "$prod_backup_dir/before.sql"
sha256sum "$prod_backup_dir/before.sql" > "$prod_backup_dir/before.sha256"
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --batch --raw --skip-column-names' < shierfeng-fastapi-backend/sql/checks/20261008_prod_material_flow_baseline.sql > "$prod_backup_dir/original-fields.before.tsv"
```
备份不使用 `--databases`,避免把原库名的 `CREATE DATABASE/USE` 写入文件,便于在另一个隔离实例恢复。备份覆盖全部表和触发器、过程、事件;快照只用于比较本次7张关联业务表的全部原字段,不能代替完整备份。空业务库的快照可能为空,但完整备份不能为空。任何命令失败都停止,不能拿半份文件继续。
## 3. 恢复演练门禁
将备份复制到受控的**独立验收 MySQL 实例**,不是本机开发库,也不是业务生产库。由 DBA 建立空的同名 `shierfeng-fastapi` 库(新补丁核对库名;实例必须隔离),恢复完整备份并核对所有表及原字段快照,再按第4节顺序试跑三份脚本和第5节验收。
不得为演练启用应用写入,需检查恢复的存储过程/事件/触发器及其 DEFINER;演练实例应禁用事件调度。本交付没有该隔离实例的地址及凭据,因此不预填可能误指向生产的恢复命令。需 DBA 留存恢复目标、命令、成功结果、原字段一致性、耗时和脚本 SHA-256。
**没有完成恢复及补丁试跑,就不能进入生产执行。** 文件非空、哈希正确或 SQL 静态检查通过不等于恢复成功。迁移期间持续停写;若中途恢复业务,原备份/基线不再代表执行起点,必须重新安排窗口和备份。
## 4. 生产逐份执行(仍保持停写)
重新确认当前终端属于生产服务器、Compose 实例与第1节一致、备份已验证可恢复、三个脚本与演练使用版本哈希相同。`prod_backup_dir` 必须仍是第2节打印的目录;重新登录终端时由维护人员明确填入并核对,禁止猜测路径。
```bash
test -d "$prod_backup_dir"
sha256sum -c "$prod_backup_dir/before.sha256"
sha256sum shierfeng-fastapi-backend/sql/patches/20261008_prod_existing_tunnel_repair_mysql.sql shierfeng-fastapi-backend/sql/patches/20261008_shipment_receipt_mysql.sql shierfeng-fastapi-backend/sql/patches/20261008_usage_fifo_mysql.sql
```
**每次只执行一份,检查退出码和日志后才进入下一份。** 下列块依赖第2节 `set -euo pipefail`,禁止 `--force`、遇错继续的图形客户端及把三份脚本拼起来盲跑。建议在专用维护终端操作,保留退出状态。
```bash
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --show-warnings --init-command="SET @shierfeng_prod_migration_approved=1"' < shierfeng-fastapi-backend/sql/patches/20261008_prod_existing_tunnel_repair_mysql.sql > "$prod_backup_dir/01-tunnel.log" 2>&1
```
第一份应输出12个新增隧道字段和1个 `line_id=YES`,以及4个隧道菜单。检查 `01-tunnel.log`,无错误才执行:
```bash
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --show-warnings' < shierfeng-fastapi-backend/sql/patches/20261008_shipment_receipt_mysql.sql > "$prod_backup_dir/02-receipt.log" 2>&1
```
检查 `02-receipt.log`:历史 `CONFIRMED` 单为 `LEGACY`、`DRAFT` 为 `PENDING`,新增1119收货菜单;无错误才执行:
```bash
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --show-warnings' < shierfeng-fastapi-backend/sql/patches/20261008_usage_fifo_mysql.sql > "$prod_backup_dir/03-fifo.log" 2>&1
```
第三份成功不一定有结果集,必须确认退出码0且日志无错误,再做完整验收。这三份脚本都不允许重复执行。
## 5. 验收与恢复服务
在启动应用前执行:
```bash
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --batch --raw --skip-column-names' < shierfeng-fastapi-backend/sql/checks/20261008_prod_material_flow_baseline.sql > "$prod_backup_dir/original-fields.after.tsv"
cmp "$prod_backup_dir/original-fields.before.tsv" "$prod_backup_dir/original-fields.after.tsv"
docker compose exec -T shierfeng-mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql --user=root --database="$MYSQL_DATABASE" --default-character-set=utf8mb4 --table' < shierfeng-fastapi-backend/sql/checks/20261008_prod_material_flow_verify.sql > "$prod_backup_dir/verify.log"
```
全部验收条件:
- `cmp` 退出0,七张表全部原字段逐行一致,包含库存、批号、日期和历史事件。
- 验收文件25行字段(含原日期和产线)均为 `OK`,6行外键均为 `OK`;不能只看 mysql 命令退出码,因为 SELECT 会正常返回 `MISSING/MISMATCH`。
- 三个 CHECK 均存在且 `ENFORCED=YES`;库存枚举含 `IN_TRANSIT`,事件枚举含 `TUNNEL_ENTERED`、`SHIPMENT_RECEIVED`;收货唯一索引 `non_unique=0` 且列顺序为 `target_site_id,receipt_idempotency_key`。
- 恰有菜单520/1116/1117/1118/1119,父ID为121/520/520/520/510,权限分别为 `master:tunnel:list/query/add/edit` 和 `business:shipment:receive`。
- `unexpected_*` 和 `remaining_migration_routines` 均为0。历史日期保持原值;新隧道、快照、收货时间等历史字段为空。
任何一项失败都不要启动。确认运行镜像已是这次适配的新版本后,再恢复原容器(不改变卷/配置、不拉取新镜像):
```bash
docker compose start shierfeng-backend-my
docker compose ps
docker compose logs --since=5m --tail=100 shierfeng-backend-my
```
确认后端健康、没有缺列/约束错误,再执行:
```bash
docker compose start shierfeng-frontend
docker compose ps
```
使用超级管理员重新登录检查库存/使用/退回报表及隧道资料页面。新菜单不会自动授予普通角色,需要按既有角色管理配置菜单和现场范围,另行审批;不要直接修改角色关联表或清空 Redis。新流程用明确授权的测试数据验收,不用正式库存随意试扣。
前述 Web Crypto/HTTPS 问题与本迁移无关;此交付不修改加密开关、证书或反向代理。
## 失败与恢复原则
- 保持后端及其他写入来源停止,保留备份、日志和实际已落库结构。
- MySQL DDL 会隐式提交,`ROLLBACK` 不能撤销前面已完成的 ALTER;不能盲目重跑、跳过错误或用反向删列 SQL“回滚”。
- 存储过程报错后可能保留临时过程名称,这是失败现场的一部分;不要直接 DROP 后重试,先由 DBA 确定失败阶段。
- 由 DBA 依据实际提交状态决定经过审核的续修,或从已验证完整备份恢复,并匹配已验证的应用版本。恢复目标、方式及可能覆盖的数据必须再次确认,文档不提供自动覆盖生产库命令。
- 不删除数据卷,不重跑初始化,不把开发库覆盖到生产;备份和快照需转存到受控备份系统,遵循保留期限,不提交仓库。
## 本次生成的验证边界
本轮仅执行不连接数据库的离线 SQL 契约检查和文件自检:6项契约检查通过,10段 Shell 命令经 `bash -n` 语法检查;非空历史字段、误写角色表、误删隧道表3种内存变异均被契约检查拒绝,未把变异写入文件。当前 SQLGlot 不能完整解析存储过程及部分 MySQL ALTER 语法,因此检查采用新增列/查询的 AST 加原 ALTER token 契约,不能称为完整 MySQL 语法验证。
没有执行 MySQL 存储过程、DDL、DML、备份恢复或生产验收;静态检查不能证明这些运行操作成功。MySQL/Compose 操作仍需第3节的独立恢复演练及生产审批。
@@ -3,6 +3,11 @@
-- 脚本包含 DROP TABLE,会删除目标库中同名表及其数据;禁止直接用于已有业务库。
-- 已有数据库请先完成备份和结构核对,再按 sql/patches/ 下对应的 MySQL 增量补丁执行。
-- Docker Compose 仅会在 MySQL 数据卷首次为空时执行本文件。
-- 当前已内置截至 2026-10-08 的测试批次、隧道关联、物料日期、收货和 FIFO 结构及菜单。
-- 新空库执行本文件后,无需再执行对应旧增量补丁;不要在本文件末尾重复追加 ALTER。
-- 含 biz_tunnel/物料日期但缺关联字段的已有生产库,按本目录
-- production-compose-material-flow-migration-20261008.md 使用专用补丁,不运行本文件。
-- 初始化与增量升级的选择见本目录 README.md。
-- ============================================================================
-- ----------------------------
@@ -0,0 +1,54 @@
# 测试数据批次操作说明
测试数据采用批次隔离。正式数据的 `test_batch_id` 为 `NULL`,测试数据必须归属于一个 `biz_test_batch` 批次。系统不会因为安装迁移脚本而自动把历史数据标记为测试数据;历史数据需要由超级管理员逐项预览并纳入批次。
## 权限边界
- 批次创建、编辑、授权、关闭/重新开启、旧数据纳入和批次清理均由后端校验超级管理员身份。
- 普通用户不能通过前端隐藏、菜单权限或直接调用接口绕过后端校验。
- 普通用户只能看到被授权且状态为 `ACTIVE` 的批次,并在 PDA 或 Web 测试模式中使用该批次。
- 物理删除不再通过菌种、客户、现场、产线、仓库、托盘、入库和发货页面提供。清理必须以批次为单位执行。
## 上线前准备
1. 先备份数据库,并确认当前没有正在进行的入库、装填、发货、使用或退回操作。
2. MySQL 执行 `shierfeng-fastapi-backend/sql/patches/20260922_test_data_batch_mysql.sql`。
3. 迁移完成后检查 `biz_test_batch`、`biz_test_batch_user` 和各业务表的 `test_batch_id` 字段及外键。
4. 配置在线清理上限。默认 `TEST_BATCH_ONLINE_PURGE_MAX_ROWS=0`,表示禁止在线清理;只有明确配置为正整数且批次影响总数不超过上限时,管理页才允许执行清理。预览令牌默认有效期为 `TEST_BATCH_PREVIEW_TTL_SECONDS=600` 秒。
迁移脚本是一次性增量脚本,重复执行前必须由数据库管理员确认目标表和字段是否已经存在。本文档不替代备份、变更审批和回滚预案。
## 新建并授权批次
1. 超级管理员登录 Web 管理端,进入“系统管理 -> 测试数据批次”,创建批次并记录批次名称。
2. 在“用户”操作中填写需要参与测试的用户 ID。保存时会完整替换该批次的授权用户列表;留空表示取消全部授权。
3. 用户在 PDA 工作台选择“测试”并选择已授权的 ACTIVE 批次。切换模式会重新打开工作台,清空当前页面未提交的表单和扫码状态。
4. 测试模式下的请求自动携带 `X-Test-Batch-Id`。批次关闭、清理、撤销授权或失效后,客户端会清除本地批次并回到正式模式。
## 将历史数据纳入批次
历史数据默认仍是正式数据。只有超级管理员可以在菌种、客户、现场、产线、仓库、托盘以及五类业务页面选择根记录,点击“纳入测试批次”。
纳入操作分为两步:
1. **预览**:系统根据根记录计算完整关联闭包,展示将被标记的记录和数量,并生成短时有效的预览令牌。
2. **执行**:管理员核对批次名称和影响范围后提交。服务端会重新加锁并计算闭包;范围变化、记录已归属于其他批次、批次关闭或令牌过期时,整个操作失败并回滚。
不要直接修改业务表的 `test_batch_id`。这样会跳过关联闭包、并发校验和审计记录,可能造成跨批次数据无法清理。
## 关闭与清理批次
1. 批次完成测试后先点击“关闭”。关闭后不能继续写入该批次,但可以重新开启;重新开启会使已有的清理预览失效。
2. 对 CLOSED 批次点击“清理”,先查看预览中的分类、数量和编号。
3. 只有在线清理上限允许时,输入完整批次名称并确认执行。服务端会校验预览令牌、批次名称、影响范围和当前行数,然后按依赖顺序在一个事务中删除批次闭包。
4. 成功后批次状态变为 `PURGED`,保留清理时间、执行人和数量摘要;已清理批次不可重新开启或再次写入。
清理是不可逆的数据删除操作。影响总数超过在线上限时,应按运维审批流程执行离线清理,不要通过 SQL 手工绕过系统约束。
## 验证清单
- 正式模式看不到 `test_batch_id` 非空的测试记录。
- 普通用户无法看到批次管理页的管理操作,也无法调用纳入和清理接口。
- PDA 测试模式的请求带有正确批次 ID,退出登录后本地批次缓存被清除。
- 关闭批次后新建和确认操作被拒绝;清理预览与实际执行之间的数据变化会使执行失败而不是误删。
- 清理完成后,批次闭包中的主数据、单据、明细、库存和事件记录数量与审计摘要一致。