diff --git a/AGENTS.md b/AGENTS.md index 77c072cba..e7699abfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 +- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 ## 不可突破的产品与数据边界 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index bc91720f2..46d7abdee 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -4,6 +4,7 @@ - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java index eeb8d43ca..ac2206a4d 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -11,6 +11,10 @@ public record TokenResponse( @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, - @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn + @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, + @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", + format = "password", nullable = true) String overseasAccessToken, + @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", + example = "900", minimum = "0") long overseasExpiresIn ) { -} \ No newline at end of file +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 72a8dab9d..ec283c23e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.ObjectProvider; import java.security.SecureRandom; import java.time.Instant; @@ -49,6 +50,8 @@ public class AuthService { private AuthorizationLoader authorizationLoader; @Resource private TokenSessionStore tokenSessionStore; + @Resource + private ObjectProvider overseasTokens; private final SecureRandom secureRandom = new SecureRandom(); @@ -134,8 +137,13 @@ public class AuthService { String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); + OverseasTokenService service = overseasTokens.getIfAvailable(); + OverseasTokenService.OverseasTokenResponse overseas = service == null + ? null : service.issue(account, authorization); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), - rawRefresh, properties.refreshTokenTtl().toSeconds()); + rawRefresh, properties.refreshTokenTtl().toSeconds(), + overseas == null ? null : overseas.accessToken(), + overseas == null ? 0L : overseas.expiresIn()); } @Transactional diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 0f7eb256a..2da6a5e9c 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -36,16 +36,22 @@ public class OverseasTokenService { throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); } AuthorizationSnapshot authorization = authorizationLoader.load(account); + return issue(account, authorization); + } + + public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) - .claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles()) + .claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId()) + .claim("membership_id", authorization.getMembershipId()) + .claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType()) + .claim("roles", authorization.getRoles()) .claim("authorities", authorization.getPermissions()) - .claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) .build(); String token = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 0d8e3851b..aed82fc6f 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -34,6 +34,7 @@ class AuthServiceSmallJwtTest { @MockBean private JwtEncoder jwtEncoder; @MockBean private AuthorizationLoader authorizationLoader; @MockBean private TokenSessionStore tokenSessionStore; + @MockBean private OverseasTokenService overseasTokenService; @Resource private AuthService service; @Configuration(proxyBeanMethods = false) @@ -59,7 +60,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list")); + "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -67,8 +68,10 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-token"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900)); - service.login(new LoginRequest("operator", "secret")); + var response = service.login(new LoginRequest("operator", "secret")); ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); verify(jwtEncoder).encode(jwt.capture()); @@ -88,6 +91,42 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); + verify(overseasTokenService).issue(account, snapshot); + assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + } + + @Test + void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + AuthAccount account = new AuthAccount(); + account.setId(52L); + account.setUsername("merchant"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(10001L); + account.setOperatorTenantId(10001L); + account.setDefaultMembershipId(94L); + account.setDefaultAppCode("MERCHANT"); + account.setTokenVersion(3); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT", + "WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + + when(tokenSessionStore.consume("refresh-old")).thenReturn( + new TokenSessionStore.RefreshSession(52L, "refresh-session")); + when(repository.findById(52L)).thenReturn(account); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(jwtEncoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900)); + + var response = service.refresh("refresh-old"); + + assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); + assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + verify(authorizationLoader).load(account); + verify(overseasTokenService).issue(account, snapshot); } @Test @@ -102,7 +141,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("MERCHANT"); account.setTokenVersion(2); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT", - "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")); + "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket( "ticket-1", 43L, 10001L, 85L, 42L, 84L, "support"); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index f2a0fe86b..49b8ee5b0 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -64,11 +64,13 @@ class OverseasTokenServiceTest { assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(parameters.getClaims().getClaims()) - .containsEntry("userId", 42L).containsEntry("tenantId", 100L) - .containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L) + .containsEntry("user_id", 42L).containsEntry("tenant_id", 100L) + .containsEntry("membership_id", 84L) + .containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB") + .containsEntry("permission_version", 7L) .containsEntry("roles", Set.of("MERCHANT_ADMIN")) .containsEntry("authorities", Set.of("merchant:order:list")) - .doesNotContainKeys("user_id", "tenant_id", "app_code"); + .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 378011e92..5f3df70ab 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -751,7 +751,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( 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', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', @@ -9636,21 +9636,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -9665,7 +9672,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), @@ -10375,22 +10381,29 @@ FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史 DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule-query:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102187,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id=5102185 OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -10405,7 +10418,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) @@ -10433,7 +10445,7 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -10442,7 +10454,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ @@ -14221,7 +14233,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'updated_by', '最 CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_code', '平台内唯一仓库编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_name', '仓库名称'); -CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC、FBA、OVERSEAS'); +CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line1', '主地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line2', '补充地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_type', '地址类型,例如OPERATING或RETURN'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 6ef1ffe70..a98ec7d28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -41,7 +41,7 @@ public class PlatformCarrierScheduleQueryController { @Operation(summary = "查询六家船公司合规适配策略") @GetMapping("/carriers") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> carriers(@AuthenticationPrincipal AuthenticatedUser user) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.carriers()); @@ -57,7 +57,7 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); @@ -65,7 +65,7 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java index 2b586c181..6b1c5c271 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -47,7 +47,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); @@ -55,7 +55,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 93a182dc1..80636650e 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -150,22 +150,29 @@ FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史 DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule-query:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102187,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id=5102185 OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -180,7 +187,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) @@ -208,7 +214,7 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -217,7 +223,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql index fb055126f..0c90d88d5 100644 --- a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql +++ b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql @@ -122,21 +122,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -151,7 +158,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index ff50fc8de..e63d175fa 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -7,11 +7,13 @@ paths: /api/platform/v1/carrier-schedule-queries/carriers: get: summary: List official carrier sources and compliant access modes + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks + x-permission: platform:firstleg:port-schedule-query:list parameters: - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} @@ -26,6 +28,7 @@ paths: '200': {description: Query tasks with source, freshness and rate-limit fields} post: summary: Create a query task linked to a real booking and allocated container + x-permission: platform:firstleg:port-schedule-query:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -38,18 +41,21 @@ paths: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get task, immutable attempts and history + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: Stable task detail} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get immutable task history + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} /api/platform/v1/carrier-schedule-queries/{queryId}/attempts: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages + x-permission: platform:firstleg:port-schedule-query:attempt parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -62,6 +68,7 @@ paths: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Link a validated manual port-schedule observation without pretending automated success + x-permission: platform:firstleg:port-schedule-query:manual parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true diff --git a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml index 4016226a5..d96d74796 100644 --- a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml +++ b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml @@ -7,6 +7,7 @@ paths: /api/platform/v1/port-schedules: get: summary: Query schedules by customer, ocean booking, allocated container or expiry + x-permission: platform:firstleg:port-schedule:list parameters: - {name: customerId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: oceanBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -20,6 +21,7 @@ paths: description: HTTP 200 business envelope containing a page of schedule views post: summary: Create a source observation linked to a real ocean booking + x-permission: platform:firstleg:port-schedule:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -32,10 +34,12 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Get schedule and immutable history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Schedule detail with computed expired flag} put: summary: Update source observation with optimistic locking + x-permission: platform:firstleg:port-schedule:edit parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -48,6 +52,7 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Query immutable source observation history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Ordered CREATE and UPDATE history records} components: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 173c7cbb1..1f1d1c0a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -17,10 +17,10 @@ class PlatformCarrierScheduleQueryControllerContractTest { assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( - "carriers", "platform:firstleg:port-schedule-query:view", + "carriers", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", - "detail", "platform:firstleg:port-schedule-query:view", - "history", "platform:firstleg:port-schedule-query:view", + "detail", "platform:firstleg:port-schedule-query:list", + "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", "attempt", "platform:firstleg:port-schedule-query:attempt", "manualResult", "platform:firstleg:port-schedule-query:manual"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java index 42959cc52..fb2edf48d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java @@ -18,8 +18,8 @@ class PlatformPortScheduleControllerContractTest { .containsExactly("/api/platform/v1/port-schedules"); Map permissions = Map.of( "page", "platform:firstleg:port-schedule:list", - "detail", "platform:firstleg:port-schedule:view", - "history", "platform:firstleg:port-schedule:view", + "detail", "platform:firstleg:port-schedule:list", + "history", "platform:firstleg:port-schedule:list", "create", "platform:firstleg:port-schedule:create", "update", "platform:firstleg:port-schedule:edit"); permissions.forEach((name, permission) -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java index e1049bca6..e5b46e04c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java @@ -37,9 +37,11 @@ class PortScheduleSqlContractTest { @Test void reservesDedicatedPlatformPermissionsForBothOperationalRoles() throws Exception { assertThat(sql()) - .contains("5102180 AND 5102183") + .contains("id IN (5102180,5102182,5102183)") .contains("platform:firstleg:port-schedule:list") - .contains("platform:firstleg:port-schedule:view") + .contains("permission.permission_code='platform:firstleg:port-schedule:view'") + .contains("permission_code='platform:firstleg:port-schedule:view'") + .doesNotContain("'platform:firstleg:port-schedule:view','查看港务船期'") .contains("platform:firstleg:port-schedule:create") .contains("platform:firstleg:port-schedule:edit") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')"); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index dfcc35264..7c9b1e6d5 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -4,6 +4,7 @@ - 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 - 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 +- 修改海外 Token、Audience、JWKS、区域路由或服务身份时,先读 `../../docs/跨区域认证与服务通信契约.md`。 ## 快速索引 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 22d1fb7e3..b085e38c4 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -4,6 +4,7 @@ - 负责客户主档、成员、企业资料/审核、平台用户、角色权限、数据范围和授权快照。 - 账号凭据/Token 属于 `auth/`,文件内容属于 `file/`;资质登记只接收 File 的内部元数据调用。 +- 修改用户可见层级、菜单权限、列表/详情只读语义或权限资源前,先读 `../../docs/平台客户层级与权限契约.md`。 - 当前仅“平台—客户”两级:`iam_tenant.id=0` 为平台作用域,`id>0` 为客户主档;不得新增真实租户层。 ## 模块入口 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index 4c16d9d87..e0f9c4b62 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.annotation.Resource; import jakarta.annotation.PostConstruct; @@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.List; @RestController @RequestMapping("/internal/warehouses") @@ -39,6 +41,13 @@ public class InternalWarehouseController { return ApiResponse.success(service.activeDetail(PLATFORM_TENANT_ID, id)); } + @GetMapping("/overseas-partners") + public ApiResponse> overseasPartners( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeOverseasPartners()); + } + private void verifyToken(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expected, supplied)) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java new file mode 100644 index 000000000..0de6fc8d7 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java @@ -0,0 +1,17 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "OverseasPartnerWarehouseResponse", description = "Active overseas partner warehouse option / 启用的海外合作仓候选") +public record OverseasPartnerWarehouseResponse( + @Schema(description = "Platform public warehouse ID / 平台公共仓库 ID") Long id, + @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称") String warehouseName, + @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode +) { + public static OverseasPartnerWarehouseResponse of(WarehouseEntity warehouse) { + return new OverseasPartnerWarehouseResponse(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getCountryCode()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index c48a3f81d..31d103bec 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -29,8 +29,8 @@ public final class WarehouseRequests { @NotBlank @Size(max = 50) String warehouseCode, @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 120) String warehouseName, - @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC") - @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, + @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}, defaultValue = "DOMESTIC") + @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}") @Pattern(regexp = "[A-Z]{2}") String countryCode, @Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8") @@ -104,8 +104,8 @@ public final class WarehouseRequests { String countryCode, @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") String capabilityCode, - @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) - @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, + @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) + @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) @Pattern(regexp = "ACTIVE|INACTIVE") String status ) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java index f990c2322..9c300aed9 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java @@ -13,7 +13,7 @@ public record WarehouseSummaryResponse( @Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId, @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, @Schema(description = "Warehouse name / 仓库名称") String warehouseName, - @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) String warehouseType, + @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) String warehouseType, @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode, @Schema(description = "UTC offset / UTC 偏移量") String timezone, @Schema(description = "Contact name / 联系人") String contactName, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java index e391ee0d4..03e447e25 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -27,7 +27,8 @@ public class WarehouseEntity extends BaseEntity { private String warehouseCode; @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓") private String warehouseName; - @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS") + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS_PARTNER", + allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) private String warehouseType; @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") private String countryCode; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 9b7c1bff3..304268c00 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -59,6 +59,15 @@ public class WarehouseRepository { .eq(WarehouseEntity::getStatus, "ACTIVE")); } + public List findActiveByType(long tenantId, String warehouseType) { + return warehouses.selectList(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseType, warehouseType) + .eq(WarehouseEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseEntity::getWarehouseName) + .orderByAsc(WarehouseEntity::getId)); + } + public boolean existsCode(long tenantId, String code, Long excludedId) { return warehouses.selectCount(new LambdaQueryWrapper() .eq(WarehouseEntity::getTenantId, tenantId) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 76456870f..06badc425 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.WarehouseSummaryResponse; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; @@ -86,6 +87,14 @@ public class WarehouseApplicationService { return repository.activePage(tenantId, query); } + public List activeOverseasPartners() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_OVERSEAS_PARTNER) + .stream() + .map(OverseasPartnerWarehouseResponse::of) + .toList(); + } + public WarehouseDetailResponse activeDetail(long tenantId, long id) { WarehouseEntity warehouse = repository.findOwned(tenantId, id); if (warehouse == null) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java index 841ae7216..4608f050b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java @@ -56,7 +56,7 @@ public class WarehouseImportService { Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明"); instruction.createCell(1).setCellValue( - "默认仅新增;仓库类型默认 DOMESTIC,状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); + "默认仅新增;仓库类型默认 DOMESTIC,可填 OVERSEAS_PARTNER 表示海外合作仓;状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); Row header = sheet.createRow(2); for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); @@ -202,8 +202,8 @@ public class WarehouseImportService { } else { optionalLength(row, errors, "仓库名称", row.name(), 120); } - if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS")) { - errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA 或 OVERSEAS")); + if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER")) { + errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER")); } if (!row.countryCode().isBlank() && !row.countryCode().matches("(?i)[A-Z]{2}")) { errors.add(new WarehouseImportError(row.row(), "国家二字码", "必须是两个英文字母")); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java index fe901037a..69d09eacf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java @@ -15,6 +15,8 @@ final class WarehouseSaveNormalizer { static final long PUBLIC_TENANT_ID = 0L; static final String TYPE_DOMESTIC = "DOMESTIC"; static final String TYPE_FBA = "FBA"; + static final String TYPE_OVERSEAS = "OVERSEAS"; + static final String TYPE_OVERSEAS_PARTNER = "OVERSEAS_PARTNER"; static final String STATUS_ACTIVE = "ACTIVE"; static final String DEFAULT_ADDRESS_TYPE = "WAREHOUSE"; static final String DEFAULT_DOMESTIC_COUNTRY = "CN"; @@ -68,8 +70,9 @@ final class WarehouseSaveNormalizer { static NormalizedSaveRequest normalize(WarehouseRequests.SaveRequest request, long tenantId) { String warehouseType = upperOrDefault(request.warehouseType(), TYPE_DOMESTIC); - if (!TYPE_DOMESTIC.equals(warehouseType) && !TYPE_FBA.equals(warehouseType) && !"OVERSEAS".equals(warehouseType)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse type must be DOMESTIC, FBA or OVERSEAS / 仓库类型只能为 DOMESTIC、FBA 或 OVERSEAS"); + if (!Set.of(TYPE_DOMESTIC, TYPE_FBA, TYPE_OVERSEAS, TYPE_OVERSEAS_PARTNER).contains(warehouseType)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "Warehouse type must be DOMESTIC, FBA, OVERSEAS or OVERSEAS_PARTNER / 仓库类型只能为 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER"); } String status = upperOrDefault(request.status(), STATUS_ACTIVE); if (!STATUS_ACTIVE.equals(status) && !"INACTIVE".equals(status)) { diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index 044e55cb3..d61950cf9 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( 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', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java index aa3ff07a4..ae64a2e25 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.service.WarehouseApplicationService; import org.junit.jupiter.api.BeforeEach; @@ -71,4 +72,15 @@ class InternalWarehouseControllerTest { verifyNoInteractions(service); } + + @Test + void listsActiveOverseasPartnerOptionsForInternalCallers() { + var option = new OverseasPartnerWarehouseResponse(200002L, "US-PARTNER-LAX", "洛杉矶合作仓", "US"); + when(service.activeOverseasPartners()).thenReturn(List.of(option)); + + var response = controller.overseasPartners(INTERNAL_TOKEN); + + assertThat(response.data()).containsExactly(option); + verify(service).activeOverseasPartners(); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index a3eddd161..bd35a1d56 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -116,4 +116,37 @@ class WarehouseApplicationServiceTest { .isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).insertWarehouse(any()); } + + @Test + void createsOverseasPartnerWarehouseAsPlatformPublicMasterData() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "US-PARTNER-LAX", "洛杉矶合作仓", + "OVERSEAS_PARTNER", "US", "UTC-8", "Alex", "+1-213-555-0100", null, null, null, + new WarehouseRequests.AddressRequest(null, "US", "CA", "Los Angeles", null, + "90001", "123 Partner Ave", null, null, null), + java.util.List.of(new WarehouseRequests.CapabilityRequest("STORAGE", "仓储", true, null, null))); + + service.create(request); + + var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class); + verify(repository).insertWarehouse(warehouse.capture()); + assertThat(warehouse.getValue().getTenantId()).isZero(); + assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("OVERSEAS_PARTNER"); + assertThat(warehouse.getValue().getCountryCode()).isEqualTo("US"); + assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8"); + } + + @Test + void exposesOnlyActiveOverseasPartnerWarehousesToInternalConsumers() { + WarehouseEntity partner = new WarehouseEntity(); + partner.setId(2L); + partner.setWarehouseCode("US-PARTNER-LAX"); + partner.setWarehouseName("洛杉矶合作仓"); + partner.setCountryCode("US"); + when(repository.findActiveByType(0L, "OVERSEAS_PARTNER")).thenReturn(java.util.List.of(partner)); + + assertThat(service.activeOverseasPartners()) + .extracting("id", "warehouseCode", "warehouseName", "countryCode") + .containsExactly(tuple(2L, "US-PARTNER-LAX", "洛杉矶合作仓", "US")); + verify(repository).findActiveByType(0L, "OVERSEAS_PARTNER"); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java index aa7ef5033..1e0837d5a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java @@ -64,6 +64,22 @@ class WarehouseImportServiceTest { assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9"); } + @Test + @DisplayName("海外合作仓类型可通过批量导入进入公共仓库主数据") + void shouldImportOverseasPartnerWarehouse() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"US-PARTNER-LAX", "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "CA", "Los Angeles", "", "123 Partner Ave", "90001", "UTC-8", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); + + assertThat(result.success()).isTrue(); + var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class); + verify(warehouseService).create(captor.capture()); + assertThat(captor.getValue().tenantId()).isZero(); + assertThat(captor.getValue().warehouseType()).isEqualTo("OVERSEAS_PARTNER"); + } + @Test @DisplayName("任一行错误时整批零写入") void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception {