diff --git a/.gitignore b/.gitignore index 2df9c1aa1..c7e897774 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ reports/uploads/ reports/db-backup*/ uploads/ .codex-temp* +.codex-tmp* diff --git a/AGENTS.md b/AGENTS.md index 89fb22ef1..a3ba731fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,28 @@ - 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。 - 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。 -## 当前主体层级(重要) +## 当前主体与隔离模型(重要,所有阶段必须遵守) -- 完整目标模型原计划为“租户—平台—客户”三级,但当前阶段采用 **单租户、多客户**:整个系统只有一个隐式的默认租户,平台在该租户内运营和管理多个客户,客户之间必须严格隔离。 -- 当前不向业务侧引入或开放租户概念,不建设租户管理、租户切换、租户级配置或多租户隔离能力;现阶段需求、接口、权限、数据范围和用户可见文案均按“平台—客户”两级使用。 -- 当前真正的数据隔离边界是客户。服务端必须校验客户归属和访问权限,不得因为系统处于单租户模式而放松客户之间的数据隔离。 -- 代码、数据库和接口中已有的 `tenant`、`Tenant*`、`tenant_id`、`merchant` 等历史结构和兼容契约不得仅因当前未开放租户概念而贸然删除或重命名;修改前必须核对其当前实际承载的是默认租户、客户归属还是历史命名,避免破坏现有调用方和未来演进空间。 -- 预计约一年后再独立引入显式租户能力。届时存量客户统一归属到一个默认租户,再基于确认后的三级关系、租户隔离规则和迁移方案扩展多租户;当前开发不得提前实现未经确认的多租户业务,也不得做会显著增加未来迁移成本的不可逆改动。 +- 完整目标模型原计划为“租户—平台—客户”三级,但为尽快上线,当前约一年内采用 **业务单租户、平台管理多客户、客户严格隔离** 的模式。当前没有可管理、可选择或可切换的上级租户,也没有真实的“租户—平台—客户”三级关系。 +- “业务单租户”只是产品阶段约束,不表示代码中存在一个供所有数据共用的默认租户 ID。现有代码和数据库的 `tenant_id` 已经广泛承载 **客户 ID**,是当前客户隔离键;严禁把所有客户数据的 `tenant_id` 统一改成同一个值,否则会直接破坏客户隔离。 +- 当前需求、一期、二期、三期及后续未明确启用租户能力的开发,统一按“平台—客户”两级实现。不得自行新增租户主表、租户管理、租户切换、租户级配置、客户所属租户选择、租户级权限或其他显式多租户能力。 + +当前代码中的主体标识固定按下表理解: + +| 现有结构 | 当前业务含义 | 修改约束 | +|---|---|---| +| `iam_tenant.id = 0`、`tenant_type = PLATFORM` | 平台技术作用域 | 必须保留为平台账号、平台角色和平台权限范围,不是未来的默认租户 | +| `iam_tenant.id > 0`、`tenant_type = MERCHANT` | 平台直接服务的客户;`iam_tenant` 的这些记录当前直接作为客户主档 | 客户列表和客户业务只能使用 `MERCHANT` 记录,不得把平台记录当客户 | +| Auth/JWT/业务表中的 `tenant_id > 0` | 当前登录客户或数据所属客户的 ID | 商户端从认证上下文取得;平台代客操作显式携带目标客户;Repository/Mapper 必须按该值过滤 | +| RBAC 数据中的 `tenant_id = 0` | 平台权限或供客户复用的全局角色/权限模板 | 不代表真实上级租户,不能作为客户数据隔离条件 | +| Order/Firstleg 等业务中的 `customer_id` | 订单往来单位、发货客户或其他业务客户快照,具体以模块契约为准 | 不是 IAM 平台客户 ID,不能替代 `tenant_id` 做当前客户隔离 | +| `DEFAULT_TENANT_ID`、`public-tenant-id` 等配置 | 平台公共仓库和主数据的技术归属配置,当前固定为平台作用域 `0` | 不代表未来默认租户;公共资源不得再绑定普通客户 `10001`,变更时必须同步 Warehouse 种子、存量迁移及全部消费者 | + +- 当前真正的数据隔离边界是上述客户 ID。服务端必须校验客户归属和访问权限;商户请求不得信任前端传入其他客户 ID,平台跨客户查询或代客操作必须同时受平台应用域、权限码和目标客户条件约束。 +- 平台维护并供所有客户使用的公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id = 0`;客户订单、文件、费用和其他客户私有数据继续使用客户 `tenant_id > 0`。往来单位不是客户私有数据,不能按客户复制或隔离。不得再使用演示客户 `10001` 充当公共资源所有者。 +- 代码、数据库和接口中已有的 `tenant`、`Tenant*`、`tenant_id`、`merchant` 等名称属于历史命名和兼容契约,当前不做大规模重命名;但新增代码和文档必须按本节解释其真实含义,禁止看到名称后推断系统已经存在显式租户层。 +- 预计约一年后再以独立需求引入真实租户。届时应优先新增独立租户主表或明确的上级 `root_tenant_id`,把所有存量 `MERCHANT` 客户统一归属到一个默认租户,再迁移 Auth、JWT、跨服务 DTO 和各服务数据库;不得原地把现有 `iam_tenant.id` 或业务 `tenant_id` 从客户 ID 改义为上级租户 ID。 +- 用户在后续对话明确调整本模型之前,本节是所有新模块和分期任务的事实来源;原型、历史设计或代码中的“租户”文案不得自动扩大实现范围。 ## 模块快速索引 @@ -91,7 +106,7 @@ ## 通用修改约束 - Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。 -- 当前采用单租户多客户模式。客户数据必须保留客户归属维度,并由服务端执行平台/客户访问域、客户数据隔离、数据范围和按钮权限校验;默认租户不能替代客户隔离条件,也不得据此允许跨客户访问。 +- 当前采用业务单租户、平台管理多客户模式。现有 `tenant_id` 是客户归属与隔离键,客户数据必须保留该维度,并由服务端执行平台/客户访问域、客户数据隔离、数据范围和按钮权限校验;不得把它统一替换为所谓默认租户 ID,也不得据此允许跨客户访问。 - 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 - 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。 diff --git a/README.md b/README.md index 3f883e5c6..884c9de44 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 + +> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 ## 模块 - `auth`:统一登录、令牌签发与刷新。 -- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。 +- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java index 57eaad8cd..fce5e77b1 100644 --- a/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java @@ -13,7 +13,7 @@ public final class AuditPlatformScopeGuard { if (user == null || user.userId() == null) { throw new BizException(ResultCode.UNAUTHORIZED, "请先登录"); } - if (!"PLATFORM".equals(user.appCode())) { + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) { throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志"); } } diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java index 37d91da8b..95f9dc7c9 100644 --- a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java @@ -51,6 +51,17 @@ class PlatformAuditLogControllerContractTest { verifyNoInteractions(service); } + @Test + void platformApplicationWithCustomerTenantCannotReadLogs() { + AuthenticatedUser malformed = new AuthenticatedUser(7L, 10001L, 10001L, 1L, + "异常平台账号", "PLATFORM", "WEB", Set.of("platform:audit:list")); + + assertThatThrownBy(() -> controller.page(malformed, 1, 20, null, null, null, null, + null, null, null, null, null)).isInstanceOf(BizException.class) + .hasMessage("仅平台端可查看操作日志"); + verifyNoInteractions(service); + } + @Test void platformCanReadLogDetail() { AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null, diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 53b00c291..ae2d1ae61 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -3,7 +3,7 @@ ## 职责边界 - 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。 -- 租户、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。 +- 客户主档、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。现有账号/JWT 中的 `tenant_id > 0` 当前是客户 ID,`tenant_id=0` 是平台技术作用域;字段名保持兼容,不代表已经存在真实租户层。 ## 快速索引 @@ -17,7 +17,7 @@ | 商户注册协作 | `service/MerchantRegistrationService.java` | | IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` | | 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` | -| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、租户、成员与应用端 | +| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、兼容字段 `tenantId`、成员与应用端 | | 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` | | 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` | @@ -27,6 +27,6 @@ - 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。 - 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。 - 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。 -- 平台统一用户管理跨租户移动成员时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 默认租户、成员和应用端;修改该契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。 +- 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。 - 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。 - 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java index 6cdc1e02f..9e4f51a9d 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -10,6 +10,8 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Objects; + /** * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 */ @@ -42,10 +44,17 @@ public class AuthorizationLoader { } throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); } - if (response.data() == null) { + AuthorizationSnapshot snapshot = response.data(); + if (snapshot == null) { throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); } - return response.data(); + if (!Objects.equals(account.getId(), snapshot.getUserId()) + || !Objects.equals(account.getDefaultTenantId(), snapshot.getTenantId()) + || !Objects.equals(account.getDefaultMembershipId(), snapshot.getMembershipId()) + || !Objects.equals(account.getDefaultAppCode(), snapshot.getAppCode())) { + throw new BizException(ResultCode.FORBIDDEN, "IAM 授权快照身份与账号归属不一致"); + } + return snapshot; } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); } catch (BizException exception) { diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 0494b1431..fad98d34f 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -25,7 +25,15 @@ public class InternalAccountController { private String configuredToken; private byte[] tokenBytes; - @PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); } + @PostConstruct + void init() { + byte[] configured = configuredToken == null + ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + tokenBytes = configured; + } @PostMapping public ApiResponse create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java new file mode 100644 index 000000000..d0768d74c --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java @@ -0,0 +1,77 @@ +package com.baiyue.auth.client; + +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthorizationLoaderTest { + private static final String INTERNAL_TOKEN = "test-internal-service-token-at-least-32-bytes"; + + @Mock private IamAuthorizationClient client; + @InjectMocks private AuthorizationLoader loader; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(loader, "internalToken", INTERNAL_TOKEN); + loader.validateConfiguration(); + } + + @Test + void returnsSnapshotWhenEchoedIdentityMatchesAccount() { + AuthAccount account = account(); + AuthorizationSnapshot snapshot = snapshot(42L, 10001L, 9L, "MERCHANT"); + when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot)); + + assertSame(snapshot, loader.load(account)); + } + + @Test + void rejectsEveryMismatchedEchoedIdentityField() { + AuthAccount account = account(); + + assertIdentityMismatch(account, snapshot(43L, 10001L, 9L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10002L, 9L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10001L, 10L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10001L, 9L, "PLATFORM")); + } + + private void assertIdentityMismatch(AuthAccount account, AuthorizationSnapshot snapshot) { + when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot)); + + BizException exception = assertThrows(BizException.class, () -> loader.load(account)); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + private AuthAccount account() { + AuthAccount account = new AuthAccount(); + account.setId(42L); + account.setDefaultTenantId(10001L); + account.setDefaultMembershipId(9L); + account.setDefaultAppCode("MERCHANT"); + return account; + } + + private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) { + return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode, + Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view")); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/InternalAccountControllerConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/controller/InternalAccountControllerConfigurationTest.java new file mode 100644 index 000000000..e1865ef1d --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/InternalAccountControllerConfigurationTest.java @@ -0,0 +1,21 @@ +package com.baiyue.auth.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class InternalAccountControllerConfigurationTest { + @Test + void rejectsEmptyAndShortInternalServiceTokens() { + assertTokenRejected(null); + assertTokenRejected(""); + assertTokenRejected("short-token"); + } + + private void assertTokenRejected(String token) { + InternalAccountController controller = new InternalAccountController(); + ReflectionTestUtils.setField(controller, "configuredToken", token); + assertThrows(IllegalStateException.class, controller::init); + } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java index 9a67d641e..d309acfbd 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java @@ -14,6 +14,8 @@ public @interface BusinessAudit { String event(); String resourceType(); String resourceId() default ""; // SpEL, e.g. #request.orderId + /** 业务数据所属客户 ID 的可选 SpEL;平台代客操作未配置时自动识别名为 tenantId 的参数。 */ + String tenantId() default ""; String description() default ""; boolean captureBefore() default true; boolean captureAfter() default true; diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java index 305522764..140facb0a 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java @@ -22,7 +22,6 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.time.Instant; -import java.util.Arrays; import java.util.UUID; /** @@ -57,9 +56,10 @@ public class BusinessAuditAspect { HttpServletRequest req = request(); AuthenticatedUser user = currentUser(); String resourceId = evaluateResourceId(pjp, a.resourceId()); + Long tenantId = resolveTenantId(pjp, a, user, req); sink.publish(new AuditEvent(UUID.randomUUID().toString(), a.event(), a.resourceType(), resourceId, a.description(), req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req), - user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(), + tenantId, user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(), user == null ? null : user.userId(), user == null ? null : user.username(), user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(), @@ -81,9 +81,50 @@ public class BusinessAuditAspect { } private String evaluateResourceId(ProceedingJoinPoint p, String expression) { if (expression == null || expression.isBlank()) return null; + Object value = evaluate(p, expression); + return value == null ? null : value.toString(); + } + private Long resolveTenantId(ProceedingJoinPoint p, BusinessAudit audit, AuthenticatedUser user, + HttpServletRequest request) { + if (user == null) return headerLong(request, "X-Tenant-Id"); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) { + return user.tenantId(); + } + Long explicit = evaluatePositiveLong(p, audit.tenantId()); + if (explicit != null) return explicit; + MethodSignature signature = (MethodSignature) p.getSignature(); + String[] parameterNames = signature.getParameterNames(); + if (parameterNames == null) { + parameterNames = new DefaultParameterNameDiscoverer().getParameterNames(signature.getMethod()); + } + Object[] arguments = p.getArgs(); + if (parameterNames != null) { + for (int index = 0; index < parameterNames.length && index < arguments.length; index++) { + if ("tenantId".equals(parameterNames[index])) { + Long candidate = positiveLong(arguments[index]); + if (candidate != null) return candidate; + } + } + } + return user.tenantId(); + } + private Long evaluatePositiveLong(ProceedingJoinPoint p, String expression) { + if (expression == null || expression.isBlank()) return null; + return positiveLong(evaluate(p, expression)); + } + private Object evaluate(ProceedingJoinPoint p, String expression) { MethodSignature s = (MethodSignature) p.getSignature(); var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer()); - Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString(); + return parser.parseExpression(expression).getValue(ctx); + } + private static Long positiveLong(Object value) { + if (value == null) return null; + try { + long candidate = value instanceof Number number ? number.longValue() : Long.parseLong(value.toString()); + return candidate > 0 ? candidate : null; + } catch (NumberFormatException ignored) { + return null; + } } private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); } private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } } diff --git a/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java index 9bbe9d633..3248bb1de 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java @@ -30,7 +30,11 @@ public class SensitiveDataSanitizer { JsonNode root = mapper.valueToTree(value); redact(root); String json = mapper.writeValueAsString(root); - return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]"; + if (json.length() <= maxLength) return json; + ObjectNode truncated = mapper.createObjectNode(); + truncated.put("_truncated", true); + truncated.put("_originalLength", json.length()); + return mapper.writeValueAsString(truncated); } catch (IllegalArgumentException | JsonProcessingException e) { return "\"[unserializable]\""; } diff --git a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java new file mode 100644 index 000000000..e8b2a8781 --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java @@ -0,0 +1,84 @@ +package com.baiyue.common.audit; + +import com.baiyue.common.security.AuthenticatedUser; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessAuditAspectTest { + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable { + AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L, + "platform.admin", "PLATFORM", "WEB", Set.of()), 10001L); + + assertThat(event.tenantId()).isEqualTo(10001L); + assertThat(event.operatorTenantId()).isZero(); + assertThat(event.userId()).isEqualTo(7L); + assertThat(event.appCode()).isEqualTo("PLATFORM"); + } + + @Test + void merchantOperationIgnoresTargetCustomerArgument() throws Throwable { + AuditEvent event = invoke(new AuthenticatedUser(8L, 10001L, 10001L, 2L, + "merchant.user", "MERCHANT", "WEB", Set.of()), 20002L); + + assertThat(event.tenantId()).isEqualTo(10001L); + assertThat(event.operatorTenantId()).isEqualTo(10001L); + } + + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable { + AuditSink sink = mock(AuditSink.class); + BusinessAuditAspect aspect = new BusinessAuditAspect(); + ReflectionTestUtils.setField(aspect, "sink", sink); + ReflectionTestUtils.setField(aspect, "sanitizer", new SensitiveDataSanitizer(new ObjectMapper(), 1024)); + ReflectionTestUtils.setField(aspect, "serviceName", "test-service"); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + + TestOperations target = new TestOperations(); + Method method = TestOperations.class.getDeclaredMethod("update", long.class); + MethodSignature signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(method); + when(signature.getParameterNames()).thenReturn(null); + + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.getArgs()).thenReturn(new Object[]{requestedTenantId}); + when(joinPoint.proceed()).thenReturn("ok"); + + aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + + ArgumentCaptor event = ArgumentCaptor.forClass(AuditEvent.class); + verify(sink).publish(event.capture()); + return event.getValue(); + } + + private static final class TestOperations { + @BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureBefore = false, captureAfter = false) + public String update(long tenantId) { + return "ok"; + } + } +} diff --git a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java new file mode 100644 index 000000000..76be507e3 --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java @@ -0,0 +1,24 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SensitiveDataSanitizerTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void truncatedSnapshotRemainsValidJson() throws Exception { + SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 40); + + String sanitized = sanitizer.sanitize(Map.of("description", "x".repeat(200))); + JsonNode parsed = mapper.readTree(sanitized); + + assertThat(parsed.path("_truncated").asBoolean()).isTrue(); + assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40); + } +} diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index b782e8f27..3a5a42502 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -36,11 +36,8 @@ public class BaiyueOpenApiAutoConfiguration { } @Bean - OperationCustomizer tenantHeaderCustomizer() { + OperationCustomizer correlationHeaderCustomizer() { return (operation, method) -> operation - .addParametersItem(header("X-Tenant-Id", "Current tenant ID / 当前租户 ID", false)) - .addParametersItem(header("X-Operator-Tenant-Id", "Operator tenant ID / 运营方租户 ID", false)) - .addParametersItem(header("X-App-Code", "Application code / 应用编码", false)) .addParametersItem(header("X-Request-Id", "Request correlation ID / 请求关联 ID", false)) .addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false)); } @@ -62,20 +59,20 @@ public class BaiyueOpenApiAutoConfiguration { } @Bean - GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) { + GroupedOpenApi platformApi(OperationCustomizer correlationHeaderCustomizer) { return GroupedOpenApi.builder().group("platform") .displayName("Platform APIs / 平台端接口") .pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**") - .addOperationCustomizer(tenantHeaderCustomizer).build(); + .addOperationCustomizer(correlationHeaderCustomizer).build(); } @Bean - GroupedOpenApi merchantApi(OperationCustomizer tenantHeaderCustomizer) { + GroupedOpenApi merchantApi(OperationCustomizer correlationHeaderCustomizer) { return GroupedOpenApi.builder().group("merchant") .displayName("Merchant APIs / 商户端接口") .pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**", "/api/firstleg/**", "/api/lastleg/**") - .addOperationCustomizer(tenantHeaderCustomizer).build(); + .addOperationCustomizer(correlationHeaderCustomizer).build(); } private Parameter header(String name, String description, boolean required) { diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java new file mode 100644 index 000000000..d37ee4589 --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java @@ -0,0 +1,25 @@ +package com.baiyue.common.openapi; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springdoc.core.customizers.OperationCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OpenAPI 请求头定制") +class CorrelationHeaderCustomizerTest { + + @Test + void shouldOnlyExposeCallerControlledCorrelationHeaders() { + OperationCustomizer customizer = new BaiyueOpenApiAutoConfiguration().correlationHeaderCustomizer(); + + Operation operation = customizer.customize(new Operation(), null); + + assertThat(operation.getParameters()) + .extracting(Parameter::getName) + .containsExactly("X-Request-Id", "X-Trace-Id") + .doesNotContain("X-Tenant-Id", "X-Operator-Tenant-Id", "X-App-Code"); + } +} diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/cn/baiyue-common.yml index d22c8b329..26edaa23b 100644 --- a/config/nacos/cn/baiyue-common.yml +++ b/config/nacos/cn/baiyue-common.yml @@ -9,7 +9,7 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} warehouse: - public-tenant-id: ${DEFAULT_TENANT_ID:10001} + public-tenant-id: ${DEFAULT_TENANT_ID:0} openapi.enabled: ${SWAGGER_ENABLED:false} audit: enabled: true diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 580aeb623..7b0678f9f 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -12,6 +12,7 @@ firstleg: allow-rejected-measurement: false default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} reservation-ttl: ${PALLET_RESERVATION_TTL:30m} + warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0} measurement-openapi: enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml index cba3e34a4..2f114af3e 100644 --- a/config/nacos/local/baiyue-common.yml +++ b/config/nacos/local/baiyue-common.yml @@ -9,7 +9,7 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} warehouse: - public-tenant-id: ${DEFAULT_TENANT_ID:10001} + public-tenant-id: ${DEFAULT_TENANT_ID:0} openapi.enabled: ${SWAGGER_ENABLED:true} audit: enabled: true diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 4b0f45091..2b2b4a974 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -11,7 +11,7 @@ firstleg: allow-rejected-measurement: false default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} reservation-ttl: ${PALLET_RESERVATION_TTL:30m} - warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:10001} + warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0} measurement-openapi: enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f29ea10e0..56bb1d6b9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -9,7 +9,7 @@ - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 -- 完整执行后只有关键表、字段和 28 项二期权限全部存在,末尾才会返回 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 +- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 后续追加增量必须满足: @@ -79,6 +79,77 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready - 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。 - 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。 +## 当前主体字段语义核验 + +- 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目和入库费用明细沿用该物理字段名。 +- `fl_third_party_call_log.tenant_id = 0` 仅表示第三方主体未知或尚未解析的哨兵值,不是平台客户记录或平台操作作用域,绝不能据此授权、查询或写入客户业务数据。上线后可只读统计: + +```sql +SELECT COUNT(*) AS unresolved_third_party_calls + FROM baiyue_firstleg.fl_third_party_call_log + WHERE tenant_id = 0; +``` + +- 平台公共仓库、地址、服务能力、往来单位 `wh_partner`、快递公司、托盘、库位、预占和占用固定归平台作用域 `tenant_id=0`;Firstleg 的 `warehouse_resource_tenant_id` 同步固定为 `0`。 +- 客户订单、客户文件和客户费用规则仍使用 `tenant_id>0` 做客户隔离;往来单位不是客户私有数据,统一脚本会把 `wh_partner` 和 `wh_partner_type` 的作用域归零。 + +## 平台公共资源归零迁移与核验 + +测试环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不要再串行执行模块脚本。执行前可只读盘点公共资源旧归属: + +```sql +SELECT 'wh_warehouse' AS resource, COUNT(*) AS total, COALESCE(SUM(tenant_id <> 0), 0) AS non_platform + FROM baiyue_warehouse.wh_warehouse +UNION ALL SELECT 'wh_warehouse_address', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_warehouse_address +UNION ALL SELECT 'wh_service_capability', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_service_capability +UNION ALL SELECT 'wh_express_company', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_express_company +UNION ALL SELECT 'wh_pallet', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_pallet +UNION ALL SELECT 'wh_location', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location +UNION ALL SELECT 'wh_location_reservation', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_reservation +UNION ALL SELECT 'wh_location_occupancy', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_occupancy +UNION ALL SELECT 'fl_inbound_pallet_allocation.resource_scope', COUNT(*), + COALESCE(SUM(warehouse_resource_tenant_id <> 0), 0) + FROM baiyue_firstleg.fl_inbound_pallet_allocation; +``` + +脚本会先把明确公共资源迁到 `0`,按父仓库回填地址和能力,再迁移平台往来单位、托盘、库位、预占、占用及 Firstleg 资源归属快照。末尾 `PASS` 前会强校验公共资源(含往来单位)全部归零、父子一致,以及客户费用规则不存在 `tenant_id=0`。执行后可再只读核验: + +```sql +SELECT + (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner + WHERE tenant_id <> 0 OR data_scope_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type + WHERE tenant_id <> 0 OR data_scope_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) + + (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation + WHERE warehouse_resource_tenant_id <> 0) AS public_scope_violations, + (SELECT COUNT(*) FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) + AS customer_scope_violations; + +SELECT + (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address a + JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id + WHERE a.tenant_id <> w.tenant_id) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability c + JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id + WHERE c.tenant_id <> w.tenant_id) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet p + JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id + WHERE p.tenant_id <> w.tenant_id) + + (SELECT COUNT(*) FROM baiyue_warehouse.wh_location l + JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id + WHERE l.tenant_id <> w.tenant_id) AS parent_child_scope_mismatches; +``` + +上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。 + ## 海外仓阶梯费用增量 - `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6ee299bff..493b5412f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -598,8 +598,8 @@ USE baiyue_warehouse; CREATE TABLE IF NOT EXISTS wh_warehouse ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', - warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', + warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', @@ -620,11 +620,11 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( KEY idx_wh_tenant_status (tenant_id, status, deleted), KEY idx_wh_tenant_type_status (tenant_id, warehouse_type, status, deleted), KEY idx_wh_country (tenant_id, country_code, deleted) -) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +) ENGINE=InnoDB COMMENT='平台公共仓库主数据表:统一归属平台作用域 tenant_id=0'; CREATE TABLE IF NOT EXISTS wh_warehouse_address ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库地址固定使用 tenant_id=0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', country_code CHAR(2) NOT NULL COMMENT '国家二字码', @@ -649,7 +649,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse_address ( CREATE TABLE IF NOT EXISTS wh_service_capability ( id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库服务能力固定使用 tenant_id=0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', @@ -690,20 +690,20 @@ CREATE TABLE IF NOT EXISTS wh_partner ( 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 COMMENT='仓储域内的租户往来单位配置'; +) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL, PRIMARY KEY(partner_id,partner_type), KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id) -) ENGINE=InnoDB COMMENT='往来单位稳定业务类型'; +) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型'; INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) -VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') -ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE'; +VALUES(191000000000000001,0,0,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id),partner_name=VALUES(partner_name),status='ACTIVE'; INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type) VALUES -(191000000000000001,10001,10001,'CUSTOMER'), -(191000000000000001,10001,10001,'SHIPPER') +(191000000000000001,0,0,'CUSTOMER'), +(191000000000000001,0,0,'SHIPPER') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id); -- ============================================================================ @@ -714,8 +714,8 @@ USE baiyue_warehouse; 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 '租户内唯一快递公司编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共快递公司固定使用 tenant_id=0', + company_code VARCHAR(50) NOT NULL COMMENT '平台内唯一快递公司编码', company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称', service_phone VARCHAR(32) DEFAULT NULL COMMENT '客服电话', website VARCHAR(255) DEFAULT NULL COMMENT '官方网站', @@ -732,7 +732,7 @@ CREATE TABLE IF NOT EXISTS wh_express_company ( UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted), UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted), KEY idx_wh_express_tenant_status (tenant_id, status, deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共快递公司主数据,固定归属 tenant_id=0'; -- ============================================================================ -- 来源:firstleg\src\main\resources\db\firstleg.sql @@ -1709,6 +1709,54 @@ CALL add_column_if_missing('wh_express_company', 'updated_at', 'DATETIME(3) NOT CALL add_column_if_missing('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0'); +-- 平台公共仓储主数据和平台往来单位统一归平台作用域 0;订单自身仍按客户 tenant_id 隔离。 +UPDATE wh_warehouse SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_warehouse_address a +JOIN wh_warehouse w ON w.id = a.warehouse_id +SET a.tenant_id = w.tenant_id +WHERE a.tenant_id <> w.tenant_id; +UPDATE wh_service_capability c +JOIN wh_warehouse w ON w.id = c.warehouse_id +SET c.tenant_id = w.tenant_id +WHERE c.tenant_id <> w.tenant_id; +UPDATE wh_warehouse_address SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_service_capability SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_express_company SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_partner SET tenant_id = 0, data_scope_id = 0 + WHERE tenant_id <> 0 OR data_scope_id <> 0; +UPDATE wh_partner_type SET tenant_id = 0, data_scope_id = 0 + WHERE tenant_id <> 0 OR data_scope_id <> 0; + +DROP PROCEDURE IF EXISTS assert_platform_public_resource_scope; +DELIMITER $$ +CREATE PROCEDURE assert_platform_public_resource_scope() +BEGIN + IF (SELECT COUNT(*) FROM wh_warehouse WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_warehouse_address WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_service_capability WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_express_company WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_partner WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_partner_type WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Warehouse 迁移失败:平台公共仓储或往来单位仍有非0作用域'; + END IF; + IF EXISTS ( + SELECT 1 FROM wh_warehouse_address a + JOIN wh_warehouse w ON w.id = a.warehouse_id + WHERE a.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM wh_service_capability c + JOIN wh_warehouse w ON w.id = c.warehouse_id + WHERE c.tenant_id <> w.tenant_id + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Warehouse 迁移失败:仓库子表与父仓库平台归属不一致'; + END IF; +END$$ +DELIMITER ; +CALL assert_platform_public_resource_scope(); +DROP PROCEDURE IF EXISTS assert_platform_public_resource_scope; + DROP PROCEDURE IF EXISTS add_column_if_missing; -- MySQL 8 的 ALTER ADD INDEX 不在所有小版本都支持 IF NOT EXISTS,使用元数据判断以保证重复执行安全。 @@ -1774,9 +1822,9 @@ USE baiyue_warehouse; CREATE TABLE IF NOT EXISTS wh_pallet ( id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共托盘固定使用 tenant_id=0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码', + pallet_code VARCHAR(50) NOT NULL COMMENT '平台仓库内唯一托盘编码', pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', @@ -1798,11 +1846,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', zone_code VARCHAR(50) NULL COMMENT '库区编码', zone_name VARCHAR(100) NULL COMMENT '库区名称', - location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码', + location_code VARCHAR(50) NOT NULL COMMENT '平台仓库内唯一库位编码', location_name VARCHAR(100) NULL COMMENT '库位名称', location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截', @@ -1825,8 +1873,8 @@ CREATE TABLE IF NOT EXISTS wh_location ( CREATE TABLE IF NOT EXISTS wh_location_reservation ( id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位预占固定使用 tenant_id=0', + reservation_no VARCHAR(40) NOT NULL COMMENT '平台内唯一预占业务号', idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', @@ -1863,8 +1911,8 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位占用固定使用 tenant_id=0', + occupancy_no VARCHAR(40) NOT NULL COMMENT '平台内唯一占用业务号', reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID', source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', @@ -2058,6 +2106,52 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_sc 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +-- 托盘、库位、预占和占用均为平台公共仓储资源,存量测试数据统一迁移到平台作用域 0。 +UPDATE wh_pallet SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_location SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_location_reservation SET tenant_id = 0 WHERE tenant_id <> 0; +UPDATE wh_location_occupancy SET tenant_id = 0 WHERE tenant_id <> 0; + +DROP PROCEDURE IF EXISTS assert_phase2_public_resource_scope; +DELIMITER $$ +CREATE PROCEDURE assert_phase2_public_resource_scope() +BEGIN + IF (SELECT COUNT(*) FROM wh_pallet WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_location WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_location_reservation WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM wh_location_occupancy WHERE tenant_id <> 0) > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Warehouse 二期迁移失败:托盘、库位、预占或占用仍有 tenant_id<>0'; + END IF; + IF EXISTS ( + SELECT 1 FROM wh_pallet p JOIN wh_warehouse w ON w.id = p.warehouse_id + WHERE p.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM wh_location l JOIN wh_warehouse w ON w.id = l.warehouse_id + WHERE l.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM wh_location_reservation r + JOIN wh_warehouse w ON w.id = r.warehouse_id + JOIN wh_pallet p ON p.id = r.pallet_id + JOIN wh_location l ON l.id = r.location_id + WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id + ) OR EXISTS ( + SELECT 1 FROM wh_location_occupancy o + JOIN wh_warehouse w ON w.id = o.warehouse_id + JOIN wh_pallet p ON p.id = o.pallet_id + JOIN wh_location l ON l.id = o.location_id + JOIN wh_location_reservation r ON r.id = o.reservation_id + WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id + OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Warehouse 二期迁移失败:公共仓储资源父子归属不一致'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase2_public_resource_scope(); +DROP PROCEDURE IF EXISTS assert_phase2_public_resource_scope; + -- ============================================================================ -- 来源:order\src\main\resources\db\phase2-order-carrier-migration.sql -- ============================================================================ @@ -2514,7 +2608,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', - warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID', + warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', @@ -2777,7 +2871,7 @@ USE baiyue_firstleg; -- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权', app_key VARCHAR(128) NULL COMMENT '第三方应用标识', signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', @@ -2802,6 +2896,17 @@ CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) ) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; +-- 既有表也要同步未知主体 0 的语义,避免历史注释继续误导为真实租户。 +SET @third_party_tenant_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权'; +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_third_party_call_log' + AND column_name = 'tenant_id' AND column_comment = @third_party_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_third_party_call_log MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\phase2-third-party-single-measurement-migration.sql -- ============================================================================ @@ -2898,17 +3003,18 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', - "BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id"); + "BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id"); --- 旧分配记录创建于共享仓储资源租户拆分前,按原业务租户回填后再收口非空约束。 +-- 仓储资源是平台公共主数据;存量分配快照统一迁移到平台作用域 0,再收口非空约束。 UPDATE fl_inbound_pallet_allocation -SET warehouse_resource_tenant_id = tenant_id -WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0; -SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS +SET warehouse_resource_tenant_id = 0 +WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id <> 0; +SET @resource_tenant_needs_modify = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation' - AND COLUMN_NAME = 'warehouse_resource_tenant_id'); -SET @sql = IF(@resource_tenant_nullable = 'YES', - "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'", + AND COLUMN_NAME = 'warehouse_resource_tenant_id' + AND (IS_NULLABLE = 'YES' OR COLUMN_COMMENT <> '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键')); +SET @sql = IF(@resource_tenant_needs_modify > 0, + "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键'", 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -3460,8 +3566,7 @@ DELIMITER ; CALL unified_assert_overseas_storage_fee(); DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee; -SELECT 'PASS' AS migration_status, - '统一增量执行完成,海外仓阶梯费用表、索引和权限校验通过' AS message; +-- 海外仓阶梯费用结构校验已完成;最终 PASS 统一在脚本末尾输出。 -- ============================================================ -- 二期:费用项目主数据与入库单应收应付费用明细 @@ -3470,7 +3575,7 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户', category VARCHAR(64) NOT NULL COMMENT '费用分类', trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', @@ -3496,7 +3601,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', @@ -3538,6 +3643,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id) ) ENGINE=InnoDB COMMENT='入库单应收应付费用明细'; +-- 既有表同步字段注释:tenant_id 当前承载客户 ID,不是上级租户 ID。 +SET @fee_tenant_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户'; +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_fee_item' + AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_fee_item MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' + AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee_detail MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + USE baiyue_iam; INSERT INTO iam_permission @@ -3571,8 +3696,7 @@ FROM iam_permission WHERE id IN (5102070, 5102071, 5102072, 5102073, 5102081, 5102082, 5102083, 5102084) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -SELECT 'PASS' AS migration_status, - '费用项目、入库费用明细及按钮权限增量已执行' AS message; +-- 费用项目、入库费用明细及按钮权限结构校验延后至脚本末尾统一执行。 -- ============================================================ -- 可靠审计:本地 Outbox、RocketMQ 消费幂等和死信补偿 @@ -3637,4 +3761,166 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( KEY idx_audit_dead_retry(status,next_retry_at,lease_until) ) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; -SELECT 'PASS' AS migration_status,'可靠审计Outbox、幂等和死信补偿表已就绪' AS message; +-- ============================================================ +-- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS +-- ============================================================ +DROP PROCEDURE IF EXISTS unified_assert_final_extensions; +DELIMITER $$ +CREATE PROCEDURE unified_assert_final_extensions() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细表未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail') + AND column_name = 'tenant_id' + AND column_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户') <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND index_name IN ('idx_fee_item_query', 'idx_fee_item_name', + 'idx_inbound_fee_detail', 'idx_inbound_fee_review', + 'idx_inbound_fee_item')) <> 5 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细索引未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'firstleg:fee-item:view', 'firstleg:fee-item:create', + 'firstleg:fee-item:edit', 'firstleg:fee-item:delete', + 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', + 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review')) <> 8 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细权限未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', + 'baiyue_firstleg', 'baiyue_warehouse') + AND table_name = 'sys_audit_outbox') <> 5 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 未覆盖五个业务库'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_schema, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', + 'baiyue_firstleg', 'baiyue_warehouse') + AND table_name = 'sys_audit_outbox' + AND index_name IN ('PRIMARY', 'idx_audit_outbox_publish', + 'idx_audit_outbox_retention')) <> 15 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 主键或索引未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', + 'baiyue_firstleg', 'baiyue_warehouse') + AND table_name = 'sys_audit_outbox' + AND column_name IN ('event_id', 'payload_json', 'status', + 'next_retry_at', 'lease_until')) <> 25 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 关键字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_audit' + AND table_name = 'sys_audit_dead_letter') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表未创建'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = 'baiyue_audit' + AND table_name = 'sys_audit_dead_letter' + AND index_name IN ('PRIMARY', 'uk_audit_dead_event', 'idx_audit_dead_retry')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表主键或索引未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log' + AND column_name = 'event_id') <> 1 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log' + AND index_name = 'uk_audit_event_id' AND non_unique = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:审计消费幂等 event_id 唯一约束未就绪'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_third_party_call_log' + AND column_name = 'tenant_id' AND column_default = '0' + AND column_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:第三方未知主体 tenant_id=0 哨兵语义未就绪'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner + WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type + WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) > 0 + OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation + WHERE warehouse_resource_tenant_id <> 0) > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:平台公共仓储资源仍有 tenant_id<>0'; + END IF; + IF EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_warehouse_address a + JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id + WHERE a.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_service_capability c + JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id + WHERE c.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_pallet p + JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id + WHERE p.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_location l + JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id + WHERE l.tenant_id <> w.tenant_id + ) OR EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_location_reservation r + JOIN baiyue_warehouse.wh_warehouse w ON w.id = r.warehouse_id + JOIN baiyue_warehouse.wh_pallet p ON p.id = r.pallet_id + JOIN baiyue_warehouse.wh_location l ON l.id = r.location_id + WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id + ) OR EXISTS ( + SELECT 1 FROM baiyue_warehouse.wh_location_occupancy o + JOIN baiyue_warehouse.wh_warehouse w ON w.id = o.warehouse_id + JOIN baiyue_warehouse.wh_pallet p ON p.id = o.pallet_id + JOIN baiyue_warehouse.wh_location l ON l.id = o.location_id + JOIN baiyue_warehouse.wh_location_reservation r ON r.id = o.reservation_id + WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id + OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:Warehouse 公共资源父子归属不一致'; + END IF; + IF EXISTS (SELECT 1 FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户费用规则缺少客户 tenant_id'; + END IF; +END$$ +DELIMITER ; +CALL unified_assert_final_extensions(); +DROP PROCEDURE IF EXISTS unified_assert_final_extensions; + +-- tenant_id=0 在此表仅为未知/未解析第三方主体;数量可非零,但不得用于业务授权。 +SELECT COUNT(*) AS unresolved_third_party_calls + FROM baiyue_firstleg.fl_third_party_call_log + WHERE tenant_id = 0; + +SELECT 'PASS' AS migration_status, + '统一增量全部执行完成,平台公共资源归0、Warehouse父子回填、费用及可靠审计结构校验通过' AS message; diff --git a/docs/2026-07-31-P0-P2问题修复回归报告.md b/docs/2026-07-31-P0-P2问题修复回归报告.md index 312d00088..8fb914b43 100644 --- a/docs/2026-07-31-P0-P2问题修复回归报告.md +++ b/docs/2026-07-31-P0-P2问题修复回归报告.md @@ -82,7 +82,7 @@ mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" -pl iam,warehouse,orde 2. 在宿主机预建 File 上传目录和 11 个日志目录,确认 UID 10001 可以创建和删除文件。 3. 确认 Nacos 中只有当前版本实例,并验证 Gateway 到 `file-service`、`warehouse-service` 的负载均衡路由。 4. 使用受限商户成员重放新增和编辑成员请求:携带角色但无授角权限必须返回 403;角色不变的资料编辑必须成功。 -5. 使用启用仓库、启用往来单位创建头程草稿;再分别验证不存在、跨租户、停用和重复提交。 +5. 使用平台公共作用域 `0` 的启用仓库、启用往来单位创建头程草稿;再分别验证不存在、非平台公共作用域、停用和重复提交。 6. 上传一张脱敏营业执照并完成商户、平台预览;不得继续使用 `/phase1-seed/...` 路径。 7. 从 `http://localhost:5174` 直连 Gateway 验证登录预检、登录、401、403 和非白名单来源。 8. 检查宿主机固定日志目录,确认每个服务产生独立日志文件并按策略滚动。 diff --git a/docs/2026-07-31后端缺口修复回归报告.md b/docs/2026-07-31后端缺口修复回归报告.md index fad6996fb..8f02a702f 100644 --- a/docs/2026-07-31后端缺口修复回归报告.md +++ b/docs/2026-07-31后端缺口修复回归报告.md @@ -42,7 +42,7 @@ - Firstleg 增加任务分页、详情、按订单查询、分配历史和分配/改派权限,所有仓储查询均带目标租户。 - `idx_task_tenant_created` 同时写入新库建表脚本和可重复执行的既有库增量迁移。 -- Warehouse 增加租户级往来单位、快递公司分页、详情、新增、编辑和启停;停用保留历史主数据,不物理删除。 +- Warehouse 增加往来单位、快递公司分页、详情、新增、编辑和启停;该报告产生时曾按客户作用域实现,现已于 2026-08-05 统一改为平台公共作用域 `0`,停用仍保留历史主数据。 - IAM 同步新增六个 Warehouse 按钮权限及四个 Firstleg 权限,并保持平台管理员、订单运营角色授权一致。 ## 2. 自动化回归 diff --git a/docs/2026-07-31平台管理与头程履约补齐回归报告.md b/docs/2026-07-31平台管理与头程履约补齐回归报告.md index 0337e7c48..4810f7697 100644 --- a/docs/2026-07-31平台管理与头程履约补齐回归报告.md +++ b/docs/2026-07-31平台管理与头程履约补齐回归报告.md @@ -14,7 +14,7 @@ - 平台客户档案分页、详情、编辑、启停,并阻止通过启停接口绕过企业审核。 - 平台内部用户的成员主键和角色回显,以及新增、编辑、启停、重置密码、角色分配闭环。 - 权限资源编辑、启停、删除;系统资源、存在子节点或已绑定角色的资源禁止删除。 -- 平台快递公司和往来单位的分页、详情、新增、编辑、启停,所有读写强制携带目标租户。 +- 历史实现曾要求平台快递公司和往来单位的所有读写携带目标租户;该口径已于 2026-08-05 废止,两类主数据现统一归属平台作用域 `0`。 - 平台代客创建、编辑、提交头程订单;商户端和平台端创建、提交均消费 `Idempotency-Key`。 - 订单不存在统一返回 HTTP 404;Warehouse 下游不可用统一映射为受控 503。 - Firstleg 履约任务列表、详情、按订单查询、分配历史和分页查询。 @@ -34,7 +34,7 @@ 本地 File 存储目录 `D:\baiyue-firstleg-runtime\uploads` 已存在租户 `10001` 的 UUID 资质图片。使用商户 Token 请求 `/api/merchant/v1/files/qualifications/a1d6caa0-14df-4988-831c-d1a8f5d142ff.png` 返回 HTTP 200、`image/png`、47772 字节。旧 `/phase1-seed/demo-business-license.jpg` 记录状态已是 `SUPERSEDED`,不再作为当前有效资质。 -关键索引只读核验结果:订单幂等唯一索引、Firstleg 租户任务索引、往来单位和快递公司的租户编码唯一索引均已存在。 +关键索引只读核验结果:订单幂等唯一索引、Firstleg 客户隔离任务索引、往来单位和快递公司的兼容作用域编码唯一索引均已存在;后两者当前固定使用平台公共作用域 `0`。 迁移实测发现 MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,IAM 与 Warehouse 增量脚本已改为 `information_schema + 动态 SQL` 的可重复写法,并在本地成功执行。远程测试环境部署时仍必须核对迁移版本;健康检查成功不能证明业务表结构已同步。 @@ -67,7 +67,7 @@ mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" clean test - 商户端、平台端统一登录均成功;平台权限快照返回 61 项权限,不依赖角色列表权限。 - 平台客户分页返回 16 条;内部用户返回 `membershipId` 和 `roleIds`,ID 的 JSON 类型为字符串。 - 商户仓库、往来单位候选、平台仓库、平台往来单位、平台快递公司均返回真实本地数据;19 位仓库 ID 保持 `190000000000000001`,未发生精度截断。 -- 平台往来单位和快递公司均完成新增、编辑、停用、详情回读;测试记录随后按租户和精确 ID 清理。 +- 平台往来单位和快递公司均完成新增、编辑、停用、详情回读;该历史回归当时按客户作用域清理,当前两类主数据已统一归平台公共作用域 `0`。 - 商户头程订单完成创建草稿、相同 `Idempotency-Key` 重放返回同一订单 ID、提交、相同提交键重放、详情读取和取消;测试订单及子表、Outbox、幂等记录随后精确清理。 - 不存在订单返回 HTTP 404;Order OpenAPI 已声明必填 `Idempotency-Key`,`OrderEntity.id` 类型为 `string`,平台提交路径正确。 - 有效资质文件鉴权预览返回 HTTP 200,文件内容与元数据路径一致。 diff --git a/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md b/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md new file mode 100644 index 000000000..39953ddb3 --- /dev/null +++ b/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md @@ -0,0 +1,68 @@ +# Java 单租户多客户设计审计汇总结论 + +## 结论 + +当前 Java 主体模型整体符合“业务单租户、平台管理多客户、客户严格隔离”: + +- `iam_tenant` 的 `MERCHANT` 记录当前可以继续直接作为客户主档。 +- Auth/JWT 和各业务表的 `tenant_id > 0` 当前承载客户 ID,是客户隔离键。 +- 平台技术作用域和 RBAC 模板使用 `tenant_id = 0`。 +- Order/Firstleg 中的 `customer_id` 多数承载往来单位或业务客户快照,不是 IAM 客户隔离 ID。 +- 未发现可通过当前正常公开链路直接完成跨客户读写的 P0,不需要为上线重构全部主体模型。 + +## 已完成的上线前 P1 + +1. 公共仓库、往来单位、快递公司和部分种子数据使用 `tenant_id=10001`,而 IAM 又把 `10001` 建成普通 `MERCHANT` 演示客户。现已确认平台公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id=0`;必须同步新库种子、配置、消费者和可重复执行的存量迁移,客户订单、文件和费用等私有数据不得随之改为 `0`。 +2. Warehouse 存量子表迁移新增 `tenant_id` 时直接默认填 `0`,却没有按父仓库或父记录回填,可能导致存量地址、能力、往来单位和快递公司丢失归属。 +3. 统一迁移脚本在真正结束前已经输出 `PASS`,后面仍继续建表;成功标志和最终结构校验必须收敛到脚本末尾。 +4. IAM/Auth 的部分内部 Controller 在内部令牌为空时可能把“空配置”和“缺失请求头”比较为相等,必须统一启动失败校验。 +5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。 +6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。 +7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。 +8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,三期存在重新按三级租户开发的返工风险。 + +## P2 最小加固 + +- 删除或拒绝 Gateway 未分端域的 `/api/iam/**` 通用路由。 +- Auth 校验 IAM 授权快照回显的 `userId/tenantId/membershipId/appCode` 与请求完全一致。 +- IAM 商户资料读取和修改再次限定 `tenant_type=MERCHANT`。 +- Order/Firstleg/Warehouse/File 的平台守卫统一校验平台主体为 `tenant_id=0`。 +- 对少量 `updateById`、按裸主键删除、Partner 类型关联操作增加仓储层客户条件。 +- Order Outbox 投递前核对事件行和 Payload 的客户、订单及聚合类型。 +- Event Bridge 重复事件比较同时核对客户和信封身份字段。 +- File 平台测量图片上传增加目标入库单客户归属校验,避免孤儿或错误归属文件。 +- 公共 OpenAPI 不再把可信身份头描述成普通客户端请求头。 +- 增加客户 A/B、异常平台 Token、内部 API 错误客户、Outbox 归属漂移和迁移结果的负向测试。 +- 修正 Order 取消事件长期停留 `PENDING` 的非隔离功能问题,或明确当前不发布该事件的终态。 + +## 本轮不实施 + +- 不新增真实租户表、租户切换、租户级 RBAC 或租户级配置。 +- 不批量重命名 `tenant`、`merchant`、`Tenant*`、`tenant_id`。 +- 不把所有现有 `tenant_id` 统一成默认租户 ID。 +- 往来单位与快递公司均由平台联系和统一维护,`wh_partner`、`wh_partner_type` 与其他公共仓储主数据一起迁移为 `0`;客户订单、文件、费用等私有数据保持客户 ID。迁移必须按明确的公共资源表和父子关系执行。 +- 不触碰生产环境;测试环境已按用户明确授权执行统一增量 SQL,并完成迁移后断言。 + +## 测试环境迁移结果 + +- 统一增量 SQL 已在测试 MySQL 执行,最终输出 `migration_status = PASS`。 +- `wh_partner` 4 行、`wh_partner_type` 8 行、`wh_express_company` 4 行均已归平台公共作用域 `0`,非平台作用域违规数均为 0。 +- 公共仓库、地址、能力、托盘、库位、预占和占用的非平台作用域违规数均为 0。 +- 订单、文件和费用等客户私有表的 `tenant_id = 0` 违规数均为 0。 + +## 验证结果 + +- `mvn test`:19 个 Reactor 模块全部 `SUCCESS`,共执行 634 项测试,0 失败、0 错误、1 项跳过。 +- 重点业务模块:IAM 128 项、Order 67 项、Firstleg 112 项、Warehouse 102 项、File 81 项均通过。 +- 其余服务:Print 35 项(1 项跳过)、Audit 15 项、Integration 11 项、Event Bridge 10 项均通过。 +- `mvn -DskipTests validate` 与 `git diff --check` 均通过。 + +## 第二轮文件所有权 + +- 身份安全:`iam/`、`auth/`、`gateway/`、`common-security/` 的生产代码与测试。 +- 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。 +- 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。 +- Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。 +- 文档契约:模块 `AGENTS.md`、README、OpenAPI 和一期/二期/三期指导性文档;不修改 Java 或 SQL。 + +跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。 diff --git a/docs/2026-08-05-Java单租户多客户设计审计计划.md b/docs/2026-08-05-Java单租户多客户设计审计计划.md new file mode 100644 index 000000000..c4b3f7d63 --- /dev/null +++ b/docs/2026-08-05-Java单租户多客户设计审计计划.md @@ -0,0 +1,130 @@ +# Java 单租户多客户设计审计计划 + +> 状态说明:本文保留第一轮只读审计的原始计划。用户随后明确授权修改代码、初始化 SQL 与测试环境数据;实际执行结果以同日《Java 单租户多客户设计审计汇总结论》和统一增量 SQL 为准。 + +## 一、目标 + +对 `baiyue-firstleg/` 全部 Java 服务、数据库脚本和跨服务契约做一次系统性只读审计,确认当前实现符合根 `AGENTS.md` 已确定的阶段模型: + +- 当前业务单租户,平台管理多个客户,客户之间严格隔离。 +- `iam_tenant` 中 `id > 0 && tenant_type = MERCHANT` 的记录当前直接作为客户主档。 +- Auth、JWT 和业务表中的 `tenant_id > 0` 当前承载客户 ID,是客户隔离键。 +- `tenant_id = 0` 是平台或 RBAC 模板技术作用域,不是未来的默认租户。 +- 平台公共仓库、往来单位、快递公司、托盘、库位等公共资源归属平台技术作用域 `tenant_id = 0`;客户订单、文件和费用等私有数据仍使用客户 ID。 +- 当前不新增真实租户层,不重命名现有兼容字段,不做大规模架构改造。 + +审计完成后,主对话统一汇总结论,再以最小改造原则安排修改。第一轮各审计对话一律只读,不修改任何文件。 + +## 二、非目标 + +- 不在本轮设计或实现真实多租户。 +- 不把现有 `tenant_id` 统一替换为默认租户 ID。 +- 不批量重命名 `tenant`、`merchant`、`Tenant*`、`tenant_id`。 +- 不因为未来可能支持多租户而提前增加租户表、租户切换、租户级 RBAC 或租户级配置。 +- 第一轮审计阶段不修改测试环境数据;后续已按用户在当前对话中的明确授权执行统一迁移。 + +## 三、统一审计判定标准 + +每个业务入口和数据链路都按以下问题检查: + +1. 平台账号是否固定为 `app_code = PLATFORM && tenant_id = 0`。 +2. 客户账号是否固定为 `app_code = MERCHANT && tenant_id > 0`,成员关系是否属于同一客户。 +3. 商户端是否只从可信认证上下文取得当前客户 ID,而不是信任请求中的目标 `tenantId`。 +4. 平台端跨客户查询或代客操作是否同时校验平台应用域、权限码和目标客户条件。 +5. Repository、Mapper、SQL 是否在客户数据的查询、更新、删除和唯一约束中携带当前客户 ID。 +6. 子表、历史、审计、Outbox、幂等、文件和缓存键是否继承主聚合的客户 ID。 +7. 内部 API、Feign DTO 和事件是否完整传递客户 ID,并在被调用方再次按归属校验。 +8. `tenant_id = 0` 是否只用于平台或全局模板,是否被错误当成客户或未来默认租户。 +9. `customer_id`、`data_scope_id`、`operator_tenant_id`、`public-tenant-id` 等相邻字段是否被误用为 IAM 客户隔离键。 +10. 是否存在通用路由、可选过滤、按主键裸查、跨库查询或平台/商户端域混用造成的跨客户访问风险。 +11. 当前实现是否偷偷引入真实租户层,或制造一年后无法通过新增上级租户字段迁移的不可逆结构。 +12. 自动化测试是否至少覆盖客户 A 不能读取或修改客户 B 数据,以及平台合法跨客户操作。 + +## 四、第一轮多对话分工(全部只读) + +### 对话 A:IAM、Auth、Common Security、Gateway + +负责目录:`iam/`、`auth/`、`common-security/`、`gateway/`。 + +重点检查账号归属、注册、成员、角色权限模板、授权快照、JWT Claim、刷新撤销、可信身份头、平台/商户访问域和 Gateway 路由。确认平台 `0`、客户 `>0` 的规则在所有入口一致,检查历史账号脏数据风险及通用路由绕过。 + +### 对话 B:Order 客户隔离 + +负责目录:`order/`。 + +重点检查商户下单、平台代客、列表详情、状态流转、货物/参与方/历史/备注、幂等、Outbox、IAM 资格查询和 Warehouse 主数据调用。必须区分当前隔离键 `tenant_id` 与往来单位语义的 `customer_id`。 + +### 对话 C:Firstleg 履约与二期入库 + +负责目录:`firstleg/`。 + +重点检查履约任务、入库单、测量、审核、FBA 计费、费用发布、托盘库位协作、第三方测量、内部接口、幂等和 Outbox。核对所有状态推进、详情和图片链路是否使用同一个客户 ID。 + +### 对话 D:Warehouse 与 File 资源归属 + +负责目录:`warehouse/`、`file/`。 + +重点检查仓库、往来单位、快递公司、托盘、库位、预占占用、资质文件、测量图片和导出任务。单独核对 `DEFAULT_TENANT_ID` / `public-tenant-id` 是否固定为平台作用域 `0`,确保公共仓库、往来单位和快递公司等资源不再绑定普通客户 `10001`;客户文件仍按客户隔离。 + +### 对话 E:其余服务与公共契约 + +负责目录:`common-core/`、`common-web/`、`common-audit/`、`common-event-contract/`、`common-openapi/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`print/`。 + +重点检查公共实体、审计上下文、事件信封、内部令牌、OpenAPI 自动配置、第三方适配和打印下载链路是否丢失、混淆或信任外部提交的客户 ID。 + +### 对话 F:数据库、迁移与部署配置 + +只负责各模块 `src/main/resources/db/`、`deploy/sql/`、`deploy/`、`config/nacos/`、相关初始化脚本,不审计 Java 业务实现。 + +重点检查表、索引、唯一键、增量迁移、种子数据和环境配置中的客户隔离维度;盘点 `tenant_id = 0`、`10001` 等保留值的真实用途;提出上线前只读数据核验 SQL,不执行写操作。 + +### 对话 G:测试、OpenAPI、文档与三期防返工 + +只负责 `src/test/`、各模块 `src/main/resources/openapi/`、`docs/` 和模块 `README` / `AGENTS.md`,不修改生产代码。 + +检查测试是否覆盖跨客户拒绝、平台跨客户、内部调用归属和端域隔离;检查契约及文档是否仍把当前 `tenant_id` 描述成真实上级租户;定向核对三期文档,列出可能诱导后续按三级租户开发的内容。 + +## 五、每个审计对话的统一输出格式 + +每项发现必须包含: + +- 编号和风险级别。 +- 结论:符合、缺口或需要业务确认。 +- 可复核证据:绝对文件路径和准确行号。 +- 触发场景及可能造成的跨客户或上线影响。 +- 最小修复建议,明确涉及的文件和消费者。 +- 建议增加或调整的测试。 +- 是否应延后到约一年后的真实租户迭代。 + +风险级别统一为: + +- `P0`:可直接造成未授权跨客户读写、身份伪造或生产数据破坏,阻断上线。 +- `P1`:明确的隔离缺口、端域绕过或高概率配置错误,应在上线前修复。 +- `P2`:防御性加固、测试缺口、易误用设计或运维核验项,可在上线前排期完成。 +- `Deferred`:只与未来真实多租户有关,当前禁止提前改造,仅记录迁移边界。 + +没有发现问题的负责范围也必须给出检查过的入口、隔离链路和测试证据,不能只回复“未发现问题”。 + +## 六、汇总与裁决流程 + +1. 主对话等待全部只读审计完成,去重并处理跨模块结论冲突。 +2. 以当前根 `AGENTS.md` 为事实来源,把发现分成“必须上线前修改、上线前建议加固、仅需数据核验、延后到真实租户阶段”。 +3. 对跨模块字段和契约,由主对话确定唯一兼容策略;任何审计对话不得自行决定重定义 `tenant_id`。 +4. 输出修改清单、文件所有权、依赖顺序、验证命令和回滚边界,用户确认后进入修改阶段。 + +## 七、第二轮修改对话安排原则 + +- 修改对话数量由最终发现决定,不预先假设每个审计对话都有代码要改。 +- 每个修改对话拥有互斥的生产文件范围;公共 DTO、事件、OpenAPI 和统一 SQL 入口由主对话协调。 +- 优先处理 P0/P1,再处理测试和文档;`Deferred` 项不得实现。 +- 每个修改对话只运行目标模块实际声明的测试;主对话最后执行受影响模块及跨模块消费者验证。 +- 所有修改完成后再次定向检索 `tenant_id`、`customer_id`、`operator_tenant_id`、`DEFAULT_TENANT_ID` 和通用 `/api/iam/**` 路由,确认没有新增语义漂移。 + +## 八、最终交付物 + +- 全仓单租户多客户设计审计结论。 +- 按优先级排序的最小修改清单及完成状态。 +- 上线前只读数据核验 SQL 和配置核对清单。 +- 受影响模块测试与跨服务契约验证结果。 +- 更新后的根/模块 `AGENTS.md`、必要 OpenAPI 和回归记录。 +- 约一年后引入真实租户时必须保留的迁移边界清单。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 119fa517b..8b68a2789 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Objects; /** * 入站事件校验与幂等落库服务。 @@ -58,9 +59,24 @@ public class EventInboxService { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); } + if (!hasSameEnvelopeIdentity(existing, envelope)) { + throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件"); + } return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } + private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) { + return Objects.equals(existing.getTenantId(), envelope.tenantId()) + && Objects.equals(existing.getEventType(), envelope.eventType()) + && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) + && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) + && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) + && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) + && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) + && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) + && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()); + } + private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 4c55e03b0..a0e9e5a42 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -25,7 +25,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,7 +64,7 @@ class EventInboxServiceTest { @Test void identicalEventAndDigestDoesNotPersistAgain() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope.payloadHash()); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -74,17 +73,43 @@ class EventInboxServiceTest { @Test void sameEventWithDifferentDigestRejectsOverwrite() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())) + .thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); } + @Test + void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() { + EventEnvelope original = withHash(envelope("sha256:placeholder")); + EventEnvelope otherTenant = withTenant(original, "20002"); + when(store.findByEventId(otherTenant.eventId())) + .thenReturn(existing(original, original.payloadHash())); + + assertThatThrownBy(() -> service.receive(otherTenant)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("信封身份不一致"); + } + + @Test + void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() { + EventEnvelope original = withHash(envelope("sha256:placeholder")); + EventEnvelope otherEnvelope = withAggregateVersion(original, 2L); + when(store.findByEventId(otherEnvelope.eventId())) + .thenReturn(existing(original, original.payloadHash())); + + assertThatThrownBy(() -> service.receive(otherEnvelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("信封身份不一致"); + } + @Test void duplicateInsertAfterRaceIsTreatedAsDuplicate() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())) + .thenReturn(null, existing(envelope, envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -115,9 +140,32 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private BridgeInbox existing(String hash) { + private EventEnvelope withTenant(EventEnvelope source, String tenantId) { + return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), + source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(), + source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), + source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); + } + + private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) { + return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), + source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), + source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(), + source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); + } + + private BridgeInbox existing(EventEnvelope envelope, String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); + inbox.setEventId(envelope.eventId()); + inbox.setTenantId(envelope.tenantId()); + inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); + inbox.setSourceRegion(envelope.sourceRegion().name()); + inbox.setTargetRegion(envelope.targetRegion().name()); + inbox.setAggregateType(envelope.aggregateType()); + inbox.setAggregateId(envelope.aggregateId()); + inbox.setAggregateVersion(envelope.aggregateVersion()); + inbox.setHandoverVersion(envelope.handoverVersion()); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/file/AGENTS.md b/file/AGENTS.md index 18f1778c4..e8ec8f827 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -35,18 +35,18 @@ ## 修改与验证 -- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。 +- 严格校验客户归属、文件名/路径、大小、类型和读取权限,防止目录穿越、跨客户访问和任意文件上传;接口和存储路径中的 `tenantId`、`tenant_id` 当前是客户隔离兼容字段。 - 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。 - 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。 - 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。 - 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。 - 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。 -- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他租户文件。 +- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他客户文件。 - 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。 - 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。 - 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。 - 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。 -- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标租户安全清理旧 UUID 文件。 +- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录兼容字段 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标客户安全清理旧 UUID 文件。 - 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。 - 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。 - `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。 diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java index 8a549a77c..88f283971 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java @@ -83,6 +83,7 @@ public class PlatformMeasurementImageController { private long requirePlatform(AuthenticatedUser user) { if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) { throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份"); } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 35342145c..9efc74797 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -47,6 +47,7 @@ public class PlatformQualificationFileController { @RequestParam(required = false) LocalDate validTo, @RequestParam(defaultValue = "false") boolean replace) { requirePlatform(user); + requireCustomer(tenantId); qualificationType = QualificationTypes.requireOptional(qualificationType); var stored = storage.store(tenantId, file); try { @@ -94,6 +95,7 @@ public class PlatformQualificationFileController { @RequestParam(defaultValue = "false") boolean replaceBusinessLicense, @RequestParam(defaultValue = "false") boolean replaceLegalPersonId) { requirePlatform(user); + requireCustomer(tenantId); return ApiResponse.success(packageService.upload(tenantId, businessLicense, legalPersonId, unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo, replaceBusinessLicense, replaceLegalPersonId, true)); @@ -105,6 +107,7 @@ public class PlatformQualificationFileController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { requirePlatform(user); + requireCustomer(tenantId); var resource = storage.load(tenantId, fileName); String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE); @@ -113,8 +116,15 @@ public class PlatformQualificationFileController { } private void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); } } + + private void requireCustomer(long tenantId) { + if (tenantId <= 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "Customer identity must be positive"); + } + } } diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index 578bf3462..e8b144ab1 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -187,7 +187,7 @@ public class ExportTaskService { private void requirePlatform(AuthenticatedUser user) { if (user == null || user.userId() == null || user.tenantId() == null - || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + || user.tenantId() != 0L || !"PLATFORM".equalsIgnoreCase(user.appCode())) { throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); } } diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java index ef7d04f7a..fd0d78555 100644 --- a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java @@ -61,4 +61,14 @@ class PlatformMeasurementImageControllerContractTest { verify(images).download(10001L, 88L, 99L); } + + @Test + void rejectsPlatformTokenWithCustomerTenantScope() { + var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1}); + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.upload(malformed, 10001L, 88L, file)) + .isInstanceOf(BizException.class); + } } diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index caf4ed36d..a52aee208 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -89,6 +89,32 @@ class QualificationFileContractTest { assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value()); } + @Test + void platformQualificationRejectsCustomerScopedPlatformToken() { + var storage = mock(LocalQualificationStorage.class); + var controller = platformController(storage, mock(IamQualificationClient.class)); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + + assertThrows(BizException.class, + () -> controller.upload(malformed, 10001L, file, + "BANK_ACCOUNT_LICENSE", null, null, false)); + verify(storage, never()).store(any(Long.class), any()); + } + + @Test + void platformQualificationRejectsPlatformScopeAsCustomerTarget() { + var storage = mock(LocalQualificationStorage.class); + var controller = platformController(storage, mock(IamQualificationClient.class)); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(platformUser(), 0L, file, + "BANK_ACCOUNT_LICENSE", null, null, false)); + verify(storage, never()).store(any(Long.class), any()); + } + @Test void platformReplacementIsForwardedAndCleansThePreviousFileAfterIamSucceeds() { var storage = mock(LocalQualificationStorage.class); diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index 6eb736d5d..7abc756f3 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -74,6 +74,16 @@ class ExportTaskServiceTest { assertEquals(ResultCode.VALIDATION_FAILED,error.getResultCode()); } + @Test + void rejectsPlatformTokenWithCustomerTenantScope() { + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "user1", "PLATFORM", "WEB", Set.of("platform:customer:export")); + assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, + () -> service.create(malformed, + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of()))) + .getResultCode()); + } + private AuthenticatedUser user(long id, Set permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); } diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java index fb9960b74..0b35d4405 100644 --- a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -3,6 +3,11 @@ package com.baiyue.file.service; import com.baiyue.file.client.WeightOcrClient; import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; @@ -28,6 +33,14 @@ class MeasurementImageServiceEvidenceTest { @MockBean private FileObjectMapper files; @MockBean private WeightOcrClient ocr; @Resource private MeasurementImageService service; + + @BeforeAll + static void initializeEntityMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "measurement-image-test"), + FileObjectEntity.class); + } + @Test void shouldHandleCase01() { var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, @@ -62,4 +75,20 @@ class MeasurementImageServiceEvidenceTest { .hasMessageContaining("不执行重量OCR"); verifyNoInteractions(ocr); } + + @Test + void refusesAFileTupleFromAnotherCustomer() { + when(files.selectOne(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + LambdaQueryWrapper query = invocation.getArgument(0); + query.getSqlSegment(); + assertThat(query.getParamNameValuePairs().values()) + .contains(11L, 41L, "21", false); + return null; + }); + + assertThatThrownBy(() -> service.download(11L, 21L, 41L)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verifyNoInteractions(storage); + } } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 30c1be11f..9acffc455 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -41,12 +41,12 @@ - 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。 - 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。 -- 所有查询必须带目标租户并在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在使用 HTTP 200 + body code `A0404`。 +- 所有客户归属查询必须带目标客户并按兼容字段 `tenant_id` 在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在使用 HTTP 200 + body code `A0404`。 - 普通任务不存在、状态冲突和参数错误均遵循 HTTP 200 + body code,平台 Security 拒绝仍保留真实 403。 - 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。 - 修改任务查询、状态或分配逻辑时同步扩展 Service 测试和 Controller 契约测试,不以模块构建代替行为验证。 -- 二期入库所有平台查询和写入必须显式校验目标租户;商户费用查询只能使用登录态租户。状态推进、审核、计费和完成入库必须保留乐观锁、幂等键、历史和审计语义。 -- 第三方测量入口必须执行签名、时间窗、防重放和请求哈希校验;一条回传绑定一条订单货物明细及一张 File 证据图片,不得信任第三方直接提交的租户或订单快照。 +- 二期入库所有平台查询和写入必须显式校验目标客户;接口中的 `tenantId` 当前是客户隔离兼容字段。商户费用查询只能使用登录态客户归属。状态推进、审核、计费和完成入库必须保留乐观锁、幂等键、历史和审计语义。 +- 第三方测量入口必须执行签名、时间窗、防重放和请求哈希校验;一条回传绑定一条订单货物明细及一张 File 证据图片,不得信任第三方直接提交的客户隔离字段或订单快照。 - Firstleg 调用 Order、Warehouse、File 的内部接口统一使用共享 `INTERNAL_SERVICE_TOKEN`,只通过客户端 DTO 协作;任何内部接口、Feign DTO 或状态变化都要同步检查被调用模块和契约测试。 - 测量审核通过后的自动链路依次完成计费、确认、费用发布与入库完成;异常放行必须受配置开关、权限和审核历史共同约束,不能跳过测量门禁直接改状态。 - 从父目录运行 `mvn -pl firstleg -am test`。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java index f366c525a..49c420014 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java @@ -18,6 +18,6 @@ public class InboundProperties { private boolean allowRejectedMeasurement = false; private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000"); private Duration reservationTtl = Duration.ofMinutes(30); - /** 共享收货仓资源所属租户;0 表示与入库业务租户一致。 */ + /** 历史配置兼容字段;平台公共仓资源当前固定使用技术作用域 0。 */ private long warehouseResourceTenantId = 0L; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java index 990efd3e7..49043ecc8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java @@ -3,10 +3,9 @@ package com.baiyue.firstleg.controller; import jakarta.annotation.Resource; import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.MerchantInboundBillingResponse; +import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.MerchantInboundBillingService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -33,10 +32,7 @@ public class MerchantInboundBillingController { description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。") public ApiResponse find( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) { - if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问"); - } - return ApiResponse.success(service.find(user.tenantId(), orderId)); + return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId)); } @GetMapping("/{orderId}/inbound-billing/images/{fileId}") @@ -46,10 +42,7 @@ public class MerchantInboundBillingController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId, @PathVariable long fileId) { - if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问"); - } - var image = service.downloadImage(user.tenantId(), orderId, fileId); + var image = service.downloadImage(FirstlegScopeGuard.merchant(user), orderId, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(image.contentType())) .contentLength(image.content().length).body(image.content()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index 362caf316..4ad0fdf8f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -4,8 +4,6 @@ import jakarta.annotation.Resource; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; @@ -114,9 +112,7 @@ public class PlatformFbaMasterDataController { } private static Long optionalPlatformTenant(AuthenticatedUser user, Long tenantId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } + FirstlegScopeGuard.platform(user); return tenantId == null ? null : FirstlegScopeGuard.platform(user, tenantId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java index 2aa01fa7a..027f83d73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java @@ -3,14 +3,13 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.AssignRequest; import com.baiyue.firstleg.dto.FulfillmentTaskQuery; import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.service.FulfillmentTaskService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -65,8 +64,7 @@ public class PlatformFulfillmentAssignmentController { @Parameter(description = "First-leg fulfillment task ID / 头程履约任务 ID", example = "190000000000000101", required = true) @PathVariable long taskId, @Valid @RequestBody AssignRequest request) { - requirePlatform(user); - requireTenant(tenantId); + FirstlegScopeGuard.platform(user, tenantId); service.assign( tenantId, user.userId(), @@ -89,8 +87,7 @@ public class PlatformFulfillmentAssignmentController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @Valid FulfillmentTaskQuery query) { - requirePlatform(user); - requireTenant(tenantId); + FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.page(tenantId, query)); } @@ -105,8 +102,7 @@ public class PlatformFulfillmentAssignmentController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long taskId) { - requirePlatform(user); - requireTenant(tenantId); + FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.detail(tenantId, taskId)); } @@ -121,8 +117,7 @@ public class PlatformFulfillmentAssignmentController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long orderId) { - requirePlatform(user); - requireTenant(tenantId); + FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.byOrder(tenantId, orderId)); } @@ -139,20 +134,8 @@ public class PlatformFulfillmentAssignmentController { @PathVariable long taskId, @RequestParam(defaultValue = "1") @Min(1) long page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) { - requirePlatform(user); - requireTenant(tenantId); + FirstlegScopeGuard.platform(user, tenantId); return ApiResponse.success(service.assignmentHistory(tenantId, taskId, page, size)); } - private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } - } - - private static void requireTenant(long tenantId) { - if (tenantId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); - } - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 1cbe280c0..6a5721959 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -9,6 +9,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.InboundMeasurementService; import com.baiyue.firstleg.service.MeasurementSubmissionService; import io.swagger.v3.oas.annotations.Operation; @@ -120,10 +121,8 @@ public class PlatformInboundMeasurementController { } private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } - if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) { + FirstlegScopeGuard.platform(user, tenantId); + if (user.userId() == null || user.userId() <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index 7e258b5c9..eddba69aa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -5,14 +5,13 @@ import jakarta.annotation.Resource; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.service.MeasurementReviewService; import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -108,11 +107,6 @@ public class PlatformInboundMeasurementReviewController { } private static void validateScope(AuthenticatedUser user, long tenantId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } - if (tenantId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); - } + FirstlegScopeGuard.platform(user, tenantId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index 3c9f37771..5d2c7bb14 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundOrderDetail; import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.InboundOrderQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -61,15 +62,10 @@ public class PlatformInboundOrderController { } private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) { - requirePlatform(user); - if (tenantId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); - } + FirstlegScopeGuard.platform(user, tenantId); } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } + FirstlegScopeGuard.platform(user); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java index c391c472a..b70fc6591 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java @@ -11,7 +11,8 @@ public interface InboundIdempotencyMapper extends BaseMapper() + .eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId()) .eq(InboundPricingIdempotencyEntity::getId, value.getId()) .eq(InboundPricingIdempotencyEntity::getStatus, "PROCESSING") .set(InboundPricingIdempotencyEntity::getStatus, "COMPLETED") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 98f3b8c78..68d586479 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -60,11 +60,15 @@ public class FeeItemRepository { } public int updateItem(FeeItemEntity item) { - return feeItems.updateById(item); + return feeItems.update(item, new LambdaUpdateWrapper() + .eq(FeeItemEntity::getTenantId, item.getTenantId()) + .eq(FeeItemEntity::getId, item.getId())); } - public int deleteItem(long id) { - return feeItems.deleteById(id); + public int deleteItem(long tenantId, long id) { + return feeItems.delete(new LambdaQueryWrapper() + .eq(FeeItemEntity::getTenantId, tenantId) + .eq(FeeItemEntity::getId, id)); } public long countBindings(long tenantId, long feeItemId) { @@ -99,11 +103,15 @@ public class FeeItemRepository { } public int updateDetail(InboundFeeDetailEntity detail) { - return feeDetails.updateById(detail); + return feeDetails.update(detail, new LambdaUpdateWrapper() + .eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId()) + .eq(InboundFeeDetailEntity::getId, detail.getId())); } - public int deleteDetail(long detailId) { - return feeDetails.deleteById(detailId); + public int deleteDetail(long tenantId, long detailId) { + return feeDetails.delete(new LambdaQueryWrapper() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getId, detailId)); } public int review(long tenantId, long inboundId, long detailId, String targetStatus, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java index 31074f097..3b4ce85f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java @@ -5,6 +5,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.FulfillmentTaskQuery; 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 com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; @@ -53,7 +54,9 @@ public class FulfillmentTaskRepository { } public boolean update(FulfillmentTaskEntity task) { - return tasks.updateById(task) == 1; + return tasks.update(task, new LambdaUpdateWrapper() + .eq(FulfillmentTaskEntity::getTenantId, task.getTenantId()) + .eq(FulfillmentTaskEntity::getId, task.getId())) == 1; } public void insertHistory(AssignmentHistoryEntity history) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 3f1fa4baa..bff704a3e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.repository; import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -42,7 +43,9 @@ public class InboundMeasurementRepository { } public boolean updateInbound(InboundOrderEntity inbound) { - return inboundOrders.updateById(inbound) == 1; + return inboundOrders.update(inbound, new LambdaUpdateWrapper() + .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) + .eq(InboundOrderEntity::getId, inbound.getId())) == 1; } public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { @@ -72,7 +75,9 @@ public class InboundMeasurementRepository { } public boolean updateMeasurement(InboundMeasurementEntity measurement) { - return measurements.updateById(measurement) == 1; + return measurements.update(measurement, new LambdaUpdateWrapper() + .eq(InboundMeasurementEntity::getTenantId, measurement.getTenantId()) + .eq(InboundMeasurementEntity::getId, measurement.getId())) == 1; } public void replaceItems(long tenantId, long measurementId, @@ -109,7 +114,9 @@ public class InboundMeasurementRepository { } public boolean updateWeight(InboundWeightResultEntity weight) { - return weights.updateById(weight) == 1; + return weights.update(weight, new LambdaUpdateWrapper() + .eq(InboundWeightResultEntity::getTenantId, weight.getTenantId()) + .eq(InboundWeightResultEntity::getId, weight.getId())) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java index 4e2fb4061..33d272740 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java @@ -108,8 +108,8 @@ public class InboundWorkflowRepository { .eq(InboundIdempotencyEntity::getIdempotencyKey, key)); } - public int completeIdempotency(long id, String resultJson) { - return idempotencies.complete(id, resultJson); + public int completeIdempotency(long tenantId, long id, String resultJson) { + return idempotencies.complete(tenantId, id, resultJson); } public InboundFeeEntity findConfirmedFee(long tenantId, long inboundId, long feeId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java index 83d06453d..f9ccba6ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java @@ -67,7 +67,7 @@ public class FeeItemService { if (repository.countBindings(tenantId, id) > 0) { throw conflict("费用项目已被入库费用明细使用,只能停用,不能删除"); } - if (repository.deleteItem(id) != 1) throw conflict("费用项目删除冲突"); + if (repository.deleteItem(tenantId, id) != 1) throw conflict("费用项目删除冲突"); } private FeeItemEntity required(long tenantId, long id) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java index 70645f2ef..ec300426f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java @@ -4,17 +4,30 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; -/** 头程平台接口租户边界校验。 */ +/** 头程平台与客户接口的主体边界校验。 */ public final class FirstlegScopeGuard { private FirstlegScopeGuard() {} public static long platform(AuthenticatedUser user, long targetTenantId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } + platform(user); if (targetTenantId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); } return targetTenantId; } + + public static void platform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } + + public static long merchant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问"); + } + return user.tenantId(); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 543903b2e..684622219 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -59,7 +59,7 @@ public class InboundFeeDetailService { @Transactional public void delete(long tenantId, long inboundId, long detailId) { requiredEditable(tenantId, inboundId, detailId); - if (repository.deleteDetail(detailId) != 1) throw conflict("费用明细删除冲突"); + if (repository.deleteDetail(tenantId, detailId) != 1) throw conflict("费用明细删除冲突"); } @Transactional diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java index 78b09c04f..d8b31515c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java @@ -44,7 +44,7 @@ public class InboundIdempotencyService { record.setUpdatedAt(record.getCreatedAt()); try { repository.insertIdempotency(record); - return new Context(record.getId(), false); + return new Context(tenantId, record.getId(), false); } catch (DuplicateKeyException duplicate) { InboundIdempotencyEntity existing = repository.findIdempotency( tenantId, operation, operatorId, key); @@ -53,7 +53,7 @@ public class InboundIdempotencyService { "Idempotency-Key 已用于不同请求"); } if ("SUCCESS".equals(existing.getStatus())) { - return new Context(existing.getId(), true); + return new Context(tenantId, existing.getId(), true); } throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试"); } @@ -61,7 +61,8 @@ public class InboundIdempotencyService { public void complete(Context context, long inboundId) { if (!context.replayed() - && repository.completeIdempotency(context.id(), "{\"inboundId\":\"" + inboundId + "\"}") != 1) { + && repository.completeIdempotency(context.tenantId(), context.id(), + "{\"inboundId\":\"" + inboundId + "\"}") != 1) { throw new BizException(ResultCode.CONFLICT, "幂等记录已被并发修改"); } } @@ -75,6 +76,6 @@ public class InboundIdempotencyService { } } - public record Context(long id, boolean replayed) { + public record Context(long tenantId, long id, boolean replayed) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 6d41023ca..a713cd696 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -20,6 +20,7 @@ import java.time.LocalDateTime; @Service public class MeasurementLocationOccupationService { private static final String SOURCE_SERVICE = "firstleg-service"; + private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; @Resource private WarehousePalletLocationClient warehouse; @Resource @@ -35,8 +36,7 @@ public class MeasurementLocationOccupationService { int measurementVersion) { InboundOrderEntity order = requireOrder(tenantId, inboundId); long warehouseId = requireWarehouse(order); - long resourceTenantId = properties.getWarehouseResourceTenantId() > 0 - ? properties.getWarehouseResourceTenantId() : tenantId; + long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID; BigDecimal volume = requireVolume(order); String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion; WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java index aa4511fbd..b75a13dfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java @@ -4,6 +4,7 @@ import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baiyue.firstleg.config.MeasurementOpenApiProperties; import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity; import com.baiyue.firstleg.mapper.ThirdPartyCallLogMapper; @@ -55,6 +56,10 @@ public class ThirdPartyCallLogService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void complete(long id, String status, String code, String message, String resultJson) { + Long tenantId = tenantId(id); + if (tenantId == null) { + return; + } ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity(); update.setId(id); update.setProcessingStatus(status); @@ -62,18 +67,25 @@ public class ThirdPartyCallLogService { update.setResultMessage(limit(message, 1000)); update.setResultJson(resultJson); update.setProcessedAt(LocalDateTime.now()); - mapper.updateById(update); + mapper.update(update, new LambdaUpdateWrapper() + .eq(ThirdPartyCallLogEntity::getTenantId, tenantId) + .eq(ThirdPartyCallLogEntity::getId, id)); } /** 签名通过后原子认领 Idempotency-Key;非法签名不会污染幂等空间。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean claimIdempotency(long logId) { + Long tenantId = tenantId(logId); + if (tenantId == null) { + return false; + } ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity(); update.setId(logId); update.setIdempotencyAnchor(1); try { - mapper.updateById(update); - return true; + return mapper.update(update, new LambdaUpdateWrapper() + .eq(ThirdPartyCallLogEntity::getTenantId, tenantId) + .eq(ThirdPartyCallLogEntity::getId, logId)) == 1; } catch (DuplicateKeyException exception) { return false; } @@ -87,6 +99,11 @@ public class ThirdPartyCallLogService { .eq(ThirdPartyCallLogEntity::getIdempotencyAnchor, 1)); } + private Long tenantId(long id) { + ThirdPartyCallLogEntity log = mapper.selectById(id); + return log == null ? null : log.getTenantId(); + } + private String limit(String value, int max) { if (value == null || value.length() <= max) { return value; diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index f36f3cb49..5024450c5 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -41,17 +41,18 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', - "BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id"); + "BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id"); --- 旧分配记录创建于共享仓储资源租户拆分前,按原业务租户回填后再收口非空约束。 +-- 仓储资源是平台公共主数据;存量分配快照统一迁移到平台作用域 0,再收口非空约束。 UPDATE fl_inbound_pallet_allocation -SET warehouse_resource_tenant_id = tenant_id -WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0; -SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS +SET warehouse_resource_tenant_id = 0 +WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id <> 0; +SET @resource_tenant_needs_modify = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation' - AND COLUMN_NAME = 'warehouse_resource_tenant_id'); -SET @sql = IF(@resource_tenant_nullable = 'YES', - "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'", + AND COLUMN_NAME = 'warehouse_resource_tenant_id' + AND (IS_NULLABLE = 'YES' OR COLUMN_COMMENT <> '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键')); +SET @sql = IF(@resource_tenant_needs_modify > 0, + "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键'", 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql index 40f252e92..8971af49b 100644 --- a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql +++ b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql @@ -3,7 +3,7 @@ SET NAMES utf8mb4; CREATE TABLE IF NOT EXISTS fl_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户', category VARCHAR(64) NOT NULL COMMENT '费用分类', trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', @@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', @@ -70,3 +70,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail ( CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id) ) ENGINE=InnoDB COMMENT='入库单应收应付费用明细'; + +-- 既有表同步字段注释:tenant_id 当前承载客户 ID,不是上级租户 ID。 +SET @fee_tenant_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户'; +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_fee_item' + AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_fee_item MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' + AND column_name = 'tenant_id' AND column_comment = @fee_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee_detail MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT ''当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index b341765cc..aaf7ad059 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -423,7 +423,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', - warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID', + warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', diff --git a/firstleg/src/main/resources/db/phase2-measurement-openapi.sql b/firstleg/src/main/resources/db/phase2-measurement-openapi.sql index 418902c66..5ca0ffd3c 100644 --- a/firstleg/src/main/resources/db/phase2-measurement-openapi.sql +++ b/firstleg/src/main/resources/db/phase2-measurement-openapi.sql @@ -4,7 +4,7 @@ USE baiyue_firstleg; -- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权', app_key VARCHAR(128) NULL COMMENT '第三方应用标识', signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', @@ -28,3 +28,14 @@ CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( KEY idx_third_call_nonce (tenant_id, app_key, nonce), UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) ) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; + +-- 既有表也要同步未知主体 0 的语义,避免历史注释继续误导为真实租户。 +SET @third_party_tenant_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权'; +SET @ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_third_party_call_log' + AND column_name = 'tenant_id' AND column_comment = @third_party_tenant_comment) = 1, + 'SELECT 1', + 'ALTER TABLE fl_third_party_call_log MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权''' +); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 60af9cf57..c0038edae 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -91,8 +91,8 @@ components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} parameters: - OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + OptionalTenantId: {name: tenantId, in: query, required: false, description: 可选目标客户 ID;参数名为历史兼容,不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}} Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e331db6e5..c01e047d9 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -133,7 +133,7 @@ paths: - {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}} responses: '200': - description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId + description: 成功或业务失败,按响应体 code 判断;每条记录携带后续客户隔离操作所需的 tenantId,该字段当前表示客户 ID content: application/json: schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'} @@ -184,7 +184,7 @@ paths: - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': {description: 成功或业务失败,按响应体 code 判断} - '403': {description: 非本租户或无订单查看权限} + '403': {description: 非当前客户或无订单查看权限} /api/platform/v1/inbounds/{inboundId}/measurements/exception-release: post: summary: 测量异常放行 @@ -276,7 +276,7 @@ components: required: [id, tenantId] properties: id: {type: string, pattern: '^[1-9][0-9]*$'} - tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属企业租户 ID,用于详情和处理接口的租户隔离} + tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属客户 ID;字段名为历史兼容,用于详情和处理接口的客户隔离} orderId: {type: string, nullable: true} orderNo: {type: string, nullable: true} inboundCode: {type: string, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java index afa4b83e1..7de6ce9e5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java @@ -14,6 +14,8 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @SpringJUnitConfig(MerchantInboundBillingController.class) class MerchantInboundBillingControllerContractTest { @@ -38,6 +40,17 @@ class MerchantInboundBillingControllerContractTest { "admin", "PLATFORM", "WEB", Set.of()); assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class); } + + @Test + void merchantTechnicalTenantCannotReadCustomerBilling() { + var invalidMerchant = new AuthenticatedUser(1L, 0L, 0L, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.find(invalidMerchant, 77L)) + .isInstanceOf(BizException.class); + verify(service, never()).find(0L, 77L); + } + @Test void shouldHandleCase03() throws Exception { var method = MerchantInboundBillingController.class.getDeclaredMethod( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 12c9341ea..653c42a8b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -1,9 +1,13 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; import com.baiyue.firstleg.mapper.InboundFeeItemMapper; @@ -85,6 +89,26 @@ class FbaPricingRepositoryRuleListTest { assertThat(result).isEqualTo(4); } + @Test + void idempotencyCompletionKeepsTenantAndRecordFilters() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPricingIdempotencyEntity.class); + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setTenantId(10001L); + value.setId(88L); + + repository.completeIdempotency(value, 501L, java.time.LocalDateTime.now()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(idempotency).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .containsPattern("(?i)(^|\\W)id\\s*="); + assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L); + } + private Collection queryValues(Long tenantId, String ruleCode) { when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); @@ -111,4 +135,9 @@ class FbaPricingRepositoryRuleListTest { return wrapper.getValue().getParamNameValuePairs().values(); } + @SuppressWarnings("rawtypes") + private static java.util.Map params(Wrapper wrapper) { + return ((AbstractWrapper) wrapper).getParamNameValuePairs(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 71783181b..36a47d521 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -35,7 +35,7 @@ class InboundWorkflowSqlContractTest { assertThat(migration) .contains("phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'") - .contains("SET warehouse_resource_tenant_id = tenant_id") + .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java new file mode 100644 index 000000000..3cc3dff9b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java @@ -0,0 +1,117 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.mapper.AssignmentHistoryMapper; +import com.baiyue.firstleg.mapper.FeeItemMapper; +import com.baiyue.firstleg.mapper.FulfillmentTaskMapper; +import com.baiyue.firstleg.mapper.InboundFeeDetailMapper; +import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper; +import com.baiyue.firstleg.mapper.InboundMeasurementMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundWeightResultMapper; +import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper; +import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper; +import jakarta.annotation.Resource; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig({FulfillmentTaskRepository.class, InboundMeasurementRepository.class, + FeeItemRepository.class}) +class TenantScopedMutationRepositoryTest { + private static final long TENANT = 10001L; + private static final long ID = 88L; + + @MockBean private FulfillmentTaskMapper tasks; + @MockBean private AssignmentHistoryMapper histories; + @MockBean private InboundOrderMapper inboundOrders; + @MockBean private InboundMeasurementMapper measurements; + @MockBean private InboundMeasurementItemMapper measurementItems; + @MockBean private InboundWeightResultMapper weights; + @MockBean private InboundWeightRevisionMapper revisions; + @MockBean private MeasureReviewRecordMapper reviews; + @MockBean private FeeItemMapper feeItems; + @MockBean private InboundFeeDetailMapper feeDetails; + @Resource private FulfillmentTaskRepository fulfillmentRepository; + @Resource private InboundMeasurementRepository measurementRepository; + @Resource private FeeItemRepository feeRepository; + + @BeforeAll + static void initializeMetadata() { + initialize(FulfillmentTaskEntity.class); + initialize(InboundWeightResultEntity.class); + initialize(InboundFeeDetailEntity.class); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void fulfillmentUpdateMatchesTenantAndId() { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setTenantId(TENANT); + task.setId(ID); + when(tasks.update(any(), any(Wrapper.class))).thenReturn(1); + + assertThat(fulfillmentRepository.update(task)).isTrue(); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(tasks).update(eq(task), wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void measurementUpdateMatchesTenantAndId() { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setTenantId(TENANT); + weight.setId(ID); + when(weights.update(any(), any(Wrapper.class))).thenReturn(1); + + assertThat(measurementRepository.updateWeight(weight)).isTrue(); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(weights).update(eq(weight), wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void feeDetailDeleteMatchesTenantAndId() { + when(feeDetails.delete(any(Wrapper.class))).thenReturn(1); + + assertThat(feeRepository.deleteDetail(TENANT, ID)).isEqualTo(1); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(feeDetails).delete(wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + } + + private static void initialize(Class entityType) { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), entityType); + } + + private static void assertTenantAndId(Wrapper wrapper) { + assertThat(wrapper.getCustomSqlSegment()) + .contains("tenant_id") + .containsPattern("(?i)(^|\\W)id\\s*="); + assertThat(((AbstractWrapper) wrapper).getParamNameValuePairs().values()) + .contains(TENANT, ID); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index f77dbbd5b..b9aff45b5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -49,6 +49,18 @@ class FbaPricingServiceTest { @MockBean private FbaPricingRepository repository; @Resource private FbaPricingService service; + @Test + @DisplayName("客户B不能读取客户A的费用快照") + void rejectsCrossCustomerFeeLookup() { + assertThatThrownBy(() -> service.latest(10002L, INBOUND)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + + verify(repository).findLatestFee(10002L, INBOUND); + verify(repository, never()).findLatestFee(TENANT, INBOUND); + } + @Test @DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入") void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 9e0c65ac4..c27320759 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -22,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,6 +36,17 @@ class FeeManagementServiceTest { @Resource private InboundFeeDetailService details; + @Test + @DisplayName("客户B不能删除客户A的费用明细") + void rejectsCrossCustomerFeeDetailDelete() { + assertThatThrownBy(() -> details.delete(10002L, 21L, 31L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用明细不存在"); + + verify(repository).findDetail(10002L, 21L, 31L); + verify(repository, never()).deleteDetail(10002L, 31L); + } + @Test @DisplayName("费用项目列表返回分页结果") void pagesItems() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java new file mode 100644 index 000000000..153137c19 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstlegScopeGuardTest { + @Test + void platformRequiresZeroTenantAndPositiveTargetCustomer() { + AuthenticatedUser platform = user(0L, "PLATFORM"); + + assertThat(FirstlegScopeGuard.platform(platform, 10001L)).isEqualTo(10001L); + assertForbidden(() -> FirstlegScopeGuard.platform(user(10001L, "PLATFORM"), 10002L)); + assertForbidden(() -> FirstlegScopeGuard.platform(user(0L, "MERCHANT"), 10002L)); + } + + @Test + void merchantRequiresPositiveAuthenticatedCustomer() { + assertThat(FirstlegScopeGuard.merchant(user(10001L, "MERCHANT"))).isEqualTo(10001L); + assertForbidden(() -> FirstlegScopeGuard.merchant(user(0L, "MERCHANT"))); + assertForbidden(() -> FirstlegScopeGuard.merchant(user(10001L, "PLATFORM"))); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(9L, tenantId, tenantId, 1L, + "operator", appCode, "WEB", Set.of()); + } + + private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java index a53b5d734..1760b04e2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java @@ -115,7 +115,7 @@ class InboundCompletionOrchestratorTest { private Fixture fixture(String reviewStatus, boolean feeRelease) { when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(new InboundIdempotencyService.Context(1L, false)); + .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("FEE_CONFIRMING"); order.setFeeStatus("CONFIRMED"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java index 5c453b512..3aad109e2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java @@ -37,7 +37,11 @@ class InboundIdempotencyServiceTest { InboundIdempotencyService.Context context = service.begin( 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|"); - assertThat(context.replayed()).isTrue(); + assertThat(context) + .extracting(InboundIdempotencyService.Context::tenantId, + InboundIdempotencyService.Context::id, + InboundIdempotencyService.Context::replayed) + .containsExactly(10001L, 1L, true); } @Test @@ -59,11 +63,12 @@ class InboundIdempotencyServiceTest { @Test @DisplayName("新请求完成时只允许PROCESSING条件更新一次") void shouldCompleteProcessingRecordOnce() { - when(repository.completeIdempotency(1L, "{\"inboundId\":\"10\"}")).thenReturn(1); + when(repository.completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}")) + .thenReturn(1); - service.complete(new InboundIdempotencyService.Context(1L, false), 10L); + service.complete(new InboundIdempotencyService.Context(10001L, 1L, false), 10L); - verify(repository).completeIdempotency(1L, "{\"inboundId\":\"10\"}"); + verify(repository).completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}"); } private InboundIdempotencyEntity existing(String status) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java index dd1852439..f0c15d6ea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -69,6 +69,23 @@ class InboundMeasurementOpenApiServiceTest { @Resource private ObjectMapper objectMapper; @Resource private InboundMeasurementOpenApiService service; + @Test + @DisplayName("第三方客户A不能访问只存在于客户B的入库单") + void shouldRejectInboundOwnedByAnotherCustomer() { + Fixture fixture = fixture(); + when(fixture.repository.findInbound(10002L, "RK202608010001")).thenReturn(inbound()); + + assertThatThrownBy(() -> fixture.service.receive(headers("nonce-cross"), BODY, image())) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("入仓码不存在或不属于绑定仓库"); + + verify(fixture.repository).findInbound(10001L, "RK202608010001"); + verify(fixture.repository, never()).findInbound(10002L, "RK202608010001"); + verify(fixture.images, never()).upload(anyString(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), any()); + } + @Test @DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口") void shouldPersistMeasurementsWithoutUpdatingInboundOrder() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 7f4afb397..187430ca7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -23,6 +23,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,6 +36,17 @@ class InboundMeasurementServiceTest { @MockBean private MeasurementBatchService batchService; @Resource private InboundMeasurementService service; + @Test + void customerCannotReadAnotherCustomersMeasurement() { + assertThatThrownBy(() -> service.detail(2L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("入库单不存在"); + + verify(repository).findInbound(2L, 10L); + verify(repository, never()).findInbound(1L, 10L); + verify(repository, never()).listWeights(anyLong(), anyLong()); + } + @Test void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { InboundOrderEntity inbound = inbound("MEASURING"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 1ebb61e52..29c1676bb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -30,7 +30,7 @@ import static org.mockito.Mockito.when; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MeasurementLocationOccupationServiceTest { private static final long BUSINESS_TENANT = 10001L; - private static final long RESOURCE_TENANT = 90001L; + private static final long PLATFORM_RESOURCE_TENANT = 0L; private static final long WAREHOUSE = 200L; private static final long INBOUND = 88L; private static final long OPERATOR = 9L; @@ -43,8 +43,8 @@ class MeasurementLocationOccupationServiceTest { @Resource private MeasurementLocationOccupationService service; @Test - void sharedWarehouseResourcesUseResourceTenantWhileSnapshotsRemainInBusinessTenant() { - properties.setWarehouseResourceTenantId(RESOURCE_TENANT); + void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() { + properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); @@ -56,27 +56,27 @@ class MeasurementLocationOccupationServiceTest { ArgumentCaptor reserve = ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class); verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); - assertThat(reserve.getValue().tenantId()).isEqualTo(RESOURCE_TENANT); + assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND); assertThat(reserve.getValue().idempotencyKey()).isEqualTo("measure-88-30-3"); ArgumentCaptor confirm = ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); - assertThat(confirm.getValue().tenantId()).isEqualTo(RESOURCE_TENANT); + assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND); verify(allocations, times(2)).replace( org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.eq(WAREHOUSE), - org.mockito.ArgumentMatchers.eq(RESOURCE_TENANT), + org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), org.mockito.ArgumentMatchers.any()); } @Test - void zeroResourceTenantFallsBackToBusinessTenant() { + void defaultResourceTenantRemainsPlatformZero() { properties.setWarehouseResourceTenantId(0L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), @@ -89,17 +89,25 @@ class MeasurementLocationOccupationServiceTest { ArgumentCaptor reserve = ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class); verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); - assertThat(reserve.getValue().tenantId()).isEqualTo(BUSINESS_TENANT); + assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); ArgumentCaptor confirm = ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class); verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); - assertThat(confirm.getValue().tenantId()).isEqualTo(BUSINESS_TENANT); + assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); + + verify(allocations, times(2)).replace( + org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), + org.mockito.ArgumentMatchers.eq(INBOUND), + org.mockito.ArgumentMatchers.eq(WAREHOUSE), + org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), + org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), + org.mockito.ArgumentMatchers.any()); } @Test void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() { - properties.setWarehouseResourceTenantId(RESOURCE_TENANT); + properties.setWarehouseResourceTenantId(90001L); when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED"))); @@ -115,9 +123,9 @@ class MeasurementLocationOccupationServiceTest { ArgumentCaptor release = ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class); verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture()); - assertThat(release.getValue().tenantId()).isEqualTo(RESOURCE_TENANT); + assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(release.getValue().businessId()).isEqualTo(INBOUND); - verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, RESOURCE_TENANT, + verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, new BigDecimal("1.200000"), batch("RESERVED")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 374895c7b..056322888 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -40,6 +40,23 @@ class MeasurementReviewServiceTest { @MockBean private InboundIdempotencyService idempotency; @MockBean private InboundProperties properties; @Resource private MeasurementReviewService service; + + @Test + @DisplayName("客户B不能审核客户A的测量") + void shouldRejectCrossCustomerReview() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(10002L, 1L, false)); + + assertThatThrownBy(() -> service.approve(10002L, 9L, "审核员", 10L, "cross-customer", + new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + + verify(repository).findOwned(10002L, 10L); + verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()); + } + @Test @DisplayName("审核通过是测量到计费的唯一正常推进关口") void shouldApproveOnlyPendingSubmittedMeasurement() { @@ -73,7 +90,7 @@ class MeasurementReviewServiceTest { void shouldReplaySameReviewRequest() { Fixture fixture = fixture(false); when(fixture.idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(new InboundIdempotencyService.Context(1L, true)); + .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, true)); fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1", new MeasurementReviewRequest(20L, 3, null)); @@ -147,7 +164,7 @@ class MeasurementReviewServiceTest { private Fixture fixture(boolean allowRelease) { when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(new InboundIdempotencyService.Context(1L, false)); + .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("MEASURE_PENDING"); diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 9733d6cad..3b46efe90 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -3,7 +3,7 @@ ## 职责边界 - 统一承接前端流量,负责服务路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离及 Swagger 聚合。 -- 网关完成入口认证,但业务服务仍必须执行租户、数据范围和权限校验;业务编排不得写入网关。 +- 网关完成入口认证,但业务服务仍必须执行客户归属、数据范围和权限校验;业务编排不得写入网关。Claim 中的 `tenant_id > 0` 当前是客户隔离兼容字段,`tenant_id=0` 是平台技术作用域,不得据此新增租户切换或真实租户访问域。 ## 快速索引 diff --git a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java index 5dae7304a..ccc0f3efa 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -26,7 +26,18 @@ final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager } private boolean hasExpectedScope(Authentication authentication) { - return authentication instanceof JwtAuthenticationToken jwt - && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); + if (!(authentication instanceof JwtAuthenticationToken jwt) + || !expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"))) { + return false; + } + Object tenantIdClaim = jwt.getToken().getClaim("tenant_id"); + if (!(tenantIdClaim instanceof Number tenantId)) { + return false; + } + return switch (expectedAppCode) { + case "PLATFORM" -> tenantId.longValue() == 0L; + case "MERCHANT" -> tenantId.longValue() > 0L; + default -> false; + }; } } diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 0ffe1dbbd..f6b2546a4 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -35,6 +35,7 @@ public class GatewaySecurityConfig { CorsConfigurationSource corsConfigurationSource) { var exchanges = http.authorizeExchange(); exchanges.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll(); + exchanges.pathMatchers("/api/iam/**").denyAll(); properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); exchanges.pathMatchers("/api/merchant/**") .access(new AppScopeAuthorizationManager("MERCHANT")); diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 712f98623..c3002f435 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -49,7 +49,7 @@ spring: - id: iam-service uri: lb://iam-service predicates: - - Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/** + - Path=/api/platform/v1/iam/**,/api/merchant/v1/iam/** - id: audit-service uri: lb://audit-service predicates: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java index f7b102276..0c6df4d36 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -15,7 +15,15 @@ class AppScopeAuthorizationManagerTest { @Test void acceptsMatchingApplicationScope() { var manager = new AppScopeAuthorizationManager("MERCHANT"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 10001L)), null)) + .expectNextMatches(decision -> decision.isGranted()) + .verifyComplete(); + } + + @Test + void acceptsPlatformOnlyInPlatformTenant() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("PLATFORM", 0L)), null)) .expectNextMatches(decision -> decision.isGranted()) .verifyComplete(); } @@ -23,7 +31,23 @@ class AppScopeAuthorizationManagerTest { @Test void rejectsDifferentApplicationScope() { var manager = new AppScopeAuthorizationManager("PLATFORM"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 10001L)), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsPlatformApplicationWithCustomerTenant() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("PLATFORM", 10001L)), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsMerchantApplicationWithoutPositiveCustomerTenant() { + var manager = new AppScopeAuthorizationManager("MERCHANT"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 0L)), null)) .expectNextMatches(decision -> !decision.isGranted()) .verifyComplete(); } @@ -43,13 +67,14 @@ class AppScopeAuthorizationManagerTest { .verifyComplete(); } - private JwtAuthenticationToken authentication(String appCode) { + private JwtAuthenticationToken authentication(String appCode, long tenantId) { Jwt jwt = Jwt.withTokenValue("token") .header("alg", "none") .subject("1") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(60)) .claim("app_code", appCode) + .claim("tenant_id", tenantId) .build(); return new JwtAuthenticationToken(jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated"))); diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 6e3e6b2fa..64f50f6b7 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -35,6 +35,22 @@ class GatewayFileRouteConfigurationTest { }); } + @Test + void iamRouteDoesNotExposeUnscopedCompatibilityPath() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "iam-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + assertThat(String.join(",", predicate.getArgs().values())) + .contains("/api/platform/v1/iam/**") + .contains("/api/merchant/v1/iam/**") + .doesNotContain("/api/iam/**"); + }); + } + @Test void warehouseResourceRequestsRouteToWarehouseService() { var route = gatewayProperties.getRoutes().stream() diff --git a/iam/AGENTS.md b/iam/AGENTS.md index b79bfdae9..a9ac1f02d 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -4,8 +4,9 @@ - 商户侧资质公开接口在 IAM 中只提供查询;真实文件上传统一由 File 服务的 multipart 接口完成,IAM 仅接受 File 服务携带内部令牌提交的资质元数据。 -- 负责租户、用户、成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照。 +- 负责客户主档、用户、企业成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照;`iam_tenant`、`Tenant*` 及 `/tenants` 是历史兼容命名。 - 账号凭据与 Token 属于 `auth/`;资质文件内容属于 `file/`。IAM 只维护业务元数据和跨服务协作。 +- 当前只实现“平台—客户”两级:`iam_tenant.id > 0` 的 `MERCHANT` 记录直接作为客户主档,相关 `tenant_id` 是客户隔离兼容字段;`iam_tenant.id = 0` 是平台账号、平台 RBAC 和平台公共资源技术作用域。不得由本模块提前引入真实租户、租户选择或租户级 RBAC。 ## 功能快速索引 @@ -16,37 +17,37 @@ | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | | 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 | -| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台租户和 PLATFORM 角色 | +| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台技术作用域和 PLATFORM 角色 | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | | Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` | | 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 | | DTO | `src/main/java/com/baiyue/iam/dto/` | 按 Authorization、Member、Onboarding、Profile、Rbac、Tenant 分类 | -| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | +| 持久化 | `entity/`、`mapper/`、`repository/` | 客户主档、成员、角色、权限、地址、资质与审核模型;表字段保留 `tenant` 兼容命名 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | | OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 | | 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、内部用户、平台资质和 RBAC 测试 | ## 修改与验证 -- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业租户成员必须走商户成员边界。 -- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台租户且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。 -- `/users` 不提供跨租户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。 +- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业客户成员必须走商户成员边界。 +- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台技术作用域且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。 +- `/users` 不提供跨客户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。 - 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 - 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。 -- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。 +- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标客户现有资质,避免并发上传或替换产生多个有效同类型记录;方法名和查询字段保留历史兼容,该查询不得改回普通无锁列表。 - 企业送审和审核通过只要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID` 两类必填资质均为 `PENDING`;`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 为选填,已上传时仍随审核统一处理。审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。 - 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。 - 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。 -- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 +- `tenant_id > 0` 是当前客户数据隔离核心;平台端与商户端访问域、角色和权限不可混用,`tenant_id=0` 只表示平台技术作用域。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 - `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 - 平台企业客户分页仅查询商户租户;分页参数类型错误使用 body code `A0400`,Bean Validation 范围或状态约束错误使用 `A0422`,两者普通业务响应均为 HTTP 200。 - 成员账号操作跨 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`,比较和写入均限定当前租户。 +- 平台内部用户角色分配使用 `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。 diff --git a/iam/README-IAM.md b/iam/README-IAM.md index dc356e983..3fcc74bed 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -2,9 +2,9 @@ ## 数据库归属 / Database ownership -全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。商户数据通过 `tenant_id` 强制隔离;平台租户使用 `tenant_id=0`,权限通过 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。 +全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。当前产品只包含“平台—客户”两级:商户数据通过兼容字段 `tenant_id > 0` 按客户强制隔离;平台账号、平台 RBAC 和平台公共资源使用技术作用域 `tenant_id=0`,平台身份使用 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。`tenant` 相关表、类、路径和字段为历史兼容命名,不表示已经存在真实租户层。 -All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. Every merchant query must constrain `tenant_id`. Platform scope uses `tenant_id=0` and `app_code=PLATFORM`; merchant scope uses `app_code=MERCHANT`. +All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. The current product has only platform and customer levels. Every merchant query must constrain the compatibility field `tenant_id`, which currently stores the customer ID. Platform identities, RBAC, and public warehouse master data use technical scope `tenant_id=0`; platform identity also uses `app_code=PLATFORM`, while merchant scope uses `app_code=MERCHANT`. Historical tenant-named tables, types, paths, and fields do not establish a real tenant layer. ## 代码落位 / Code placement @@ -21,7 +21,7 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe | POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 | | GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 | -| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform tenant and PLATFORM role options / 平台租户与平台角色选项 | +| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform technical scope and PLATFORM role options / 平台技术作用域与平台角色选项 | | PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit internal-user profile and roles / 编辑内部用户资料和角色 | | PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 | | PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 | @@ -45,4 +45,4 @@ OpenAPI is synchronized in `openapi/iam-v1.yaml`. - 新增、编辑成员基础资料分别需要 `merchant:member:create`、`merchant:member:edit`。 - 新增请求的 `roleIds` 非空,或编辑请求的完整目标角色集合与当前集合不同时,还必须具有 `merchant:member:grant-role`。 -- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前租户下的 `roleIds`。 +- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前客户下的 `roleIds`。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java index f3152b3c6..737a6f1db 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java @@ -30,7 +30,12 @@ public class CustomerListExportProviderController { @PostConstruct void initializeToken() { - this.internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); + byte[] configured = internalTokenValue == null + ? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + this.internalToken = configured; } @PostMapping("/batches") diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index 7243ddecb..e7def5792 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -40,7 +40,14 @@ public class InternalTenantController { private byte[] internalToken; @PostConstruct - void initializeToken() { internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); } + void initializeToken() { + byte[] configured = internalTokenValue == null + ? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + internalToken = configured; + } @PostMapping("/merchant-registrations") public ApiResponse register( diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java index 8ff2a9e30..11f846750 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java @@ -8,8 +8,11 @@ public final class TenantGuard { public static long tenant(AuthenticatedUser user,String expectedApp){ if(user==null||user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"Authentication required / 请先登录"); if(!expectedApp.equals(user.appCode())) throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope / 应用权限域不匹配"); - if("PLATFORM".equals(expectedApp)) return 0L; - if(user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); + if("PLATFORM".equals(expectedApp)) { + if(user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"Platform tenant must be zero / 平台作用域必须为 0"); + return 0L; + } + if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); return user.tenantId(); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f7aa8f4c1..6c5e9aaf8 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -199,10 +199,7 @@ public class TenantService { } public TenantResponse profile(long tenantId) { - IamTenant tenant = repository.findTenant(tenantId); - if (tenant == null || !tenantIdEquals(tenant, tenantId)) { - throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); - } + IamTenant tenant = requireMerchant(tenantId); return merchantVisibleResponse(tenant); } @@ -285,10 +282,7 @@ public class TenantService { @BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", description = "编辑企业资料") public TenantResponse update(long tenantId, UpdateProfileRequest request) { - IamTenant tenant = repository.findTenant(tenantId); - if (tenant == null || !tenantIdEquals(tenant, tenantId)) { - throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); - } + IamTenant tenant = requireMerchant(tenantId); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 12071a205..7ab2688fc 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Baiyue IAM API / 百悦身份与权限 API - description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。 + description: Customer master data, members, roles, menus, pages, buttons and API permissions for the current single-tenant product. Historical tenant-named paths, schemas and fields are retained for compatibility; tenantId > 0 currently means customer ID, while 0 is technical scope for platform identities, RBAC, and public warehouse resources. / 当前单租户产品的客户主档、成员、角色、菜单、页面、按钮和 API 权限管理。tenant 命名的路径、模型和字段仅为历史兼容;tenantId 大于 0 当前表示客户 ID,0 表示平台身份、RBAC 和公共仓储资源技术作用域。 version: 1.0.1 servers: - url: / @@ -9,9 +9,9 @@ security: - bearerAuth: [] tags: - name: Platform Tenant / 平台租户 - description: Enterprise registration and review / 企业注册与审核 + description: Historical tag for platform customer administration; it does not represent a real tenant layer. / 历史兼容标签,实际用于平台客户管理,不表示真实租户层。 - name: Merchant Tenant / 商户企业 - description: Current tenant profile and members / 当前企业资料与成员 + description: Current customer profile and members; tenant naming is retained for compatibility. / 当前客户企业资料与成员;tenant 命名仅为兼容。 - name: IAM Resources / IAM 资源 description: Internal users, addresses and qualifications / 内部用户、地址与资质 - name: RBAC @@ -237,7 +237,7 @@ paths: /api/platform/v1/iam/users/options: get: tags: [IAM Resources / IAM 资源] - summary: 查询平台租户和 PLATFORM 角色选项 + summary: 查询平台技术作用域和 PLATFORM 角色选项 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -363,7 +363,7 @@ paths: put: tags: [IAM 资源] summary: 全量分配平台内部用户角色 - description: 仅允许分配平台租户下已启用的平台端角色;空集合表示清空角色。 + description: 仅允许分配平台技术作用域(tenant_id=0)下已启用的平台端角色;空集合表示清空角色。 requestBody: required: true content: @@ -382,7 +382,7 @@ paths: get: tags: [Merchant Tenant / 商户企业] summary: Get current tenant profile / 查询当前企业资料 - description: Tenant ID comes only from the verified JWT. / 租户 ID 仅取自已验证 JWT。 + description: The compatibility field tenant ID comes only from the verified JWT and currently identifies the customer. / 客户隔离兼容字段 tenant ID 仅取自已验证 JWT,当前表示登录客户 ID。 responses: '200': {$ref: '#/components/responses/Success'} '401': {$ref: '#/components/responses/Unauthorized'} @@ -432,7 +432,7 @@ paths: get: tags: [Merchant Tenant / 商户企业] summary: 查询成员详情及当前角色 - description: 返回当前租户成员资料,roleIds 为服务端实时查询的当前角色集合;跨租户成员按不存在处理。 + description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -493,7 +493,7 @@ paths: get: tags: [RBAC] summary: List roles / 查询角色 - description: List roles in the selected application and tenant scope. / 查询指定应用及租户范围内的角色。 + description: List roles in the selected application and current customer or platform technical scope. / 查询指定应用及当前客户范围或平台技术作用域内的角色。 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -553,7 +553,7 @@ paths: put: tags: [RBAC] summary: Grant permissions to role / 设置角色权限 - description: Replace role permissions; cross-tenant and cross-application grants are rejected. / 替换角色权限,拒绝跨租户和跨应用授权。 + description: Replace role permissions; cross-customer and cross-application grants are rejected. / 替换角色权限,拒绝跨客户和跨应用授权。 parameters: - {$ref: '#/components/parameters/App'} requestBody: @@ -719,7 +719,7 @@ components: name: tenantId in: path required: true - description: 企业客户租户 ID + description: 企业客户 ID;tenantId 为历史兼容参数名 schema: {type: string, pattern: '^\d+$'} PermissionId: name: permissionId @@ -732,7 +732,7 @@ components: 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'}}}} + Forbidden: {description: Permission, customer scope or platform technical 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'}}}} @@ -977,7 +977,7 @@ components: displayName: {type: string, maxLength: 64} phone: {type: string, pattern: '^1\\d{10}$'} email: {type: string, format: email, maxLength: 128} - tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0} + tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} @@ -988,7 +988,7 @@ components: displayName: {type: string, maxLength: 64} phone: {type: string, pattern: '^1\\d{10}$'} email: {type: string, format: email, maxLength: 128} - tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0} + tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} CreateInternalUserRequest: type: object diff --git a/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java index 6b24092b8..132d87169 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java @@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @SpringJUnitConfig(CustomerListExportProviderController.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") class CustomerListExportProviderControllerTest { @MockBean private CustomerListExportProviderService service; @Resource private CustomerListExportProviderController controller; @@ -48,7 +48,7 @@ class CustomerListExportProviderControllerTest { ExportBatchResponse batch = new ExportBatchResponse(1, "sha256:test", "snapshot", List.of(), List.of(), null, false, 0L); when(service.exportBatch(request)).thenReturn(batch); - var response = controller.batch("internal-token", request); + var response = controller.batch("test-internal-service-token-at-least-32-bytes", request); assertEquals(ResultCode.SUCCESS.code(), response.code()); assertEquals(batch, response.data()); diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index c58e98fe9..97345fce8 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -21,7 +21,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @SpringJUnitConfig(InternalTenantController.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") class InternalCustomerExportContractTest { @MockBean private MerchantOnboardingService onboardingService; @MockBean private ProfileResourceService profileResourceService; @@ -36,7 +36,7 @@ class InternalCustomerExportContractTest { when(service.exportMerchants(null, 100, "柏悦", "ACTIVE")) .thenReturn(new CustomerExportBatch(List.of(), null, false)); - var response = controller.exportCustomers("internal-token", null, 100, "柏悦", "ACTIVE"); + var response = controller.exportCustomers("test-internal-service-token-at-least-32-bytes", null, 100, "柏悦", "ACTIVE"); assertEquals(ResultCode.SUCCESS.code(), response.code()); verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); } diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java new file mode 100644 index 000000000..a68cd054e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java @@ -0,0 +1,34 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class InternalServiceTokenConfigurationTest { + @Test + void internalTenantControllerRejectsEmptyAndShortTokens() { + assertInternalTenantTokenRejected(null); + assertInternalTenantTokenRejected(""); + assertInternalTenantTokenRejected("short-token"); + } + + @Test + void customerExportControllerRejectsEmptyAndShortTokens() { + assertCustomerExportTokenRejected(null); + assertCustomerExportTokenRejected(""); + assertCustomerExportTokenRejected("short-token"); + } + + private void assertInternalTenantTokenRejected(String token) { + InternalTenantController controller = new InternalTenantController(); + ReflectionTestUtils.setField(controller, "internalTokenValue", token); + assertThrows(IllegalStateException.class, controller::initializeToken); + } + + private void assertCustomerExportTokenRejected(String token) { + CustomerListExportProviderController controller = new CustomerListExportProviderController(); + ReflectionTestUtils.setField(controller, "internalTokenValue", token); + assertThrows(IllegalStateException.class, controller::initializeToken); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index 287f132c7..fd566636c 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -51,7 +51,7 @@ class CustomerProfileMigrationContractTest { @Test void shouldHandleCase03() throws IOException { Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); - Path migration = repositoryRoot.resolve("deploy/sql/2026-07-31一期回归测试环境增量迁移.sql"); + Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); Path readme = repositoryRoot.resolve("deploy/sql/README.md"); assertThat(migration).exists(); @@ -60,27 +60,18 @@ class CustomerProfileMigrationContractTest { String instructions = Files.readString(readme, StandardCharsets.UTF_8); assertThat(sql) - .contains("CALL regression_assert_table('iam_tenant')") - .contains("CALL regression_assert_table('iam_tenant_review')") - .contains("CALL regression_assert_table('iam_tenant_qualification')") + .contains("ALTER TABLE iam_tenant MODIFY COLUMN status") .contains("DEFAULT 'PROFILE_REQUIRED'") - .contains("CREATE PROCEDURE regression_add_iam_column") - .contains("regression_add_iam_column('approved_at'") - .contains("regression_add_iam_column('approved_by'") - .contains("regression_add_iam_column('reject_reason'") - .contains("regression_add_iam_related_column('iam_tenant_qualification', 'review_comment'") - .contains("'IAM注册审核字段'") - .contains("'IAM资质与成员审计字段'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'") + .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'") .contains("platform:customer:view") .contains("platform:customer:export") .contains("platform:download:list") .contains("'MERCHANT_OWNER'") - .contains("IAM全局企业管理员模板") .contains("owner_role.id, permission.id, 0") - .contains("'IAM客户档案字段' AS check_item") - .contains("10 AS expected_count") - .contains("47 AS expected_count") - .contains("MIGRATION_COMPLETED"); + .contains("统一增量全部执行完成"); assertThat(instructions) .contains("phase1-platform-master-data-and-permission-management.sql") .contains("phase1-customer-profile.sql") diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java new file mode 100644 index 000000000..170a61ef4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java @@ -0,0 +1,38 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TenantGuardTest { + @Test + void acceptsPlatformOnlyAtZeroTenant() { + assertEquals(0L, TenantGuard.tenant(user(0L, "PLATFORM"), "PLATFORM")); + + BizException exception = assertThrows(BizException.class, + () -> TenantGuard.tenant(user(10001L, "PLATFORM"), "PLATFORM")); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + @Test + void acceptsMerchantOnlyAtPositiveCustomerTenant() { + assertEquals(10001L, TenantGuard.tenant(user(10001L, "MERCHANT"), "MERCHANT")); + + BizException exception = assertThrows(BizException.class, + () -> TenantGuard.tenant(user(0L, "MERCHANT"), "MERCHANT")); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + private AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(42L, tenantId, tenantId, 9L, + "operator", appCode, appCode, Set.of()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 6649d45cb..12d3ca2cf 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; @@ -122,6 +123,23 @@ class TenantServiceTest { assertEquals(ResultCode.CONFLICT, exception.getResultCode()); } + @Test + void merchantProfileCannotReadOrUpdatePlatformTenant() { + IamTenant platform = new IamTenant(); + platform.setId(0L); + platform.setTenantType("PLATFORM"); + when(repository.findTenant(0L)).thenReturn(platform); + + BizException readFailure = assertThrows(BizException.class, () -> service.profile(0L)); + BizException updateFailure = assertThrows(BizException.class, () -> service.update(0L, + new UpdateProfileRequest("Platform", "Platform", "Owner", "Contact", + "13800138000", "platform@example.com"))); + + assertEquals(ResultCode.NOT_FOUND, readFailure.getResultCode()); + assertEquals(ResultCode.NOT_FOUND, updateFailure.getResultCode()); + verify(repository, never()).saveTenant(platform); + } + @Test void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { diff --git a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java index 567962c8d..7739b1fb9 100644 --- a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java +++ b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java @@ -12,13 +12,19 @@ import java.security.MessageDigest; @Component public class InternalServiceTokenGuard { + private static final String INSECURE_PLACEHOLDER = + "change-me-to-an-internal-token-at-least-32-bytes-long"; + @Value("${baiyue.security.internal-service-token}") private String token; private byte[] expectedToken; @PostConstruct void initializeToken() { - if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) { - throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + if (token == null + || token.isBlank() + || INSECURE_PLACEHOLDER.equals(token) + || token.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 必须配置为至少 32 字节的安全令牌"); } this.expectedToken = token.getBytes(StandardCharsets.UTF_8); } diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 931d8b69f..e5508b64f 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -33,7 +33,7 @@ spring: baiyue: security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} integration: baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} diff --git a/integration-service/src/test/java/com/baiyue/integration/security/InternalServiceTokenGuardTest.java b/integration-service/src/test/java/com/baiyue/integration/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..7e4f3fdb6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,49 @@ +package com.baiyue.integration.security; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private static final String VALID_TOKEN = "integration-service-test-token-32-bytes"; + + @Test + void rejectsMissingEmptyAndShortTokens() { + assertInvalidConfiguration(null); + assertInvalidConfiguration(""); + assertInvalidConfiguration(" "); + assertInvalidConfiguration("too-short"); + } + + @Test + void rejectsKnownInsecurePlaceholder() { + assertInvalidConfiguration("change-me-to-an-internal-token-at-least-32-bytes-long"); + } + + @Test + void acceptsConfiguredTokenWithAtLeastThirtyTwoBytes() { + InternalServiceTokenGuard guard = guardWithToken(VALID_TOKEN); + + assertThatCode(guard::initializeToken).doesNotThrowAnyException(); + assertThatCode(() -> guard.verify(VALID_TOKEN)).doesNotThrowAnyException(); + assertThatThrownBy(() -> guard.verify("different-internal-service-token")) + .isInstanceOf(BizException.class); + } + + private static void assertInvalidConfiguration(String token) { + InternalServiceTokenGuard guard = guardWithToken(token); + + assertThatThrownBy(guard::initializeToken) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("INTERNAL_SERVICE_TOKEN"); + } + + private static InternalServiceTokenGuard guardWithToken(String token) { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token); + return guard; + } +} diff --git a/order/AGENTS.md b/order/AGENTS.md index a882c7dc1..31981c4ef 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -17,7 +17,7 @@ | 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` | | 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java`;往来单位响应同时包含多值 `types` 与兼容字段 `partnerType` | -| 租户与访问范围 | `service/OrderScopeGuard.java` | +| 客户归属与访问范围 | `service/OrderScopeGuard.java`;类内 `tenant` 命名为历史兼容 | | 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` | | 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` | | 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` | @@ -26,16 +26,17 @@ ## 修改与验证 -- 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。 +- 所有商户查询和写入必须执行客户归属/访问范围校验;业务表 `tenant_id` 当前承载客户 ID。平台操作必须验证平台技术作用域、目标客户、权限及合法状态迁移。 - 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。 - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 -- 客户列表中的真实运单数归 Order,不得写回 IAM。`GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效租户 ID,并对没有订单的租户返回 0。 +- 客户列表中的真实运单数归 Order,不得写回 IAM。历史接口 `GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效客户 ID,并对没有订单的客户返回 0;路径和参数名保持兼容。 - Warehouse 往来单位已支持多类型;下单校验优先使用 `PartnerClient.Partner.types`,`partnerType` 只作为旧数据兼容。类型语义变化需同步 Warehouse 的 `wh_partner_type`、Order 客户端契约与快照测试。 +- 往来单位是平台统一维护、供所有客户共享的公共主数据,Order 调用 Warehouse 内部接口时固定使用公共作用域 `tenant_id=0`;订单自己的 `tenant_id` 仍是客户隔离键,不能拿来查询往来单位。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 二期第三方测量绑定订单货物时,Firstleg 只能通过 `InternalOrderCargoController` 校验 `tenantId + orderId + cargoId` 并读取必要快照;该接口必须保留内部令牌校验,不能暴露到 Gateway,也不能返回 Order Entity。 - Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 -- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 +- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按客户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 - 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 HTTP 200 + `S0500`,真实原因只记录在服务日志。 - 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。 - 普通业务失败使用 HTTP 200 + body code;Feign 收到 HTTP 非 200 代表鉴权、协议或基础设施异常,应归一为 `S0503`,不能用 Feign 状态码推断 404/409 业务语义。 @@ -45,5 +46,5 @@ - `POST /api/platform/v1/orders/first-leg?tenantId={tenantId}`:平台代客创建草稿,校验 `platform:order:create`,必须携带 `Idempotency-Key`。 - `PUT /api/platform/v1/orders/first-leg/{id}?tenantId={tenantId}`:平台编辑草稿,校验 `platform:order:edit`。 - `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。 -- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以租户、操作人、操作和幂等键建立唯一约束。 +- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。 - 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。 diff --git a/order/README-ORDER.md b/order/README-ORDER.md index d4f0cdcb4..aa7a04019 100644 --- a/order/README-ORDER.md +++ b/order/README-ORDER.md @@ -5,16 +5,17 @@ - `baiyue_order`:订单主数据、货物、收发货快照、目的仓快照、内部备注、状态历史和事务发件箱。 / Order master data, cargo, sender/receiver and destination snapshots, internal notes, status history, and transactional outbox. - `baiyue_firstleg`:头程履约任务、指派历史与事件消费幂等。 / First-leg fulfillment tasks, assignment history, and event-consumer idempotency. - 两个服务仅通过 `tenant_id + order_id + order_no` 逻辑关联,不建立跨库外键。 / Services use logical `tenant_id + order_id + order_no` references and never create cross-database foreign keys. +- 当前 `tenant_id` 是客户归属与隔离兼容字段;接口、表和类型中的 `tenant` 命名保持兼容,不表示真实上级租户。`tenant_id=0` 只用于平台技术作用域,客户订单必须使用大于 0 的客户 ID。 ## 接口隔离 / API separation - 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs. -- 平台端 `/api/platform/v1/orders/first-leg`:跨租户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-tenant list, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes. +- 平台端 `/api/platform/v1/orders/first-leg`:跨客户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-customer list, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes. - 平台指派 `/api/platform/v1/first-leg/tasks/{taskId}/assignment`。 / Platform fulfillment assignment. -商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台列表接口要求 `appCode=PLATFORM`,省略 `tenantId` 时查询全部客户租户,传入时按单个租户筛选;平台详情和写操作仍必须显式传入目标 `tenantId`。 +商户接口要求 `appCode=MERCHANT`,客户归属从 JWT 的兼容字段取得。平台列表接口要求 `appCode=PLATFORM`,省略 `tenantId` 时查询全部客户,传入时按单个客户筛选;平台详情和写操作仍必须显式传入代表目标客户的 `tenantId`。 -Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. The platform list requires `appCode=PLATFORM`: omitting `tenantId` queries all customer tenants, while supplying it filters one tenant. Platform detail and write operations still require an explicit target `tenantId`. +Merchant APIs require `appCode=MERCHANT` and derive the customer scope from the JWT compatibility field. The platform list requires `appCode=PLATFORM`: omitting `tenantId` queries all customers, while supplying it filters one customer. Platform detail and write operations still require an explicit `tenantId` representing the target customer. ## 状态与审计 / State and audit diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 3b5b1af26..f018fdd4c 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -34,7 +34,12 @@ public class OrderRepository { @Resource private OrderIdempotencyRecordMapper idempotencyMapper; public void insertOrder(OrderEntity order) { orderMapper.insert(order); } - public int saveOrder(OrderEntity order) { return orderMapper.updateById(order); } + public int saveOrder(long tenantId, OrderEntity order) { + if (order.getTenantId() == null || order.getTenantId() != tenantId) return 0; + return orderMapper.update(order, new LambdaUpdateWrapper() + .eq(OrderEntity::getId, order.getId()) + .eq(OrderEntity::getTenantId, tenantId)); + } public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); } public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); } public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); } @@ -42,27 +47,38 @@ public class OrderRepository { public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); } public List pendingInboundEvents(int limit) { return outboxMapper.selectList(new LambdaQueryWrapper() - .eq(OutboxEventEntity::getEventType, "FIRST_LEG_ORDER_APPROVED") + .eq(OutboxEventEntity::getAggregateType, "FIRST_LEG_ORDER") .eq(OutboxEventEntity::getPublishStatus, "PENDING") .and(v -> v.isNull(OutboxEventEntity::getNextRetryAt) .or().le(OutboxEventEntity::getNextRetryAt, LocalDateTime.now())) .orderByAsc(OutboxEventEntity::getCreatedAt) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public void markOutboxPublished(long id, LocalDateTime publishedAt) { + public void markOutboxPublished(long tenantId, long id, LocalDateTime publishedAt) { outboxMapper.update(null, new LambdaUpdateWrapper() .eq(OutboxEventEntity::getId, id) + .eq(OutboxEventEntity::getTenantId, tenantId) .eq(OutboxEventEntity::getPublishStatus, "PENDING") .set(OutboxEventEntity::getPublishStatus, "PUBLISHED") .set(OutboxEventEntity::getPublishedAt, publishedAt)); } - public void markOutboxRetry(long id, LocalDateTime nextRetryAt) { + public void markOutboxRetry(long tenantId, long id, LocalDateTime nextRetryAt) { outboxMapper.update(null, new LambdaUpdateWrapper() .eq(OutboxEventEntity::getId, id) + .eq(OutboxEventEntity::getTenantId, tenantId) .eq(OutboxEventEntity::getPublishStatus, "PENDING") .setSql("retry_count = retry_count + 1") .set(OutboxEventEntity::getNextRetryAt, nextRetryAt)); } + public void markOutboxFailed(Long tenantId, long id) { + outboxMapper.update(null, new LambdaUpdateWrapper() + .eq(OutboxEventEntity::getId, id) + .eq(tenantId != null, OutboxEventEntity::getTenantId, tenantId) + .isNull(tenantId == null, OutboxEventEntity::getTenantId) + .eq(OutboxEventEntity::getPublishStatus, "PENDING") + .set(OutboxEventEntity::getPublishStatus, "FAILED") + .set(OutboxEventEntity::getNextRetryAt, null)); + } public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); } public OrderIdempotencyRecordEntity findIdempotency( @@ -74,11 +90,15 @@ public class OrderRepository { .eq(OrderIdempotencyRecordEntity::getIdempotencyKey, idempotencyKey)); } - public int completeIdempotency(OrderIdempotencyRecordEntity record, Long resultOrderId) { + public int completeIdempotency(long tenantId, OrderIdempotencyRecordEntity record, Long resultOrderId) { + if (record.getTenantId() == null || record.getTenantId() != tenantId) return 0; record.setStatus("COMPLETED"); record.setResultOrderId(resultOrderId); record.setCompletedAt(java.time.LocalDateTime.now()); - return idempotencyMapper.updateById(record); + return idempotencyMapper.update(record, new LambdaUpdateWrapper() + .eq(OrderIdempotencyRecordEntity::getId, record.getId()) + .eq(OrderIdempotencyRecordEntity::getTenantId, tenantId) + .eq(OrderIdempotencyRecordEntity::getStatus, "PROCESSING")); } public OrderEntity findOwned(long tenantId, long orderId) { diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 95bfd1fd2..14154e49f 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -67,7 +67,7 @@ public class OrderApplicationService { throw new BizException("Only draft or rejected orders can be edited"); } apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); - if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update"); + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); repository.replaceDraftChildren(tenantId, id); saveChildren(tenantId, userId, id, request); } @@ -90,7 +90,7 @@ public class OrderApplicationService { order.setStatus(target); order.setUpdatedBy(userId); if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now()); if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); - if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update"); + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target); } @@ -142,7 +142,7 @@ public class OrderApplicationService { order.setExpressCompany(request.expressCompany().trim()); order.setExpressTrackingNo(request.expressTrackingNo().trim()); order.setUpdatedBy(userId); - if (repository.saveOrder(order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改"); + if (repository.saveOrder(tenantId, order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改"); } private void validatePrototypeFields(OrderRequests.SaveRequest request) { @@ -248,7 +248,7 @@ public class OrderApplicationService { private void completeIdempotency(IdempotencyContext context, Long resultOrderId) { if (context.enabled() && !context.replayed()) { - if (repository.completeIdempotency(context.record(), resultOrderId) != 1) { + if (repository.completeIdempotency(context.record().getTenantId(), context.record(), resultOrderId) != 1) { throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "幂等记录已被并发修改"); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index a3c8dc349..5cc25cfd9 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -7,6 +7,7 @@ import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -15,6 +16,7 @@ import java.time.LocalDateTime; /** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */ @Component +@Slf4j public class OrderInboundOutboxPublisher { @Resource private OrderRepository repository; @Resource private FirstlegInboundClient client; @@ -24,17 +26,40 @@ public class OrderInboundOutboxPublisher { @Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}") public void publishApprovedOrders() { for (OutboxEventEntity event : repository.pendingInboundEvents(50)) { + if (!"FIRST_LEG_ORDER".equals(event.getAggregateType()) + || !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) { + failPermanently(event, "unsupported order event"); + continue; + } + FirstlegInboundClient.OrderApprovedInboundCommand command; try { - FirstlegInboundClient.OrderApprovedInboundCommand command = json.readValue( + command = json.readValue( event.getPayloadJson(), FirstlegInboundClient.OrderApprovedInboundCommand.class); + } catch (Exception exception) { + failPermanently(event, "invalid event payload"); + continue; + } + if (event.getTenantId() == null || event.getAggregateId() == null + || event.getTenantId() != command.tenantId() + || event.getAggregateId() != command.orderId()) { + failPermanently(event, "event ownership does not match payload"); + continue; + } + try { ApiResponse response = client.create(internalToken, command); if (response == null || !response.successful()) { throw new IllegalStateException("头程入库服务返回失败"); } - repository.markOutboxPublished(event.getId(), LocalDateTime.now()); + repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now()); } catch (Exception exception) { - repository.markOutboxRetry(event.getId(), LocalDateTime.now().plusSeconds(10)); + repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10)); } } } + + private void failPermanently(OutboxEventEntity event, String reason) { + log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}", + reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType()); + repository.markOutboxFailed(event.getTenantId(), event.getId()); + } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index b71f55a2f..3840cb691 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -23,11 +23,11 @@ public class OrderMasterDataGuard { @Resource private WarehouseClient warehouses; @Resource private PartnerClient partners; @Value("${baiyue.security.internal-service-token}") private String token; - @Value("${baiyue.warehouse.public-tenant-id:10001}") - private long publicTenantId = 10001L; + @Value("${baiyue.warehouse.public-tenant-id:0}") + private long publicTenantId = 0L; public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) { try { - PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(tenantId,request.customerId()); + PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(request.customerId()); var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库"); requireWarehouseType(receiving, "DOMESTIC", "收货仓库"); if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull)) @@ -59,7 +59,7 @@ public class OrderMasterDataGuard { public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId, String destinationType) { try { - if (partnerId!=null) loadPartner(tenantId,partnerId); + if (partnerId!=null) loadPartner(partnerId); if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用"); WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库"); requireWarehouseType(receiving,"DOMESTIC","收货仓库"); @@ -78,18 +78,18 @@ public class OrderMasterDataGuard { throw downstreamFailure(exception); } } - private PartnerClient.Partner loadPartner(long tenantId,long id) { + private PartnerClient.Partner loadPartner(long id) { ApiResponse response; try { - response=partners.active(token,tenantId,id); + response=partners.active(token,publicTenantId,id); } catch (FeignException.NotFound exception) { - throw new BizException(ResultCode.NOT_FOUND,"\u672a\u7ed1\u5b9a\u53ef\u7528\u5f80\u6765\u5ba2\u6237"); + throw new BizException(ResultCode.NOT_FOUND,"平台往来单位不存在或不可用"); } requireSuccessful(response,"往来单位"); if (response.data()==null) throw unavailable("往来单位"); var value=response.data(); - if (!Long.valueOf(tenantId).equals(value.tenantId())) - throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业"); + if (!Long.valueOf(publicTenantId).equals(value.tenantId())) + throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于平台公共主数据"); if (!"ACTIVE".equals(value.status())) throw new BizException(ResultCode.CONFLICT,"往来单位已停用"); if (value.types() == null || !value.types().contains("SHIPPER")) diff --git a/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java index fe33d3ee3..d9b3d478e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java @@ -5,7 +5,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exce */ public final class OrderScopeGuard { private OrderScopeGuard(){} public static long merchant(AuthenticatedUser u){requireApp(u,"MERCHANT");if(u.tenantId()==null||u.tenantId()<=0)throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant is required");return u.tenantId();} - public static void platform(AuthenticatedUser u){requireApp(u,"PLATFORM");} - public static long platform(AuthenticatedUser u,long targetTenantId){requireApp(u,"PLATFORM");if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;} + public static void platform(AuthenticatedUser u){requireApp(u,"PLATFORM");requirePlatformTenant(u);} + public static long platform(AuthenticatedUser u,long targetTenantId){platform(u);if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;} private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");} + private static void requirePlatformTenant(AuthenticatedUser u){if(u.tenantId()==null||u.tenantId()!=0)throw new BizException(ResultCode.FORBIDDEN,"Platform tenant must be 0");} } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5e068eef5..a7ae00244 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -9,7 +9,7 @@ paths: /api/merchant/v1/orders/first-leg: get: summary: 客户端入库订单聚合列表 - description: 按当前登录租户分页返回订单、货物汇总、收货方快照及头程入仓编码,需要 merchant:order:list 权限。 + description: 按当前登录客户分页返回订单、货物汇总、收货方快照及头程入仓编码,需要 merchant:order:list 权限;客户 ID 从 JWT 的 tenantId 兼容字段取得。 parameters: - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} - {name: orderNo, in: query, required: false, schema: {type: string, maxLength: 64}} @@ -53,12 +53,12 @@ paths: /api/platform/v1/orders/first-leg: get: summary: 平台分页查询头程订单 - description: tenantId 省略时查询全部客户租户订单,传入时仅查询指定租户。 + description: tenantId 是目标客户 ID 的兼容参数名;省略时查询全部客户订单,传入时仅查询指定客户。 parameters: - name: tenantId in: query required: false - description: 可选的目标租户筛选 + description: 可选的目标客户筛选;tenantId 为历史兼容参数名 schema: {type: integer, format: int64, minimum: 1} responses: '200': {description: 查询成功或业务失败,按响应体 code 判断} @@ -79,7 +79,7 @@ paths: /api/platform/v1/orders/first-leg/tenant-counts: get: summary: 批量查询客户运单数 - description: 按客户租户 ID 返回真实头程运单数量,最多 200 个租户。 + description: 按客户 ID 返回真实头程运单数量,tenantIds 为历史兼容参数名;最多 200 个客户。 parameters: - name: tenantIds in: query @@ -133,5 +133,5 @@ components: name: Idempotency-Key in: header required: true - description: 同一租户和操作者重试同一请求时必须保持不变 + description: 同一客户和操作者重试同一请求时必须保持不变 schema: {type: string, minLength: 1, maxLength: 128} diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 0c419a953..9bbfa2589 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -13,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @SpringJUnitConfig(InternalOrderCargoController.class) @@ -45,4 +46,16 @@ class InternalOrderCargoControllerTest { .isInstanceOf(BizException.class) .hasMessageContaining("凭证无效"); } + + @Test + void cargoFromAnotherCustomerIsNotExposed() { + when(orders.findCargo(12L, 21L, 31L)).thenReturn(null); + + assertThatThrownBy(() -> controller.detail("internal-token", 12L, 21L, 31L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在或不属于该订单"); + + verify(orders).findCargo(12L, 21L, 31L); + verifyNoMoreInteractions(orders); + } } diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 32a96a7db..fe6b5970b 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -27,7 +27,7 @@ class PlatformOrderPageControllerTest { @Test void omittedTenantQueriesAcrossAllTenantsForPlatformUser() { - controller.page(user("PLATFORM"), null, query); + controller.page(user("PLATFORM", 0L), null, query); verify(service).page(null, query); } @@ -35,7 +35,7 @@ class PlatformOrderPageControllerTest { @Test void suppliedTenantFiltersOneTenantForPlatformUser() { - controller.page(user("PLATFORM"), 2084455952401629185L, query); + controller.page(user("PLATFORM", 0L), 2084455952401629185L, query); verify(service).page(2084455952401629185L, query); } @@ -43,7 +43,7 @@ class PlatformOrderPageControllerTest { @Test void invalidTenantFilterIsRejectedBeforeQuery() { - assertThatThrownBy(() -> controller.page(user("PLATFORM"), 0L, query)) + assertThatThrownBy(() -> controller.page(user("PLATFORM", 0L), 0L, query)) .isInstanceOf(BizException.class) .hasMessageContaining("Target tenant is required"); verifyNoInteractions(service); @@ -52,13 +52,21 @@ class PlatformOrderPageControllerTest { @Test void merchantCannotUseCrossTenantPlatformList() { - assertThatThrownBy(() -> controller.page(user("MERCHANT"), null, query)) + assertThatThrownBy(() -> controller.page(user("MERCHANT", 10001L), null, query)) .isInstanceOf(BizException.class) .hasMessageContaining("Wrong application scope"); verifyNoInteractions(service); } - private AuthenticatedUser user(String appCode) { - return new AuthenticatedUser(1L, null, null, null, "operator", appCode, "WEB", Set.of()); + @Test + void platformTokenWithCustomerTenantIsRejected() { + assertThatThrownBy(() -> controller.page(user("PLATFORM", 10001L), null, query)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Platform tenant must be 0"); + verifyNoInteractions(service); + } + + private AuthenticatedUser user(String appCode, Long tenantId) { + return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of()); } } diff --git a/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java new file mode 100644 index 000000000..025b8bf5b --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java @@ -0,0 +1,149 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderIdempotencyRecordEntity; +import com.baiyue.order.entity.OutboxEventEntity; +import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OutboxEventMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OrderRepositoryIsolationUpdateTest { + @BeforeAll + static void initializeTableMetadata() { + initialize(OrderEntity.class, "order-update-test"); + initialize(OrderIdempotencyRecordEntity.class, "idempotency-update-test"); + initialize(OutboxEventEntity.class, "outbox-update-test"); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void orderUpdateIsLimitedByCustomerAndOrderId() { + OrderMapper mapper = mock(OrderMapper.class); + OrderRepository repository = repository("orderMapper", mapper); + OrderEntity order = new OrderEntity(); + order.setId(21L); + order.setTenantId(11L); + when(mapper.update(any(OrderEntity.class), any(Wrapper.class))).thenReturn(1); + + repository.saveOrder(11L, order); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).update(any(OrderEntity.class), wrapper.capture()); + assertScope(wrapper.getValue(), 11L, 21L); + } + + @Test + void mismatchedEntityCustomerCannotBeMovedByUpdate() { + OrderMapper mapper = mock(OrderMapper.class); + OrderRepository repository = repository("orderMapper", mapper); + OrderEntity order = new OrderEntity(); + order.setId(21L); + order.setTenantId(12L); + + assertThat(repository.saveOrder(11L, order)).isZero(); + + verifyNoInteractions(mapper); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void idempotencyCompletionIsLimitedByCustomerRecordAndProcessingState() { + OrderIdempotencyRecordMapper mapper = mock(OrderIdempotencyRecordMapper.class); + OrderRepository repository = repository("idempotencyMapper", mapper); + OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity(); + record.setId(31L); + record.setTenantId(11L); + record.setStatus("PROCESSING"); + when(mapper.update(any(OrderIdempotencyRecordEntity.class), any(Wrapper.class))).thenReturn(1); + + repository.completeIdempotency(11L, record, 21L); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).update(any(OrderIdempotencyRecordEntity.class), wrapper.capture()); + assertScope(wrapper.getValue(), 11L, 31L); + assertThat(wrapper.getValue().getSqlSegment()).contains("status"); + assertThat(params(wrapper.getValue()).values()).contains("PROCESSING"); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void everyOutboxStateUpdateIsLimitedByCustomerEventAndPendingState() { + OutboxEventMapper mapper = mock(OutboxEventMapper.class); + OrderRepository repository = repository("outboxMapper", mapper); + + repository.markOutboxPublished(11L, 41L, LocalDateTime.now()); + repository.markOutboxRetry(11L, 42L, LocalDateTime.now()); + repository.markOutboxFailed(11L, 43L); + + ArgumentCaptor> wrappers = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper, times(3)).update(isNull(), wrappers.capture()); + List> values = wrappers.getAllValues(); + assertScope(values.get(0), 11L, 41L); + assertScope(values.get(1), 11L, 42L); + assertScope(values.get(2), 11L, 43L); + assertThat(values).allSatisfy(value -> { + assertThat(value.getSqlSegment()).contains("publish_status"); + assertThat(params(value).values()).contains("PENDING"); + }); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void pendingOrderEventsIncludeCancellationForTerminalHandling() { + OutboxEventMapper mapper = mock(OutboxEventMapper.class); + OrderRepository repository = repository("outboxMapper", mapper); + when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of()); + + repository.pendingInboundEvents(50); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectList(wrapper.capture()); + assertThat(wrapper.getValue().getSqlSegment()) + .contains("aggregate_type", "publish_status") + .doesNotContain("event_type"); + assertThat(params(wrapper.getValue()).values()) + .contains("FIRST_LEG_ORDER", "PENDING"); + } + + private static void initialize(Class entityType, String resource) { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), resource), entityType); + } + + private OrderRepository repository(String field, Object mapper) { + OrderRepository repository = new OrderRepository(); + ReflectionTestUtils.setField(repository, field, mapper); + return repository; + } + + private void assertScope(Wrapper wrapper, long tenantId, long id) { + assertThat(wrapper.getSqlSegment()).contains("tenant_id", "id"); + assertThat(params(wrapper).values()).contains(tenantId, id); + } + + @SuppressWarnings("rawtypes") + private static java.util.Map params(Wrapper wrapper) { + return ((AbstractWrapper) wrapper).getParamNameValuePairs(); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index d90ea515a..0fe4215ec 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -47,7 +47,7 @@ class OrderApplicationServiceStateTest { OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); OrderRequests.SaveRequest request = request(); when(masterData.validate(10001L, request)).thenReturn(snapshots()); - when(repository.completeIdempotency(any(), anyLong())).thenReturn(1); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); Long orderId = service(repository, eligibility, masterData) .createDraft(10001L, 2L, request, "draft-before-approval"); @@ -69,13 +69,13 @@ class OrderApplicationServiceStateTest { order.setStatus(OrderStatus.DRAFT); when(repository.findOwned(10001L, 1L)).thenReturn(order); when(masterData.validate(10001L, request)).thenReturn(snapshots()); - when(repository.saveOrder(order)).thenReturn(1); + when(repository.saveOrder(10001L, order)).thenReturn(1); service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, request); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).replaceDraftChildren(10001L, 1L); - verify(repository).saveOrder(argThat(saved -> saved.getStatus() == OrderStatus.DRAFT)); + verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT)); } @Test @@ -97,7 +97,7 @@ class OrderApplicationServiceStateTest { .isEqualTo(ResultCode.FORBIDDEN); verify(eligibility).requireApproved(10001L); - verify(repository, never()).saveOrder(any()); + verify(repository, never()).saveOrder(anyLong(), any()); } @Test @@ -113,7 +113,7 @@ class OrderApplicationServiceStateTest { ReflectionTestUtils.setField(service,"json",new ObjectMapper()); assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null)) .isInstanceOf(BizException.class).hasMessageContaining("重复提交"); - verify(repository,never()).saveOrder(any()); + verify(repository,never()).saveOrder(anyLong(), any()); } @Test @@ -123,7 +123,7 @@ class OrderApplicationServiceStateTest { OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "OVERSEAS"); when(masterData.validate(20002L, request)).thenReturn(snapshots(null)); - when(repository.completeIdempotency(any(), anyLong())).thenReturn(1); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); service(repository, eligibility, masterData) .createDraft(20002L, 2L, request, "public-destination-no-parties"); @@ -175,7 +175,21 @@ class OrderApplicationServiceStateTest { .isInstanceOf(BizException.class) .hasMessageContaining("上门提货时必须填写发货信息"); - verify(repository, never()).saveOrder(any()); + verify(repository, never()).saveOrder(anyLong(), any()); + } + + @Test + void updatingAnotherCustomersOrderStopsBeforePersistence() { + OrderRepository repository = mock(OrderRepository.class); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, request())) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + + verify(repository).findOwned(20002L, 1L); + verify(repository, never()).saveOrder(anyLong(), any()); } private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, @@ -215,11 +229,11 @@ class OrderApplicationServiceStateTest { } private OrderMasterDataGuard.Snapshots snapshots() { - return snapshots(new PartnerClient.Partner(7L, 10001L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER"))); + return snapshots(new PartnerClient.Partner(7L, 0L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER"))); } private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) { - var warehouse = new WarehouseClient.Warehouse(8L, 10001L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE"); + var warehouse = new WarehouseClient.Warehouse(8L, 0L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE"); var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null); var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0)); var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability)); diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 7315583b0..8f53b4125 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -118,7 +118,7 @@ class OrderIdempotencyServiceTest { service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key"); verify(repository, never()).findOwned(anyLong(), anyLong()); - verify(repository, never()).saveOrder(any()); + verify(repository, never()).saveOrder(anyLong(), any()); } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java new file mode 100644 index 000000000..0aa72e870 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -0,0 +1,123 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.entity.OutboxEventEntity; +import com.baiyue.order.repository.OrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OrderInboundOutboxPublisherTest { + @Test + void matchingApprovedEventIsDeliveredAndPublished() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L))); + when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.client).create(eq("internal-token"), any()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); + verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); + } + + @Test + void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L))); + + fixture.publisher.publishApprovedOrders(); + + verifyNoInteractions(fixture.client); + verify(fixture.repository).markOutboxFailed(11L, 41L); + verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); + } + + @Test + void mismatchedAggregateIsPermanentlyFailedBeforeDelivery() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 22L))); + + fixture.publisher.publishApprovedOrders(); + + verifyNoInteractions(fixture.client); + verify(fixture.repository).markOutboxFailed(11L, 41L); + } + + @Test + void unsupportedOrderEventReachesFailedTerminalState() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_CANCELLED", 11L, 21L, payload(11L, 21L))); + + fixture.publisher.publishApprovedOrders(); + + verifyNoInteractions(fixture.client); + verify(fixture.repository).markOutboxFailed(11L, 41L); + verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); + } + + @Test + void unknownOrderEventReachesFailedTerminalState() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_UNKNOWN", 11L, 21L, payload(11L, 21L))); + + fixture.publisher.publishApprovedOrders(); + + verifyNoInteractions(fixture.client); + verify(fixture.repository).markOutboxFailed(11L, 41L); + } + + @Test + void transientFirstlegFailureRemainsPendingForRetry() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L))); + when(fixture.client.create(eq("internal-token"), any())) + .thenThrow(new IllegalStateException("temporary failure")); + + fixture.publisher.publishApprovedOrders(); + + verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class)); + verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any()); + verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); + } + + private Fixture fixture(OutboxEventEntity event) { + OrderRepository repository = mock(OrderRepository.class); + FirstlegInboundClient client = mock(FirstlegInboundClient.class); + when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); + OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher(); + ReflectionTestUtils.setField(publisher, "repository", repository); + ReflectionTestUtils.setField(publisher, "client", client); + ReflectionTestUtils.setField(publisher, "json", new ObjectMapper()); + ReflectionTestUtils.setField(publisher, "internalToken", "internal-token"); + return new Fixture(publisher, repository, client); + } + + private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) { + OutboxEventEntity event = new OutboxEventEntity(); + event.setId(41L); + event.setTenantId(tenantId); + event.setAggregateType("FIRST_LEG_ORDER"); + event.setAggregateId(aggregateId); + event.setEventType(eventType); + event.setPayloadJson(payload); + event.setPublishStatus("PENDING"); + return event; + } + + private String payload(long tenantId, long orderId) { + return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + "}"; + } + + private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository, + FirstlegInboundClient client) { + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 929c07d50..1bf86be13 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -30,29 +30,29 @@ class OrderMasterDataGuardTest { @MockBean private WarehouseClient warehouses; @MockBean private PartnerClient partners; @Resource private OrderMasterDataGuard guard; - private static final long PUBLIC_TENANT_ID = 10001L; + private static final long PUBLIC_TENANT_ID = 0L; @Test void validMasterDataReturnsTrustedServerSnapshot() { - var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); + var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null); var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0)); - var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability)); - when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner)); - when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh)); + var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability)); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner)); + when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh)); var result=guard.validate(10001,request(7L,8L)); assertThat(result.partner().partnerName()).isEqualTo("服务端客户名"); assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); } @Test void inactiveOrCrossTenantMasterDataIsRejected() { - when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null)); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null)); assertThatThrownBy(() -> guard.validate(10002,request(7L,8L))) .isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用"); } @Test void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() { - when(partners.active("token",10001,7)).thenReturn( + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn( ApiResponse.failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null)); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) @@ -64,11 +64,11 @@ class OrderMasterDataGuardTest { } @Test void masterDataResponseWithNullElementReturnsControlledBusinessError() { - var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); - var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"), + var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); + var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"), Collections.singletonList(null), Collections.singletonList(null)); - when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner)); - when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh)); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner)); + when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh)); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -77,7 +77,7 @@ class OrderMasterDataGuardTest { } @Test void masterDataResponseMissingSnapshotNameIsServiceUnavailable() { - when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE","SHIPPER",List.of("SHIPPER")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,null,"ACTIVE","SHIPPER",List.of("SHIPPER")))); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -87,7 +87,7 @@ class OrderMasterDataGuardTest { } @Test void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() { - when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败")); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("解码失败")); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -96,7 +96,7 @@ class OrderMasterDataGuardTest { } @Test void downstreamForbiddenMapsToServiceUnavailable() { - when(partners.active("token",10001,7)).thenThrow(feign(403)); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(403)); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -104,18 +104,18 @@ class OrderMasterDataGuardTest { .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @Test - void downstreamHttpNotFoundReturnsUnboundCustomerMessage() { - when(partners.active("token",10001,7)).thenThrow(feign(404)); + void downstreamHttpNotFoundReturnsPlatformPartnerUnavailableMessage() { + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(404)); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) - .hasMessage("\u672a\u7ed1\u5b9a\u53ef\u7528\u5f80\u6765\u5ba2\u6237") + .hasMessage("平台往来单位不存在或不可用") .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.NOT_FOUND); } @Test void wrappedDownstreamHttpConflictMapsToServiceUnavailable() { - when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("wrapped", feign(409))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("wrapped", feign(409))); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -124,7 +124,7 @@ class OrderMasterDataGuardTest { } @Test void downstreamServerErrorMapsToServiceUnavailable() { - when(partners.active("token",10001,7)).thenThrow(feign(500)); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(500)); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -133,8 +133,8 @@ class OrderMasterDataGuardTest { } @Test void inactiveMasterDataInSuccessfulResponseMapsToConflict() { - when(partners.active("token",10001,7)) - .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE","SHIPPER",List.of("SHIPPER")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)) + .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","INACTIVE","SHIPPER",List.of("SHIPPER")))); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -143,8 +143,8 @@ class OrderMasterDataGuardTest { } @Test void partnerWithoutShipperTypeIsRejected() { - when(partners.active("token",10001,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,10001L,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE")))); assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -154,8 +154,8 @@ class OrderMasterDataGuardTest { } @Test void customerTenantCanUseDomesticWarehouseFromPublicTenant() { - when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); @@ -164,6 +164,19 @@ class OrderMasterDataGuardTest { verify(warehouses).active("token",PUBLIC_TENANT_ID,8); } @Test + void customersAAndBUseTheSamePlatformWarehouseAndPartner() { + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"平台共享单位","ACTIVE","SHIPPER",List.of("SHIPPER")))); + when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( + warehouse(8L,"DOMESTIC",true))); + + guard.validate(20002,request(7L,8L)); + guard.validate(30003,request(7L,8L)); + + verify(partners,times(2)).active("token",PUBLIC_TENANT_ID,7); + verify(warehouses,times(2)).active("token",PUBLIC_TENANT_ID,8); + } + @Test void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() { when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); @@ -177,8 +190,8 @@ class OrderMasterDataGuardTest { } @Test void fbaDestinationMustReferenceMatchingPublicWarehouse() { - when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success( @@ -191,8 +204,8 @@ class OrderMasterDataGuardTest { } @Test void privateDestinationCannotReferencePublicWarehouse() { - when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( - new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); + when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); diff --git a/order/src/test/java/com/baiyue/order/service/OrderScopeGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderScopeGuardTest.java new file mode 100644 index 000000000..dcbb3e6f0 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderScopeGuardTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderScopeGuardTest { + @Test + void merchantRequiresPositiveCustomerIsolationKey() { + assertThat(OrderScopeGuard.merchant(user("MERCHANT", 10001L))).isEqualTo(10001L); + assertThatThrownBy(() -> OrderScopeGuard.merchant(user("MERCHANT", 0L))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Merchant tenant is required"); + } + + @Test + void platformRequiresZeroTenantInToken() { + OrderScopeGuard.platform(user("PLATFORM", 0L)); + + assertThatThrownBy(() -> OrderScopeGuard.platform(user("PLATFORM", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Platform tenant must be 0"); + assertThatThrownBy(() -> OrderScopeGuard.platform(user("PLATFORM", 10001L))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Platform tenant must be 0"); + } + + @Test + void validPlatformCanSelectPositiveCustomer() { + assertThat(OrderScopeGuard.platform(user("PLATFORM", 0L), 20002L)).isEqualTo(20002L); + } + + private AuthenticatedUser user(String appCode, Long tenantId) { + return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of()); + } +} diff --git a/print/README.md b/print/README.md index 0d2d5abf9..b760b6449 100644 --- a/print/README.md +++ b/print/README.md @@ -11,7 +11,7 @@ - 本地直连:`http://127.0.0.1:9207`。 - 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。 - 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。 -- Admin 请求业务服务;业务服务完成 JWT、租户、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。 +- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。 调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。 diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 492eb2c89..1435ecbab 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -33,14 +33,14 @@ ## 修改与验证 -- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 -- 平台仓库是平台公共主数据:列表接口默认查询全部仓库,并可用兼容字段 `tenantId` 做可选客户范围筛选;不得要求 Admin 先选择客户才能取得仓库候选。详情和写操作现阶段仍按数据表兼容字段校验范围。接口同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。 +- 往来单位、仓库、快递公司、托盘、库位等均是平台公共主数据,统一归属平台技术作用域 `tenant_id=0`,不得绑定任一客户;客户只读取平台共享往来单位。平台启停必须检查业务约束,不能只改状态字段。列表接口默认查询全部公共资源,不得要求 Admin 先选择客户,也不得让客户筛选控制仓库、往来单位、快递、托盘或库位候选范围。历史接口或字段仍出现 `tenantId` 时只保留兼容,只允许传平台公共作用域 `0`,不得传客户 ID 作为资源归属。接口同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 - 往来单位类型以 `types` 多值集合和 `wh_partner_type` 为准,允许同一单位同时作为客户、供应商等;`wh_partner.partner_type` 与 DTO 的 `partnerType` 仅保留旧数据兼容。写入时 Entity、Mapper、Repository、平台保存/导入和 Order 客户端必须一起核对。 - 往来单位与快递公司导入只接收标准 XLSX 模板,文件上限 5MB、最多 1000 行;先完成整表头、字段、文件内重复和数据库重复校验,再原子写入,任一行失败整批不落库。权限分别是 `platform:counterparty:import` 与 `platform:express:import`。 - 模板与导入 DTO 在 `dto/MasterDataQueries.java`,Apache POI 依赖在 `pom.xml`;调整模板版本、列顺序或枚举时同步修改生成、解析、错误行号和两组 ImportService 测试。 -- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`;不存在或跨租户使用 HTTP 200 + body code `A0404`,已停用使用 HTTP 200 + `A0409`,`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。 -- 托盘和库位平台接口必须执行平台访问域、目标租户和对应权限校验;预占、确认、释放只通过 `InternalPalletLocationController` 提供给 Firstleg,不加入 Gateway 公网路由。 +- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`:公共仓库和平台共享往来单位均按 `tenant_id=0` 校验;往来单位不存在使用 HTTP 200 + body code `A0404`,资源已停用使用 HTTP 200 + `A0409`。`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。 +- 托盘和库位平台接口必须执行平台访问域、平台技术作用域 `tenant_id=0` 和对应权限校验,不得要求目标客户;预占、确认、释放只通过 `InternalPalletLocationController` 提供给 Firstleg,不加入 Gateway 公网路由。Firstleg 的入库单仍按客户 `tenant_id>0` 隔离,调用公共仓储资源时必须把业务客户归属与公共资源归属分开校验。 +- 公共资源与客户数据必须分开:公共仓库、往来单位、快递公司、托盘、库位使用 `tenant_id=0`;客户订单、文件和费用使用所属客户 `tenant_id>0`。`10001` 是客户 ID,不得再用于平台公共资源归属。 - 库位容量、共享溢出、预占和占用必须在数据库事务及条件更新中保持一致;修改分配算法时同步覆盖并发冲突、容量不足、重复确认、释放和共享溢出测试。 - Firstleg 只保存托盘/库位逻辑引用和入库快照。内部 DTO、预占状态或容量口径变化时同步检查 `firstleg/client/WarehousePalletLocationClient.java`、`MeasurementLocationOccupationService.java` 和双方测试。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java index 38a4f959a..0db5e83f4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java @@ -31,6 +31,8 @@ public class InternalPartnerController { @PathVariable long tenantId,@PathVariable long id) { if (token==null || !MessageDigest.isEqual(expected,token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN,"内部服务凭证无效"); + if (tenantId != 0L) + throw new BizException(ResultCode.BAD_REQUEST,"平台共享往来单位作用域必须为0"); return ApiResponse.success(service.active(tenantId,id)); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java index 4e8374903..28af44d45 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java @@ -1,12 +1,15 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.service.PartnerQueryService; import jakarta.annotation.Resource; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Value; import java.util.List; @RestController @@ -14,14 +17,25 @@ import java.util.List; public class MerchantPartnerController { @Resource private PartnerQueryService service; + @Value("${baiyue.warehouse.public-tenant-id:0}") + private long publicTenantId; @GetMapping("/candidates") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String keyword,@RequestParam(required=false) String type) { - return ApiResponse.success(service.candidates(user.tenantId(),keyword,type)); + requireMerchant(user); + return ApiResponse.success(service.candidates(publicTenantId,keyword,type)); } @GetMapping("/{id}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { - return ApiResponse.success(service.active(user.tenantId(),id)); + requireMerchant(user); + return ApiResponse.success(service.active(publicTenantId,id)); + } + + private static void requireMerchant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0L) { + throw new BizException(ResultCode.FORBIDDEN, "需要客户身份"); + } } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index 7a2cac41d..bbcf1a31d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -2,6 +2,8 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; @@ -33,7 +35,7 @@ public class MerchantWarehouseController { @Resource private WarehouseApplicationService service; - @Value("${baiyue.warehouse.public-tenant-id:10001}") + @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicTenantId; @Operation( @@ -48,6 +50,7 @@ public class MerchantWarehouseController { public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseRequests.QueryRequest query) { + requireMerchant(user); return ApiResponse.success(service.merchantPage(publicTenantId, query)); } @@ -64,6 +67,14 @@ public class MerchantWarehouseController { @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @PathVariable long id) { + requireMerchant(user); return ApiResponse.success(service.activeDetail(publicTenantId, id)); } + + private static void requireMerchant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0L) { + throw new BizException(ResultCode.FORBIDDEN, "需要客户身份"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index 9deb3848b..f57a010a4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -44,7 +44,7 @@ import java.nio.charset.StandardCharsets; /** 平台快递公司管理接口。 */ @Validated -@Tag(name = "平台快递公司", description = "跨企业分页查询和按目标租户维护快递公司主数据") +@Tag(name = "平台快递公司", description = "平台统一维护、供全部客户共享的快递公司主数据") @RestController @RequestMapping("/api/platform/v1/express-companies") public class PlatformExpressCompanyController { @@ -55,7 +55,7 @@ public class PlatformExpressCompanyController { @GetMapping @PreAuthorize("hasAuthority('platform:express:list')") - @Operation(summary = "分页查询快递公司", description = "默认查询全部企业;可按目标租户、关键字和状态筛选") + @Operation(summary = "分页查询快递公司", description = "查询平台共享快递公司;可按关键字和状态筛选") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @@ -64,11 +64,12 @@ public class PlatformExpressCompanyController { }) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") - @RequestParam(required = false) @Min(1) Long tenantId, + @Parameter(description = "兼容参数,只允许平台公共作用域 0") + @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject ExpressCompanyPageQuery query) { requirePlatform(user); - return ApiResponse.success(service.page(tenantId, query)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.page(0L, query)); } @GetMapping("/import-template") @@ -88,9 +89,10 @@ public class PlatformExpressCompanyController { @Operation(summary = "批量导入快递公司", description = "使用标准 XLSX 模板整批校验并新增快递公司") public ApiResponse importCompanies( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestPart("file") MultipartFile file) { requirePlatform(user); + requirePlatformScope(tenantId); try { return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes())); } catch (IOException exception) { @@ -100,35 +102,38 @@ public class PlatformExpressCompanyController { @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:express:list')") - @Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情") + @Operation(summary = "查询快递公司详情", description = "查询平台共享快递公司详情") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.detail(tenantId, id)); } @PostMapping @PreAuthorize("hasAuthority('platform:express:create')") - @Operation(summary = "新增快递公司", description = "在目标租户内新增快递公司") + @Operation(summary = "新增快递公司", description = "新增平台共享快递公司") public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @Valid @RequestBody ExpressCompanySaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.create(tenantId, request)); } @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:express:edit')") - @Operation(summary = "编辑快递公司", description = "编辑目标租户内的快递公司") + @Operation(summary = "编辑快递公司", description = "编辑平台共享快递公司") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody ExpressCompanySaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); service.update(tenantId, id, request); return ApiResponse.success(null); } @@ -138,17 +143,25 @@ public class PlatformExpressCompanyController { @Operation(summary = "启用或停用快递公司", description = "通过状态变更停用主数据,不物理删除历史记录") public ApiResponse changeStatus( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody StatusRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); service.changeStatus(tenantId, id, request.status()); return ApiResponse.success(null); } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } + + private static void requirePlatformScope(Long tenantId) { + if (tenantId != null && tenantId != 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java index b63ccb1e4..fc915c962 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java @@ -56,10 +56,11 @@ public class PlatformLocationController { public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") - @RequestParam(required = false) @Min(1) Long tenantId, + @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PageQuery query) { requirePlatform(user); - return ApiResponse.success(service.page(tenantId, query)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.page(0L, query)); } @GetMapping("/{id}") @@ -71,10 +72,11 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "库位不存在") }) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.detail(tenantId, warehouseId, id)); } @@ -86,10 +88,11 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围") }) public ApiResponse> available(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int limit) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.findAvailable(tenantId, warehouseId, limit)); } @@ -102,9 +105,10 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位编码重复") }) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @Valid @RequestBody SaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.create(tenantId, request)); } @@ -117,11 +121,12 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突、编码重复或库位正在被使用") }) public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id, @Valid @RequestBody SaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); service.update(tenantId, warehouseId, id, request); return ApiResponse.success(null); } @@ -135,11 +140,12 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突或库位正在被使用") }) public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id, @Valid @RequestBody StatusRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); service.changeStatus(tenantId, warehouseId, id, request.status(), request.version(), requireUserId(user)); return ApiResponse.success(null); } @@ -153,18 +159,20 @@ public class PlatformLocationController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位未停用、正在被使用或版本冲突") }) public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id, @Parameter(description = "乐观锁版本号", required = true) @RequestParam @Min(0) int version) { requirePlatform(user); + requirePlatformScope(tenantId); service.delete(tenantId, warehouseId, id, version, requireUserId(user)); return ApiResponse.success(null); } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } @@ -175,4 +183,10 @@ public class PlatformLocationController { } return user.userId(); } + + private static void requirePlatformScope(Long tenantId) { + if (tenantId != null && tenantId != 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java index c79082c07..b2da3a629 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java @@ -86,7 +86,8 @@ public class PlatformOverseasStorageFeeController { } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 7e988a56b..76bb66ec4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -43,20 +43,22 @@ public class PlatformPalletController { @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限")) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "目标企业租户ID;不传时查询全部企业") - @RequestParam(required = false) @Min(1) Long tenantId, + @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PalletRequests.PageQuery query) { requirePlatform(user); - return ApiResponse.success(service.page(tenantId, query)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.page(0L, query)); } @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:pallet:list')") @Operation(summary = "查询托盘详情") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.detail(tenantId, warehouseId, id)); } @@ -64,9 +66,10 @@ public class PlatformPalletController { @PreAuthorize("hasAuthority('platform:pallet:create')") @Operation(summary = "新增托盘") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @Valid @RequestBody PalletRequests.SaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); return ApiResponse.success(service.create(tenantId, request)); } @@ -74,11 +77,12 @@ public class PlatformPalletController { @PreAuthorize("hasAuthority('platform:pallet:edit')") @Operation(summary = "编辑托盘") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id, @Valid @RequestBody PalletRequests.SaveRequest request) { requirePlatform(user); + requirePlatformScope(tenantId); service.update(tenantId, warehouseId, id, request); return ApiResponse.success(null); } @@ -87,17 +91,25 @@ public class PlatformPalletController { @PreAuthorize("hasAuthority('platform:pallet:delete')") @Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, @PathVariable long id) { requirePlatform(user); + requirePlatformScope(tenantId); service.delete(tenantId, warehouseId, id); return ApiResponse.success(null); } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } + + private static void requirePlatformScope(Long tenantId) { + if (tenantId != null && tenantId != 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index 299bdab4c..382e558e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -42,9 +42,9 @@ import org.springframework.http.ResponseEntity; import java.io.IOException; import java.nio.charset.StandardCharsets; -/** 平台往来单位管理接口。 */ +/** 平台共享往来单位管理接口。 */ @Validated -@Tag(name = "平台往来单位", description = "跨企业分页查询和按目标租户维护往来单位主数据") +@Tag(name = "平台往来单位", description = "平台统一维护、供全部客户共享的往来单位主数据") @RestController @RequestMapping("/api/platform/v1/partners") public class PlatformPartnerController { @@ -70,11 +70,12 @@ public class PlatformPartnerController { @Operation(summary = "批量导入往来单位", description = "使用标准 XLSX 模板整批校验并新增往来单位") public ApiResponse importPartners( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @RequestPart("file") MultipartFile file) { requirePlatform(user); + requirePlatformScope(tenantId); try { - return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes())); + return ApiResponse.success(importService.importFile(0L, file.getOriginalFilename(), file.getBytes())); } catch (IOException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败"); } @@ -83,7 +84,7 @@ public class PlatformPartnerController { @GetMapping @PreAuthorize("hasAuthority('platform:counterparty:list')") @Operation(summary = "分页查询往来单位", - description = "默认分页查询全部企业往来单位;可按目标租户、关键字、类型和状态筛选") + description = "查询平台统一维护、供全部客户共享的往来单位;可按关键字、类型和状态筛选") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @@ -92,45 +93,49 @@ public class PlatformPartnerController { }) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") - @RequestParam(required = false) @Min(1) Long tenantId, + @Parameter(description = "兼容参数,只允许平台公共作用域 0") + @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PartnerPageQuery query) { requirePlatform(user); - return ApiResponse.success(service.page(tenantId, query)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.page(0L, query)); } @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:counterparty:list')") - @Operation(summary = "查询往来单位详情", description = "查询目标租户内的往来单位详情") + @Operation(summary = "查询往来单位详情", description = "查询平台共享往来单位详情") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id) { requirePlatform(user); - return ApiResponse.success(service.detail(tenantId, id)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.detail(0L, id)); } @PostMapping @PreAuthorize("hasAuthority('platform:counterparty:create')") - @Operation(summary = "新增往来单位", description = "在目标租户内新增往来单位") + @Operation(summary = "新增往来单位", description = "新增平台共享往来单位") public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @Valid @RequestBody PartnerSaveRequest request) { requirePlatform(user); - return ApiResponse.success(service.create(tenantId, request)); + requirePlatformScope(tenantId); + return ApiResponse.success(service.create(0L, request)); } @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:counterparty:edit')") - @Operation(summary = "编辑往来单位", description = "编辑目标租户内的往来单位") + @Operation(summary = "编辑往来单位", description = "编辑平台共享往来单位") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody PartnerSaveRequest request) { requirePlatform(user); - service.update(tenantId, id, request); + requirePlatformScope(tenantId); + service.update(0L, id, request); return ApiResponse.success(null); } @@ -139,17 +144,25 @@ public class PlatformPartnerController { @Operation(summary = "启用或停用往来单位", description = "通过状态变更停用主数据,不物理删除历史记录") public ApiResponse changeStatus( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody StatusRequest request) { requirePlatform(user); - service.changeStatus(tenantId, id, request.status()); + requirePlatformScope(tenantId); + service.changeStatus(0L, id, request.status()); return ApiResponse.success(null); } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } + + private static void requirePlatformScope(Long tenantId) { + if (tenantId != null && tenantId != 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "平台共享往来单位作用域必须为0"); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 612346ab9..b41e0b39c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -106,7 +106,7 @@ public class PlatformWarehouseController { @Valid WarehouseRequests.QueryRequest request) { requirePlatform(user); if (tenantId != null) requireTenant(tenantId); - return ApiResponse.success(service.page(tenantId, request)); + return ApiResponse.success(service.page(0L, request)); } @Operation( @@ -158,14 +158,15 @@ public class PlatformWarehouseController { } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); } } private static void requireTenant(Long tenantId) { - if (tenantId == null || tenantId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + if (tenantId == null || tenantId != 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0"); } } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java index f5fa561d7..e8aac8088 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -62,7 +62,7 @@ public final class MasterDataQueries { @Schema(name = "PartnerSaveRequest", description = "平台往来单位保存请求") public record PartnerSaveRequest( - @Schema(description = "租户内唯一往来单位编码", example = "CUS-001") + @Schema(description = "平台内唯一往来单位编码", example = "CUS-001") @NotBlank @Size(max = 50) String partnerCode, @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") @NotBlank @Size(max = 128) String partnerName, @@ -110,7 +110,7 @@ public final class MasterDataQueries { @Schema(name = "ExpressCompanySaveRequest", description = "平台快递公司保存请求") public record ExpressCompanySaveRequest( - @Schema(description = "租户内唯一快递公司编码", example = "SF") + @Schema(description = "平台内唯一快递公司编码", example = "SF") @NotBlank @Size(max = 50) String companyCode, @Schema(description = "快递公司名称", example = "顺丰速运") @NotBlank @Size(max = 128) String companyName, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java index 414d22351..b8efa9e5e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java @@ -8,8 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; -/** 租户维度快递公司主数据。 */ -@Schema(name = "ExpressCompany", description = "租户维度快递公司主数据") +/** 平台统一维护、供全部客户共享的快递公司主数据。 */ +@Schema(name = "ExpressCompany", description = "平台共享快递公司主数据") @Getter @Setter @TableName("wh_express_company") @@ -17,9 +17,9 @@ public class ExpressCompanyEntity extends BaseEntity { @Schema(description = "快递公司记录 ID", example = "192000000000000001") @TableId(type = IdType.ASSIGN_ID) private Long id; - @Schema(description = "所属租户 ID", example = "10001") + @Schema(description = "平台公共作用域 ID,固定为 0", example = "0") private Long tenantId; - @Schema(description = "租户内唯一快递公司编码", example = "SF") + @Schema(description = "平台内唯一快递公司编码", example = "SF") private String companyCode; @Schema(description = "快递公司名称", example = "顺丰速运") private String companyName; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java index d8ba05569..5a24de83b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -11,18 +11,18 @@ import lombok.Setter; import java.util.List; -/** 仓储域内的租户往来单位配置。 */ -@Schema(name = "Partner", description = "租户维度往来单位主数据") +/** 仓储域内由平台统一维护、供客户共享的往来单位主数据。 */ +@Schema(name = "Partner", description = "平台共享往来单位主数据") @Getter @Setter @TableName("wh_partner") public class PartnerEntity extends BaseEntity { @Schema(description = "往来单位记录 ID", example = "191000000000000001") @TableId(type = IdType.ASSIGN_ID) private Long id; - @Schema(description = "所属租户 ID", example = "10001") + @Schema(description = "平台公共作用域 ID,固定为 0", example = "0") private Long tenantId; - @Schema(description = "所属业务数据范围 ID,由服务端维护", example = "10001") + @Schema(description = "平台公共数据范围 ID,固定为 0", example = "0") private Long dataScopeId; - @Schema(description = "租户内唯一往来单位编码", example = "CUS-001") + @Schema(description = "平台内唯一往来单位编码", example = "CUS-001") private String partnerCode; @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") private String partnerName; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java index 75ee15a48..9af4520f6 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java @@ -7,6 +7,7 @@ import lombok.Setter; @Getter @Setter public class PartnerTypeEntity { + private Long tenantId; private Long partnerId; private String partnerType; } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java index 354ed3672..b7998c1c3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java @@ -14,14 +14,17 @@ import java.util.List; public interface PartnerMapper extends BaseMapper { @Select(""" """) - List selectTypes(@Param("partnerIds") Collection partnerIds); + List selectTypes(@Param("tenantIds") Collection tenantIds, + @Param("partnerIds") Collection partnerIds); @Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}") List selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type); @@ -76,8 +79,8 @@ public interface PartnerMapper extends BaseMapper { @Param("status") String status, @Param("keyword") String keyword); - @Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}") - void deleteTypes(@Param("partnerId") long partnerId); + @Delete("DELETE FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_id = #{partnerId}") + void deleteTypes(@Param("tenantId") long tenantId, @Param("partnerId") long partnerId); @Insert("""