diff --git a/Jenkinsfile b/Jenkinsfile index 5a1264f5c..e0f8a152a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -56,6 +56,8 @@ def commonPathServices = [ ] def databaseMigrationPaths = ['deploy/sql/'] +// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。 +def databaseSchemaContractEnabled = false def downstreamResults = [:] def downstreamBuildUrls = [:] @@ -369,6 +371,28 @@ pipeline { } } + stage('数据库结构只读门禁') { + when { + expression { databaseSchemaContractEnabled } + } + agent { label 'java-builder' } + steps { + script { + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly" + withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) { + sh ''' + set -eu + set -a + . "$SCHEMA_CHECK_ENV_FILE" + set +a + test "${SCHEMA_CHECK_READ_ONLY:-}" = "true" + node scripts/database-schema-contract.mjs --region BOTH + ''' + } + } + } + } + stage('统一 Maven 测试门禁') { when { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml index 8174995ba..1c0753a99 100644 --- a/auth/src/main/resources/application-dev.yml +++ b/auth/src/main/resources/application-dev.yml @@ -1,4 +1,8 @@ spring: + cloud: + nacos: + discovery: + ip: ${NACOS_DISCOVERY_IP:127.0.0.1} datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index 3713986d1..f27f21ff9 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -84,7 +84,7 @@ public class BaiyueSecurityAutoConfiguration { .exceptionHandling(errors -> errors .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/api/pda-updates/**").permitAll() .requestMatchers("/internal/**").permitAll() .requestMatchers("/openapi/**").permitAll() .requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT") diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4b703a3c0..f33ade8d6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -163,6 +163,7 @@ services: FILE_DB_USERNAME: ${FILE_DB_USERNAME} FILE_DB_PASSWORD: ${FILE_DB_PASSWORD} FILE_STORAGE_ROOT: /data/files + PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-} JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f82584788..5cc03e831 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -62,7 +62,16 @@ ## 后续迁移工具(已开始实施) -机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量转换成迁移链。当前已登记一份生产尾程结构补齐迁移 `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`,由 `scripts/prod-database-schema-acceptance.mjs` 默认只读核对,只有显式确认参数才执行;它只新增字段、放宽生成后才确定字段的可空性并创建缺失表,不执行 DML。 +机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量自动转换成迁移链。现有候选迁移包括: + +| 区域 / schema | 迁移 | 目的 | +|---|---|---| +| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 | +| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 | +| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql` | 补齐 PDA 发布表和固定渠道,收敛文件业务类型长度 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql` | 补齐尾程字段并收敛字段定义和排序规则;收紧前检查存量数据 | + +上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql new file mode 100644 index 000000000..da1641e0b --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql @@ -0,0 +1,59 @@ +-- Region: CN +-- Schema: baiyue_file +-- Purpose: 补齐 PDA 发布表,并将文件业务类型长度收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_file;既有 business_type 不超过 32 字符。 +-- Postconditions: PDA 发布表及固定渠道存在,file_object.business_type 为 varchar(32)。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM file_object WHERE CHAR_LENGTH(business_type) > 32 LIMIT 1), + 'SELECT 1 FROM migration_blocked_file_business_type_exceeds_32_chars', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'file_object' AND column_name = 'business_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'YES')), + 'ALTER TABLE file_object MODIFY COLUMN business_type varchar(32) DEFAULT NULL COMMENT ''关联业务类型''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS file_pda_release_channel ( + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + current_apk_id char(36) DEFAULT NULL, + current_wgt_id char(36) DEFAULT NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针'; + +CREATE TABLE IF NOT EXISTS file_pda_release ( + id char(36) NOT NULL, + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + package_type varchar(8) NOT NULL, + version_name varchar(32) NOT NULL, + version_code int unsigned NOT NULL, + min_app_version_code int unsigned NOT NULL, + mandatory tinyint(1) NOT NULL DEFAULT '0', + release_notes varchar(1000) NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'DRAFT', + object_key varchar(128) NOT NULL, + sha256 char(64) NOT NULL, + size_bytes bigint unsigned NOT NULL, + created_by bigint unsigned NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at datetime(3) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name), + KEY idx_pda_release_created (app_code,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史'; + +INSERT INTO file_pda_release_channel (app_code,app_id) +SELECT 'domestic','__UNI__9BFFAF2' +WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'domestic'); + +INSERT INTO file_pda_release_channel (app_code,app_id) +SELECT 'overseas','__UNI__936D079' +WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'overseas'); diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql new file mode 100644 index 000000000..3ae25fb1d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql @@ -0,0 +1,21 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 将一件代发作业托盘码可空性收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_firstleg;fl_dropship_pallet.pallet_no 不存在 NULL。 +-- Postconditions: pallet_no 为 varchar(32) NOT NULL。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM fl_dropship_pallet WHERE pallet_no IS NULL LIMIT 1), + 'SELECT 1 FROM migration_blocked_firstleg_pallet_no_contains_null', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_dropship_pallet' AND column_name = 'pallet_no' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO')), + 'ALTER TABLE fl_dropship_pallet MODIFY COLUMN pallet_no varchar(32) NOT NULL COMMENT ''现场扫描的系统DP托盘码''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql new file mode 100644 index 000000000..e6005ed14 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql @@ -0,0 +1,43 @@ +-- Region: CN +-- Schema: baiyue_iam +-- Purpose: 将客户编号、昵称和资质原文件名定义收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_iam;待收紧字段不存在 NULL,tenant_code 不超过 8 字符。 +-- Postconditions: 三个字段的类型和可空性与头程统一初始化 SQL 一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant WHERE customer_nickname IS NULL LIMIT 1), + 'SELECT 1 FROM migration_blocked_iam_customer_nickname_contains_null', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant WHERE CHAR_LENGTH(tenant_code) > 8 LIMIT 1), + 'SELECT 1 FROM migration_blocked_iam_tenant_code_exceeds_8_chars', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant_qualification WHERE original_file_name IS NULL LIMIT 1), + 'SELECT 1 FROM migration_blocked_iam_original_file_name_contains_null', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'tenant_code' AND (column_type <> 'varchar(8)' OR is_nullable <> 'NO')) + OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'customer_nickname' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO')), + 'ALTER TABLE iam_tenant MODIFY COLUMN tenant_code varchar(8) NOT NULL COMMENT ''客户编号:企业客户固定五位数字,平台技术主体为PLATFORM'', MODIFY COLUMN customer_nickname varchar(32) NOT NULL COMMENT ''客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant_qualification' AND column_name = 'original_file_name' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO')), + 'ALTER TABLE iam_tenant_qualification MODIFY COLUMN original_file_name varchar(255) NOT NULL COMMENT ''上传时原始文件名''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql new file mode 100644 index 000000000..b8369fedf --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql @@ -0,0 +1,70 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 补齐测试与生产各自缺失的尾程字段,并将字段定义收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_lastleg;待收紧字段不存在 NULL,review_decision 不超过 16 字符。 +-- Postconditions: 本文件涉及的字段集合、类型、默认值、可空性和测量照片表排序规则与海外统一初始化 SQL 一致。 + +SET NAMES utf8mb4; + +-- 收紧测试库漂移字段前先阻断不满足目标定义的数据,不执行自动回填或清理。 +SET @guard = IF(EXISTS (SELECT 1 FROM ll_delivery_order WHERE delivery_group_key IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_delivery_group_key_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_local_looseboard_order WHERE container_type IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_looseboard_container_type_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_us_truck_order WHERE container_type IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_truck_container_type_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_outbound_pda_pick WHERE inventory_position_id IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_inventory_position_id_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE new_sku_code IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_new_sku_code_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE source_warehouse_id IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_source_warehouse_id_null', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_pda_task WHERE CHAR_LENGTH(review_decision) > 16 LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_review_decision_exceeds_16_chars', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 生产环境当前缺失、测试已存在的换标与退货字段。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')) + OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), + 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + (SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci', + 'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9bc6efa61..d4f4fcce5 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4449,6 +4449,39 @@ CREATE TABLE file_object ( KEY idx_file_tenant_created (tenant_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件元数据表'; +CREATE TABLE file_pda_release_channel ( + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + current_apk_id char(36) DEFAULT NULL, + current_wgt_id char(36) DEFAULT NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针'; + +INSERT INTO file_pda_release_channel (app_code,app_id) VALUES +('domestic','__UNI__9BFFAF2'),('overseas','__UNI__936D079'); + +CREATE TABLE file_pda_release ( + id char(36) NOT NULL, + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + package_type varchar(8) NOT NULL, + version_name varchar(32) NOT NULL, + version_code int unsigned NOT NULL, + min_app_version_code int unsigned NOT NULL, + mandatory tinyint(1) NOT NULL DEFAULT '0', + release_notes varchar(1000) NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'DRAFT', + object_key varchar(128) NOT NULL, + sha256 char(64) NOT NULL, + size_bytes bigint unsigned NOT NULL, + created_by bigint unsigned NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at datetime(3) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name), + KEY idx_pda_release_created (app_code,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史'; + -- 服务 schema:baiyue_audit CREATE DATABASE IF NOT EXISTS baiyue_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_audit; @@ -6004,6 +6037,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101034,0,'PLATFORM','BUTTON',5101030,'platform:permission:delete','删除权限资源',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101040,0,'PLATFORM','MENU',5101000,'platform:member:list','平台成员',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101041,0,'PLATFORM','BUTTON',5101040,'platform:member:grant-role','分配成员角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101060,0,'PLATFORM','MENU',5101000,'platform:pda-release:list','PDA版本','/system/pda-releases',NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101061,0,'PLATFORM','BUTTON',5101060,'platform:pda-release:manage','发布PDA版本',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102000,0,'PLATFORM','MENU',0,'platform:order','头程订单','/first-leg-orders',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102010,0,'PLATFORM','MENU',5102000,'platform:order:list','订单列表','/first-leg-orders/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102011,0,'PLATFORM','BUTTON',5102010,'platform:order:approve','订单审核通过',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6023,6 +6058,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102025,0,'PLATFORM','BUTTON',5102010,'platform:order:submit','平台代客提交订单',NULL,NULL,110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102026,0,'PLATFORM','BUTTON',5102010,'platform:order:export','导出订单列表',NULL,NULL,120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102027,0,'PLATFORM','BUTTON',5102010,'platform:order:declaration:edit','修改订单申报与运营预估',NULL,NULL,130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102028,0,'PLATFORM','BUTTON',5102010,'platform:order:import','导入旧系统订单',NULL,NULL,140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102030,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:list','入库单','/inbounds','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102031,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:view','查看入库单详情',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold','扣货与解除扣货',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6328,7 +6364,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6380,6 +6416,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101034,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101040,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101061,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102011,0,CURRENT_TIMESTAMP(3)); @@ -6399,6 +6437,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102025,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102028,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102030,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102032,0,CURRENT_TIMESTAMP(3)); @@ -6676,6 +6715,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102025,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102028,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 03d53351d..82bf8d971 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2314,6 +2314,7 @@ CREATE TABLE ll_relabel_pda_unit_fact ( result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码', reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因', + quantity int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1', operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', occurred_at datetime(3) NOT NULL COMMENT '逐件事实发生时间', @@ -2360,6 +2361,7 @@ CREATE TABLE ll_return_inventory_carton ( source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID', source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', @@ -2382,8 +2384,8 @@ CREATE TABLE ll_return_inventory_carton ( UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), - CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), - CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; CREATE TABLE ll_return_inventory_carton_item ( @@ -2432,6 +2434,8 @@ CREATE TABLE ll_return_order ( expected_quantity bigint NOT NULL COMMENT '预报总数量', received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注', + discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因', + manually_completed_at datetime(3) DEFAULT NULL COMMENT '手动差异完结时间', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID', @@ -2466,6 +2470,7 @@ CREATE TABLE ll_return_receipt ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号', bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', diff --git a/file/AGENTS.md b/file/AGENTS.md index 063fc77f7..7d16583d5 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -26,6 +26,7 @@ | 派送单三类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;依次为装车照片 `DELIVERY_LOADING_PHOTO`、BOL文件(含签名)`DELIVERY_BOL`、签收文件(POD签收/快递签收)`DELIVERY_RECEIPT`,按客户、派送内部ID、类别隔离,任意格式单文件50MiB;Lastleg持有分类绑定、保存、替换删除和历史可见性,按非空类别计数x/3,不强制必传或识别签名;File保留不可变对象与真实createdAt | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | +| 国内/海外 PDA 版本 | `controller/PdaReleaseController.java`、`service/PdaReleaseService.java`、`service/PdaPackageStorage.java`、`src/main/resources/openapi/pda-releases-v1.yaml`;平台上传草稿、发布和停发,匿名清单与包下载只暴露当前已发布/历史未停发文件;国内和海外 AppID、指针独立 | | 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` | ## 修改约束 diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 64cac7d87..2e1e1b5bd 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -44,6 +44,15 @@ public class InternalBusinessFileController { return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file)); } + @GetMapping("/{tenantId}/{businessType}/{businessId}") + public ApiResponse> list( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId) { + check(token); checkTenantId(tenantId); + return ApiResponse.success(service.list(tenantId, businessType, businessId)); + } + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse metadata( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java b/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java new file mode 100644 index 000000000..33dab387c --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java @@ -0,0 +1,88 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.PdaReleaseService; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +@RestController +public class PdaReleaseController { + private final PdaReleaseService releases; + + public PdaReleaseController(PdaReleaseService releases) { this.releases = releases; } + + @GetMapping("/api/platform/v1/files/pda-releases/{app}") + @PreAuthorize("hasAuthority('platform:pda-release:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String app) { + requirePlatform(user); + return ApiResponse.success(releases.list(app)); + } + + @PostMapping(value = "/api/platform/v1/files/pda-releases/{app}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String app, @RequestParam String packageType, @RequestParam String versionName, + @RequestParam int versionCode, @RequestParam int minAppVersionCode, + @RequestParam(defaultValue = "false") boolean mandatory, @RequestParam(defaultValue = "") String releaseNotes, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(releases.upload(app, packageType, versionName, versionCode, + minAppVersionCode, mandatory, releaseNotes, file, requirePlatform(user))); + } + + @PostMapping("/api/platform/v1/files/pda-releases/{id}/publish") + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse publish(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String id) { + requirePlatform(user); + return ApiResponse.success(releases.publish(id)); + } + + @PostMapping("/api/platform/v1/files/pda-releases/{id}/withdraw") + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse withdraw(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String id) { + requirePlatform(user); + return ApiResponse.success(releases.withdraw(id)); + } + + @GetMapping("/api/pda-updates/{app}/latest") + public Map latest(@PathVariable String app, @RequestParam String appid, + @RequestParam String platform, @RequestParam int appVersionCode, @RequestParam String wgtVersion) { + return releases.latest(app, appid, platform, appVersionCode, wgtVersion); + } + + @GetMapping("/api/pda-updates/{app}/{id}/package") + public ResponseEntity download(@PathVariable String app, @PathVariable String id) { + PdaReleaseService.PackageFile file = releases.packageFile(app, id); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(file.sizeBytes()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(file.resource()); + } + + private static long requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份"); + } + return user.userId(); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index b82ed4fd8..2bc77a24b 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.util.List; @Service public class InternalBusinessFileService { @Resource private FileObjectMapper files; @@ -52,6 +53,16 @@ public class InternalBusinessFileService { return metadata(required(tenantId, businessType, businessId, fileId)); } + public List list(long tenantId, String businessType, String businessId) { + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); category.storageBusinessId(businessId); + return files.selectList(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getBusinessType, businessType) + .eq(FileObjectEntity::getBusinessId, businessId).eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false).orderByAsc(FileObjectEntity::getCreatedAt, FileObjectEntity::getId)) + .stream().map(InternalBusinessFileService::metadata).toList(); + } + public Download download(long tenantId, String businessType, String businessId, long fileId) { FileObjectEntity value = required(tenantId, businessType, businessId, fileId); return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); diff --git a/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java b/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java new file mode 100644 index 000000000..9f7f3df5f --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java @@ -0,0 +1,84 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.zip.ZipFile; + +@Component +public class PdaPackageStorage { + private static final long MAX_BYTES = 200L * 1024 * 1024; + private final Path root; + + public PdaPackageStorage(@Value("${baiyue.file.storage-root}") String storageRoot) { + root = Path.of(storageRoot).toAbsolutePath().normalize().resolve("pda-packages"); + } + + public record Stored(String key, long sizeBytes, String sha256) { } + + public Stored store(String app, String id, String type, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) { + throw new BizException(ResultCode.BAD_REQUEST, "更新包为空或超过 200MB"); + } + String name = file.getOriginalFilename(); + if (name == null || !name.toLowerCase().endsWith("." + type)) { + throw new BizException(ResultCode.BAD_REQUEST, "文件扩展名与包类型不符"); + } + String key = app + "/" + id + "." + type; + Path target = path(key); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + try (ZipFile zip = new ZipFile(target.toFile())) { + String required = type.equals("apk") ? "AndroidManifest.xml" : "manifest.json"; + if (zip.getEntry(required) == null) throw new IOException("missing " + required); + } + return new Stored(key, Files.size(target), sha256(target)); + } catch (IOException | IllegalStateException exception) { + delete(key); + throw new BizException(ResultCode.BAD_REQUEST, "更新包不是有效的 APK/WGT 文件"); + } + } + + public Resource load(String key) { + Path file = path(key); + if (!Files.isRegularFile(file)) throw new BizException(ResultCode.NOT_FOUND, "更新包文件不存在"); + return new PathResource(file); + } + + public void delete(String key) { + try { Files.deleteIfExists(path(key)); } catch (IOException ignored) { } + } + + private Path path(String key) { + if (key == null || !key.matches("(domestic|overseas)/[a-f0-9-]{36}\\.(apk|wgt)")) { + throw new BizException(ResultCode.BAD_REQUEST, "更新包路径无效"); + } + Path path = root.resolve(key).normalize(); + if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "更新包路径越界"); + return path; + } + + private static String sha256(Path file) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java b/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java new file mode 100644 index 000000000..f5468b559 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java @@ -0,0 +1,229 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +@Service +public class PdaReleaseService { + private final JdbcTemplate jdbc; + private final PdaPackageStorage storage; + private final String publicBaseUrl; + private final String domesticAppId; + private final String overseasAppId; + + public PdaReleaseService(JdbcTemplate jdbc, PdaPackageStorage storage, + @Value("${baiyue.file.pda.public-base-url:}") String publicBaseUrl, + @Value("${baiyue.file.pda.domestic-appid:__UNI__9BFFAF2}") String domesticAppId, + @Value("${baiyue.file.pda.overseas-appid:__UNI__936D079}") String overseasAppId) { + this.jdbc = jdbc; + this.storage = storage; + this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", ""); + this.domesticAppId = domesticAppId; + this.overseasAppId = overseasAppId; + if (!this.publicBaseUrl.isEmpty() && !this.publicBaseUrl.startsWith("https://")) { + throw new IllegalArgumentException("PDA_UPDATE_PUBLIC_BASE_URL must use HTTPS"); + } + } + + public record Release(String id, String app, String appid, String packageType, String versionName, + int versionCode, int minAppVersionCode, boolean mandatory, String releaseNotes, + String status, boolean current, String sha256, long sizeBytes, String downloadUrl, + LocalDateTime createdAt, LocalDateTime publishedAt) { } + public record PackageFile(Resource resource, long sizeBytes, String fileName) { } + + private record Row(String id, String app, String appid, String type, String name, int code, + int minCode, boolean mandatory, String notes, String status, + String key, String sha256, long bytes, LocalDateTime createdAt, + LocalDateTime publishedAt) { } + private record Channel(String appid, String apkId, String wgtId) { } + + public Release upload(String app, String type, String versionName, int versionCode, + int minAppVersionCode, boolean mandatory, String notes, + MultipartFile file, long operatorId) { + String appid = appid(app); + type = type == null ? "" : type.toLowerCase(); + if (!type.equals("apk") && !type.equals("wgt")) throw bad("只支持 APK 或 WGT"); + if (versionName == null || versionName.length() > 32 + || !versionName.matches("\\d+(\\.\\d+)+")) throw bad("版本号格式无效"); + if (versionCode < 1 || minAppVersionCode < 1 + || (type.equals("apk") && versionCode != minAppVersionCode)) { + throw bad("APK 版本号或 WGT 最低基座版本无效"); + } + if (notes != null && notes.length() > 1000) throw bad("更新说明过长"); + String id = UUID.randomUUID().toString(); + PdaPackageStorage.Stored stored = storage.store(app, id, type, file); + try { + jdbc.update(""" + INSERT INTO file_pda_release + (id,app_code,app_id,package_type,version_name,version_code,min_app_version_code, + mandatory,release_notes,status,object_key,sha256,size_bytes,created_by) + VALUES (?,?,?,?,?,?,?,?,?,'DRAFT',?,?,?,?) + """, id, app, appid, type, versionName, versionCode, minAppVersionCode, + mandatory, notes == null ? "" : notes, stored.key(), stored.sha256(), + stored.sizeBytes(), operatorId); + } catch (RuntimeException exception) { + storage.delete(stored.key()); + throw exception; + } + return view(get(id)); + } + + @Transactional + public Release publish(String id) { + Row row = get(id); + if (!row.status().equals("DRAFT")) throw conflict("只有草稿可以发布"); + if (publicBaseUrl.isEmpty()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置"); + } + Channel channel = channel(row.app(), true); + if (!Objects.equals(channel.appid(), row.appid())) throw conflict("应用 AppID 已改变,请重新上传"); + Row current = current(row.type().equals("apk") ? channel.apkId() : channel.wgtId()); + if (current != null && (row.type().equals("apk") + ? row.code() <= current.code() + : compareVersion(row.name(), current.name()) <= 0)) { + throw conflict("新版本必须高于当前发布版本"); + } + if (row.type().equals("wgt")) { + Row apk = current(channel.apkId()); + if (apk == null || row.minCode() > apk.code()) throw conflict("请先发布兼容的 APK 基座"); + } + storage.load(row.key()); + if (jdbc.update("UPDATE file_pda_release SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='DRAFT'", id) != 1) { + throw conflict("版本状态已改变"); + } + String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id"; + jdbc.update("UPDATE file_pda_release_channel SET " + column + "=? WHERE app_code=?", id, row.app()); + return view(get(id)); + } + + @Transactional + public Release withdraw(String id) { + Row row = get(id); + Channel channel = channel(row.app(), true); + if (!row.status().equals("PUBLISHED")) throw conflict("只有已发布版本可以停发"); + String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id"; + String currentId = row.type().equals("apk") ? channel.apkId() : channel.wgtId(); + if (id.equals(currentId)) { + jdbc.update("UPDATE file_pda_release_channel SET " + column + "=NULL WHERE app_code=?", row.app()); + } + jdbc.update("UPDATE file_pda_release SET status='WITHDRAWN' WHERE id=?", id); + return view(get(id)); + } + + public List list(String app) { + appid(app); + Channel channel = channel(app, false); + List rows = jdbc.query("SELECT * FROM file_pda_release WHERE app_code=? ORDER BY created_at DESC LIMIT 100", + (rs, index) -> map(rs), app); + return rows.stream().map(row -> view(row, channel)).toList(); + } + + public Map latest(String app, String requestedAppid, String platform, + int appVersionCode, String wgtVersion) { + if (!appid(app).equals(requestedAppid) || !"android".equalsIgnoreCase(platform) + || appVersionCode < 1 || wgtVersion == null || !wgtVersion.matches("\\d+(\\.\\d+)+")) { + throw bad("更新检查参数无效"); + } + Channel channel = channel(app, false); + Row apk = current(channel.apkId()); + if (apk != null && apk.code() > appVersionCode) return manifest(apk); + Row wgt = current(channel.wgtId()); + if (wgt != null && wgt.minCode() <= appVersionCode + && compareVersion(wgt.name(), wgtVersion) > 0) return manifest(wgt); + return Map.of("update", false); + } + + public PackageFile packageFile(String app, String id) { + Row row = get(id); + if (!row.app().equals(app) || !row.status().equals("PUBLISHED")) { + throw new BizException(ResultCode.NOT_FOUND, "更新包不存在"); + } + return new PackageFile(storage.load(row.key()), row.bytes(), + row.app() + "-pda-" + row.name() + "." + row.type()); + } + + private Map manifest(Row row) { + if (publicBaseUrl.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置"); + return Map.of("update", true, "appid", row.appid(), "packageType", row.type(), + "versionName", row.name(), "versionCode", row.code(), "minAppVersionCode", row.minCode(), + "mandatory", row.mandatory(), "releaseNotes", row.notes(), "downloadUrl", url(row)); + } + + private Release view(Row row) { + return view(row, channel(row.app(), false)); + } + + private Release view(Row row, Channel channel) { + boolean current = row.id().equals(row.type().equals("apk") ? channel.apkId() : channel.wgtId()); + return new Release(row.id(), row.app(), row.appid(), row.type(), row.name(), row.code(), + row.minCode(), row.mandatory(), row.notes(), row.status(), current, row.sha256(), row.bytes(), + row.status().equals("PUBLISHED") && !publicBaseUrl.isEmpty() ? url(row) : null, + row.createdAt(), row.publishedAt()); + } + + private String url(Row row) { + return publicBaseUrl + "/api/pda-updates/" + row.app() + "/" + row.id() + "/package"; + } + + private Channel channel(String app, boolean locked) { + String sql = "SELECT app_id,current_apk_id,current_wgt_id FROM file_pda_release_channel WHERE app_code=?" + + (locked ? " FOR UPDATE" : ""); + List rows = jdbc.query(sql, (rs, index) -> + new Channel(rs.getString(1), rs.getString(2), rs.getString(3)), app); + if (rows.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA 发布通道未初始化"); + return rows.get(0); + } + + private Row current(String id) { return id == null ? null : get(id); } + + private Row get(String id) { + List rows = jdbc.query("SELECT * FROM file_pda_release WHERE id=?", (rs, index) -> map(rs), id); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "PDA 版本不存在"); + return rows.get(0); + } + + private String appid(String app) { + return switch (app) { + case "domestic" -> domesticAppId; + case "overseas" -> overseasAppId; + default -> throw bad("PDA 应用类型无效"); + }; + } + + private static Row map(ResultSet rs) throws SQLException { + return new Row(rs.getString("id"), rs.getString("app_code"), rs.getString("app_id"), + rs.getString("package_type"), rs.getString("version_name"), rs.getInt("version_code"), + rs.getInt("min_app_version_code"), rs.getBoolean("mandatory"), rs.getString("release_notes"), + rs.getString("status"), rs.getString("object_key"), rs.getString("sha256"), rs.getLong("size_bytes"), + rs.getTimestamp("created_at").toLocalDateTime(), + rs.getTimestamp("published_at") == null ? null : rs.getTimestamp("published_at").toLocalDateTime()); + } + + private static int compareVersion(String left, String right) { + String[] a = left.split("\\."); + String[] b = right.split("\\."); + for (int i = 0; i < Math.max(a.length, b.length); i++) { + int x = i < a.length ? Integer.parseInt(a[i]) : 0; + int y = i < b.length ? Integer.parseInt(b[i]) : 0; + if (x != y) return Integer.compare(x, y); + } + return 0; + } + + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index ef4179c70..be91f53d6 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -40,8 +40,8 @@ spring: read-timeout: 15000 servlet: multipart: - max-file-size: 50MB - max-request-size: 51MB + max-file-size: 200MB + max-request-size: 201MB rocketmq: name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} producer: @@ -60,6 +60,10 @@ baiyue: url: ${FINANCE_SERVICE_URL:} file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} + pda: + public-base-url: ${PDA_UPDATE_PUBLIC_BASE_URL:} + domestic-appid: ${PDA_DOMESTIC_APPID:__UNI__9BFFAF2} + overseas-appid: ${PDA_OVERSEAS_APPID:__UNI__936D079} price-book: max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760} export: diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 17c8d2f4d..45f809085 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -28,6 +28,40 @@ CREATE TABLE IF NOT EXISTS file_object ( KEY idx_file_tenant_created (tenant_id, created_at) ) ENGINE=InnoDB COMMENT='文件元数据表'; +CREATE TABLE IF NOT EXISTS file_pda_release_channel ( + app_code VARCHAR(16) NOT NULL, + app_id VARCHAR(64) NOT NULL, + current_apk_id CHAR(36) NULL, + current_wgt_id CHAR(36) NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB COMMENT='国内/海外PDA当前发布指针'; + +INSERT INTO file_pda_release_channel (app_code, app_id) VALUES + ('domestic', '__UNI__9BFFAF2'), ('overseas', '__UNI__936D079') +ON DUPLICATE KEY UPDATE app_id=VALUES(app_id); + +CREATE TABLE IF NOT EXISTS file_pda_release ( + id CHAR(36) NOT NULL, + app_code VARCHAR(16) NOT NULL, + app_id VARCHAR(64) NOT NULL, + package_type VARCHAR(8) NOT NULL, + version_name VARCHAR(32) NOT NULL, + version_code INT UNSIGNED NOT NULL, + min_app_version_code INT UNSIGNED NOT NULL, + mandatory TINYINT(1) NOT NULL DEFAULT 0, + release_notes VARCHAR(1000) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', + object_key VARCHAR(128) NOT NULL, + sha256 CHAR(64) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code, package_type, version_name), + KEY idx_pda_release_created (app_code, created_at) +) ENGINE=InnoDB COMMENT='PDA安装包与热更新发布历史'; + CREATE TABLE IF NOT EXISTS file_download_token ( id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/file/src/main/resources/openapi/pda-releases-v1.yaml b/file/src/main/resources/openapi/pda-releases-v1.yaml new file mode 100644 index 000000000..eae49d46c --- /dev/null +++ b/file/src/main/resources/openapi/pda-releases-v1.yaml @@ -0,0 +1,77 @@ +openapi: 3.0.3 +info: + title: PDA version releases + version: 1.0.0 + description: Domestic and overseas PDA packages have independent AppIDs and publication pointers. +paths: + /api/platform/v1/files/pda-releases/{app}: + parameters: + - $ref: '#/components/parameters/app' + get: + summary: List recent releases for one PDA application + security: [{ bearerAuth: [] }] + description: Requires platform:pda-release:list and PLATFORM membership. + responses: + '200': { description: ApiResponse containing release records and current flags } + post: + summary: Upload a draft APK or WGT (does not publish) + security: [{ bearerAuth: [] }] + description: Requires platform:pda-release:manage and PLATFORM membership. Maximum 200 MiB. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [packageType, versionName, versionCode, minAppVersionCode, file] + properties: + packageType: { type: string, enum: [apk, wgt] } + versionName: { type: string, example: 0.0.4 } + versionCode: { type: integer, minimum: 1 } + minAppVersionCode: { type: integer, minimum: 1 } + mandatory: { type: boolean } + releaseNotes: { type: string, maxLength: 1000 } + file: { type: string, format: binary } + responses: + '200': { description: ApiResponse containing a DRAFT release } + /api/platform/v1/files/pda-releases/{id}/publish: + post: + summary: Publish a draft and switch the current package pointer + security: [{ bearerAuth: [] }] + description: Requires platform:pda-release:manage and PLATFORM membership. + parameters: [{ $ref: '#/components/parameters/id' }] + responses: + '200': { description: ApiResponse containing the published release } + /api/platform/v1/files/pda-releases/{id}/withdraw: + post: + summary: Stop serving an active release + security: [{ bearerAuth: [] }] + description: Requires platform:pda-release:manage and PLATFORM membership. + parameters: [{ $ref: '#/components/parameters/id' }] + responses: + '200': { description: ApiResponse containing the withdrawn release } + /api/pda-updates/{app}/latest: + get: + summary: Anonymous update manifest for one PDA application + parameters: + - $ref: '#/components/parameters/app' + - { name: appid, in: query, required: true, schema: { type: string } } + - { name: platform, in: query, required: true, schema: { type: string, enum: [android] } } + - { name: appVersionCode, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: wgtVersion, in: query, required: true, schema: { type: string } } + responses: + '200': + description: Plain JSON with update=false or an APK/WGT manifest including appid and minAppVersionCode. + /api/pda-updates/{app}/{id}/package: + get: + summary: Download a published package + parameters: [{ $ref: '#/components/parameters/app' }, { $ref: '#/components/parameters/id' }] + responses: + '200': { description: APK or WGT binary } + '404': { description: Draft or withdrawn package is not available } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + app: { name: app, in: path, required: true, schema: { type: string, enum: [domestic, overseas] } } + id: { name: id, in: path, required: true, schema: { type: string, format: uuid } } diff --git a/file/src/test/java/com/baiyue/file/service/PdaPackageStorageTest.java b/file/src/test/java/com/baiyue/file/service/PdaPackageStorageTest.java new file mode 100644 index 000000000..0fe199bba --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PdaPackageStorageTest.java @@ -0,0 +1,52 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PdaPackageStorageTest { + @TempDir Path root; + + @Test + void rejectsWrongContentAndExtension() throws Exception { + var storage = new PdaPackageStorage(root.toString()); + var invalid = new MockMultipartFile("file", "app.wgt", "application/octet-stream", new byte[] {1, 2, 3}); + assertThatThrownBy(() -> storage.store("domestic", "11111111-1111-1111-1111-111111111111", "wgt", invalid)) + .isInstanceOf(BizException.class); + var wrongName = new MockMultipartFile("file", "app.apk", "application/octet-stream", archive("manifest.json")); + assertThatThrownBy(() -> storage.store("domestic", "11111111-1111-1111-1111-111111111111", "wgt", wrongName)) + .isInstanceOf(BizException.class); + } + + @Test + void validatesPackageStructureAndReadsStoredContent() throws Exception { + var storage = new PdaPackageStorage(root.toString()); + byte[] content = archive("manifest.json"); + var file = new MockMultipartFile("file", "app.wgt", "application/octet-stream", content); + var stored = storage.store("overseas", "22222222-2222-2222-2222-222222222222", "wgt", file); + assertThat(stored.key()).isEqualTo("overseas/22222222-2222-2222-2222-222222222222.wgt"); + assertThat(stored.sha256()).hasSize(64); + assertThat(storage.load(stored.key()).getContentAsByteArray()).isEqualTo(content); + assertThatThrownBy(() -> storage.load("../domestic/22222222-2222-2222-2222-222222222222.wgt")) + .isInstanceOf(BizException.class); + } + + private static byte[] archive(String entryName) throws Exception { + var output = new ByteArrayOutputStream(); + try (var zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write("{}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return output.toByteArray(); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java b/file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java new file mode 100644 index 000000000..4d80e021c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java @@ -0,0 +1,81 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PdaReleaseServiceTest { + private static final String APK = "11111111-1111-1111-1111-111111111111"; + private static final String WGT = "22222222-2222-2222-2222-222222222222"; + + @Test + void selectsApkForOldBaseThenWgtForCompatibleBase() throws Exception { + var jdbc = mock(JdbcTemplate.class); + stubQueries(jdbc); + var service = new PdaReleaseService(jdbc, mock(PdaPackageStorage.class), + "https://updates.example.com", "domestic-app", "overseas-app"); + + assertThat(service.latest("domestic", "domestic-app", "android", 1, "1.0.0")) + .containsEntry("packageType", "apk").containsEntry("appid", "domestic-app"); + assertThat(service.latest("domestic", "domestic-app", "android", 2, "1.0.0")) + .containsEntry("packageType", "wgt").containsEntry("minAppVersionCode", 2); + assertThat(service.latest("domestic", "domestic-app", "android", 2, "1.0.10")) + .isEqualTo(Map.of("update", false)); + assertThatThrownBy(() -> service.latest("domestic", "overseas-app", "android", 2, "1.0.0")) + .hasMessageContaining("参数无效"); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void stubQueries(JdbcTemplate jdbc) throws Exception { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + RowMapper mapper = invocation.getArgument(1); + if (sql.contains("file_pda_release_channel")) { + ResultSet rs = mock(ResultSet.class); + when(rs.getString(1)).thenReturn("domestic-app"); + when(rs.getString(2)).thenReturn(APK); + when(rs.getString(3)).thenReturn(WGT); + return List.of(mapper.mapRow(rs, 0)); + } + if (sql.contains("file_pda_release")) { + String id = invocation.getArgument(2); + ResultSet rs = releaseRow(id); + return List.of(mapper.mapRow(rs, 0)); + } + return List.of(); + }); + } + + private static ResultSet releaseRow(String id) throws Exception { + ResultSet rs = mock(ResultSet.class); + boolean apk = id.equals(APK); + when(rs.getString("id")).thenReturn(id); + when(rs.getString("app_code")).thenReturn("domestic"); + when(rs.getString("app_id")).thenReturn("domestic-app"); + when(rs.getString("package_type")).thenReturn(apk ? "apk" : "wgt"); + when(rs.getString("version_name")).thenReturn(apk ? "1.0.0" : "1.0.10"); + when(rs.getInt("version_code")).thenReturn(2); + when(rs.getInt("min_app_version_code")).thenReturn(2); + when(rs.getString("release_notes")).thenReturn("Update"); + when(rs.getString("status")).thenReturn("PUBLISHED"); + when(rs.getString("object_key")).thenReturn("domestic/" + id + (apk ? ".apk" : ".wgt")); + when(rs.getString("sha256")).thenReturn("a".repeat(64)); + when(rs.getLong("size_bytes")).thenReturn(256L); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 9, 20, 1, 0))); + when(rs.getTimestamp("published_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 9, 20, 2, 0))); + return rs; + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 32fffe3f1..8d8cf8596 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -37,7 +37,7 @@ | 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | -| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java index bad194527..e70c5bbdf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; @@ -29,6 +31,13 @@ public interface PendingReviewOrderClient { @RequestParam long page, @RequestParam long size); + @PostMapping("/batch") + ApiResponse> batch( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BatchRequest request); + + record BatchRequest(List orderIds) {} + record Row(Long orderId, String orderNo, String inboundCode, Long customerId, String customerName, Long warehouseId, String channelCode, String channelName, int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 43973be5d..a734756ee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -12,6 +12,8 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -58,6 +60,15 @@ public class InternalPriceBookController { request.channelCode(), request.destinationType(), request.requireInboundCodePrefix())); } + @GetMapping("/channels") + public ApiResponse> channels( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long tenantId, + @RequestParam long originWarehouseId) { + requireToken(token); + return ApiResponse.success(service.channels(tenantId, originWarehouseId)); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java index 6bc428f35..2be528ce5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java @@ -13,6 +13,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo /** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */ @Mapper public interface ColoadGroupMapper { + @Select({""}) + List existingOrderIds(@Param("orderIds") List orderIds); + @Select({"