Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
45 changed files with 1479 additions and 403 deletions
Showing only changes of commit ced7f83038 - Show all commits
@@ -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;
+27
View File
@@ -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 索引核验均通过。
+4 -1
View File
@@ -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 回归。
+6
View File
@@ -32,3 +32,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`。
@@ -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<TenantResponse> 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<TenantResponse> 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<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -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<PageResult<TenantResponse>> 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<TenantResponse> 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<TenantResponse> 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<TenantResponse> 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<Void> 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<Void> 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());
@@ -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<InternalUserResponse> 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<IamTenantAddress> 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<List<IamTenantQualification>> 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<List<IamTenantAddress>> platformAddresses(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.platformAddresses(tenantId));
}
}
@@ -2,10 +2,17 @@ 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.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
@@ -22,11 +29,15 @@ public final class TenantDtos {
@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,
@Size(max = 64) String servicePackage,
@PositiveOrZero Integer creditDays,
@Size(max = 64) String salesPerson,
@Size(max = 255) String businessAddress,
@Size(max = 500) String remark
@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 / 企业资料更新")
@@ -46,18 +57,16 @@ public final class TenantDtos {
@NotBlank String contactName,
@NotBlank String contactPhone,
@Email String contactEmail,
@Size(max = 64) String servicePackage,
@PositiveOrZero Integer creditDays,
@Size(max = 64) String salesPerson,
@Size(max = 255) String businessAddress,
@Size(max = 500) String remark
) {
public UpdateTenantRequest(String tenantName, String legalName, String contactName,
String contactPhone, String contactEmail) {
this(tenantName, legalName, contactName, contactPhone, contactEmail,
null, null, null, null, null);
}
}
@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")
public record ChangeTenantStatusRequest(
@@ -66,14 +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 servicePackage,
Integer creditDays, String salesPerson, String businessAddress,
String remark, LocalDateTime createdAt) { }
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) { }
}
@@ -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,11 +27,15 @@ public class IamTenant extends BaseEntity {
private String contactName;
private String contactPhone;
private String contactEmail;
private String servicePackage;
private Integer creditDays;
private String salesPerson;
private String businessAddress;
private String remark;
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;
@@ -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;
/**
@@ -48,22 +46,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<IamTenantReview>()
.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<IamTenantReview> findPendingReviews() {
return reviewMapper.selectList(new LambdaQueryWrapper<IamTenantReview>()
@@ -83,7 +66,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));
}
@@ -200,19 +200,11 @@ public class MemberService {
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> 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();
}
}
@@ -321,19 +321,11 @@ public class PlatformInternalUserService {
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> 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();
}
}
@@ -41,6 +41,16 @@ public class ProfileResourceService {
public List<IamTenantQualification> qualifications(long tenant) { return repository.findQualifications(tenant); }
public List<IamTenantAddress> platformAddresses(long tenantId) {
requireMerchant(tenantId);
return repository.findAddresses(tenantId);
}
public List<IamTenantQualification> 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 / 企业客户不存在");
}
}
}
@@ -59,11 +59,15 @@ public class TenantService {
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail());
tenant.setServicePackage(request.servicePackage());
tenant.setCreditDays(request.creditDays() == null ? 0 : request.creditDays());
tenant.setSalesPerson(request.salesPerson());
tenant.setBusinessAddress(request.businessAddress());
tenant.setRemark(request.remark());
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();
@@ -82,7 +86,7 @@ public class TenantService {
public PageResult<TenantResponse> pageMerchants(long page, long size, String keyword, String status) {
PageResult<IamTenant> 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());
}
@@ -104,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) {
@@ -149,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);
}
/** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */
@@ -167,11 +171,15 @@ public class TenantService {
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail());
tenant.setServicePackage(request.servicePackage());
tenant.setCreditDays(request.creditDays() == null ? 0 : request.creditDays());
tenant.setSalesPerson(request.salesPerson());
tenant.setBusinessAddress(request.businessAddress());
tenant.setRemark(request.remark());
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);
}
@@ -204,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) {
@@ -223,9 +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.getServicePackage(),
tenant.getCreditDays(), tenant.getSalesPerson(), tenant.getBusinessAddress(),
tenant.getRemark(), tenant.getCreatedAt()
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);
}
}
+9 -5
View File
@@ -16,11 +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 '联系邮箱',
service_package VARCHAR(64) NULL COMMENT '客户服务套餐',
credit_days INT NOT NULL DEFAULT 0 COMMENT '账期天数',
sales_person VARCHAR(64) NULL COMMENT '负责销售人员',
business_address VARCHAR(255) NULL COMMENT '企业经营地址',
remark VARCHAR(500) 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 '驳回原因',
@@ -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;
@@ -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),
@@ -51,3 +52,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);
+211 -28
View File
@@ -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/users:
get:
tags: [IAM Resources / IAM 资源]
@@ -234,7 +279,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'}
@@ -248,7 +295,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'}
@@ -262,7 +312,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'}
@@ -276,7 +328,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'}
@@ -295,7 +349,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 / 商户企业]
@@ -305,6 +361,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 / 更新当前企业资料
@@ -315,7 +372,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 / 商户企业]
@@ -326,7 +385,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:
@@ -334,7 +393,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 资源]
@@ -352,6 +442,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:
@@ -380,7 +471,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]
@@ -395,6 +488,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:
@@ -416,7 +510,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]
@@ -431,6 +527,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:
@@ -447,7 +544,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: 删除未绑定的自定义权限资源
@@ -455,6 +554,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'}
@@ -469,7 +569,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:
@@ -502,7 +604,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
@@ -511,14 +613,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}
@@ -547,6 +654,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'}
@@ -563,7 +676,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]}
@@ -572,12 +685,58 @@ components:
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
servicePackage: {type: string, maxLength: 64}
creditDays: {type: integer, minimum: 0}
salesPerson: {type: string, maxLength: 64}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
createdAt: {type: string, format: date-time}
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]
@@ -588,11 +747,15 @@ components:
contactName: {type: string, example: 李四}
contactPhone: {type: string, example: '13800138000'}
contactEmail: {type: string, format: email, example: contact@example.com}
servicePackage: {type: string, maxLength: 64, example: 标准版}
creditDays: {type: integer, minimum: 0, example: 30}
salesPerson: {type: string, maxLength: 64, example: 李四}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
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]
@@ -611,11 +774,15 @@ components:
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
servicePackage: {type: string, maxLength: 64}
creditDays: {type: integer, minimum: 0}
salesPerson: {type: string, maxLength: 64}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
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]
@@ -707,9 +874,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]
@@ -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);
@@ -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<String, Object> openApi = new Yaml().load(document);
assertThat(openApi).containsEntry("openapi", "3.0.3");
@@ -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());
}
}
@@ -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;
@@ -11,11 +10,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);
@@ -26,7 +24,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");
@@ -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<String> 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 extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
@@ -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
@@ -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("测试环境只执行统一入口");
}
}
@@ -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);
@@ -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.<Void>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,
@@ -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.<AuthAccountClient.AccountSummary>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");
@@ -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);
@@ -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);
+2 -1
View File
@@ -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`。
@@ -59,6 +59,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 = "需要平台快递公司查询权限")
})
@@ -86,6 +86,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 = "需要平台往来单位查询权限")
})
@@ -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<Long> 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<Void> 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<PageResult<WarehouseEntity>> 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<WarehouseDetailResponse> 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<Void> status(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@@ -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));
}
}
@@ -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");
@@ -29,8 +39,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");
@@ -63,7 +72,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<? extends java.lang.annotation.Annotation> 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);
}
}
@@ -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("数据库");
@@ -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", "需要执行迁移"));
@@ -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));
@@ -17,14 +17,11 @@ 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());
when(mapper.selectPartnerIdsByType(10001,"SHIPPER")).thenReturn(List.of(1L));
new PartnerRepository(mapper).activeCandidates(10001,"示例","SHIPPER");
@@ -49,9 +46,7 @@ class PartnerRepositoryTest {
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L,2L);
}
@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<LambdaQueryWrapper<PartnerEntity>> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
@@ -59,9 +54,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.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L));
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -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", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name",
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
@@ -37,8 +33,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");
@@ -46,8 +41,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")
@@ -26,7 +26,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");
@@ -38,7 +38,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");
@@ -21,11 +21,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 +32,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 +43,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 +53,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("往来单位新增写入租户和请求字段")
void shouldPersistTenantAndRequestFieldsWhenCreatingPartner() {
void 往来单位新增写入租户和请求字段() {
PartnerRepository repository = mock(PartnerRepository.class);
doAnswer(invocation -> {
PartnerEntity entity = invocation.getArgument(0);
@@ -80,8 +75,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("往来单位编辑保留归属并更新字段")
void shouldPreserveOwnershipAndUpdateFieldsWhenEditingPartner() {
void 往来单位编辑保留归属并更新字段() {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerEntity entity = new PartnerEntity();
entity.setId(19L);
@@ -97,8 +91,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("往来单位停用保留主数据")
void shouldPreservePartnerMasterDataWhenDeactivating() {
void 往来单位停用保留主数据() {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerEntity entity = new PartnerEntity();
entity.setId(8L);
@@ -114,8 +107,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("快递公司详情强制租户隔离")
void shouldEnforceTenantIsolationForExpressCompanyDetail() {
void 快递公司详情强制租户隔离() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
@@ -124,8 +116,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);
@@ -159,8 +150,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("快递公司新增写入租户和请求字段")
void shouldPersistTenantAndRequestFieldsWhenCreatingExpressCompany() {
void 快递公司新增写入租户和请求字段() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
doAnswer(invocation -> {
ExpressCompanyEntity entity = invocation.getArgument(0);
@@ -180,8 +170,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("快递公司编辑保留归属并更新字段")
void shouldPreserveOwnershipAndUpdateFieldsWhenEditingExpressCompany() {
void 快递公司编辑保留归属并更新字段() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(29L);
@@ -197,8 +186,7 @@ class PlatformMasterDataManagementServiceTest {
}
@Test
@DisplayName("快递公司停用保留主数据")
void shouldPreserveExpressCompanyMasterDataWhenDeactivating() {
void 快递公司停用保留主数据() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setId(9L);
@@ -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);
@@ -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());