From dc02c7a62dc884646b15f935568b4ab49706096b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 3 Aug 2026 12:43:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-31一期回归测试环境增量迁移.sql | 709 ++++++++++++++++++ deploy/sql/README.md | 27 + iam/AGENTS.md | 5 +- iam/README-IAM.md | 6 + .../controller/MerchantTenantController.java | 3 + .../controller/PlatformTenantController.java | 24 +- .../controller/ProfileResourceController.java | 16 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 40 +- .../java/com/baiyue/iam/entity/IamTenant.java | 11 + .../iam/repository/TenantRepository.java | 22 +- .../com/baiyue/iam/service/MemberService.java | 14 +- .../service/PlatformInternalUserService.java | 14 +- .../iam/service/ProfileResourceService.java | 18 + .../com/baiyue/iam/service/TenantService.java | 73 +- iam/src/main/resources/db/iam.sql | 9 + .../resources/db/phase1-customer-profile.sql | 48 ++ ...-master-data-and-permission-management.sql | 16 + iam/src/main/resources/openapi/iam-v1.yaml | 225 +++++- .../AuthorizationControllerContractTest.java | 4 +- .../controller/IamOpenApiContractTest.java | 24 +- .../PlatformCustomerResourceContractTest.java | 29 + .../PlatformInternalUserContractTest.java | 8 +- .../PlatformTenantControllerContractTest.java | 23 +- .../QualificationUploadContractTest.java | 2 - .../CustomerProfileMigrationContractTest.java | 60 ++ .../AuthorizationSnapshotServiceTest.java | 4 +- .../baiyue/iam/service/MemberServiceTest.java | 44 +- .../PlatformInternalUserServiceTest.java | 44 +- .../baiyue/iam/service/RbacServiceTest.java | 42 +- .../baiyue/iam/service/TenantServiceTest.java | 86 +-- warehouse/AGENTS.md | 3 +- .../PlatformExpressCompanyController.java | 1 + .../controller/PlatformPartnerController.java | 1 + .../PlatformWarehouseController.java | 25 +- .../WarehouseDataAccessExceptionHandler.java | 4 +- ...tformMasterDataControllerContractTest.java | 43 +- ...rehouseDataAccessExceptionHandlerTest.java | 7 +- .../WarehouseHealthControllerTest.java | 5 +- .../ExpressCompanyRepositoryTest.java | 7 +- .../repository/PartnerRepositoryTest.java | 16 +- .../WarehouseSchemaContractTest.java | 14 +- .../service/PartnerQueryServiceTest.java | 6 +- ...atformMasterDataManagementServiceTest.java | 38 +- .../WarehouseApplicationServiceTest.java | 11 +- .../WarehouseSchemaReadinessServiceTest.java | 8 +- 45 files changed, 1485 insertions(+), 354 deletions(-) create mode 100644 deploy/sql/2026-07-31一期回归测试环境增量迁移.sql create mode 100644 deploy/sql/README.md create mode 100644 iam/src/main/resources/db/phase1-customer-profile.sql create mode 100644 iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java diff --git a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql new file mode 100644 index 000000000..0b775bc94 --- /dev/null +++ b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql @@ -0,0 +1,709 @@ +-- 一期回归测试环境增量迁移汇总 +-- 适用版本:MySQL 8.0 +-- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg +-- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。 +-- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。 +-- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。 + +SET NAMES utf8mb4; + +-- ============================================================================ +-- 1. IAM:权限资源结构、平台/商户按钮权限及管理员授权 +-- ============================================================================ +USE baiyue_iam; + +DROP PROCEDURE IF EXISTS regression_assert_table; +DELIMITER $$ +CREATE PROCEDURE regression_assert_table(IN p_table_name VARCHAR(64)) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = p_table_name + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'baiyue_iam 缺少基础表,请先执行 IAM 基线建表脚本'; + END IF; +END$$ +DELIMITER ; + +CALL regression_assert_table('iam_permission'); +CALL regression_assert_table('iam_role'); +CALL regression_assert_table('iam_role_permission'); +CALL regression_assert_table('iam_tenant'); +DROP PROCEDURE regression_assert_table; + +SET @iam_permission_built_in_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'iam_permission' + AND column_name = 'built_in' + ), + 'SELECT 1', + 'ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT ''是否为系统预置权限;预置资源不可通过管理接口修改'' AFTER status' +); +PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql; +EXECUTE iam_permission_built_in_stmt; +DEALLOCATE PREPARE iam_permission_built_in_stmt; + +-- 一期客户档案字段;与 iam/db/phase1-customer-profile.sql 保持一致。 +DROP PROCEDURE IF EXISTS regression_add_iam_column; +DELIMITER $$ +CREATE PROCEDURE regression_add_iam_column( + IN p_column_name VARCHAR(64), + IN p_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'iam_tenant' + AND column_name = p_column_name + ) THEN + SET @regression_iam_sql = CONCAT( + 'ALTER TABLE iam_tenant ADD COLUMN `', p_column_name, '` ', p_definition + ); + PREPARE regression_iam_stmt FROM @regression_iam_sql; + EXECUTE regression_iam_stmt; + DEALLOCATE PREPARE regression_iam_stmt; + END IF; +END$$ +DELIMITER ; + +CALL regression_add_iam_column('contact_wechat', 'VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); +CALL regression_add_iam_column('sales_owner_name', 'VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); +CALL regression_add_iam_column('payment_term_days', 'SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); +CALL regression_add_iam_column('enterprise_type', 'VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); +CALL regression_add_iam_column('established_date', 'DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); +CALL regression_add_iam_column('registered_capital', 'DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); +CALL regression_add_iam_column('registered_capital_currency', 'CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital'); +CALL regression_add_iam_column('registered_address', 'VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency'); +CALL regression_add_iam_column('internal_remark', 'VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); +DROP PROCEDURE regression_add_iam_column; + +-- 先写入父菜单,再写入按钮,避免新增环境出现悬空父节点。 +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, deleted) +VALUES + (5100000, 0, 'PLATFORM', 'MENU', 0, 'platform:customer', '客户管理', '/customers', NULL, 10, 'ACTIVE', 1, 0), + (5101000, 0, 'PLATFORM', 'MENU', 0, 'platform:system', '系统管理', '/system', NULL, 20, 'ACTIVE', 1, 0), + (5102000, 0, 'PLATFORM', 'MENU', 0, 'platform:order', '头程订单', '/first-leg-orders', NULL, 30, 'ACTIVE', 1, 0), + (5103000, 0, 'PLATFORM', 'MENU', 0, 'platform:master-data', '基础资料', '/master-data', NULL, 40, 'ACTIVE', 1, 0), + (5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 1, 0), + (5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 1, 0), + (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 1, 0), + (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 1, 0), + (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', '/system/members', NULL, 40, 'ACTIVE', 1, 0), + (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 1, 0), + (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 1, 0), + (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 1, 0), + (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), + parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), + route_path = VALUES(route_path), + http_method = VALUES(http_method), + sort_no = VALUES(sort_no), + status = 'ACTIVE', + built_in = 1, + deleted = 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, deleted) +VALUES + (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', NULL, NULL, 15, 'ACTIVE', 1, 0), + (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增内部用户', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑内部用户', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停内部用户', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置内部用户密码', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', NULL, NULL, 50, 'ACTIVE', 1, 0), + (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', NULL, NULL, 50, 'ACTIVE', 1, 0), + (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配平台成员角色', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 1, 0), + (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 1, 0), + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 1, 0), + (5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), + parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), + sort_no = VALUES(sort_no), + status = 'ACTIVE', + built_in = 1, + deleted = 0; + +-- 商户角色维护权限仅在对应父菜单已存在时补齐,不创建演示租户或角色。 +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, sort_no, status, built_in, deleted) +SELECT 5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) +UNION ALL +SELECT 5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) +UNION ALL +SELECT 5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) +UNION ALL +SELECT 5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) +UNION ALL +SELECT 5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) +UNION ALL +SELECT 5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 1, 0 +WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + parent_id = VALUES(parent_id), + sort_no = VALUES(sort_no), + status = 'ACTIVE', + built_in = 1, + deleted = 0; + +UPDATE iam_permission + SET built_in = 1 + WHERE id BETWEEN 5000000 AND 5399999 + AND built_in = 0; + +-- 平台超级管理员自动获得本轮新增权限;不存在该角色时不插入任何授权记录。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p + ON p.tenant_id = 0 + AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 + AND r.app_code = 'PLATFORM' + AND r.role_code = 'PLATFORM_ADMIN' + AND r.deleted = 0 + AND p.permission_code IN ( + 'platform:customer', 'platform:customer:list', 'platform:customer:create', + 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', + 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', + 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', + 'platform:role:list', 'platform:role:create', 'platform:role:grant', + 'platform:role:edit', 'platform:role:status', 'platform:role:delete', + 'platform:member:list', 'platform:member:grant-role', + 'platform:permission:list', 'platform:permission:create', 'platform:permission:edit', + 'platform:permission:status', 'platform:permission:delete', + 'platform:order', 'platform:order:list', 'platform:order:create', + 'platform:order:edit', 'platform:order:submit', + 'platform:firstleg:task:list', 'platform:firstleg:task:view', + 'platform:firstleg:assignment:history', 'platform:firstleg:assignment:assign', + 'platform:master-data', 'platform:express:list', 'platform:express:create', + 'platform:express:edit', 'platform:express:status', + 'platform:counterparty:list', 'platform:counterparty:create', + 'platform:counterparty:edit', 'platform:counterparty:status' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 既有客户列表或审核角色继承详情查看权限,避免升级后丢失原查询能力。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id + FROM iam_role_permission existing_grant + JOIN iam_permission existing_permission + ON existing_permission.id = existing_grant.permission_id + JOIN iam_permission view_permission + ON view_permission.tenant_id = 0 + AND view_permission.app_code = 'PLATFORM' + AND view_permission.permission_code = 'platform:customer:view' + WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 已存在的商户超级管理员/企业管理员获得对应租户的角色维护权限。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, r.tenant_id + FROM iam_role r + JOIN iam_permission p + ON p.tenant_id = r.tenant_id + AND p.app_code = 'MERCHANT' + WHERE r.app_code = 'MERCHANT' + AND r.role_code IN ('MERCHANT_OWNER', 'MERCHANT_ADMIN') + AND r.deleted = 0 + AND p.permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 2. Warehouse:仓库、地址、能力、往来单位、快递公司结构兼容迁移 +-- ============================================================================ +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_partner ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + partner_code VARCHAR(50) NOT NULL, + partner_name VARCHAR(128) NOT NULL, + partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', + contact_name VARCHAR(64) NOT NULL, + contact_phone VARCHAR(32) NOT NULL, + contact_email VARCHAR(128) NULL, + country_code CHAR(2) NOT NULL, + province VARCHAR(64) NULL, + city VARCHAR(64) NOT NULL, + district VARCHAR(64) NULL, + address_line VARCHAR(255) NOT NULL, + postal_code VARCHAR(20) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted), + KEY idx_wh_partner_tenant_status (tenant_id, status, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储域内的租户往来单位配置'; + +CREATE TABLE IF NOT EXISTS wh_express_company ( + id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID', + company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码', + company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称', + service_phone VARCHAR(32) NULL COMMENT '客服电话', + website VARCHAR(255) NULL COMMENT '官方网站', + tracking_url_template VARCHAR(500) NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted), + KEY idx_wh_express_tenant_status (tenant_id, status, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户维度快递公司主数据'; + +DROP PROCEDURE IF EXISTS regression_assert_warehouse_table; +DELIMITER $$ +CREATE PROCEDURE regression_assert_warehouse_table(IN p_table_name VARCHAR(64)) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = p_table_name + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'baiyue_warehouse 缺少仓库基础表,请先执行 warehouse.sql'; + END IF; +END$$ +DELIMITER ; +CALL regression_assert_warehouse_table('wh_warehouse'); +CALL regression_assert_warehouse_table('wh_warehouse_address'); +CALL regression_assert_warehouse_table('wh_service_capability'); +DROP PROCEDURE regression_assert_warehouse_table; + +DROP PROCEDURE IF EXISTS regression_add_column; +DELIMITER $$ +CREATE PROCEDURE regression_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @regression_column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE regression_column_stmt FROM @regression_column_sql; + EXECUTE regression_column_stmt; + DEALLOCATE PREPARE regression_column_stmt; + END IF; +END$$ +DELIMITER ; + +CALL regression_add_column('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); +CALL regression_add_column('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); +CALL regression_add_column('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); +CALL regression_add_column('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); +CALL regression_add_column('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); +CALL regression_add_column('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); +CALL regression_add_column('wh_warehouse', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER contact_email'); +CALL regression_add_column('wh_warehouse', 'remark', 'VARCHAR(500) NULL AFTER status'); +CALL regression_add_column('wh_warehouse', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_warehouse', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL regression_add_column('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL regression_add_column('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL regression_add_column('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL regression_add_column('wh_warehouse_address', 'address_type', 'VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' AFTER warehouse_id'); +CALL regression_add_column('wh_warehouse_address', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER address_type'); +CALL regression_add_column('wh_warehouse_address', 'state_province', 'VARCHAR(100) NULL'); +CALL regression_add_column('wh_warehouse_address', 'city', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER state_province'); +CALL regression_add_column('wh_warehouse_address', 'district', 'VARCHAR(100) NULL'); +CALL regression_add_column('wh_warehouse_address', 'postal_code', 'VARCHAR(20) NULL'); +CALL regression_add_column('wh_warehouse_address', 'address_line1', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER postal_code'); +CALL regression_add_column('wh_warehouse_address', 'address_line2', 'VARCHAR(255) NULL'); +CALL regression_add_column('wh_warehouse_address', 'latitude', 'DECIMAL(10,7) NULL'); +CALL regression_add_column('wh_warehouse_address', 'longitude', 'DECIMAL(10,7) NULL'); +CALL regression_add_column('wh_warehouse_address', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_warehouse_address', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_warehouse_address', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_warehouse_address', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_warehouse_address', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL regression_add_column('wh_warehouse_address', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL regression_add_column('wh_service_capability', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL regression_add_column('wh_service_capability', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL regression_add_column('wh_service_capability', 'capability_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER warehouse_id'); +CALL regression_add_column('wh_service_capability', 'capability_name', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER capability_code'); +CALL regression_add_column('wh_service_capability', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1'); +CALL regression_add_column('wh_service_capability', 'cutoff_time', 'TIME NULL'); +CALL regression_add_column('wh_service_capability', 'description', 'VARCHAR(500) NULL'); +CALL regression_add_column('wh_service_capability', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_service_capability', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_service_capability', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_service_capability', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_service_capability', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL regression_add_column('wh_service_capability', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL regression_add_column('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL regression_add_column('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL regression_add_column('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); +CALL regression_add_column('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); +CALL regression_add_column('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL regression_add_column('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); +CALL regression_add_column('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); +CALL regression_add_column('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); +CALL regression_add_column('wh_partner', 'province', 'VARCHAR(64) NULL'); +CALL regression_add_column('wh_partner', 'city', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER province'); +CALL regression_add_column('wh_partner', 'district', 'VARCHAR(64) NULL'); +CALL regression_add_column('wh_partner', 'address_line', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER district'); +CALL regression_add_column('wh_partner', 'postal_code', 'VARCHAR(20) NULL'); +CALL regression_add_column('wh_partner', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER postal_code'); +CALL regression_add_column('wh_partner', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_partner', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL regression_add_column('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL regression_add_column('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL regression_add_column('wh_express_company', 'company_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL regression_add_column('wh_express_company', 'company_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER company_code'); +CALL regression_add_column('wh_express_company', 'service_phone', 'VARCHAR(32) NULL'); +CALL regression_add_column('wh_express_company', 'website', 'VARCHAR(255) NULL'); +CALL regression_add_column('wh_express_company', 'tracking_url_template', 'VARCHAR(500) NULL'); +CALL regression_add_column('wh_express_company', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER tracking_url_template'); +CALL regression_add_column('wh_express_company', 'remark', 'VARCHAR(500) NULL'); +CALL regression_add_column('wh_express_company', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_express_company', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_express_company', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL regression_add_column('wh_express_company', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL regression_add_column('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL regression_add_column('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0'); + +DROP PROCEDURE regression_add_column; + +DROP PROCEDURE IF EXISTS regression_add_index; +DELIMITER $$ +CREATE PROCEDURE regression_add_index( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_index_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @regression_index_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD ', p_index_definition + ); + PREPARE regression_index_stmt FROM @regression_index_sql; + EXECUTE regression_index_stmt; + DEALLOCATE PREPARE regression_index_stmt; + END IF; +END$$ +DELIMITER ; + +CALL regression_add_index('wh_warehouse', 'idx_wh_tenant_status', 'KEY idx_wh_tenant_status (tenant_id, status, deleted)'); +CALL regression_add_index('wh_warehouse_address', 'idx_address_warehouse', 'KEY idx_address_warehouse (tenant_id, warehouse_id, deleted)'); +CALL regression_add_index('wh_service_capability', 'idx_capability_query', 'KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted)'); +CALL regression_add_index('wh_partner', 'idx_wh_partner_tenant_status', 'KEY idx_wh_partner_tenant_status (tenant_id, status, deleted)'); +CALL regression_add_index('wh_partner', 'uk_wh_partner_tenant_code', 'UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted)'); +CALL regression_add_index('wh_express_company', 'idx_wh_express_tenant_status', 'KEY idx_wh_express_tenant_status (tenant_id, status, deleted)'); +CALL regression_add_index('wh_express_company', 'uk_wh_express_tenant_code', 'UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)'); +DROP PROCEDURE regression_add_index; + +-- ============================================================================ +-- 3. Order:一期订单快照字段、货物字段和持久化幂等记录 +-- ============================================================================ +USE baiyue_order; + +DROP PROCEDURE IF EXISTS regression_assert_order_table; +DELIMITER $$ +CREATE PROCEDURE regression_assert_order_table(IN p_table_name VARCHAR(64)) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = p_table_name + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'baiyue_order 缺少订单基础表,请先执行 order.sql'; + END IF; +END$$ +DELIMITER ; +CALL regression_assert_order_table('ord_order'); +CALL regression_assert_order_table('ord_order_cargo'); +DROP PROCEDURE regression_assert_order_table; + +DROP PROCEDURE IF EXISTS regression_add_order_column; +DELIMITER $$ +CREATE PROCEDURE regression_add_order_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @regression_order_sql = CONCAT( + 'ALTER TABLE `', p_table_name, '` ADD COLUMN `', p_column_name, '` ', p_definition + ); + PREPARE regression_order_stmt FROM @regression_order_sql; + EXECUTE regression_order_stmt; + DEALLOCATE PREPARE regression_order_stmt; + END IF; +END$$ +DELIMITER ; + +CALL regression_add_order_column('ord_order', 'pickup_method', 'VARCHAR(24) NULL COMMENT ''提货方式'''); +CALL regression_add_order_column('ord_order', 'receiving_warehouse_id', 'BIGINT UNSIGNED NULL COMMENT ''国内收货仓ID'''); +CALL regression_add_order_column('ord_order', 'receiving_warehouse_name', 'VARCHAR(128) NULL COMMENT ''国内收货仓名称快照'''); +CALL regression_add_order_column('ord_order', 'receiving_warehouse_address', 'VARCHAR(512) NULL COMMENT ''国内收货仓完整地址快照'''); +CALL regression_add_order_column('ord_order', 'receiving_time', 'VARCHAR(128) NULL COMMENT ''国内收货仓收货时间快照'''); +CALL regression_add_order_column('ord_order', 'channel_code', 'VARCHAR(64) NULL COMMENT ''运输渠道编码'''); +CALL regression_add_order_column('ord_order', 'channel_name', 'VARCHAR(128) NULL COMMENT ''运输渠道名称快照'''); +CALL regression_add_order_column('ord_order', 'tax_mode', 'VARCHAR(24) NULL COMMENT ''报关买单或退税模式'''); +CALL regression_add_order_column('ord_order', 'container_mode', 'VARCHAR(24) NULL COMMENT ''整柜或散货模式'''); +CALL regression_add_order_column('ord_order', 'customs_inspection', 'TINYINT(1) NULL COMMENT ''是否需要海关查验'''); +CALL regression_add_order_column('ord_order', 'destination_type', 'VARCHAR(24) NULL COMMENT ''FBA、海外仓或私人地址'''); +CALL regression_add_order_column('ord_order', 'amazon_warehouse_code', 'VARCHAR(32) NULL COMMENT ''亚马逊仓库编码'''); +CALL regression_add_order_column('ord_order', 'shipment_id', 'VARCHAR(64) NULL COMMENT ''亚马逊Shipment ID'''); +CALL regression_add_order_column('ord_order', 'reference_id', 'VARCHAR(64) NULL COMMENT ''亚马逊Reference ID'''); +CALL regression_add_order_column('ord_order', 'express_company', 'VARCHAR(64) NULL COMMENT ''履约快递公司名称快照'''); +CALL regression_add_order_column('ord_order', 'express_tracking_no', 'VARCHAR(128) NULL COMMENT ''履约快递单号'''); + +CALL regression_add_order_column('ord_order_cargo', 'sku_no', 'VARCHAR(64) NULL COMMENT ''SKU编号'''); +CALL regression_add_order_column('ord_order_cargo', 'upc_no', 'VARCHAR(64) NULL COMMENT ''UPC编号'''); +CALL regression_add_order_column('ord_order_cargo', 'cargo_name_en', 'VARCHAR(128) NULL COMMENT ''货物英文名称'''); +CALL regression_add_order_column('ord_order_cargo', 'customs_hs_code', 'VARCHAR(20) NULL COMMENT ''目的国海关HS编码'''); +CALL regression_add_order_column('ord_order_cargo', 'units_per_package', 'INT UNSIGNED NULL COMMENT ''每箱件数'''); +CALL regression_add_order_column('ord_order_cargo', 'total_quantity', 'INT UNSIGNED NULL COMMENT ''总件数'''); +CALL regression_add_order_column('ord_order_cargo', 'net_weight_kg', 'DECIMAL(18,3) NULL COMMENT ''净重千克'''); +CALL regression_add_order_column('ord_order_cargo', 'unit_declared_value', 'DECIMAL(18,2) NULL COMMENT ''单件申报价值'''); +CALL regression_add_order_column('ord_order_cargo', 'material', 'VARCHAR(128) NULL COMMENT ''材质'''); +CALL regression_add_order_column('ord_order_cargo', 'product_usage', 'VARCHAR(128) NULL COMMENT ''用途'''); +CALL regression_add_order_column('ord_order_cargo', 'model', 'VARCHAR(64) NULL COMMENT ''型号'''); +CALL regression_add_order_column('ord_order_cargo', 'brand', 'VARCHAR(64) NULL COMMENT ''品牌'''); +DROP PROCEDURE regression_add_order_column; + +CREATE TABLE IF NOT EXISTS ord_idempotency_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operation VARCHAR(32) NOT NULL COMMENT '操作类型', + resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态', + result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单ID', + completed_at DATETIME(3) NULL COMMENT '完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单写接口持久化幂等记录'; + +-- 兼容曾经只建表但未建立完整索引的测试库。 +SET @order_idempotency_unique_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'ord_idempotency_record' + AND index_name = 'uk_order_idempotency_scope' + ), + 'SELECT 1', + 'ALTER TABLE ord_idempotency_record ADD UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)' +); +PREPARE order_idempotency_unique_stmt FROM @order_idempotency_unique_sql; +EXECUTE order_idempotency_unique_stmt; +DEALLOCATE PREPARE order_idempotency_unique_stmt; + +SET @order_idempotency_created_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'ord_idempotency_record' + AND index_name = 'idx_order_idempotency_created' + ), + 'SELECT 1', + 'ALTER TABLE ord_idempotency_record ADD KEY idx_order_idempotency_created (created_at)' +); +PREPARE order_idempotency_created_stmt FROM @order_idempotency_created_sql; +EXECUTE order_idempotency_created_stmt; +DEALLOCATE PREPARE order_idempotency_created_stmt; + +-- ============================================================================ +-- 4. Firstleg:履约任务分页索引 +-- ============================================================================ +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS regression_assert_firstleg_table; +DELIMITER $$ +CREATE PROCEDURE regression_assert_firstleg_table() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'fl_fulfillment_task' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'baiyue_firstleg 缺少履约任务基础表,请先执行 firstleg.sql'; + END IF; +END$$ +DELIMITER ; +CALL regression_assert_firstleg_table(); +DROP PROCEDURE regression_assert_firstleg_table; + +SET @firstleg_task_list_index_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_fulfillment_task' + AND index_name = 'idx_task_tenant_created' + ), + 'SELECT 1', + 'CREATE INDEX idx_task_tenant_created ON fl_fulfillment_task (tenant_id, created_at)' +); +PREPARE firstleg_task_list_index_stmt FROM @firstleg_task_list_index_sql; +EXECUTE firstleg_task_list_index_stmt; +DEALLOCATE PREPARE firstleg_task_list_index_stmt; + +-- ============================================================================ +-- 5. 执行后只读核验 +-- ============================================================================ +SELECT + 'IAM权限资源' AS check_item, + COUNT(*) AS actual_count, + 44 AS expected_count, + IF(COUNT(*) = 44, '通过', '请检查缺失权限') AS check_result +FROM baiyue_iam.iam_permission +WHERE deleted = 0 + AND permission_code IN ( + 'platform:customer', 'platform:customer:list', 'platform:customer:create', + 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', + 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', + 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', + 'platform:role:list', 'platform:role:create', 'platform:role:grant', + 'platform:role:edit', 'platform:role:status', 'platform:role:delete', + 'platform:member:list', 'platform:member:grant-role', + 'platform:permission:list', 'platform:permission:create', 'platform:permission:edit', + 'platform:permission:status', 'platform:permission:delete', + 'platform:order', 'platform:order:list', 'platform:order:create', + 'platform:order:edit', 'platform:order:submit', + 'platform:firstleg:task:list', 'platform:firstleg:task:view', + 'platform:firstleg:assignment:history', 'platform:firstleg:assignment:assign', + 'platform:master-data', 'platform:express:list', 'platform:express:create', + 'platform:express:edit', 'platform:express:status', + 'platform:counterparty:list', 'platform:counterparty:create', + 'platform:counterparty:edit', 'platform:counterparty:status' + ); + +SELECT + 'IAM客户档案字段' AS check_item, + COUNT(*) AS actual_count, + 9 AS expected_count, + IF(COUNT(*) = 9, '通过', '请检查客户档案字段迁移') AS check_result +FROM information_schema.columns +WHERE table_schema = 'baiyue_iam' + AND table_name = 'iam_tenant' + AND column_name IN ( + 'contact_wechat', 'sales_owner_name', 'payment_term_days', 'enterprise_type', + 'established_date', 'registered_capital', 'registered_capital_currency', + 'registered_address', 'internal_remark' + ); + +SELECT + table_name, + COUNT(*) AS column_count +FROM information_schema.columns +WHERE table_schema = 'baiyue_warehouse' + AND table_name IN ( + 'wh_warehouse', 'wh_warehouse_address', 'wh_service_capability', + 'wh_partner', 'wh_express_company' + ) +GROUP BY table_name +ORDER BY table_name; + +SELECT + IF(COUNT(*) = 1, '通过', '缺失') AS idempotency_table, + 'baiyue_order.ord_idempotency_record' AS object_name +FROM information_schema.tables +WHERE table_schema = 'baiyue_order' + AND table_name = 'ord_idempotency_record'; + +SELECT + IF(COUNT(*) > 0, '通过', '缺失') AS idempotency_unique_index, + 'uk_order_idempotency_scope' AS object_name +FROM information_schema.statistics +WHERE table_schema = 'baiyue_order' + AND table_name = 'ord_idempotency_record' + AND index_name = 'uk_order_idempotency_scope'; + +SELECT + IF(COUNT(*) > 0, '通过', '缺失') AS firstleg_task_index, + 'idx_task_tenant_created' AS object_name +FROM information_schema.statistics +WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_fulfillment_task' + AND index_name = 'idx_task_tenant_created'; + +SELECT 'MIGRATION_COMPLETED' AS migration_status, + '请确认上方所有核验结果均为通过后再启动服务回归' AS message; diff --git a/deploy/sql/README.md b/deploy/sql/README.md new file mode 100644 index 000000000..d5d634534 --- /dev/null +++ b/deploy/sql/README.md @@ -0,0 +1,27 @@ +# 测试环境 SQL 执行说明 + +## 本轮一期回归 + +- 统一入口:`2026-07-31一期回归测试环境增量迁移.sql` +- 适用数据库:MySQL 8.0。 +- 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。 +- 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。 +- 脚本可重复执行。MySQL 的 DDL 会隐式提交,因此不能把整份跨库脚本视为可整体回滚的事务。 +- 统一入口已内联 IAM 客户档案字段迁移和 `platform:customer:view` 权限迁移;测试环境不要再额外拼接或重复按模块脚本执行。 + +## IAM 来源与执行顺序 + +统一入口中的 IAM 段按以下模块迁移依赖顺序组织,并通过元数据判断或 `ON DUPLICATE KEY UPDATE` 保持可重复执行: + +1. IAM 基线表必须已存在(`iam.sql`)。 +2. 权限父菜单和既有客户列表/审核权限基线(`phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`)。 +3. `phase1-platform-master-data-and-permission-management.sql` 中的 `platform:customer:view`、既有角色继承及管理员授权。 +4. `phase1-customer-profile.sql` 中的九个 `iam_tenant` 客户档案字段。 + +测试环境只执行统一入口;模块脚本用于新环境基线构建或单模块升级,不应在统一入口执行后再次串行执行。 + +## 执行方式 + +在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 + +执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案九字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 1b77a634c..cc093bf70 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -15,6 +15,7 @@ | 商户成员增改、启停、重置密码 | `MerchantTenantController.java` | `MemberService.java`,账号协作看 `client/AuthAccountClient.java` | | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | +| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java` | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | @@ -28,16 +29,18 @@ ## 修改与验证 - 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 +- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。 - 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。 - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 -- 成员新增携带角色、编辑时变更或清空角色,必须额外具有 `merchant:member:grant-role`;仅资料编辑且角色集合不变时不要求该权限。 - `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 - 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。 - 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。 +- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。 - 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。 - SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。 +- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。 - 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。 diff --git a/iam/README-IAM.md b/iam/README-IAM.md index 58d0e9de6..88247feb5 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -27,3 +27,9 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe | GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 | OpenAPI is synchronized in `openapi/iam-v1.yaml`. + +## 商户成员角色字段级授权 + +- 新增、编辑成员基础资料分别需要 `merchant:member:create`、`merchant:member:edit`。 +- 新增请求的 `roleIds` 非空,或编辑请求的完整目标角色集合与当前集合不同时,还必须具有 `merchant:member:grant-role`。 +- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前租户下的 `roleIds`。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index 23a4ce1da..84491bb47 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -56,6 +56,7 @@ public class MerchantTenantController { @GetMapping("/tenant/profile") @PreAuthorize("hasAuthority('merchant:profile:view')") @Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")}) public ApiResponse profile(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(tenantService.profile(TenantGuard.tenant(user, "MERCHANT"))); } @@ -63,6 +64,7 @@ public class MerchantTenantController { @PutMapping("/tenant/profile") @PreAuthorize("hasAuthority('merchant:profile:edit')") @Operation(summary = "Update current enterprise profile / 更新当前企业资料", description = "Update basic profile fields within the authenticated tenant. / 在当前认证租户内更新企业基础资料。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")}) public ApiResponse update(@Valid @RequestBody UpdateProfileRequest request, @AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request)); @@ -86,6 +88,7 @@ public class MerchantTenantController { @PostMapping("/members") @Operation(summary = "Add enterprise member / 新增企业成员", description = "Create a membership under the current JWT tenant. / 在当前 JWT 租户下创建成员关系。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Member already exists / 成员已存在")}) @PreAuthorize("hasAuthority('merchant:member:create')") public ApiResponse addMember(@Valid @RequestBody CreateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index a237641cb..a05c8aa05 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -43,8 +43,10 @@ public class PlatformTenantController { @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效") }) public ApiResponse> page( @RequestParam(defaultValue = "1") @Min(1) long page, @@ -58,8 +60,8 @@ public class PlatformTenantController { } @GetMapping("/tenants/{tenantId:\\d+}") - @PreAuthorize("hasAuthority('platform:customer:list')") - @Operation(summary = "查询企业客户档案") + @PreAuthorize("hasAuthority('platform:customer:view')") + @Operation(summary = "查询企业客户档案", description = "返回企业基础资料、销售负责人、账期、注册地址以及入驻和审批时间。") public ApiResponse tenant(@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); @@ -71,7 +73,9 @@ public class PlatformTenantController { @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在") }) public ApiResponse register(@Valid @RequestBody RegisterRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @@ -81,7 +85,7 @@ public class PlatformTenantController { @PutMapping("/tenants/{tenantId:\\d+}") @PreAuthorize("hasAuthority('platform:customer:edit')") - @Operation(summary = "编辑企业客户档案") + @Operation(summary = "编辑企业客户档案", description = "维护一期客户档案字段;统一社会信用代码和生命周期状态不由该接口修改。") public ApiResponse updateTenant(@PathVariable long tenantId, @Valid @RequestBody UpdateTenantRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @@ -116,10 +120,11 @@ public class PlatformTenantController { @Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成") }) public ApiResponse approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, - @Valid @RequestBody ReviewRequest request, + @RequestBody ReviewRequest request, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); service.review(id, user.userId(), true, request.comment()); @@ -131,10 +136,11 @@ public class PlatformTenantController { @Operation(summary = "Reject enterprise registration / 驳回企业注册", description = "Reject a pending review and retain the rejection comment. The operation is audited. / 驳回待审核企业并保留审核意见,操作写入审计日志。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Rejected / 已驳回"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成") }) public ApiResponse reject(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, - @Valid @RequestBody ReviewRequest request, + @RequestBody ReviewRequest request, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); service.review(id, user.userId(), false, request.comment()); diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index 35cb047b7..94c3267a5 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -43,6 +43,7 @@ public class ProfileResourceController { @PostMapping("/api/platform/v1/iam/internal-users") @PreAuthorize("hasAuthority('platform:user:create')") @Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")}) public ApiResponse addUser(@Valid @RequestBody CreateInternalUserRequest request, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); @@ -100,6 +101,7 @@ public class ProfileResourceController { @PostMapping("/api/merchant/v1/iam/tenant/addresses") @PreAuthorize("hasAuthority('merchant:profile:edit')") @Operation(summary = "Add enterprise address / 新增企业地址", description = "Add an operating, registered or return address to the current tenant. / 为当前租户新增经营、注册地址或退货地址。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid address / 地址不合法")}) public ApiResponse addAddress(@Valid @RequestBody AddressRequest request, @AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.addAddress(TenantGuard.tenant(user, "MERCHANT"), request)); @@ -113,11 +115,21 @@ public class ProfileResourceController { } @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") - @PreAuthorize("hasAuthority('platform:customer:review')") + @PreAuthorize("hasAuthority('platform:customer:view')") + @Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。") public ApiResponse> platformQualifications( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.qualifications(tenantId)); + return ApiResponse.success(service.platformQualifications(tenantId)); + } + + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses") + @PreAuthorize("hasAuthority('platform:customer:view')") + @Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。") + public ApiResponse> platformAddresses( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.platformAddresses(tenantId)); } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 5bde9a48d..e68c4d0a5 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -2,10 +2,19 @@ package com.baiyue.iam.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + /** * 租户生命周期数据契约集合,定义入驻、资料更新、审核请求和租户响应。 */ @@ -19,7 +28,16 @@ public final class TenantDtos { @Schema(description = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, - @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, + @Size(max = 64) String contactWechat, + @Size(max = 64) String salesOwnerName, + @Min(0) @Max(365) Integer paymentTermDays, + @Size(max = 64) String enterpriseType, + @PastOrPresent LocalDate establishedDate, + @DecimalMin("0.00") @Digits(integer = 16, fraction = 2) BigDecimal registeredCapital, + @Pattern(regexp = "[A-Z]{3}") String registeredCapitalCurrency, + @Size(max = 500) String registeredAddress, + @Size(max = 1000) String internalRemark ) { } @Schema(name = "TenantProfileUpdateRequest", description = "Enterprise profile update / 企业资料更新") @@ -38,7 +56,16 @@ public final class TenantDtos { @NotBlank String legalName, @NotBlank String contactName, @NotBlank String contactPhone, - @Email String contactEmail + @Email String contactEmail, + @Size(max = 64) String contactWechat, + @Size(max = 64) String salesOwnerName, + @Min(0) @Max(365) Integer paymentTermDays, + @Size(max = 64) String enterpriseType, + @PastOrPresent LocalDate establishedDate, + @DecimalMin("0.00") @Digits(integer = 16, fraction = 2) BigDecimal registeredCapital, + @Pattern(regexp = "[A-Z]{3}") String registeredCapitalCurrency, + @Size(max = 500) String registeredAddress, + @Size(max = 1000) String internalRemark ) { } @Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status") @@ -48,12 +75,15 @@ public final class TenantDtos { @Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见") public record ReviewRequest( - @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") - @Size(max = 500, message = "审核意见不能超过 500 个字符") String comment + @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment ) { } @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") public record TenantResponse(Long id, String tenantCode, String tenantName, String status, String legalName, String unifiedSocialCreditCode, String contactName, - String contactPhone, String contactEmail) { } + String contactPhone, String contactEmail, String contactWechat, + String salesOwnerName, Integer paymentTermDays, String enterpriseType, + LocalDate establishedDate, BigDecimal registeredCapital, + String registeredCapitalCurrency, String registeredAddress, + String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 9a3d06472..6dd362d72 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -6,6 +6,8 @@ import lombok.Setter; import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -25,6 +27,15 @@ public class IamTenant extends BaseEntity { private String contactName; private String contactPhone; private String contactEmail; + private String contactWechat; + private String salesOwnerName; + private Integer paymentTermDays; + private String enterpriseType; + private LocalDate establishedDate; + private BigDecimal registeredCapital; + private String registeredCapitalCurrency; + private String registeredAddress; + private String internalRemark; private LocalDateTime approvedAt; private Long approvedBy; private String rejectReason; diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index a58629118..bcd4f5874 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -6,12 +6,10 @@ import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; -import java.time.LocalDateTime; import java.util.List; /** @@ -43,22 +41,7 @@ public class TenantRepository { public void insertTenant(IamTenant tenant) { tenantMapper.insert(tenant); } public void saveTenant(IamTenant tenant) { tenantMapper.updateById(tenant); } public void insertReview(IamTenantReview review) { reviewMapper.insert(review); } - - /** - * Atomically completes a pending review. The status predicate makes duplicate or concurrent - * approval requests fail instead of overwriting the first review decision. - */ - public boolean completeReview(Long reviewId, String status, Long reviewerId, String comment, - LocalDateTime reviewedAt) { - int affected = reviewMapper.update(null, new LambdaUpdateWrapper() - .eq(IamTenantReview::getId, reviewId) - .eq(IamTenantReview::getStatus, "PENDING") - .set(IamTenantReview::getStatus, status) - .set(IamTenantReview::getReviewerId, reviewerId) - .set(IamTenantReview::getReviewComment, comment) - .set(IamTenantReview::getReviewedAt, reviewedAt)); - return affected == 1; - } + public void saveReview(IamTenantReview review) { reviewMapper.updateById(review); } public List findPendingReviews() { return reviewMapper.selectList(new LambdaQueryWrapper() @@ -78,7 +61,8 @@ public class TenantRepository { .or().like(IamTenant::getLegalName, keyword) .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword)); + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); } return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 0d6f99055..80d582e35 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -200,19 +200,11 @@ public class MemberService { private ApiResponse call(RemoteCall action) { try { ApiResponse result = action.call(); - if (result == null) throw unavailable(); - if (!result.successful()) { - ResultCode code = ResultCode.of(result.code()); - String message = result.message() == null || result.message().isBlank() - ? code.message() : result.message(); - throw new BizException(code, message); - } + if (result == null || !result.successful()) throw unavailable(); return result; } catch (FeignException exception) { - throw unavailable(); - } catch (BizException exception) { - throw exception; - } catch (RuntimeException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在"); throw unavailable(); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 8a8e30655..4ecc46595 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -321,19 +321,11 @@ public class PlatformInternalUserService { private ApiResponse call(RemoteCall action) { try { ApiResponse result = action.call(); - if (result == null) throw unavailable(); - if (!result.successful()) { - ResultCode code = ResultCode.of(result.code()); - String message = result.message() == null || result.message().isBlank() - ? code.message() : result.message(); - throw new BizException(code, message); - } + if (result == null || !result.successful()) throw unavailable(); return result; } catch (FeignException exception) { - throw unavailable(); - } catch (BizException exception) { - throw exception; - } catch (RuntimeException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在"); throw unavailable(); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index ee753b121..127fff92a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -41,6 +41,16 @@ public class ProfileResourceService { public List qualifications(long tenant) { return repository.findQualifications(tenant); } + public List platformAddresses(long tenantId) { + requireMerchant(tenantId); + return repository.findAddresses(tenantId); + } + + public List platformQualifications(long tenantId) { + requireMerchant(tenantId); + return repository.findQualifications(tenantId); + } + @Transactional @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION", resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false) @@ -84,4 +94,12 @@ public class ProfileResourceService { x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING"); repository.insertQualification(x); return x; } + + private void requireMerchant(long tenantId) { + IamTenant tenant = tenantRepository.findTenant(tenantId); + if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId + || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + } } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 962c89dd4..74cb5da21 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -59,6 +59,15 @@ public class TenantService { tenant.setContactName(request.contactName()); tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); + tenant.setContactWechat(request.contactWechat()); + tenant.setSalesOwnerName(request.salesOwnerName()); + tenant.setPaymentTermDays(request.paymentTermDays()); + tenant.setEnterpriseType(request.enterpriseType()); + tenant.setEstablishedDate(request.establishedDate()); + tenant.setRegisteredCapital(request.registeredCapital()); + tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency()); + tenant.setRegisteredAddress(request.registeredAddress()); + tenant.setInternalRemark(request.internalRemark()); repository.insertTenant(tenant); IamTenantReview review = new IamTenantReview(); @@ -77,7 +86,7 @@ public class TenantService { public PageResult pageMerchants(long page, long size, String keyword, String status) { PageResult source = repository.pageMerchants(page, size, keyword, status); - return new PageResult<>(source.records().stream().map(this::response).toList(), + return new PageResult<>(source.records().stream().map(this::summaryResponse).toList(), source.page(), source.size(), source.total(), source.pages()); } @@ -99,11 +108,11 @@ public class TenantService { throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成"); } - LocalDateTime reviewedAt = LocalDateTime.now(); - if (!repository.completeReview(reviewId, approve ? "APPROVED" : "REJECTED", - reviewerId, comment, reviewedAt)) { - throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成"); - } + review.setStatus(approve ? "APPROVED" : "REJECTED"); + review.setReviewerId(reviewerId); + review.setReviewComment(comment); + review.setReviewedAt(LocalDateTime.now()); + repository.saveReview(review); IamTenant tenant = repository.findTenant(review.getTenantId()); if (tenant == null) { @@ -144,7 +153,7 @@ public class TenantService { if (tenant == null || !tenantIdEquals(tenant, tenantId)) { throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); } - return response(tenant); + return merchantVisibleResponse(tenant); } /** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */ @@ -162,6 +171,15 @@ public class TenantService { tenant.setContactName(request.contactName()); tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); + tenant.setContactWechat(request.contactWechat()); + tenant.setSalesOwnerName(request.salesOwnerName()); + tenant.setPaymentTermDays(request.paymentTermDays()); + tenant.setEnterpriseType(request.enterpriseType()); + tenant.setEstablishedDate(request.establishedDate()); + tenant.setRegisteredCapital(request.registeredCapital()); + tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency()); + tenant.setRegisteredAddress(request.registeredAddress()); + tenant.setInternalRemark(request.internalRemark()); repository.saveTenant(tenant); return response(tenant); } @@ -194,7 +212,7 @@ public class TenantService { tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); repository.saveTenant(tenant); - return response(tenant); + return merchantVisibleResponse(tenant); } private boolean tenantIdEquals(IamTenant tenant, long id) { @@ -213,7 +231,44 @@ public class TenantService { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), - tenant.getContactPhone(), tenant.getContactEmail() + tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), + tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), + tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(), + tenant.getRegisteredAddress(), tenant.getInternalRemark(), tenant.getCreatedAt(), tenant.getApprovedAt() ); } + + private TenantResponse summaryResponse(IamTenant tenant) { + TenantResponse response = response(tenant); + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.legalName(), response.unifiedSocialCreditCode(), response.contactName(), + maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), + response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), + response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(), + response.registeredAddress(), response.internalRemark(), response.createdAt(), response.approvedAt()); + } + + private TenantResponse merchantVisibleResponse(IamTenant tenant) { + TenantResponse response = response(tenant); + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.legalName(), response.unifiedSocialCreditCode(), response.contactName(), + response.contactPhone(), response.contactEmail(), response.contactWechat(), response.salesOwnerName(), + response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), + response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(), + null, response.createdAt(), response.approvedAt()); + } + + private String maskPhone(String phone) { + if (phone == null || phone.isBlank()) return phone; + if (phone.length() <= 4) return "****"; + int prefix = Math.min(3, phone.length() - 4); + return phone.substring(0, prefix) + "****" + phone.substring(phone.length() - 4); + } + + private String maskEmail(String email) { + if (email == null || email.isBlank()) return email; + int separator = email.indexOf('@'); + if (separator <= 0) return "***"; + return email.substring(0, 1) + "***" + email.substring(separator); + } } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index d132c8573..dc43c9043 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -16,6 +16,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( contact_name VARCHAR(64) NULL COMMENT '企业联系人', contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + contact_wechat VARCHAR(64) NULL COMMENT '联系微信', + sales_owner_name VARCHAR(64) NULL COMMENT '销售负责人姓名', + payment_term_days SMALLINT UNSIGNED NULL COMMENT '账期天数', + enterprise_type VARCHAR(64) NULL COMMENT '企业类型', + established_date DATE NULL COMMENT '成立日期', + registered_capital DECIMAL(18,2) NULL COMMENT '注册资本', + registered_capital_currency CHAR(3) NULL COMMENT '注册资本币种 ISO 4217', + registered_address VARCHAR(500) NULL COMMENT '注册地址文本', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', approved_at DATETIME(3) NULL COMMENT '审核通过时间', approved_by BIGINT NULL COMMENT '审核人ID', reject_reason VARCHAR(500) NULL COMMENT '驳回原因', diff --git a/iam/src/main/resources/db/phase1-customer-profile.sql b/iam/src/main/resources/db/phase1-customer-profile.sql new file mode 100644 index 000000000..019de2112 --- /dev/null +++ b/iam/src/main/resources/db/phase1-customer-profile.sql @@ -0,0 +1,48 @@ +-- 一期平台客户档案字段增量迁移。可在同一数据库重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'contact_wechat'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN contact_wechat VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); +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' AND column_name = 'sales_owner_name'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN sales_owner_name VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); +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' AND column_name = 'payment_term_days'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN payment_term_days SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); +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' AND column_name = 'enterprise_type'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN enterprise_type VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); +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' AND column_name = 'established_date'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN established_date DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); +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' AND column_name = 'registered_capital'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); +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' AND column_name = 'registered_capital_currency'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital_currency CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital'); +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' AND column_name = 'registered_address'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_address VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency'); +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' AND column_name = 'internal_remark'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql index 2a76408f6..ff0edf2e5 100644 --- a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -22,6 +22,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, built_in, deleted) VALUES + (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0), (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), @@ -50,3 +51,18 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission + ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission view_permission + ON view_permission.tenant_id = 0 + AND view_permission.app_code = 'PLATFORM' + AND view_permission.permission_code = 'platform:customer:view' +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 421e959bb..f023c496c 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Baiyue IAM API / 百悦身份与权限 API - description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。鉴权和协议层异常仍使用真实 HTTP 状态码。Tenant, member, role, menu, page, button and API permission management. + description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。 version: 1.0.1 servers: - url: / @@ -21,7 +21,7 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户列表 - description: 分页查询商户企业客户,需要 `platform:customer:list` 权限。 + description: 分页查询商户企业客户,需要 `platform:customer:list` 权限;联系电话和邮箱仅返回脱敏摘要。 parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} @@ -33,8 +33,10 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/TenantPageApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} + '422': {$ref: '#/components/responses/UnprocessableEntity'} post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业租户 @@ -45,8 +47,10 @@ paths: application/json: schema: {$ref: '#/components/schemas/RegisterRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/authorization/permissions: get: tags: [IAM Resources / IAM 资源] @@ -66,9 +70,11 @@ paths: get: tags: [Platform Tenant / 平台租户] summary: 查询企业客户档案 + description: 需要独立的 `platform:customer:view` 权限,返回完整联系方式。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantSuccess'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} put: tags: [Platform Tenant / 平台租户] summary: 编辑企业客户档案 @@ -78,8 +84,40 @@ paths: application/json: schema: {$ref: '#/components/schemas/UpdateTenantRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/addresses: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户地址 + description: 需要 `platform:customer:view` 权限;只读接口,不提供平台端地址写入。 + responses: + '200': + description: 企业客户地址查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantAddressListApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/qualifications: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户资质 + description: 需要 `platform:customer:view` 权限;只读查询资质元数据,不授予审核权限。 + responses: + '200': + description: 企业客户资质查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/platform/v1/iam/tenants/{tenantId}/status: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -92,8 +130,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/merchant/v1/iam/authorization/permissions: get: tags: [IAM Resources / IAM 资源] @@ -130,6 +171,8 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/reviews/{id}/reject: post: tags: [Platform Tenant / 平台租户] @@ -145,6 +188,8 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/internal-users: get: tags: [IAM Resources / IAM 资源] @@ -167,7 +212,9 @@ paths: schema: {$ref: '#/components/schemas/CreateInternalUserRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/internal-users/{membershipId}: parameters: - {$ref: '#/components/parameters/MembershipId'} @@ -181,7 +228,10 @@ paths: schema: {$ref: '#/components/schemas/UpdateInternalUserRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/internal-users/{membershipId}/status: parameters: - {$ref: '#/components/parameters/MembershipId'} @@ -195,7 +245,9 @@ paths: schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/platform/v1/iam/internal-users/{membershipId}/password: parameters: - {$ref: '#/components/parameters/MembershipId'} @@ -209,7 +261,9 @@ paths: schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/platform/v1/iam/internal-users/{membershipId}/roles: parameters: - {$ref: '#/components/parameters/MembershipId'} @@ -228,7 +282,9 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/InternalUserApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/merchant/v1/iam/tenant/profile: get: tags: [Merchant Tenant / 商户企业] @@ -238,6 +294,7 @@ paths: '200': {$ref: '#/components/responses/Success'} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} put: tags: [Merchant Tenant / 商户企业] summary: Update current tenant profile / 更新当前企业资料 @@ -248,7 +305,9 @@ paths: schema: {$ref: '#/components/schemas/UpdateProfileRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/merchant/v1/iam/members: get: tags: [Merchant Tenant / 商户企业] @@ -259,7 +318,7 @@ paths: post: tags: [Merchant Tenant / 商户企业] summary: Add enterprise member / 新增企业成员 - description: Attach an existing IAM user to the current tenant. / 将已有 IAM 用户加入当前企业。 + description: 新增基础资料需要 merchant:member:create;roleIds 非空时还必须具有 merchant:member:grant-role,否则返回 403/A0403。空数组表示不分配角色。 requestBody: required: true content: @@ -267,7 +326,38 @@ paths: schema: {$ref: '#/components/schemas/CreateMemberRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/merchant/v1/iam/members/{membershipId}: + parameters: + - name: membershipId + in: path + required: true + description: 当前租户内的成员关系 ID + schema: {type: string, pattern: '^\d+$'} + get: + tags: [Merchant Tenant / 商户企业] + summary: 查询成员详情及当前角色 + description: 返回当前租户成员资料,roleIds 为服务端实时查询的当前角色集合;跨租户成员按不存在处理。 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [Merchant Tenant / 商户企业] + summary: 编辑成员资料及角色 + description: 基础资料编辑需要 merchant:member:edit。仅当 roleIds 与当前角色集合实际不同时额外要求 merchant:member:grant-role;传空数组清空角色也属于角色变更。角色集合未变化时不误拒绝。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateMemberRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/merchant/v1/iam/tenant/addresses: get: tags: [IAM Resources / IAM 资源] @@ -285,6 +375,7 @@ paths: schema: {$ref: '#/components/schemas/AddressRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} /api/merchant/v1/iam/tenant/qualifications: get: @@ -313,7 +404,9 @@ paths: schema: {$ref: '#/components/schemas/CreateRoleRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/{app}/v1/iam/members/roles: put: tags: [RBAC] @@ -328,6 +421,7 @@ paths: schema: {$ref: '#/components/schemas/AssignRolesRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} /api/{app}/v1/iam/permissions: parameters: @@ -349,7 +443,9 @@ paths: schema: {$ref: '#/components/schemas/CreatePermissionRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/{app}/v1/iam/roles/permissions: put: tags: [RBAC] @@ -364,6 +460,7 @@ paths: schema: {$ref: '#/components/schemas/GrantPermissionsRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} /api/{app}/v1/iam/permissions/{permissionId}: parameters: @@ -380,7 +477,9 @@ paths: schema: {$ref: '#/components/schemas/UpdatePermissionRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} delete: tags: [RBAC] summary: 删除未绑定的自定义权限资源 @@ -388,6 +487,7 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/{app}/v1/iam/permissions/{permissionId}/status: parameters: - {$ref: '#/components/parameters/App'} @@ -402,7 +502,9 @@ paths: schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'} responses: '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} components: securitySchemes: bearerAuth: @@ -435,7 +537,7 @@ components: in: path required: true description: 企业客户租户 ID - schema: {type: integer, format: int64, minimum: 1} + schema: {type: string, pattern: '^\d+$'} PermissionId: name: permissionId in: path @@ -444,14 +546,19 @@ components: schema: {type: integer, format: int64} responses: Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}} + BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} schemas: ApiResponse: type: object required: [code, message, timestamp] properties: - code: {type: string, example: '200', description: 200 表示成功,其他值表示业务失败} + code: {type: string, example: '00000'} message: {type: string, example: Success} data: {} requestId: {type: string} @@ -480,6 +587,12 @@ components: size: {type: integer, format: int64, minimum: 1} total: {type: integer, format: int64, minimum: 0} pages: {type: integer, format: int64, minimum: 0} + TenantApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantResponse'} InternalUserListApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} @@ -496,7 +609,7 @@ components: type: object required: [id, tenantCode, tenantName, status] properties: - id: {type: integer, format: int64} + id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]} @@ -505,6 +618,58 @@ components: contactName: {type: string} contactPhone: {type: string} contactEmail: {type: string, format: email} + contactWechat: {type: string, maxLength: 64} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$', example: CNY} + registeredAddress: {type: string, maxLength: 500} + internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} + createdAt: {type: string, format: date-time, description: 入驻时间} + approvedAt: {type: string, format: date-time, description: 审批通过时间} + TenantAddressListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/TenantAddress'} + TenantQualificationListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/TenantQualification'} + TenantAddress: + type: object + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + addressType: {type: string} + countryCode: {type: string} + province: {type: string} + city: {type: string} + district: {type: string} + detail: {type: string} + postalCode: {type: string} + isDefault: {type: boolean} + TenantQualification: + type: object + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + qualificationType: {type: string} + certificateNo: {type: string} + documentUrl: {type: string} + validFrom: {type: string, format: date} + validTo: {type: string, format: date} + status: {type: string} + reviewComment: {type: string} RegisterRequest: type: object required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] @@ -515,6 +680,15 @@ components: contactName: {type: string, example: 李四} contactPhone: {type: string, example: '13800138000'} contactEmail: {type: string, format: email, example: contact@example.com} + contactWechat: {type: string, maxLength: 64} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$', example: CNY} + registeredAddress: {type: string, maxLength: 500} + internalRemark: {type: string, maxLength: 1000} UpdateProfileRequest: type: object required: [tenantName, legalName, contactName, contactPhone] @@ -533,6 +707,15 @@ components: contactName: {type: string} contactPhone: {type: string} contactEmail: {type: string, format: email} + contactWechat: {type: string, maxLength: 64} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$'} + registeredAddress: {type: string, maxLength: 500} + internalRemark: {type: string, maxLength: 1000} ChangeTenantStatusRequest: type: object required: [status] @@ -541,7 +724,7 @@ components: ReviewRequest: type: object properties: - comment: {type: string, maxLength: 500, description: Review comment or rejection reason / 审核意见或驳回原因} + comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因} CreateInternalUserRequest: type: object required: [displayName] @@ -603,9 +786,25 @@ components: displayName: {type: string, maxLength: 64} phone: {type: string, maxLength: 32} email: {type: string, format: email, maxLength: 128} - memberType: {type: string, enum: [OWNER, ADMIN, EMPLOYEE]} + memberType: {type: string, enum: [EMPLOYEE]} memberNo: {type: string} - roleIds: {type: array, items: {type: integer, format: int64}} + roleIds: + type: array + description: 初始角色 ID;非空时额外要求 merchant:member:grant-role + items: {type: string, pattern: '^\d+$'} + UpdateMemberRequest: + type: object + required: [displayName, memberType] + properties: + displayName: {type: string, maxLength: 64} + phone: {type: string, maxLength: 32} + email: {type: string, format: email, maxLength: 128} + memberType: {type: string, enum: [EMPLOYEE]} + memberNo: {type: string, maxLength: 64} + roleIds: + type: array + description: 完整目标角色集合;空数组表示清空,集合变化时额外要求 merchant:member:grant-role + items: {type: string, pattern: '^\d+$'} AddressRequest: type: object required: [addressType, detail] diff --git a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java index 5227e8f36..b2b01833f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java @@ -1,7 +1,6 @@ package com.baiyue.iam.controller; import com.baiyue.common.security.AuthenticatedUser; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -11,10 +10,9 @@ import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -@DisplayName("权限快照接口契约") class AuthorizationControllerContractTest { @Test - void currentPermissionsEndpointDoesNotRequireRoleManagementPermission() throws Exception { + void 当前权限接口不应依赖角色管理权限() throws Exception { Method method = AuthorizationController.class.getMethod("permissions", String.class, AuthenticatedUser.class); diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index bd6559e87..bf00637ab 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.iam.controller; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; @@ -10,10 +9,9 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("IAM OpenAPI 契约") class IamOpenApiContractTest { @Test - void openApiDeclaresPermissionPaginationAndInternalUserResponseSchema() throws IOException { + void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException { String document; try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) { assertThat(stream).isNotNull(); @@ -24,13 +22,29 @@ class IamOpenApiContractTest { .contains("/api/merchant/v1/iam/authorization/permissions:") .contains("PermissionCodesApiResponse:") .contains("TenantPageApiResponse:") + .contains("TenantApiResponse:") + .contains("TenantSuccess:") + .contains("salesOwnerName:") + .contains("paymentTermDays:") + .contains("registeredCapitalCurrency:") + .contains("createdAt: {type: string, format: date-time, description: 入驻时间}") + .contains("id: {type: string, pattern: '^\\d+$'") + .contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:") + .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:") + .contains("platform:customer:view") + .contains("TenantAddressListApiResponse:") + .contains("TenantQualificationListApiResponse:") .contains("InternalUserListApiResponse:") .contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:") .contains("AssignInternalUserRolesRequest:") + .contains("/api/merchant/v1/iam/members/{membershipId}:") + .contains("仅当 roleIds 与当前角色集合实际不同时额外要求 merchant:member:grant-role") + .contains("传空数组清空角色也属于角色变更") + .contains("roleIds 为服务端实时查询的当前角色集合") + .contains("UpdateMemberRequest:") .contains("membershipId: {type: integer, format: int64}") .contains("roleIds: {type: array, items: {type: integer, format: int64}}") - .contains("example: '200'") - .doesNotContain("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); + .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); Map openApi = new Yaml().load(document); assertThat(openApi).containsEntry("openapi", "3.0.3"); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java new file mode 100644 index 000000000..b0202fa5d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlatformCustomerResourceContractTest { + + @Test + void 平台地址和资质只读查询统一使用客户查看权限() throws Exception { + assertReadOnlyViewContract("platformAddresses", + "/api/platform/v1/iam/tenants/{tenantId}/addresses"); + assertReadOnlyViewContract("platformQualifications", + "/api/platform/v1/iam/tenants/{tenantId}/qualifications"); + } + + private void assertReadOnlyViewContract(String methodName, String path) throws Exception { + Method method = ProfileResourceController.class.getMethod( + methodName, long.class, AuthenticatedUser.class); + assertEquals(path, method.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:view')", + method.getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index eb5a40c10..c5b329514 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.iam.controller; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -10,11 +9,10 @@ import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.*; -@DisplayName("平台内部用户接口契约") class PlatformInternalUserContractTest { @Test - void internalUserListRequiresListPermission() throws Exception { + void 内部用户列表必须校验列表权限() throws Exception { Method method = ProfileResourceController.class.getMethod("internalUsers", com.baiyue.common.security.AuthenticatedUser.class); @@ -25,7 +23,7 @@ class PlatformInternalUserContractTest { } @Test - void platformUserWriteEndpointsAddressMembershipId() { + void 平台用户写接口以成员关系主键寻址() { assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}"); assertMapping("changeInternalUserStatus", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status"); @@ -36,7 +34,7 @@ class PlatformInternalUserContractTest { } @Test - void tenantProfileReadRequiresViewPermission() throws Exception { + void 企业资料查看必须校验查看权限() throws Exception { Method method = MerchantTenantController.class.getMethod("profile", com.baiyue.common.security.AuthenticatedUser.class); assertEquals("hasAuthority('merchant:profile:view')", diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index c58ca1d27..9139e81dc 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -5,9 +5,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import jakarta.validation.Valid; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -21,10 +18,9 @@ import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -@DisplayName("平台客户接口契约") class PlatformTenantControllerContractTest { @Test - void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndUnifiedBusinessStatus() throws Exception { + void 客户分页接口声明权限分页约束和错误状态() throws Exception { Method method = PlatformTenantController.class.getMethod("page", long.class, long.class, String.class, String.class, AuthenticatedUser.class); @@ -40,11 +36,11 @@ class PlatformTenantControllerContractTest { Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) .map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode) .collect(Collectors.toSet()); - assertEquals(Set.of("200", "401", "403"), statuses); + assertEquals(Set.of("200", "400", "401", "403", "422"), statuses); } @Test - void pendingMerchantListRequiresMerchantApprovalPermission() throws Exception { + void 待审列表必须校验客户审核权限() throws Exception { Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class); assertEquals("hasAuthority('platform:customer:review')", @@ -52,16 +48,11 @@ class PlatformTenantControllerContractTest { } @Test - void reviewRequestsAreValidatedAndCommentMatchesDatabaseLimit() throws Exception { - Method approve = PlatformTenantController.class.getMethod("approve", long.class, - com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class); - Method reject = PlatformTenantController.class.getMethod("reject", long.class, - com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class); - Method comment = com.baiyue.iam.dto.TenantDtos.ReviewRequest.class.getMethod("comment"); + void 客户详情必须使用独立查看权限() throws Exception { + Method method = PlatformTenantController.class.getMethod("tenant", long.class, AuthenticatedUser.class); - assertNotNull(annotation(approve, 1, Valid.class)); - assertNotNull(annotation(reject, 1, Valid.class)); - assertEquals(500, comment.getAnnotation(Size.class).max()); + assertEquals("hasAuthority('platform:customer:view')", + method.getAnnotation(PreAuthorize.class).value()); } private T annotation(Method method, int parameter, Class type) { diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java index ee2f47c7e..cc6af9acd 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.iam.controller; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.web.bind.annotation.PostMapping; @@ -11,7 +10,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -@DisplayName("企业资质上传接口契约") class QualificationUploadContractTest { @Test diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java new file mode 100644 index 000000000..9e0ae9496 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -0,0 +1,60 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerProfileMigrationContractTest { + @Test + void 客户档案迁移覆盖全部字段并通过元数据判断保持可重复执行() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/phase1-customer-profile.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql) + .contains("USE baiyue_iam;") + .contains("SET NAMES utf8mb4;") + .contains("information_schema.columns") + .contains("contact_wechat") + .contains("sales_owner_name") + .contains("payment_term_days") + .contains("enterprise_type") + .contains("established_date") + .contains("registered_capital") + .contains("registered_capital_currency") + .contains("registered_address") + .contains("internal_remark"); + } + + @Test + void 统一测试环境入口包含客户字段查看权限和执行后核验() throws IOException { + Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); + Path migration = repositoryRoot.resolve("deploy/sql/2026-07-31一期回归测试环境增量迁移.sql"); + Path readme = repositoryRoot.resolve("deploy/sql/README.md"); + + assertThat(migration).exists(); + assertThat(readme).exists(); + String sql = Files.readString(migration, StandardCharsets.UTF_8); + String instructions = Files.readString(readme, StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CALL regression_assert_table('iam_tenant')") + .contains("CREATE PROCEDURE regression_add_iam_column") + .contains("platform:customer:view") + .contains("'IAM客户档案字段' AS check_item") + .contains("9 AS expected_count") + .contains("44 AS expected_count") + .contains("MIGRATION_COMPLETED"); + assertThat(instructions) + .contains("phase1-platform-master-data-and-permission-management.sql") + .contains("phase1-customer-profile.sql") + .contains("测试环境只执行统一入口"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 4829b6f69..b878d69a8 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.mapper.AuthorizationQueryMapper; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -17,7 +16,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("权限快照服务") class AuthorizationSnapshotServiceTest { @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { @@ -84,7 +82,7 @@ class AuthorizationSnapshotServiceTest { } @Test - void emptyRoleAndPermissionQueriesDoNotTriggerServerError() { + void 空角色和权限查询结果不应触发五百错误() { AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 827a529e8..a9e193697 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -11,7 +11,6 @@ import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; @@ -21,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -@DisplayName("商户成员服务") class MemberServiceTest { private final MemberRepository repository = mock(MemberRepository.class); private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); @@ -39,7 +37,7 @@ class MemberServiceTest { when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); } - @Test void memberDetailsReturnContactInformationAndExistingRoles() { + @Test void 成员详情返回联系方式和已有角色() { user.setPhone("13800138000"); user.setEmail("member@example.com"); MemberResponse result = service.detail(100L, 20L); assertEquals("测试成员", result.displayName()); @@ -48,12 +46,12 @@ class MemberServiceTest { verifyNoInteractions(auth); } - @Test void crossTenantLookupIsRejected() { + @Test void 跨租户查询被拒绝() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); assertThrows(BizException.class, () -> service.detail(200L, 20L)); } - @Test void tenantOwnerCannotBeDisabled() { + @Test void 企业所有者不可停用() { member.setMemberType("OWNER"); BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 20L)); @@ -61,7 +59,7 @@ class MemberServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } - @Test void disablingMemberSynchronizesAuthAccountAndPersistsLocalStatus() { + @Test void 停用成员同步认证账号并保存本地状态() { when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null)); MemberResponse result = service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L); @@ -70,28 +68,14 @@ class MemberServiceTest { verify(repository).saveUser(user); } - @Test void authServiceFailureDoesNotPersistLocalStatus() { + @Test void 认证服务失败时不得写入本地状态() { when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败")); - BizException exception = assertThrows(BizException.class, - () -> service.changeStatus(100L, 20L, - new ChangeMemberStatusRequest("DISABLED"), 99L)); - assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); - verify(repository, never()).save(member); - } - - @Test void authHttp200BusinessFailureKeepsBusinessCodeAndDoesNotPersistLocalStatus() { - when(auth.status(anyString(), eq(40L), any())).thenReturn( - ApiResponse.failure(ResultCode.NOT_FOUND, "认证账号不存在", null, null)); - - BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, + assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L)); - - assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); - assertEquals("认证账号不存在", exception.getMessage()); verify(repository, never()).save(member); } - @Test void tenantOwnerCannotBeDowngradedOrHaveRolesChangedThroughMemberUpdate() { + @Test void 企业所有者不能通过成员编辑接口降级或变更角色() { member.setMemberType("OWNER"); UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, "EMPLOYEE", null, List.of()); @@ -101,7 +85,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void createWithRolesRequiresGrantRolePermission() { + @Test void 新增成员携带非空角色但无授权权限时返回403且不创建账号() { CreateMemberRequest request = new CreateMemberRequest("member01", "Password1", "成员", null, null, "EMPLOYEE", null, List.of(50L)); @@ -113,7 +97,7 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } - @Test void profileOnlyUpdateAllowsUnchangedRolesWithoutGrantPermission() { + @Test void 编辑资料且角色集合未变化时不要求角色授权权限() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null, @@ -126,7 +110,7 @@ class MemberServiceTest { verify(repository).replaceRoles(100L, 20L, List.of(50L)); } - @Test void roleChangeRequiresGrantRolePermission() { + @Test void 编辑成员变更角色但无授权权限时返回403() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of(60L)); @@ -138,7 +122,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void clearingRolesAlsoRequiresGrantRolePermission() { + @Test void 空集合清空已有角色同样要求角色授权权限() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of()); @@ -149,7 +133,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void grantRolePermissionAllowsClearingRoles() { + @Test void 具有角色授权权限时允许清空已有角色() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of()); @@ -159,7 +143,7 @@ class MemberServiceTest { verify(repository).replaceRoles(100L, 20L, List.of()); } - @Test void crossTenantUpdateReturnsNotFoundBeforeReadingRoles() { + @Test void 跨租户编辑先按目标租户查成员并返回404() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of(50L)); @@ -178,7 +162,7 @@ class MemberServiceTest { return role; } - @Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() { + @Test void 企业所有者密码不能被成员管理员重置() { member.setMemberType("OWNER"); assertThrows(BizException.class, () -> service.resetPassword(100L, 20L, diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index 20126ea9f..a2fb8253d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -1,7 +1,6 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.PlatformInternalUserDtos.*; @@ -11,7 +10,6 @@ import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -21,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -@DisplayName("平台内部用户服务") class PlatformInternalUserServiceTest { private final MemberRepository repository = mock(MemberRepository.class); private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); @@ -48,7 +45,7 @@ class PlatformInternalUserServiceTest { } @Test - void listReturnsMembershipIdContactInformationAndCurrentRoles() { + void 列表返回成员主键联系方式和当前角色() { when(repository.findByTenant(0L)).thenReturn(List.of(membership)); InternalUserResponse result = service.list().get(0); @@ -60,30 +57,7 @@ class PlatformInternalUserServiceTest { } @Test - void authHttp200BusinessFailureKeepsBusinessCode() { - when(repository.findByTenant(0L)).thenReturn(List.of(membership)); - when(auth.summary("internal-token", 10L)).thenReturn( - ApiResponse.failure( - ResultCode.NOT_FOUND, "认证账号不存在", null, null)); - - BizException exception = assertThrows(BizException.class, service::list); - - assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); - assertEquals("认证账号不存在", exception.getMessage()); - } - - @Test - void authDecodeOrTransportFailureMapsToServiceUnavailable() { - when(repository.findByTenant(0L)).thenReturn(List.of(membership)); - when(auth.summary("internal-token", 10L)).thenThrow(new RuntimeException("decode failed")); - - BizException exception = assertThrows(BizException.class, service::list); - - assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); - } - - @Test - void createPlatformUserCreatesPlatformAccountInAuthAndMembershipInIam() { + void 新建平台用户在Auth创建平台账号并写入IAM成员() { IamRole role = new IamRole(); role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); when(roleMapper.selectById(40L)).thenReturn(role); @@ -107,7 +81,7 @@ class PlatformInternalUserServiceTest { } @Test - void localCreationFailureCompensatesAuthAccountWithoutRecordingPassword() { + void 本地创建失败会补偿认证账号且不记录密码() { when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); @@ -121,7 +95,7 @@ class PlatformInternalUserServiceTest { } @Test - void cannotDisableCurrentlyAuthenticatedPlatformMember() { + void 不能停用当前登录的平台成员() { assertThrows(BizException.class, () -> service.changeStatus(30L, new ChangeInternalUserStatusRequest("DISABLED"), 30L)); @@ -129,7 +103,7 @@ class PlatformInternalUserServiceTest { } @Test - void localStatusPersistenceFailureRestoresAuthAndAvoidsFurtherRemoteLookupsAfterSuccess() { + void 状态本地写入失败时恢复Auth且成功后不再远程查询() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); @@ -144,7 +118,7 @@ class PlatformInternalUserServiceTest { } @Test - void statusCompensationRestoresOriginalAuthStatusInsteadOfOldIamStatus() { + void 状态补偿应恢复Auth原状态而不是IAM旧状态() { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED"))); when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); @@ -160,7 +134,7 @@ class PlatformInternalUserServiceTest { } @Test - void resetPasswordDelegatesOnlyToAuthWithoutWritingIam() { + void 重置密码只转交Auth不写入IAM() { when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1")); @@ -171,7 +145,7 @@ class PlatformInternalUserServiceTest { } @Test - void roleAssignmentAcceptsEnabledPlatformRolesOnlyAndReturnsLatestView() { + void 角色分配只接受平台已启用角色并返回最新回显() { IamRole role = new IamRole(); role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); when(roleMapper.selectById(41L)).thenReturn(role); @@ -184,7 +158,7 @@ class PlatformInternalUserServiceTest { } @Test - void preventsAssigningMerchantRolesToPlatformUser() { + void 禁止给平台用户分配商户角色() { IamRole merchantRole = new IamRole(); merchantRole.setId(42L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE"); diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 48ac78566..85bfbe967 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -16,7 +16,6 @@ import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.RbacRepository; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -32,7 +31,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -@DisplayName("角色与权限服务") class RbacServiceTest { @Mock private RbacRepository repository; @Mock private MemberRepository memberRepository; @@ -46,7 +44,7 @@ class RbacServiceTest { } @Test - void roleListReturnsCurrentPermissionsForSafeView() { + void 角色列表返回当前权限用于安全回显() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); @@ -58,7 +56,7 @@ class RbacServiceTest { } @Test - void permissionListPrefersCurrentTenantAndRemovesDuplicateGlobalTemplatesWithSameCode() { + void 权限列表同码时优先当前租户并去除全局模板重复项() { IamPermission global = permission(21L, 0L, "merchant:member:list"); IamPermission tenant = permission(22L, 100L, "merchant:member:list"); IamPermission fallback = permission(23L, 0L, "merchant:member:status"); @@ -73,7 +71,7 @@ class RbacServiceTest { } @Test - void crossTenantRoleAccessIsRejected() { + void 跨租户角色访问被拒绝() { when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null); BizException exception = assertThrows(BizException.class, @@ -83,7 +81,7 @@ class RbacServiceTest { } @Test - void systemRoleCannotBeDeleted() { + void 系统角色禁止删除() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -95,7 +93,7 @@ class RbacServiceTest { } @Test - void customRoleAssignedToMembersCannotBeDeleted() { + void 已分配成员的自定义角色禁止删除() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(true); @@ -108,7 +106,7 @@ class RbacServiceTest { } @Test - void emptyPermissionSetExplicitlyClearsAuthorization() { + void 空权限集合可以明确清空授权() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); @@ -118,7 +116,7 @@ class RbacServiceTest { } @Test - void emptyRoleSetExplicitlyClearsMemberAuthorization() { + void 空角色集合可以明确清空成员授权() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -130,7 +128,7 @@ class RbacServiceTest { } @Test - void memberRoleViewReturnsRolesFromCurrentApplicationDomainOnly() { + void 成员角色回显只返回当前应用域角色() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -146,7 +144,7 @@ class RbacServiceTest { } @Test - void duplicateRoleCodeWithinTenantAndApplicationReturnsConflict() { + void 同一租户同一应用角色编码重复时返回冲突() { when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true); BizException exception = assertThrows(BizException.class, () -> service.createRole( @@ -157,7 +155,7 @@ class RbacServiceTest { } @Test - void disabledRoleCannotBeAssignedToMember() { + void 停用角色不得继续分配给成员() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -174,7 +172,7 @@ class RbacServiceTest { } @Test - void customRoleCanBeUpdatedAndDisabledWhileRetainingPermissionView() { + void 自定义角色可以编辑和停用并保持权限回显() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); @@ -191,7 +189,7 @@ class RbacServiceTest { } @Test - void unreferencedCustomRoleCanBeDeletedSafely() { + void 未使用的自定义角色可以安全删除() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(false); @@ -202,7 +200,7 @@ class RbacServiceTest { } @Test - void tenantOwnerRolesCannotBeChangedThroughRegularMemberAuthorizationEndpoint() { + void 企业所有者角色不能通过普通成员授权接口修改() { IamMembership owner = new IamMembership(); owner.setId(31L); owner.setTenantId(100L); @@ -217,7 +215,7 @@ class RbacServiceTest { } @Test - void systemRolePermissionsCannotBeChangedThroughRegularAuthorizationEndpoint() { + void 系统角色权限不能通过普通授权接口修改() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -229,7 +227,7 @@ class RbacServiceTest { } @Test - void tenantOwnerSystemRoleCannotBeAssignedToRegularMember() { + void 企业所有者系统角色不能分配给普通成员() { IamMembership member = new IamMembership(); member.setId(31L); member.setTenantId(100L); @@ -247,7 +245,7 @@ class RbacServiceTest { } @Test - void duplicatePermissionCodeReturnsStableConflictWithoutDatabaseWrite() { + void 权限编码重复时返回稳定冲突且不写数据库() { when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test")) .thenReturn(true); CreatePermissionRequest request = new CreatePermissionRequest( @@ -261,7 +259,7 @@ class RbacServiceTest { } @Test - void builtInSystemPermissionCannotBeUpdatedDisabledOrDeleted() { + void 系统预置权限不允许编辑启停或删除() { IamPermission builtIn = permission(21L, 0L, "platform:permission:list"); builtIn.setAppCode("PLATFORM"); builtIn.setBuiltIn(true); @@ -276,7 +274,7 @@ class RbacServiceTest { } @Test - void customPermissionCanBeUpdatedAndToggledWithoutChangingCode() { + void 自定义权限能编辑和启停但保持编码不变() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); @@ -292,7 +290,7 @@ class RbacServiceTest { } @Test - void permissionResourceWithChildrenOrRoleBindingsRejectsDeletion() { + void 带子节点或角色绑定的权限资源拒绝删除() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true); @@ -310,7 +308,7 @@ class RbacServiceTest { } @Test - void unreferencedCustomPermissionCanBeDeleted() { + void 无引用的自定义权限可以删除() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 313c5dfed..84c3af268 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -2,27 +2,26 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamTenant; -import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.ProfileResourceRepository; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertThrows; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; -@DisplayName("企业租户服务") class TenantServiceTest { @Test @@ -41,6 +40,11 @@ class TenantServiceTest { tenant.setContactName("Alice"); tenant.setContactPhone("13800138000"); tenant.setContactEmail("alice@example.com"); + tenant.setSalesOwnerName("Sales Zhang"); + tenant.setPaymentTermDays(30); + tenant.setEnterpriseType("LIMITED_COMPANY"); + tenant.setCreatedAt(LocalDateTime.of(2026, 7, 1, 9, 30)); + tenant.setApprovedAt(LocalDateTime.of(2026, 7, 2, 10, 0)); when(repository.pageMerchants(1, 10, "Example", "ACTIVE")) .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); @@ -49,6 +53,12 @@ class TenantServiceTest { assertEquals(1, result.total()); assertEquals("TEN10001", result.records().get(0).tenantCode()); assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("Sales Zhang", result.records().get(0).salesOwnerName()); + assertEquals(30, result.records().get(0).paymentTermDays()); + assertEquals(LocalDateTime.of(2026, 7, 1, 9, 30), result.records().get(0).createdAt()); + assertEquals(LocalDateTime.of(2026, 7, 2, 10, 0), result.records().get(0).approvedAt()); + assertEquals("138****8000", result.records().get(0).contactPhone()); + assertEquals("a***@example.com", result.records().get(0).contactEmail()); verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } @@ -61,10 +71,21 @@ class TenantServiceTest { when(repository.findTenant(10001L)).thenReturn(tenant); var updated = service.updateMerchant(10001L, - new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com")); + new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com", + "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); + assertEquals("alice_wechat", updated.contactWechat()); + assertEquals("Sales Zhang", updated.salesOwnerName()); + assertEquals(45, updated.paymentTermDays()); + assertEquals("LIMITED_COMPANY", updated.enterpriseType()); + assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); + assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); + assertEquals("CNY", updated.registeredCapitalCurrency()); + assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); + assertEquals("一期重点客户", updated.internalRemark()); assertEquals("DISABLED", disabled.status()); verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } @@ -83,7 +104,7 @@ class TenantServiceTest { } @Test - void activeTenantStillRequiresApprovedQualificationToPlaceOrder() { + void 企业激活不等于具备下单资格仍需已审核资质() { TenantRepository repository = mock(TenantRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); TenantService service = new TenantService(); @@ -100,59 +121,6 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } - @Test - void reviewCompletesPendingTicketWithConditionalUpdate() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); - IamTenantReview review = pendingReview(90001L, 10001L); - IamTenant tenant = merchant(10001L, "PENDING"); - when(repository.findReview(90001L)).thenReturn(review); - when(repository.completeReview(org.mockito.ArgumentMatchers.eq(90001L), - org.mockito.ArgumentMatchers.eq("APPROVED"), org.mockito.ArgumentMatchers.eq(70001L), - org.mockito.ArgumentMatchers.eq("ok"), org.mockito.ArgumentMatchers.any())) - .thenReturn(true); - when(repository.findTenant(10001L)).thenReturn(tenant); - when(profileRepository.findQualifications(10001L)).thenReturn(List.of()); - - service.review(90001L, 70001L, true, "ok"); - - assertEquals("ACTIVE", tenant.getStatus()); - assertEquals(70001L, tenant.getApprovedBy()); - verify(repository).saveTenant(tenant); - } - - @Test - void duplicateOrConcurrentReviewIsRejectedBeforeTenantMutation() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); - when(repository.findReview(90001L)).thenReturn(pendingReview(90001L, 10001L)); - when(repository.completeReview(org.mockito.ArgumentMatchers.eq(90001L), - org.mockito.ArgumentMatchers.eq("REJECTED"), org.mockito.ArgumentMatchers.eq(70001L), - org.mockito.ArgumentMatchers.eq("no"), org.mockito.ArgumentMatchers.any())) - .thenReturn(false); - - BizException exception = assertThrows(BizException.class, - () -> service.review(90001L, 70001L, false, "no")); - - assertEquals(ResultCode.CONFLICT, exception.getResultCode()); - verify(repository, never()).findTenant(org.mockito.ArgumentMatchers.anyLong()); - verify(repository, never()).saveTenant(org.mockito.ArgumentMatchers.any()); - } - - private IamTenantReview pendingReview(long id, long tenantId) { - IamTenantReview review = new IamTenantReview(); - review.setId(id); - review.setTenantId(tenantId); - review.setStatus("PENDING"); - return review; - } - private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index cf4004b43..bb0db74b8 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -28,8 +28,9 @@ ## 修改与验证 - 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 +- 平台仓库管理接口必须同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 -- 内部有效性查询必须区分“不存在或跨租户=404”和“已停用=409”,Order 依赖该状态码生成稳定业务错误。 +- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`,不存在或跨租户返回 404,记录存在但已停用返回 409;数据库或结构故障保持 503。内部路径不加入 Gateway 公网路由。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 - 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。 - `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index 86fa128e4..0b19ff27f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -46,6 +46,7 @@ public class PlatformExpressCompanyController { @Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台快递公司查询权限") }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index 91fcf0a37..48dfa3acb 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -46,6 +46,7 @@ public class PlatformPartnerController { @Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台往来单位查询权限") }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 67787d990..71522cfd8 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -16,6 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -48,9 +49,12 @@ public class PlatformWarehouseController { ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required (platform:warehouse:create) / 需要平台仓库新增权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PostMapping + @PreAuthorize("hasAuthority('platform:warehouse:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); @@ -64,9 +68,13 @@ public class PlatformWarehouseController { ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or tenant validation failed / 需要平台仓库编辑权限,或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -86,9 +94,11 @@ public class PlatformWarehouseController { ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse list permission required (platform:warehouse:list) / 需要平台仓库查询权限") }) @GetMapping + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -105,9 +115,11 @@ public class PlatformWarehouseController { ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse list permission required (platform:warehouse:list) / 需要平台仓库查询权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -125,9 +137,12 @@ public class PlatformWarehouseController { ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse status permission required (platform:warehouse:status) / 需要平台仓库启停权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:warehouse:status')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java index 298baebda..d0f7e9680 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -/** 仓储数据库不可用或结构漂移时返回可识别的业务码,并保留完整链路日志。 */ +/** 仓储数据库不可用或结构漂移时返回可识别的 503,并保留完整链路日志。 */ @Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice(basePackages = "com.baiyue.warehouse.controller") public class WarehouseDataAccessExceptionHandler { @@ -25,7 +25,7 @@ public class WarehouseDataAccessExceptionHandler { String traceId = CorrelationContext.traceId(request); log.error("Warehouse data access failure requestId={} traceId={}", requestId, traceId, exception); ResultCode code = ResultCode.SERVICE_UNAVAILABLE; - return ResponseEntity.ok(ApiResponse.failure(code, + return ResponseEntity.status(code.httpStatus()).body(ApiResponse.failure(code, "仓储数据库暂不可用,请检查数据源和数据库迁移", requestId, traceId)); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index 3929ef91e..8f3617a33 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -1,7 +1,7 @@ package com.baiyue.warehouse.controller; import io.swagger.v3.oas.annotations.Operation; -import org.junit.jupiter.api.DisplayName; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -14,11 +14,21 @@ import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("平台主数据控制器契约") class PlatformMasterDataControllerContractTest { @Test - @DisplayName("往来单位分页路径权限和接口说明完整") - void shouldDeclareCompletePartnerListContract() throws Exception { + void 仓库平台接口按读取新增编辑和启停权限隔离() { + RequestMapping mapping = PlatformWarehouseController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).containsExactly("/api/platform/v1/warehouse-admin"); + + assertWarehouseMethod("page", GetMapping.class, "platform:warehouse:list"); + assertWarehouseMethod("detail", GetMapping.class, "platform:warehouse:list"); + assertWarehouseMethod("create", PostMapping.class, "platform:warehouse:create"); + assertWarehouseMethod("update", PutMapping.class, "platform:warehouse:edit"); + assertWarehouseMethod("status", PatchMapping.class, "platform:warehouse:status"); + } + + @Test + void 往来单位分页路径权限和接口说明完整() throws Exception { assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners"); assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list"); assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create"); @@ -27,8 +37,7 @@ class PlatformMasterDataControllerContractTest { } @Test - @DisplayName("快递公司分页路径权限和接口说明完整") - void shouldDeclareCompleteExpressCompanyListContract() throws Exception { + void 快递公司分页路径权限和接口说明完整() throws Exception { assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies"); assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list"); assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create"); @@ -59,7 +68,27 @@ class PlatformMasterDataControllerContractTest { .findFirst() .orElseThrow(); assertThat(method.getAnnotation(mapping)).isNotNull(); - assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority); + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + assertThat(preAuthorize).isNotNull(); + assertThat(preAuthorize.value()).isEqualTo("hasAuthority('" + authority + "')"); assertThat(method.getAnnotation(Operation.class).summary()).isNotBlank(); } + + private static void assertWarehouseMethod(String methodName, + Class mapping, + String authority) { + assertMethod(PlatformWarehouseController.class, methodName, mapping, authority); + Method method = java.util.Arrays.stream(PlatformWarehouseController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + ApiResponses responses = method.getAnnotation(ApiResponses.class); + assertThat(responses).isNotNull(); + assertThat(java.util.Arrays.stream(responses.value()) + .filter(response -> "403".equals(response.responseCode())) + .map(io.swagger.v3.oas.annotations.responses.ApiResponse::description)) + .singleElement() + .asString() + .contains(authority); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java index e113b36a2..e1fb525ee 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java @@ -2,7 +2,6 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import jakarta.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DataAccessResourceFailureException; @@ -10,11 +9,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -@DisplayName("仓库数据访问异常处理器") class WarehouseDataAccessExceptionHandlerTest { @Test - @DisplayName("数据源故障统一返回HTTP 200与S0503业务码并保留请求标识") - void shouldReturnServiceUnavailableAndPreserveRequestIdsForDataSourceFailure() { + void 数据源故障统一返回503并保留请求标识() { HttpServletRequest request = mock(HttpServletRequest.class); when(request.getAttribute("requestId")).thenReturn("request-1"); when(request.getAttribute("traceId")).thenReturn("trace-1"); @@ -22,7 +19,7 @@ class WarehouseDataAccessExceptionHandlerTest { var response = new WarehouseDataAccessExceptionHandler().handle( new DataAccessResourceFailureException("database unavailable"), request); - assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getStatusCode().value()).isEqualTo(503); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().code()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE.code()); assertThat(response.getBody().message()).contains("数据库"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java index eb9efb506..70e64731a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java @@ -1,18 +1,15 @@ package com.baiyue.warehouse.controller; import com.baiyue.warehouse.service.WarehouseSchemaReadinessService; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -@DisplayName("仓库健康检查控制器") class WarehouseHealthControllerTest { @Test - @DisplayName("表结构漂移时状态接口返回503") - void shouldReturnServiceUnavailableWhenSchemaDrifts() { + void 表结构漂移时状态接口返回503() { WarehouseSchemaReadinessService service = mock(WarehouseSchemaReadinessService.class); when(service.readiness()).thenReturn( new WarehouseSchemaReadinessService.Readiness(false, "DEGRADED", "需要执行迁移")); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java index b6d3f3ba1..e27391153 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java @@ -9,7 +9,6 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -19,16 +18,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("快递公司仓储") class ExpressCompanyRepositoryTest { @BeforeAll - static void initializeEntityFieldCache() { + static void 初始化实体字段缓存() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class); } @Test - @DisplayName("平台分页强制租户并支持筛选") - void shouldEnforceTenantAndSupportFilteringForPlatformPage() { + void 平台分页强制租户并支持筛选() { ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index be49d534b..1659ecbb7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -3,7 +3,6 @@ package com.baiyue.warehouse.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.mapper.PartnerMapper; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; import org.mockito.ArgumentCaptor; @@ -17,23 +16,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@DisplayName("往来单位仓储") class PartnerRepositoryTest { - @BeforeAll static void initializeEntityFieldCache() { + @BeforeAll static void 初始化实体字段缓存() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PartnerEntity.class); } - @Test - @DisplayName("候选查询强制租户和启用状态") - void shouldEnforceTenantAndActiveStatusForCandidateQuery() { + @Test void 候选查询强制租户和启用状态() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER"); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment(); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER"); } - @Test - @DisplayName("详情查询强制租户和启用状态") - void shouldEnforceTenantAndActiveStatusForDetailQuery() { + @Test void 详情查询强制租户和启用状态() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectOne(any())).thenReturn(null); new PartnerRepository(mapper).findActive(10002,9); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -41,9 +35,7 @@ class PartnerRepositoryTest { assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); } - @Test - @DisplayName("平台分页强制租户并支持筛选") - void shouldEnforceTenantAndSupportFilteringForPlatformPage() { + @Test void 平台分页强制租户并支持筛选() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index a10be05f7..e63131f57 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -1,6 +1,5 @@ package com.baiyue.warehouse.repository; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -9,11 +8,9 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; -@DisplayName("仓库数据库结构契约") class WarehouseSchemaContractTest { @Test - @DisplayName("仓库三张表包含实体全部持久化字段") - void shouldContainAllPersistentFieldsInWarehouseTables() throws IOException { + void 仓库三张表包含实体全部持久化字段() throws IOException { String sql = read("warehouse.sql"); assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code", "timezone", "contact_name", "contact_phone", "contact_email", "status", "remark"); @@ -25,8 +22,7 @@ class WarehouseSchemaContractTest { } @Test - @DisplayName("往来单位表包含实体全部持久化字段") - void shouldContainAllPersistentFieldsInPartnerTable() throws IOException { + void 往来单位表包含实体全部持久化字段() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", @@ -35,8 +31,7 @@ class WarehouseSchemaContractTest { } @Test - @DisplayName("快递公司表包含实体全部持久化字段") - void shouldContainAllPersistentFieldsInExpressCompanyTable() throws IOException { + void 快递公司表包含实体全部持久化字段() throws IOException { String sql = read("warehouse-express-company.sql"); assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website", "tracking_url_template", "status", "remark"); @@ -44,8 +39,7 @@ class WarehouseSchemaContractTest { } @Test - @DisplayName("既有库增量迁移覆盖全部仓储实体表和列表索引") - void shouldCoverAllWarehouseTablesAndListIndexesInIncrementalMigration() throws IOException { + void 既有库增量迁移覆盖全部仓储实体表和列表索引() throws IOException { String sql = read("phase1-warehouse-schema-migration.sql"); assertThat(sql).contains("FROM information_schema.columns") .contains("CREATE PROCEDURE add_column_if_missing") diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java index c7ea6da25..2237eb2cf 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java @@ -12,7 +12,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class PartnerQueryServiceTest { - @Test void missingOrCrossTenantPartnerReturnsNotFound() { + @Test void 不存在或跨租户往来单位返回404() { PartnerRepository repository = mock(PartnerRepository.class); assertThatThrownBy(() -> new PartnerQueryService(repository).active(10001L, 7L)) @@ -21,7 +21,7 @@ class PartnerQueryServiceTest { .isEqualTo(ResultCode.NOT_FOUND); } - @Test void inactivePartnerReturnsConflict() { + @Test void 停用往来单位返回409() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity partner = new PartnerEntity(); partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("INACTIVE"); @@ -33,7 +33,7 @@ class PartnerQueryServiceTest { .isEqualTo(ResultCode.CONFLICT); } - @Test void activePartnerIsReturned() { + @Test void 启用往来单位正常返回() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity partner = new PartnerEntity(); partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("ACTIVE"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index b83671c6e..6eadbc51b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -7,7 +7,6 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.ExpressCompanyRepository; import com.baiyue.warehouse.repository.PartnerRepository; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DataIntegrityViolationException; @@ -21,11 +20,9 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("平台主数据维护服务") class PlatformMasterDataManagementServiceTest { @Test - @DisplayName("往来单位详情强制租户隔离") - void shouldEnforceTenantIsolationForPartnerDetail() { + void 往来单位详情强制租户隔离() { PartnerRepository repository = mock(PartnerRepository.class); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -34,8 +31,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("往来单位编码在租户内必须唯一") - void shouldRequireUniquePartnerCodeWithinTenant() { + void 往来单位编码在租户内必须唯一() { PartnerRepository repository = mock(PartnerRepository.class); when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -46,8 +42,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("往来单位并发重复编码统一返回业务冲突") - void shouldReturnBusinessConflictForConcurrentDuplicatePartnerCode() { + void 往来单位并发重复编码统一返回业务冲突() { PartnerRepository repository = mock(PartnerRepository.class); doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -57,8 +52,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("往来单位新增写入租户和请求字段") - void shouldPersistTenantAndRequestFieldsWhenCreatingPartner() { + void 往来单位新增写入租户和请求字段() { PartnerRepository repository = mock(PartnerRepository.class); doAnswer(invocation -> { PartnerEntity entity = invocation.getArgument(0); @@ -77,8 +71,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("往来单位编辑保留归属并更新字段") - void shouldPreserveOwnershipAndUpdateFieldsWhenEditingPartner() { + void 往来单位编辑保留归属并更新字段() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity entity = new PartnerEntity(); entity.setId(19L); @@ -94,8 +87,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("往来单位停用保留主数据") - void shouldPreservePartnerMasterDataWhenDeactivating() { + void 往来单位停用保留主数据() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity entity = new PartnerEntity(); entity.setId(8L); @@ -111,8 +103,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司详情强制租户隔离") - void shouldEnforceTenantIsolationForExpressCompanyDetail() { + void 快递公司详情强制租户隔离() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -121,8 +112,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司编码在租户内必须唯一") - void shouldRequireUniqueExpressCompanyCodeWithinTenant() { + void 快递公司编码在租户内必须唯一() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); when(repository.existsCode(10001L, "SF", null)).thenReturn(true); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -133,8 +123,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司并发重复编码统一返回业务冲突") - void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() { + void 快递公司并发重复编码统一返回业务冲突() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -144,8 +133,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司新增写入租户和请求字段") - void shouldPersistTenantAndRequestFieldsWhenCreatingExpressCompany() { + void 快递公司新增写入租户和请求字段() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); doAnswer(invocation -> { ExpressCompanyEntity entity = invocation.getArgument(0); @@ -165,8 +153,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司编辑保留归属并更新字段") - void shouldPreserveOwnershipAndUpdateFieldsWhenEditingExpressCompany() { + void 快递公司编辑保留归属并更新字段() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(29L); @@ -182,8 +169,7 @@ class PlatformMasterDataManagementServiceTest { } @Test - @DisplayName("快递公司停用保留主数据") - void shouldPreserveExpressCompanyMasterDataWhenDeactivating() { + void 快递公司停用保留主数据() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(9L); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index d8b80c22e..ab6ddfee1 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -4,17 +4,14 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseRepository; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -@DisplayName("仓库应用服务") class WarehouseApplicationServiceTest { @Test - @DisplayName("商户详情只返回启用且属于租户的仓库") - void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() { + void 商户详情只返回启用且属于租户的仓库() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); when(repository.findOwned(10001,1)).thenReturn(warehouse); @@ -25,16 +22,14 @@ class WarehouseApplicationServiceTest { verify(repository).findOwned(10001,1); } @Test - @DisplayName("跨租户或不存在仓库返回 404") - void shouldTreatMissingOrCrossTenantWarehouseAsNotFound() { + void 跨租户或不存在仓库返回404() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); } @Test - @DisplayName("停用仓库返回 409") - void shouldTreatInactiveWarehouseAsConflict() { + void 停用仓库返回409() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("INACTIVE"); when(repository.findOwned(10001,1)).thenReturn(warehouse); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java index df509c781..e160433d2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java @@ -1,6 +1,5 @@ package com.baiyue.warehouse.service; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.BadSqlGrammarException; @@ -13,11 +12,9 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -@DisplayName("仓库表结构就绪检查服务") class WarehouseSchemaReadinessServiceTest { @Test - @DisplayName("全部表字段可查询时返回就绪") - void shouldReportUpWhenAllTablesAndColumnsAreQueryable() { + void 全部表字段可查询时返回就绪() { JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness(); @@ -28,8 +25,7 @@ class WarehouseSchemaReadinessServiceTest { } @Test - @DisplayName("缺表或缺列时返回降级而不是伪报正常") - void shouldReportDegradedWhenTableOrColumnIsMissing() { + void 缺表或缺列时返回降级而不是伪报正常() { JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); doThrow(new BadSqlGrammarException("probe", "SELECT", new SQLException("missing"))) .when(jdbcTemplate).execute(anyString());