Compare commits

..
2 Commits
Author SHA1 Message Date
liuyihe 7a19e2f096 暂存提交 2026-07-31 14:55:34 +08:00
liuyihe bc3c18a6e9 暂存提交 2026-07-31 13:52:35 +08:00
52 changed files with 1072 additions and 163 deletions
+7 -6
View File
@@ -18,15 +18,15 @@
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` | | `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` | | `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
| `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`src/main/resources/application*.yml` | | `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`src/main/resources/application*.yml` |
| `auth/` | 登录、令牌、注册、账号与密码 | `controller/`、`service/`、`src/main/resources/db/` | | `auth/` | 登录、令牌、注册、账号与密码 | `InternalAccountController.java`、`AccountManagementService.java`、`src/main/resources/db/` |
| `iam/` | 租户、成员、企业资料、RBAC 与授权快照 | `controller/`、`service/`、`README-IAM.md` | | `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` |
| `order/` | 订单主数据、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`README-ORDER.md` | | `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java` | | `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| `warehouse/` | 仓库、地址、服务能力与往来单位 | `controller/`、`WarehouseApplicationService.java`、`PartnerQueryService.java` | | `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` |
| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;元数据表结构尚未接入 | | `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;元数据表结构尚未接入 |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | | `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | OCR、Amazon 等第三方适配 | `provider/`、`IntegrationProperties.java` | | `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` | | `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
## 配置、数据库与验证入口 ## 配置、数据库与验证入口
@@ -34,6 +34,7 @@
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。 - 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。 - 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。 - 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。 - 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`。 - 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
Vendored
+12
View File
@@ -66,6 +66,7 @@ pipeline {
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:幂等初始化 IAM、订单、仓库与往来单位表结构及仓库样例数据')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像,无需修改') string(name: 'IMAGE_REPOSITORY', defaultValue: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像,无需修改')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像;默认使用国内镜像代理') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像;默认使用国内镜像代理')
@@ -194,6 +195,17 @@ pipeline {
} }
} }
stage('测试数据库初始化(可选)') {
when {
allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
}
}
steps {
sh 'sh deploy/scripts/initialize-test-databases.sh'
}
}
stage('部署') { stage('部署') {
when { when {
allOf { allOf {
+2 -1
View File
@@ -13,7 +13,7 @@
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) | | 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) |
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`) | | 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`) |
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` | | 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` |
| 账号状态、密码重置 | `service/AccountManagementService.java` | | 账号创建、显示名同步、状态和密码重置 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0` |
| 商户注册协作 | `service/MerchantRegistrationService.java` | | 商户注册协作 | `service/MerchantRegistrationService.java` |
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` | | IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` | | 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
@@ -26,4 +26,5 @@
- 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。 - 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。 - 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。 - 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 - 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -17,13 +18,14 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/auth/accounts") @RequestMapping("/internal/auth/accounts")
public class InternalAccountController { public class InternalAccountController {
private final AccountManagementService service; @Resource
private AccountManagementService service;
private final String configuredToken; private final String configuredToken;
private byte[] tokenBytes; private byte[] tokenBytes;
public InternalAccountController(AccountManagementService service, public InternalAccountController(
@Value("${baiyue.security.internal-service-token}") String configuredToken) { @Value("${baiyue.security.internal-service-token}") String configuredToken) {
this.service = service;
this.configuredToken = configuredToken; this.configuredToken = configuredToken;
} }
@@ -44,6 +46,11 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
verify(token); service.updateStatus(id, request); return ApiResponse.success(null); verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
} }
@PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
}
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -17,7 +17,10 @@ public final class AccountManagementDtos {
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId, @NotBlank String displayName, @NotNull Long tenantId,
@NotNull Long membershipId) { } @NotNull Long membershipId,
@Pattern(regexp = "MERCHANT|PLATFORM", message = "应用端只能是 MERCHANT 或 PLATFORM")
String appCode) { }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
@@ -11,6 +11,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale;
import java.util.Objects;
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */ /** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
@Service @Service
public class AccountManagementService { public class AccountManagementService {
@@ -35,7 +38,15 @@ public class AccountManagementService {
@BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT", @BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT",
resourceId = "#request.accountId", description = "创建认证账号") resourceId = "#request.accountId", description = "创建认证账号")
public AccountSummary create(CreateAccountRequest request) { public AccountSummary create(CreateAccountRequest request) {
if (repository.existsByUsername(request.username()) || repository.findById(request.accountId()) != null) { String appCode = request.appCode() == null ? "MERCHANT"
: request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId());
AuthAccount existing = repository.findById(request.accountId());
if (existing != null) {
if (sameIdentity(existing, request, appCode)) return summary(existing);
throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用");
}
if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
} }
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
@@ -49,7 +60,7 @@ public class AccountManagementService {
account.setDefaultTenantId(request.tenantId()); account.setDefaultTenantId(request.tenantId());
account.setOperatorTenantId(request.tenantId()); account.setOperatorTenantId(request.tenantId());
account.setDefaultMembershipId(request.membershipId()); account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode("MERCHANT"); account.setDefaultAppCode(appCode);
account.setDeleted(0); account.setDeleted(0);
repository.insert(account); repository.insert(account);
return summary(account); return summary(account);
@@ -66,6 +77,16 @@ public class AccountManagementService {
bumpVersion(account); bumpVersion(account);
} }
@Transactional
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步账号显示名称", captureAfter = false)
public void updateProfile(Long accountId, UpdateAccountProfileRequest request) {
AuthAccount account = required(accountId);
if (Objects.equals(account.getDisplayName(), request.displayName())) return;
account.setDisplayName(request.displayName());
bumpVersion(account);
}
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request) { public void resetPassword(Long accountId, ResetPasswordRequest request) {
@@ -108,4 +129,23 @@ public class AccountManagementService {
private AccountSummary summary(AuthAccount a) { private AccountSummary summary(AuthAccount a) {
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus()); return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus());
} }
private void validateScope(String appCode, Long tenantId) {
if (!"MERCHANT".equals(appCode) && !"PLATFORM".equals(appCode)) {
throw new BizException(ResultCode.BAD_REQUEST, "应用端只能是 MERCHANT 或 PLATFORM");
}
if (("PLATFORM".equals(appCode) && tenantId != 0L)
|| ("MERCHANT".equals(appCode) && tenantId <= 0L)) {
throw new BizException(ResultCode.BAD_REQUEST, "账号应用端与租户范围不匹配");
}
}
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
&& Objects.equals(account.getDefaultAppCode(), appCode)
&& !Integer.valueOf(1).equals(account.getDeleted());
}
} }
@@ -1,6 +1,8 @@
package com.baiyue.auth.service; package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
@@ -49,4 +51,41 @@ class AccountManagementServiceTest {
assertEquals("DISABLED", account.getAccountStatus()); assertEquals("DISABLED", account.getAccountStatus());
verify(sessions).revokeAll(10L); verify(sessions).revokeAll(10L);
} }
@Test void 创建平台账号保存平台应用域且密码仅保存摘要() {
when(repository.findById(20L)).thenReturn(null);
when(repository.existsByUsername("platform.user")).thenReturn(false);
var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1",
"平台用户", 0L, 30L, "PLATFORM"));
assertEquals("platform.user", result.username());
var captor = org.mockito.ArgumentCaptor.forClass(AuthAccount.class);
verify(repository).insert(captor.capture());
assertEquals("PLATFORM", captor.getValue().getDefaultAppCode());
assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash()));
assertNotEquals("Password1", captor.getValue().getPasswordHash());
}
@Test void 相同账号标识和归属重试时幂等返回() {
account.setUsername("platform.user"); account.setDisplayName("平台用户");
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
"平台用户", 0L, 30L, "PLATFORM"));
assertEquals(10L, result.accountId());
verify(repository, never()).insert(any());
}
@Test void 同步显示名撤销已有会话() {
account.setDisplayName("旧名称");
service.updateProfile(10L, new UpdateAccountProfileRequest("新名称"));
assertEquals("新名称", account.getDisplayName());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
} }
+1
View File
@@ -18,5 +18,6 @@
## 修改与验证 ## 修改与验证
- 修改异常响应时同时检查 HTTP 状态、`ApiResponse` 结构、校验错误和安全异常是否仍一致。 - 修改异常响应时同时检查 HTTP 状态、`ApiResponse` 结构、校验错误和安全异常是否仍一致。
- 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 - 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。 - 从父目录运行 `mvn -pl common-web -am test`。
@@ -15,6 +15,7 @@ import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -54,7 +55,8 @@ public class GlobalExceptionHandler {
return validationResponse(errors, request); return validationResponse(errors, request);
} }
@ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class}) @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class})
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
} }
@@ -4,6 +4,10 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -20,4 +24,21 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code()); assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code());
assertThat(response.getBody().requestId()).isEqualTo("r1"); assertThat(response.getBody().requestId()).isEqualTo("r1");
} }
@Test
void 查询参数类型错误映射为四百而不是五百() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class);
MethodParameter parameter = new MethodParameter(method, 0);
MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException(
"abc", Long.class, "page", parameter, new NumberFormatException("abc"));
var response = handler.handleBadRequest(exception, request);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
}
private void paging(long page) {
}
} }
+1
View File
@@ -73,6 +73,7 @@ SQL 入口位于各服务的 `src/main/resources/db` 目录。
| `wh_warehouse_address` | 保存仓库运营、退货等不同用途的地址和可选经纬度。 | `tenant_id`、`warehouse_id`、`address_type` | | `wh_warehouse_address` | 保存仓库运营、退货等不同用途的地址和可选经纬度。 | `tenant_id`、`warehouse_id`、`address_type` |
| `wh_service_capability` | 声明仓库可提供的收货、仓储、拣包、退货等服务能力及截单时间。 | `tenant_id`、`warehouse_id`、`capability_code`、`enabled`、`cutoff_time` | | `wh_service_capability` | 声明仓库可提供的收货、仓储、拣包、退货等服务能力及截单时间。 | `tenant_id`、`warehouse_id`、`capability_code`、`enabled`、`cutoff_time` |
| `wh_partner` | 仓储域内的租户往来单位配置,供订单选择客户、联系人和地址;商户候选查询强制过滤停用数据。 | `tenant_id`、`partner_code`、`partner_name`、`partner_type`、`contact_name`、`contact_phone`、`address_line`、`status` | | `wh_partner` | 仓储域内的租户往来单位配置,供订单选择客户、联系人和地址;商户候选查询强制过滤停用数据。 | `tenant_id`、`partner_code`、`partner_name`、`partner_type`、`contact_name`、`contact_phone`、`address_line`、`status` |
| `wh_express_company` | 租户维度快递公司主数据,供平台基础资料页分页查询。 | `tenant_id`、`company_code`、`company_name`、`service_phone`、`tracking_url_template`、`status` |
## 文件中心服务 ## 文件中心服务
+37 -2
View File
@@ -9,6 +9,8 @@ Authorization: Bearer <accessToken>
Content-Type: application/json Content-Type: application/json
``` ```
创建订单草稿和提交订单还必须携带 `Idempotency-Key`。同一操作者重试同一请求时复用原值;同一键用于不同请求返回 409,键缺失或长度超过 128 返回 400/422。
普通成功响应: 普通成功响应:
```json ```json
@@ -77,6 +79,8 @@ Content-Type: application/json
| PUT | `/api/merchant/v1/iam/tenant/profile` | 更新当前租户资料 | | PUT | `/api/merchant/v1/iam/tenant/profile` | 更新当前租户资料 |
| GET | `/api/merchant/v1/iam/tenant/qualifications` | 返回当前租户资质列表 | | GET | `/api/merchant/v1/iam/tenant/qualifications` | 返回当前租户资质列表 |
IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/qualifications` 已移除,上传必须使用 File 服务的 multipart 接口,避免数据库出现没有真实文件支撑的 URL。
个人资料与修改密码: 个人资料与修改密码:
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@@ -126,6 +130,18 @@ Content-Type: application/json
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | `platform:customer:approve` | 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/approve` | `platform:customer:approve` | 审核通过 |
| POST | `/api/platform/v1/iam/reviews/{id}/reject` | `platform:customer:reject` | 审核驳回 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | `platform:customer:reject` | 审核驳回 |
平台登录初始化与后台管理:
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/api/{platform\|merchant}/v1/iam/authorization/permissions` | 已认证有效成员 | 实时返回当前有效权限,不要求角色管理权限 |
| GET | `/api/platform/v1/iam/tenants?page=1&size=20` | `platform:customer:list` | 仅分页查询商户企业,支持关键字与状态 |
| GET | `/api/platform/v1/iam/internal-users` | `platform:user:list` | 返回 `membershipId`、账号资料和 `roleIds` |
| POST | `/api/platform/v1/iam/internal-users` | `platform:user:create` | 创建或关联平台认证账号 |
| PUT | `/api/platform/v1/iam/internal-users/{membershipId}` | `platform:user:edit` | 编辑资料与角色 |
| PUT | `/api/platform/v1/iam/internal-users/{membershipId}/status` | `platform:user:status` | 启用或停用平台用户 |
| PUT | `/api/platform/v1/iam/internal-users/{membershipId}/password` | `platform:user:reset-password` | 重置密码,密码只转交 Auth |
审核请求: 审核请求:
```json ```json
@@ -142,6 +158,8 @@ Content-Type: application/json
| GET | `/api/merchant/v1/files/qualifications/{fileName}` | 商户下载自己的资质 | | GET | `/api/merchant/v1/files/qualifications/{fileName}` | 商户下载自己的资质 |
| GET | `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` | 平台查看指定租户资质 | | GET | `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` | 平台查看指定租户资质 |
历史 `/phase1-seed/...` 路径不符合 File 服务契约,不能直接改成不存在的 UUID。处理步骤见 `docs/企业资质文件迁移说明.md`。
上传采用 `multipart/form-data`:`file` 必填,`certificateNo` 可选。只接受文件内容真实为 JPG/PNG 的图片,最大 5MB。上传成功 `data`: 上传采用 `multipart/form-data`:`file` 必填,`certificateNo` 可选。只接受文件内容真实为 JPG/PNG 的图片,最大 5MB。上传成功 `data`:
```json ```json
@@ -161,11 +179,11 @@ Content-Type: application/json
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
|---|---|---|---| |---|---|---|---|
| POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,`data` 为订单 ID | | POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,必须携带 `Idempotency-Key`,`data` 为订单 ID |
| PUT | `/api/merchant/v1/orders/first-leg/{id}` | `merchant:order:edit` | 编辑草稿 | | PUT | `/api/merchant/v1/orders/first-leg/{id}` | `merchant:order:edit` | 编辑草稿 |
| GET | `/api/merchant/v1/orders/first-leg` | 商户身份 | 分页查询自己的订单 | | GET | `/api/merchant/v1/orders/first-leg` | 商户身份 | 分页查询自己的订单 |
| GET | `/api/merchant/v1/orders/first-leg/{id}` | 商户身份 | 查询自己的订单详情 | | GET | `/api/merchant/v1/orders/first-leg/{id}` | 商户身份 | 查询自己的订单详情 |
| POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单 | | POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单,必须携带 `Idempotency-Key` |
列表参数:`keyword,status,customerId,destinationWarehouseId,createdFrom,createdTo,page,size`;`page` 从 1 开始,`size` 为 1 至 200。 列表参数:`keyword,status,customerId,destinationWarehouseId,createdFrom,createdTo,page,size`;`page` 从 1 开始,`size` 为 1 至 200。
@@ -247,6 +265,21 @@ Content-Type: application/json
| GET | `/api/merchant/v1/partners/candidates` | 当前租户启用往来单位候选 | | GET | `/api/merchant/v1/partners/candidates` | 当前租户启用往来单位候选 |
| GET | `/api/merchant/v1/partners/{partnerId}` | 当前租户启用往来单位详情 | | GET | `/api/merchant/v1/partners/{partnerId}` | 当前租户启用往来单位详情 |
| GET | `/api/platform/v1/warehouse-admin?tenantId={tenantId}&page=1&size=20` | 平台仓储列表 | | GET | `/api/platform/v1/warehouse-admin?tenantId={tenantId}&page=1&size=20` | 平台仓储列表 |
| GET | `/api/platform/v1/partners?tenantId={tenantId}&page=1&size=20` | 平台往来单位分页,需要 `platform:counterparty:list` |
| GET | `/api/platform/v1/partners/{id}?tenantId={tenantId}` | 平台往来单位详情,需要 `platform:counterparty:list` |
| POST | `/api/platform/v1/partners?tenantId={tenantId}` | 新增往来单位,需要 `platform:counterparty:create` |
| PUT | `/api/platform/v1/partners/{id}?tenantId={tenantId}` | 编辑往来单位,需要 `platform:counterparty:edit` |
| PATCH | `/api/platform/v1/partners/{id}/status?tenantId={tenantId}` | 启用或停用往来单位,需要 `platform:counterparty:status` |
| GET | `/api/platform/v1/express-companies?tenantId={tenantId}&page=1&size=20` | 平台快递公司分页,需要 `platform:express:list` |
| GET | `/api/platform/v1/express-companies/{id}?tenantId={tenantId}` | 平台快递公司详情,需要 `platform:express:list` |
| POST | `/api/platform/v1/express-companies?tenantId={tenantId}` | 新增快递公司,需要 `platform:express:create` |
| PUT | `/api/platform/v1/express-companies/{id}?tenantId={tenantId}` | 编辑快递公司,需要 `platform:express:edit` |
| PATCH | `/api/platform/v1/express-companies/{id}/status?tenantId={tenantId}` | 启用或停用快递公司,需要 `platform:express:status` |
| GET | `/api/platform/v1/first-leg/tasks?tenantId={tenantId}&page=1&size=20` | 履约任务分页,需要 `platform:firstleg:task:list` |
| GET | `/api/platform/v1/first-leg/tasks/{taskId}?tenantId={tenantId}` | 履约任务详情,需要 `platform:firstleg:task:view` |
| GET | `/api/platform/v1/first-leg/tasks/by-order/{orderId}?tenantId={tenantId}` | 按订单查询履约任务,需要 `platform:firstleg:task:view` |
| GET | `/api/platform/v1/first-leg/tasks/{taskId}/assignment-history?tenantId={tenantId}` | 分配历史,需要 `platform:firstleg:assignment:history` |
| POST | `/api/platform/v1/first-leg/tasks/{taskId}/assignment?tenantId={tenantId}` | 分配或改派,需要 `platform:firstleg:assignment:assign` |
| GET | `/api/platform/v1/iam/status` | IAM 网关路由状态 | | GET | `/api/platform/v1/iam/status` | IAM 网关路由状态 |
| GET | `/api/platform/v1/orders/status` | Order 网关路由状态 | | GET | `/api/platform/v1/orders/status` | Order 网关路由状态 |
| GET | `/api/platform/v1/first-leg/status` | Firstleg 网关路由状态 | | GET | `/api/platform/v1/first-leg/status` | Firstleg 网关路由状态 |
@@ -256,6 +289,8 @@ Content-Type: application/json
往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。 往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。
部署到既有数据库时,除主建表脚本外还要执行 `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`;该脚本可重复执行,用于补齐履约任务租户时间分页索引。
平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。 平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。
## 8. Swagger 分组 ## 8. Swagger 分组
@@ -10,7 +10,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -25,10 +25,12 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态") @Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
@RestController @RestController
@RequestMapping("/internal/event-bridge") @RequestMapping("/internal/event-bridge")
@RequiredArgsConstructor
public class EventBridgeController { public class EventBridgeController {
private final EventInboxService eventInboxService; @Resource
private final InternalServiceTokenGuard internalServiceTokenGuard; private EventInboxService eventInboxService;
@Resource
private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:CN}") @Value("${baiyue.region:CN}")
private String region; private String region;
+2
View File
@@ -2,6 +2,8 @@
## 职责边界 ## 职责边界
- File 服务是企业资质文件的唯一公开上传入口。必须先持久化并验证真实文件,再调用 IAM 内部接口登记元数据;禁止只生成或改写一个不存在文件的 URL。
- 负责企业资质文件的上传、存储、读取和访问鉴权;当前实现只使用本地磁盘,文件内容不进入 Git。 - 负责企业资质文件的上传、存储、读取和访问鉴权;当前实现只使用本地磁盘,文件内容不进入 Git。
- `src/main/resources/db/file.sql` 是文件元数据、下载令牌和下载日志的目标表结构,当前代码尚未接入 Entity、Mapper 或 Repository,不能据此判断元数据持久化已实现。 - `src/main/resources/db/file.sql` 是文件元数据、下载令牌和下载日志的目标表结构,当前代码尚未接入 Entity、Mapper 或 Repository,不能据此判断元数据持久化已实现。
- 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。 - 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。
+7 -3
View File
@@ -10,17 +10,21 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 应用入口 | `src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java` | | 应用入口 | `src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java` |
| 平台履约分配 API | `controller/PlatformFulfillmentAssignmentController.java`(`/api/platform/v1/first-leg/tasks`) | | 平台履约任务查询与分配 API | `controller/PlatformFulfillmentAssignmentController.java`(`/api/platform/v1/first-leg/tasks`) |
| 任务列表、详情、按订单查询、分配历史 | `FulfillmentTaskService.java`、`FulfillmentTaskRepository.java`;只读权限码见控制器 `@PreAuthorize` |
| 服务状态 | `controller/FirstlegHealthController.java` | | 服务状态 | `controller/FirstlegHealthController.java` |
| 分配请求 | `dto/AssignRequest.java` | | 查询与分配请求 | `dto/FulfillmentTaskQuery.java`、`dto/AssignRequest.java` |
| 履约与分配编排 | `service/FulfillmentTaskService.java` | | 履约与分配编排 | `service/FulfillmentTaskService.java` |
| 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` | | 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` |
| 表结构 | `src/main/resources/db/firstleg.sql` | | 表结构与查询索引迁移 | `src/main/resources/db/firstleg.sql`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`src/test/java/com/baiyue/firstleg/` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` |
## 修改与验证 ## 修改与验证
- 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。 - 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。
- 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。 - 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。
- 所有查询必须带目标租户并在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在时统一返回 404。
- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。
- 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。 - 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。
- 从父目录运行 `mvn -pl firstleg -am test`。 - 从父目录运行 `mvn -pl firstleg -am test`。
@@ -2,10 +2,14 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; 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.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.AssignRequest; 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.FulfillmentTaskService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -13,7 +17,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -22,11 +30,11 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* 平台端头程履约任务指派接口,在目标租户内完成任务指派或改派并记录指派历史。 * 平台端头程履约任务接口,在目标租户内提供任务查询、分配和分配历史查询。
*/ */
@Tag( @Tag(
name = "Platform first-leg assignment / 平台头程指派", name = "平台头程履约任务",
description = "Platform APIs for assigning tenant-scoped first-leg fulfillment tasks. / 平台端按租户指派头程履约任务的接口。" description = "平台端按目标租户查询和分配头程履约任务。"
) )
@RestController @RestController
@RequestMapping("/api/platform/v1/first-leg/tasks") @RequestMapping("/api/platform/v1/first-leg/tasks")
@@ -52,6 +60,7 @@ public class PlatformFulfillmentAssignmentController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Task is closed or concurrently updated / 任务已关闭或发生并发更新") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Task is closed or concurrently updated / 任务已关闭或发生并发更新")
}) })
@PostMapping("/{taskId}/assignment") @PostMapping("/{taskId}/assignment")
@PreAuthorize("hasAuthority('platform:firstleg:assignment:assign')")
public ApiResponse<Void> assign( public ApiResponse<Void> assign(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@@ -72,17 +81,84 @@ public class PlatformFulfillmentAssignmentController {
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@Operation(summary = "查询履约任务列表", description = "在指定租户内按条件分页查询头程履约任务。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查询权限")
})
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:task:list')")
public ApiResponse<PageResult<FulfillmentTaskEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid FulfillmentTaskQuery query) {
requirePlatform(user);
requireTenant(tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@Operation(summary = "查询履约任务详情", description = "查询指定租户内的头程履约任务详情。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查看权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在")
})
@GetMapping("/{taskId}")
@PreAuthorize("hasAuthority('platform:firstleg:task:view')")
public ApiResponse<FulfillmentTaskEntity> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long taskId) {
requirePlatform(user);
requireTenant(tenantId);
return ApiResponse.success(service.detail(tenantId, taskId));
}
@Operation(summary = "按订单查询履约任务", description = "使用订单逻辑ID查询指定租户内唯一的履约任务。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查看权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在")
})
@GetMapping("/by-order/{orderId}")
@PreAuthorize("hasAuthority('platform:firstleg:task:view')")
public ApiResponse<FulfillmentTaskEntity> byOrder(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId) {
requirePlatform(user);
requireTenant(tenantId);
return ApiResponse.success(service.byOrder(tenantId, orderId));
}
@Operation(summary = "查询分配历史", description = "分页查询指定履约任务不可变的分配历史。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无历史查看权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在")
})
@GetMapping("/{taskId}/assignment-history")
@PreAuthorize("hasAuthority('platform:firstleg:assignment:history')")
public ApiResponse<PageResult<AssignmentHistoryEntity>> assignmentHistory(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long taskId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
requirePlatform(user);
requireTenant(tenantId);
return ApiResponse.success(service.assignmentHistory(tenantId, taskId, page, size));
}
private static void requirePlatform(AuthenticatedUser user) { private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) { if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
"Platform application required / 仅允许平台端访问");
} }
} }
private static void requireTenant(long tenantId) { private static void requireTenant(long tenantId) {
if (tenantId <= 0) { if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
"Target tenant is required / 必须指定有效目标租户");
} }
} }
} }
@@ -1,11 +1,15 @@
package com.baiyue.firstleg.repository; package com.baiyue.firstleg.repository;
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.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.mapper.AssignmentHistoryMapper; import com.baiyue.firstleg.mapper.AssignmentHistoryMapper;
import com.baiyue.firstleg.mapper.FulfillmentTaskMapper; import com.baiyue.firstleg.mapper.FulfillmentTaskMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
/** /**
* 头程履约任务仓储,按租户查询和更新任务,并持久化每次指派历史。 * 头程履约任务仓储,按租户查询和更新任务,并持久化每次指派历史。
@@ -26,6 +30,31 @@ public class FulfillmentTaskRepository {
.eq(FulfillmentTaskEntity::getId, taskId)); .eq(FulfillmentTaskEntity::getId, taskId));
} }
public FulfillmentTaskEntity findByOrder(long tenantId, long orderId) {
return tasks.selectOne(new LambdaQueryWrapper<FulfillmentTaskEntity>()
.eq(FulfillmentTaskEntity::getTenantId, tenantId)
.eq(FulfillmentTaskEntity::getOrderId, orderId));
}
public PageResult<FulfillmentTaskEntity> page(long tenantId, FulfillmentTaskQuery query) {
LambdaQueryWrapper<FulfillmentTaskEntity> wrapper = new LambdaQueryWrapper<FulfillmentTaskEntity>()
.eq(FulfillmentTaskEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(query.status()), FulfillmentTaskEntity::getStatus, query.status())
.eq(query.assigneeId() != null, FulfillmentTaskEntity::getAssigneeId, query.assigneeId())
.like(StringUtils.hasText(query.keyword()), FulfillmentTaskEntity::getOrderNo, query.keyword())
.orderByDesc(FulfillmentTaskEntity::getCreatedAt);
return PageResult.from(tasks.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<AssignmentHistoryEntity> pageHistory(
long tenantId, long taskId, long page, long size) {
LambdaQueryWrapper<AssignmentHistoryEntity> wrapper = new LambdaQueryWrapper<AssignmentHistoryEntity>()
.eq(AssignmentHistoryEntity::getTenantId, tenantId)
.eq(AssignmentHistoryEntity::getTaskId, taskId)
.orderByDesc(AssignmentHistoryEntity::getCreatedAt);
return PageResult.from(histories.selectPage(new Page<>(page, size), wrapper));
}
public boolean update(FulfillmentTaskEntity task) { public boolean update(FulfillmentTaskEntity task) {
return tasks.updateById(task) == 1; return tasks.updateById(task) == 1;
} }
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
@@ -23,11 +25,7 @@ public class FulfillmentTaskService {
@Transactional @Transactional
public void assign(long tenantId, long operatorId, long taskId, public void assign(long tenantId, long operatorId, long taskId,
long assigneeId, String assigneeName, String reason) { long assigneeId, String assigneeName, String reason) {
FulfillmentTaskEntity task = repository.findOwned(tenantId, taskId); FulfillmentTaskEntity task = requireTask(tenantId, taskId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND,
"Fulfillment task not found / 头程履约任务不存在");
}
if ("COMPLETED".equals(task.getStatus()) || "CANCELLED".equals(task.getStatus())) { if ("COMPLETED".equals(task.getStatus()) || "CANCELLED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, throw new BizException(ResultCode.CONFLICT,
"Closed task cannot be assigned / 已关闭任务不能再次指派"); "Closed task cannot be assigned / 已关闭任务不能再次指派");
@@ -55,4 +53,38 @@ public class FulfillmentTaskService {
history.setCreatedAt(LocalDateTime.now()); history.setCreatedAt(LocalDateTime.now());
repository.insertHistory(history); repository.insertHistory(history);
} }
public PageResult<FulfillmentTaskEntity> page(long tenantId, FulfillmentTaskQuery query) {
return repository.page(tenantId, query);
}
public FulfillmentTaskEntity detail(long tenantId, long taskId) {
return requireTask(tenantId, taskId);
}
public FulfillmentTaskEntity byOrder(long tenantId, long orderId) {
FulfillmentTaskEntity task = repository.findByOrder(tenantId, orderId);
if (task == null) {
throw notFound();
}
return task;
}
public PageResult<AssignmentHistoryEntity> assignmentHistory(
long tenantId, long taskId, long page, long size) {
requireTask(tenantId, taskId);
return repository.pageHistory(tenantId, taskId, page, size);
}
private FulfillmentTaskEntity requireTask(long tenantId, long taskId) {
FulfillmentTaskEntity task = repository.findOwned(tenantId, taskId);
if (task == null) {
throw notFound();
}
return task;
}
private BizException notFound() {
return new BizException(ResultCode.NOT_FOUND, "头程履约任务不存在");
}
} }
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_task_tenant_order (tenant_id, order_id), UNIQUE KEY uk_task_tenant_order (tenant_id, order_id),
KEY idx_task_tenant_created (tenant_id, created_at),
KEY idx_task_assignee_status (tenant_id, assignee_id, status, created_at) KEY idx_task_assignee_status (tenant_id, assignee_id, status, created_at)
) ENGINE=InnoDB COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; ) ENGINE=InnoDB COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态';
+9 -1
View File
@@ -2,6 +2,8 @@
## 职责边界 ## 职责边界
- 商户侧资质公开接口在 IAM 中只提供查询;真实文件上传统一由 File 服务的 multipart 接口完成,IAM 仅接受 File 服务携带内部令牌提交的资质元数据。
- 负责租户、用户、成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照。 - 负责租户、用户、成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照。
- 账号凭据与 Token 属于 `auth/`;资质文件内容属于 `file/`。IAM 只维护业务元数据和跨服务协作。 - 账号凭据与 Token 属于 `auth/`;资质文件内容属于 `file/`。IAM 只维护业务元数据和跨服务协作。
@@ -12,9 +14,12 @@
| 商户个人与企业资料 | `controller/MerchantTenantController.java` | `PersonalProfileService.java`、`TenantService.java` | | 商户个人与企业资料 | `controller/MerchantTenantController.java` | `PersonalProfileService.java`、`TenantService.java` |
| 商户成员增改、启停、重置密码 | `MerchantTenantController.java` | `MemberService.java`,账号协作看 `client/AuthAccountClient.java` | | 商户成员增改、启停、重置密码 | `MerchantTenantController.java` | `MemberService.java`,账号协作看 `client/AuthAccountClient.java` |
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
| 平台内部用户、企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
| 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 |
| 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java` |
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
| Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.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 分类 | | DTO | `src/main/java/com/baiyue/iam/dto/` | 按 Authorization、Member、Onboarding、Profile、Rbac、Tenant 分类 |
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | | 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
@@ -25,6 +30,9 @@
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。 - SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。 - 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
@@ -14,14 +14,22 @@ public interface AuthAccountClient {
@PathVariable Long id); @PathVariable Long id);
@PutMapping("/{id}/status") ApiResponse<Void> status(@RequestHeader("X-Internal-Service-Token") String token, @PutMapping("/{id}/status") ApiResponse<Void> status(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody StatusRequest request); @PathVariable Long id, @RequestBody StatusRequest request);
@PutMapping("/{id}/profile") ApiResponse<Void> profile(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ProfileRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token, @PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request); @PathVariable Long id, @RequestBody PasswordRequest request);
@DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token, @DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id); @PathVariable Long id);
record CreateAccountRequest(Long accountId, String username, String password, String displayName, record CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId) { } Long tenantId, Long membershipId, String appCode) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId) {
this(accountId, username, password, displayName, tenantId, membershipId, "MERCHANT");
}
}
record AccountSummary(Long accountId, String username, String displayName, String status) { } record AccountSummary(Long accountId, String username, String displayName, String status) { }
record StatusRequest(String status) { } record StatusRequest(String status) { }
record ProfileRequest(String displayName) { }
record PasswordRequest(String newPassword) { } record PasswordRequest(String newPassword) { }
} }
@@ -54,6 +54,7 @@ public class MerchantTenantController {
} }
@GetMapping("/tenant/profile") @GetMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。") @Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")}) @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")})
public ApiResponse<TenantResponse> profile(@AuthenticationPrincipal AuthenticatedUser user) { public ApiResponse<TenantResponse> profile(@AuthenticationPrincipal AuthenticatedUser user) {
@@ -1,6 +1,7 @@
package com.baiyue.iam.controller; package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.dto.TenantDtos.ReviewRequest; import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
@@ -13,9 +14,13 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -25,11 +30,33 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("/api/platform/v1/iam") @RequestMapping("/api/platform/v1/iam")
@Validated
@Tag(name = "Platform tenant administration / 平台企业管理", description = "Enterprise registration review and tenant activation for platform operators. / 平台运营人员进行企业注册审核和租户启用。") @Tag(name = "Platform tenant administration / 平台企业管理", description = "Enterprise registration review and tenant activation for platform operators. / 平台运营人员进行企业注册审核和租户启用。")
public class PlatformTenantController { public class PlatformTenantController {
@Resource @Resource
private TenantService service; private TenantService service;
@GetMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效")
})
public ApiResponse<PageResult<TenantResponse>> page(
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
}
@PostMapping("/tenants") @PostMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:create')") @PreAuthorize("hasAuthority('platform:customer:create')")
@Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。")
@@ -3,12 +3,11 @@ package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest; import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest; import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
import com.baiyue.iam.dto.ProfileDtos.QualificationRequest;
import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.PlatformInternalUserService;
import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -29,23 +28,57 @@ import java.util.List;
public class ProfileResourceController { public class ProfileResourceController {
@Resource @Resource
private ProfileResourceService service; private ProfileResourceService service;
@Resource
private PlatformInternalUserService internalUserService;
@GetMapping("/api/platform/v1/iam/internal-users") @GetMapping("/api/platform/v1/iam/internal-users")
@PreAuthorize("hasAuthority('platform:user:list')")
@Operation(summary = "List platform internal users / 查询平台内部用户", description = "Platform scope is required. / 需要平台端权限域。") @Operation(summary = "List platform internal users / 查询平台内部用户", description = "Platform scope is required. / 需要平台端权限域。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")}) @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")})
public ApiResponse<List<IamUser>> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) { public ApiResponse<List<InternalUserResponse>> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.internalUsers()); return ApiResponse.success(internalUserService.list());
} }
@PostMapping("/api/platform/v1/iam/internal-users") @PostMapping("/api/platform/v1/iam/internal-users")
@PreAuthorize("hasAuthority('platform:user:create')") @PreAuthorize("hasAuthority('platform:user:create')")
@Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。") @Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")}) @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")})
public ApiResponse<IamUser> addUser(@Valid @RequestBody CreateUserRequest request, public ApiResponse<InternalUserResponse> addUser(@Valid @RequestBody CreateInternalUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.addUser(request)); return ApiResponse.success(internalUserService.create(request));
}
@PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}")
@PreAuthorize("hasAuthority('platform:user:edit')")
@Operation(summary = "编辑平台内部用户")
public ApiResponse<InternalUserResponse> updateInternalUser(
@PathVariable Long membershipId, @Valid @RequestBody UpdateInternalUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(internalUserService.update(membershipId, request));
}
@PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status")
@PreAuthorize("hasAuthority('platform:user:status')")
@Operation(summary = "启用或停用平台内部用户")
public ApiResponse<InternalUserResponse> changeInternalUserStatus(
@PathVariable Long membershipId, @Valid @RequestBody ChangeInternalUserStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(internalUserService.changeStatus(membershipId, request, user.membershipId()));
}
@PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password")
@PreAuthorize("hasAuthority('platform:user:reset-password')")
@Operation(summary = "重置平台内部用户密码")
public ApiResponse<Void> resetInternalUserPassword(
@PathVariable Long membershipId, @Valid @RequestBody ResetInternalUserPasswordRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
internalUserService.resetPassword(membershipId, request);
return ApiResponse.success(null);
} }
@GetMapping("/api/merchant/v1/iam/tenant/addresses") @GetMapping("/api/merchant/v1/iam/tenant/addresses")
@@ -79,12 +112,4 @@ public class ProfileResourceController {
return ApiResponse.success(service.qualifications(tenantId)); return ApiResponse.success(service.qualifications(tenantId));
} }
@PostMapping("/api/merchant/v1/iam/tenant/qualifications")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "Submit enterprise qualification / 提交企业资质", description = "Create a pending qualification record; document content is stored externally. / 创建待审核资质记录,文件内容由外部对象存储保存。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification submitted / 提交成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid qualification / 资质信息不合法")})
public ApiResponse<IamTenantQualification> addQualification(@Valid @RequestBody QualificationRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.addQualification(TenantGuard.tenant(user, "MERCHANT"), request));
}
} }
@@ -2,18 +2,12 @@ package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.LocalDate;
/** 用户档案、地址和资质的数据契约。 */ /** 用户档案、地址和资质的数据契约。 */
public final class ProfileDtos { public final class ProfileDtos {
private ProfileDtos() { } private ProfileDtos() { }
public record CreateUserRequest(@NotNull Long authUserId, @NotBlank String userType,
@NotBlank String displayName, String phone, String email) { }
public record PersonalProfileResponse(Long userId, Long authUserId, String username, public record PersonalProfileResponse(Long userId, Long authUserId, String username,
String displayName, String phone, String email) { } String displayName, String phone, String email) { }
@@ -25,6 +19,4 @@ public final class ProfileDtos {
String city, String district, @NotBlank String detail, String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { } String postalCode, Boolean isDefault) { }
public record QualificationRequest(@NotBlank String qualificationType, String certificateNo,
@NotBlank String documentUrl, LocalDate validFrom, LocalDate validTo) { }
} }
@@ -55,7 +55,7 @@ public interface AuthorizationQueryMapper {
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId})
AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code ORDER BY p.permission_code
""") """)
List<String> findPermissions(@Param("userId") long userId, List<String> findPermissions(@Param("userId") long userId,
@@ -27,13 +27,11 @@ public class ProfileResourceRepository {
this.qualificationMapper = qualificationMapper; this.qualificationMapper = qualificationMapper;
} }
public void insertUser(IamUser user) { userMapper.insert(user); }
public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper<IamUser>().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); } public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper<IamUser>().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); }
public void saveUser(IamUser user) { userMapper.updateById(user); } public void saveUser(IamUser user) { userMapper.updateById(user); }
public void insertAddress(IamTenantAddress address) { addressMapper.insert(address); } public void insertAddress(IamTenantAddress address) { addressMapper.insert(address); }
public void insertQualification(IamTenantQualification qualification) { qualificationMapper.insert(qualification); } public void insertQualification(IamTenantQualification qualification) { qualificationMapper.insert(qualification); }
public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); }
public List<IamUser> findInternalUsers() { return userMapper.selectList(new LambdaQueryWrapper<IamUser>().eq(IamUser::getUserType, "INTERNAL")); }
public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); } public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); }
public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); } public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); }
public long countApprovedQualifications(long tenantId) { public long countApprovedQualifications(long tenantId) {
@@ -1,11 +1,14 @@
package com.baiyue.iam.repository; package com.baiyue.iam.repository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantMapper;
import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List; import java.util.List;
@@ -45,4 +48,21 @@ public class TenantRepository {
.eq(IamTenantReview::getStatus, "PENDING") .eq(IamTenantReview::getStatus, "PENDING")
.orderByAsc(IamTenantReview::getId)); .orderByAsc(IamTenantReview::getId));
} }
public PageResult<IamTenant> pageMerchants(long page, long size, String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByDesc(IamTenant::getCreatedAt);
if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition
.like(IamTenant::getTenantCode, keyword)
.or().like(IamTenant::getTenantName, keyword)
.or().like(IamTenant::getLegalName, keyword)
.or().like(IamTenant::getUnifiedSocialCreditCode, keyword)
.or().like(IamTenant::getContactName, keyword)
.or().like(IamTenant::getContactPhone, keyword));
}
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
}
} }
@@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse;
import com.baiyue.iam.mapper.AuthorizationQueryMapper; import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
@@ -24,6 +26,7 @@ public class AuthorizationSnapshotService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public SnapshotResponse load(SnapshotRequest request) { public SnapshotResponse load(SnapshotRequest request) {
requireRequest(request);
String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT);
if (!APPS.contains(appCode)) { if (!APPS.contains(appCode)) {
throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域"); throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域");
@@ -38,11 +41,40 @@ public class AuthorizationSnapshotService {
throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效");
} }
Set<String> roles = new LinkedHashSet<>(mapper.findRoles( Set<String> roles = normalizedSet(mapper.findRoles(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
Set<String> permissions = new LinkedHashSet<>(mapper.findPermissions( Set<String> permissions = normalizedSet(mapper.findPermissions(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions)); appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions));
} }
/**
* 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。
*/
@Transactional(readOnly = true)
public Set<String> currentPermissions(AuthenticatedUser user, String appCode) {
if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整");
}
return load(new SnapshotRequest(user.userId(), user.tenantId(), user.membershipId(), appCode))
.getPermissions();
}
private void requireRequest(SnapshotRequest request) {
if (request == null || request.getUserId() == null || request.getTenantId() == null
|| request.getMembershipId() == null || request.getAppCode() == null
|| request.getAppCode().isBlank()) {
throw new BizException(ResultCode.FORBIDDEN, "Authorization identity is incomplete / 授权身份不完整");
}
}
private Set<String> normalizedSet(List<String> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
values.stream().filter(java.util.Objects::nonNull).forEach(normalized::add);
return Set.copyOf(normalized);
}
} }
@@ -6,13 +6,10 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest; import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest;
import com.baiyue.iam.dto.ProfileDtos.QualificationRequest;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.TenantRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -29,16 +26,6 @@ public class ProfileResourceService {
@Resource private ProfileResourceRepository repository; @Resource private ProfileResourceRepository repository;
@Resource private TenantRepository tenantRepository; @Resource private TenantRepository tenantRepository;
@BusinessAudit(event = "INTERNAL_USER_CREATE", resourceType = "IAM_USER",
resourceId = "#q.authUserId", description = "新增平台内部用户")
public IamUser addUser(CreateUserRequest q) {
IamUser u = new IamUser();
u.setId(IdWorker.getId()); u.setAuthUserId(q.authUserId()); u.setUserType(q.userType());
u.setDisplayName(q.displayName()); u.setPhone(q.phone()); u.setEmail(q.email()); u.setStatus("ACTIVE");
repository.insertUser(u); return u;
}
public List<IamUser> internalUsers() { return repository.findInternalUsers(); }
public List<IamTenantAddress> addresses(long tenant) { return repository.findAddresses(tenant); } public List<IamTenantAddress> addresses(long tenant) { return repository.findAddresses(tenant); }
@BusinessAudit(event = "TENANT_ADDRESS_CREATE", resourceType = "TENANT_ADDRESS", @BusinessAudit(event = "TENANT_ADDRESS_CREATE", resourceType = "TENANT_ADDRESS",
@@ -54,11 +41,6 @@ public class ProfileResourceService {
public List<IamTenantQualification> qualifications(long tenant) { return repository.findQualifications(tenant); } public List<IamTenantQualification> qualifications(long tenant) { return repository.findQualifications(tenant); }
public IamTenantQualification addQualification(long tenant, QualificationRequest q) {
throw new BizException(ResultCode.CONFLICT,
"Upload the business license through the qualification file API");
}
@Transactional @Transactional
@BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION", @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false) resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false)
@@ -2,6 +2,7 @@ package com.baiyue.iam.service;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
@@ -72,6 +73,12 @@ public class TenantService {
return repository.findPendingReviews(); return repository.findPendingReviews();
} }
public PageResult<TenantResponse> pageMerchants(long page, long size, String keyword, String status) {
PageResult<IamTenant> source = repository.pageMerchants(page, size, keyword, status);
return new PageResult<>(source.records().stream().map(this::response).toList(),
source.page(), source.size(), source.total(), source.pages());
}
@BusinessAudit( @BusinessAudit(
event = "TENANT_REVIEW", event = "TENANT_REVIEW",
resourceType = "TENANT_REVIEW", resourceType = "TENANT_REVIEW",
+1
View File
@@ -1,5 +1,6 @@
SET NAMES utf8mb4; SET NAMES utf8mb4;
-- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。 -- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。
CREATE DATABASE IF NOT EXISTS baiyue_iam CREATE DATABASE IF NOT EXISTS baiyue_iam
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_iam; USE baiyue_iam;
@@ -84,6 +84,7 @@ VALUES
(5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0), (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0),
(5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0), (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0),
(5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0),
(5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 0),
(5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0),
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0),
(5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0),
@@ -103,9 +104,18 @@ VALUES
(5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0),
(5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0),
(5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0),
(5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 0),
(5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 0),
(5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 0),
(5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0), (5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0),
(5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0), (5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0),
(5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0) (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0),
(5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0),
(5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0),
(5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0),
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -168,7 +178,7 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission SELECT 4000003, id, 0 FROM iam_permission
WHERE permission_code LIKE 'platform:order%' WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
@@ -61,11 +61,5 @@ FROM iam_permission
WHERE app_code='MERCHANT' AND tenant_id=0 WHERE app_code='MERCHANT' AND tenant_id=0
AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0; AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0;
-- Keep the phase-one demo merchant eligible for existing order acceptance tests. -- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。
INSERT INTO iam_tenant_qualification ( -- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。
id, tenant_id, qualification_type, certificate_no, document_url, status, deleted
) VALUES (
7000101, 10001, 'BUSINESS_LICENSE', '91310000PHASE10001',
'/phase1-seed/demo-business-license.jpg', 'APPROVED', 0
)
ON DUPLICATE KEY UPDATE status='APPROVED', deleted=0;
@@ -12,6 +12,9 @@ VALUES
(5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0), (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0),
(5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0), (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0),
(5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, 'ACTIVE', 0), (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, 'ACTIVE', 0),
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0),
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0),
(5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0),
(5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0),
(5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0),
(5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0),
@@ -28,7 +31,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission SELECT 4000001, id, 0 FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete', AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete',
'platform:member:list', 'platform:member:grant-role', 'platform:customer:create') 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create',
'platform:user:edit', 'platform:user:status', 'platform:user:reset-password')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+193 -29
View File
@@ -18,6 +18,25 @@ tags:
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
paths: paths:
/api/platform/v1/iam/tenants: /api/platform/v1/iam/tenants:
get:
tags: [Platform Tenant / 平台租户]
summary: List enterprise tenants / 查询企业客户列表
description: Returns paged merchant tenants. Requires `platform:customer:list`. / 分页查询商户企业客户,需要 `platform:customer:list` 权限。
parameters:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses:
'200':
description: 企业客户分页查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/TenantPageApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'422': {$ref: '#/components/responses/UnprocessableEntity'}
post: post:
tags: [Platform Tenant / 平台租户] tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业租户 summary: Register enterprise tenant / 创建企业租户
@@ -32,6 +51,32 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'} '409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
summary: Get current effective permissions / 查询当前用户有效权限
description: Returns the current active platform membership's permission codes. It only requires a valid PLATFORM JWT and does not require `platform:role:list`. / 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
responses:
'200':
description: 当前平台成员权限查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
summary: Get current effective permissions / 查询当前用户有效权限
description: Returns the current active merchant membership's permission codes. / 返回当前有效商户成员权限编码。
responses:
'200':
description: 当前商户成员权限查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/reviews: /api/platform/v1/iam/reviews:
get: get:
tags: [Platform Tenant / 平台租户] tags: [Platform Tenant / 平台租户]
@@ -77,24 +122,77 @@ paths:
/api/platform/v1/iam/internal-users: /api/platform/v1/iam/internal-users:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: List platform internal users / 查询平台内部用户 summary: 查询平台内部用户
responses: responses:
'200': {$ref: '#/components/responses/Success'} '200':
description: 平台内部用户查询成功,公开主键为 membershipId 并回显 roleIds
content:
application/json:
schema: {$ref: '#/components/schemas/InternalUserListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: Create platform internal user / 新增平台内部用户 summary: 新增平台内部用户
description: Create an IAM profile linked to an existing authentication account. / 创建关联已有认证账号的 IAM 用户资料。 description: 创建完整的 Auth/IAM 平台账号,也兼容通过 authUserId 关联已有账号。
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: {$ref: '#/components/schemas/CreateIamUserRequest'} schema: {$ref: '#/components/schemas/CreateInternalUserRequest'}
responses: responses:
'200': {$ref: '#/components/responses/Success'} '200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'} '409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users/{membershipId}:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 编辑平台内部用户
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdateInternalUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users/{membershipId}/status:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 启用或停用平台内部用户
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/internal-users/{membershipId}/password:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 重置平台内部用户密码
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/merchant/v1/iam/tenant/profile: /api/merchant/v1/iam/tenant/profile:
get: get:
tags: [Merchant Tenant / 商户企业] tags: [Merchant Tenant / 商户企业]
@@ -165,18 +263,6 @@ paths:
responses: responses:
'200': {$ref: '#/components/responses/Success'} '200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
summary: Add tenant qualification / 新增企业资质
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QualificationRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/{app}/v1/iam/roles: /api/{app}/v1/iam/roles:
parameters: parameters:
- {$ref: '#/components/parameters/App'} - {$ref: '#/components/parameters/App'}
@@ -276,6 +362,12 @@ components:
description: Application domain / 应用域 description: Application domain / 应用域
schema: {type: string, enum: [platform, merchant]} schema: {type: string, enum: [platform, merchant]}
example: platform example: platform
MembershipId:
name: membershipId
in: path
required: true
description: Platform membership ID / 平台成员关系 ID
schema: {type: integer, format: int64}
responses: responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
@@ -283,6 +375,7 @@ components:
Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} 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'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas: schemas:
ApiResponse: ApiResponse:
type: object type: object
@@ -294,6 +387,48 @@ components:
requestId: {type: string} requestId: {type: string}
traceId: {type: string} traceId: {type: string}
timestamp: {type: string, format: date-time} timestamp: {type: string, format: date-time}
PermissionCodesApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: array
uniqueItems: true
items: {type: string}
TenantPageApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/TenantResponse'}}
page: {type: integer, format: int64, minimum: 1}
size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
InternalUserListApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}}
TenantResponse:
type: object
required: [id, tenantCode, tenantName, status]
properties:
id: {type: integer, format: int64}
tenantCode: {type: string}
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
legalName: {type: string}
unifiedSocialCreditCode: {type: string}
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
RegisterRequest: RegisterRequest:
type: object type: object
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone]
@@ -317,15 +452,53 @@ components:
type: object type: object
properties: properties:
comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因} comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因}
CreateIamUserRequest: CreateInternalUserRequest:
type: object type: object
required: [authUserId, userType, displayName] required: [displayName]
properties: properties:
authUserId: {type: integer, format: int64, description: 兼容旧接口;填写后关联已有认证账号}
userType: {type: string, enum: [INTERNAL, PLATFORM], description: 兼容旧接口,可省略}
username: {type: string, minLength: 4, maxLength: 64}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
UpdateInternalUserRequest:
type: object
required: [displayName, memberNo, roleIds]
properties:
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
ChangeInternalUserStatusRequest:
type: object
required: [status]
properties:
status: {type: string, enum: [ACTIVE, DISABLED]}
ResetInternalUserPasswordRequest:
type: object
required: [newPassword]
properties:
newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
InternalUserResponse:
type: object
required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds]
properties:
membershipId: {type: integer, format: int64}
userId: {type: integer, format: int64}
authUserId: {type: integer, format: int64} authUserId: {type: integer, format: int64}
userType: {type: string, enum: [PLATFORM, ENTERPRISE]} username: {type: string}
displayName: {type: string} displayName: {type: string}
phone: {type: string} phone: {type: string}
email: {type: string, format: email} email: {type: string, format: email}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string}
status: {type: string, enum: [ACTIVE, DISABLED]}
roleIds: {type: array, items: {type: integer, format: int64}}
CreateMemberRequest: CreateMemberRequest:
type: object type: object
required: [username, password, displayName, memberType] required: [username, password, displayName, memberType]
@@ -350,15 +523,6 @@ components:
detail: {type: string} detail: {type: string}
postalCode: {type: string} postalCode: {type: string}
isDefault: {type: boolean} isDefault: {type: boolean}
QualificationRequest:
type: object
required: [qualificationType, documentUrl]
properties:
qualificationType: {type: string, example: BUSINESS_LICENSE}
certificateNo: {type: string}
documentUrl: {type: string, format: uri}
validFrom: {type: string, format: date}
validTo: {type: string, format: date}
CreateRoleRequest: CreateRoleRequest:
type: object type: object
required: [roleCode, roleName] required: [roleCode, roleName]
@@ -3,6 +3,7 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.mapper.AuthorizationQueryMapper; import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -56,6 +57,43 @@ class AuthorizationSnapshotServiceTest {
verify(mapper).countActiveIdentity(42L, 7L, 9L); verify(mapper).countActiveIdentity(42L, 7L, 9L);
} }
@Test
void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR"));
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM"))
.thenReturn(List.of("platform:order:list"));
var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(result).containsExactly("platform:order:list");
verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM");
}
@Test
void rejectsIncompleteCurrentIdentityAsBusinessError() {
assertThatThrownBy(() -> service(mock(AuthorizationQueryMapper.class))
.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, null,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED));
}
@Test
void 空角色和权限查询结果不应触发五百错误() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(result).isEmpty();
}
private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) { private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) {
AuthorizationSnapshotService service = new AuthorizationSnapshotService(); AuthorizationSnapshotService service = new AuthorizationSnapshotService();
ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "mapper", mapper);
+10 -2
View File
@@ -12,12 +12,17 @@
| 应用与配置扫描 | `src/main/java/com/baiyue/integration/IntegrationServiceApplication.java` | | 应用与配置扫描 | `src/main/java/com/baiyue/integration/IntegrationServiceApplication.java` |
| `baiyue.integration` 配置 | `config/IntegrationProperties.java` | | `baiyue.integration` 配置 | `config/IntegrationProperties.java` |
| 内部供应商状态 API | `controller/IntegrationProviderController.java` | | 内部供应商状态 API | `controller/IntegrationProviderController.java` |
| 重量 OCR 内部 API | `controller/WeightOcrController.java` |
| 能力状态模型与服务 | `dto/ProviderStatus.java`、`service/ProviderStatusService.java` | | 能力状态模型与服务 | `dto/ProviderStatus.java`、`service/ProviderStatusService.java` |
| 适配器抽象 | `provider/IntegrationProviderAdapter.java` | | 适配器抽象 | `provider/IntegrationProviderAdapter.java` |
| 百度 OCR | `provider/BaiduOcrAdapter.java` | | 百度 OCR 状态与客户端 | `provider/BaiduOcrAdapter.java`、`provider/ocr/BaiduMeterClient.java` |
| 重量 OCR 协议模型 | `dto/ocr/` |
| 重量 OCR 批处理 | `service/ocr/WeightOcrBatchService.java` |
| 图片裁剪与重量筛选 | `service/ocr/ImageCropper.java`、`service/ocr/WeightCandidateSelector.java` |
| OCR 文件访问边界 | `service/ocr/WeightOcrStoragePolicy.java` |
| Amazon | `provider/AmazonLogisticsAdapter.java` | | Amazon | `provider/AmazonLogisticsAdapter.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 配置测试 | `src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java` | | 配置与 OCR 测试 | `src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java`、`src/test/java/com/baiyue/integration/service/ocr/` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/integration-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/integration-service.yml` |
## 修改与验证 ## 修改与验证
@@ -25,4 +30,7 @@
- 第三方密钥、Token、请求原文和客户敏感数据不得写入源码或日志;配置应支持按供应商独立关闭。 - 第三方密钥、Token、请求原文和客户敏感数据不得写入源码或日志;配置应支持按供应商独立关闭。
- 适配器负责外部协议转换、超时、错误归一化和安全降级;业务编排留在调用方服务。 - 适配器负责外部协议转换、超时、错误归一化和安全降级;业务编排留在调用方服务。
- 启用真实调用前补齐超时、重试边界、限流、幂等和可观测性,并避免在测试中访问真实供应商。 - 启用真实调用前补齐超时、重试边界、限流、幂等和可观测性,并避免在测试中访问真实供应商。
- OCR 目录批处理只能访问 `WEIGHT_OCR_INPUT_ROOT` 和 `WEIGHT_OCR_OUTPUT_ROOT` 内的路径,不得恢复任意服务器路径读取。
- OCR 接口只允许内部服务令牌调用;图片大小、解码像素数、单批数量和并发批次限制不得绕过。
- OCR 原始响应和第三方密钥不得写入应用日志,真实供应商调用必须保持默认关闭。
- 从父目录运行 `mvn -pl integration-service -am test`。 - 从父目录运行 `mvn -pl integration-service -am test`。
+6 -4
View File
@@ -9,23 +9,25 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java` | | 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java`;列表校验 `merchant:order:list`,详情校验 `merchant:order:view` |
| 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java` | | 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java`;列表和详情校验 `platform:order:list` |
| 内部备注与快递信息 | `PlatformFirstLegOrderController.java` | | 内部备注与快递信息 | `PlatformFirstLegOrderController.java` |
| 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` | | 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` |
| 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java` | | 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java` |
| 租户与访问范围 | `service/OrderScopeGuard.java` | | 租户与访问范围 | `service/OrderScopeGuard.java` |
| 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` | | 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` |
| 主单、货物、参与方、历史、备注、Outbox | `entity/`、`mapper/`、`repository/OrderRepository.java` | | 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` |
| 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` | | 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` |
| 模块说明 | `README-ORDER.md` | | 模块说明 | `README-ORDER.md` |
| 状态与主数据测试 | `src/test/java/com/baiyue/order/service/` | | 状态、主数据与接口权限测试 | `src/test/java/com/baiyue/order/service/`、`src/test/java/com/baiyue/order/controller/` |
## 修改与验证 ## 修改与验证
- 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。 - 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。
- 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。
- 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。 - 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
@@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -45,13 +46,18 @@ public class MerchantFirstLegOrderController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求仍在处理中"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
}) })
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('merchant:order:create')") @PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true)
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.SaveRequest request) { @Valid @RequestBody OrderRequests.SaveRequest request) {
return ApiResponse.success(service.createDraft(OrderScopeGuard.merchant(user), user.userId(), request)); return ApiResponse.success(service.createDraft(
OrderScopeGuard.merchant(user), user.userId(), request, idempotencyKey));
} }
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿") @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
@@ -80,6 +86,7 @@ public class MerchantFirstLegOrderController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
}) })
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('merchant:order:list')")
public ApiResponse<PageResult<OrderEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<OrderEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid OrderRequests.QueryRequest query) { @Valid OrderRequests.QueryRequest query) {
return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query)); return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query));
@@ -92,6 +99,7 @@ public class MerchantFirstLegOrderController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
}) })
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<OrderDetailResponse> detail( public ApiResponse<OrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) { @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
@@ -102,15 +110,20 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。") @Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "缺少或使用了无效幂等键"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转或幂等键冲突"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
}) })
@PostMapping("/{id}/submit") @PostMapping("/{id}/submit")
@PreAuthorize("hasAuthority('merchant:order:submit')") @PreAuthorize("hasAuthority('merchant:order:submit')")
public ApiResponse<Void> submit( public ApiResponse<Void> submit(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) { @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
return transit(user, id, OrderStatus.SUBMITTED, null); @Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true)
@RequestHeader("Idempotency-Key") String idempotencyKey) {
service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, idempotencyKey);
return ApiResponse.success(null);
} }
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单") @BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单")
@@ -47,6 +47,7 @@ public class PlatformFirstLegOrderController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
}) })
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
public ApiResponse<PageResult<OrderEntity>> page( public ApiResponse<PageResult<OrderEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@@ -61,6 +62,7 @@ public class PlatformFirstLegOrderController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found in target tenant / 目标租户下订单不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found in target tenant / 目标租户下订单不存在")
}) })
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:order:list')")
public ApiResponse<OrderDetailResponse> detail( public ApiResponse<OrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@@ -24,10 +24,12 @@ public class OrderRepository {
private final OrderStatusHistoryMapper historyMapper; private final OrderStatusHistoryMapper historyMapper;
private final OutboxEventMapper outboxMapper; private final OutboxEventMapper outboxMapper;
private final OrderChildPurgeMapper childPurgeMapper; private final OrderChildPurgeMapper childPurgeMapper;
private final OrderIdempotencyRecordMapper idempotencyMapper;
public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper, public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper,
OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper, OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper,
OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper) { OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper,
OrderIdempotencyRecordMapper idempotencyMapper) {
this.orderMapper = orderMapper; this.orderMapper = orderMapper;
this.cargoMapper = cargoMapper; this.cargoMapper = cargoMapper;
this.partyMapper = partyMapper; this.partyMapper = partyMapper;
@@ -35,6 +37,7 @@ public class OrderRepository {
this.historyMapper = historyMapper; this.historyMapper = historyMapper;
this.outboxMapper = outboxMapper; this.outboxMapper = outboxMapper;
this.childPurgeMapper = childPurgeMapper; this.childPurgeMapper = childPurgeMapper;
this.idempotencyMapper = idempotencyMapper;
} }
public void insertOrder(OrderEntity order) { orderMapper.insert(order); } public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
@@ -44,6 +47,23 @@ public class OrderRepository {
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); } public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); } public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); }
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); } public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); }
public OrderIdempotencyRecordEntity findIdempotency(
long tenantId, long operatorId, String operation, String idempotencyKey) {
return idempotencyMapper.selectOne(new LambdaQueryWrapper<OrderIdempotencyRecordEntity>()
.eq(OrderIdempotencyRecordEntity::getTenantId, tenantId)
.eq(OrderIdempotencyRecordEntity::getOperatorId, operatorId)
.eq(OrderIdempotencyRecordEntity::getOperation, operation)
.eq(OrderIdempotencyRecordEntity::getIdempotencyKey, idempotencyKey));
}
public int completeIdempotency(OrderIdempotencyRecordEntity record, Long resultOrderId) {
record.setStatus("COMPLETED");
record.setResultOrderId(resultOrderId);
record.setCompletedAt(java.time.LocalDateTime.now());
return idempotencyMapper.updateById(record);
}
public OrderEntity findOwned(long tenantId, long orderId) { public OrderEntity findOwned(long tenantId, long orderId) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>() return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
@@ -6,18 +6,21 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*; import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -27,13 +30,26 @@ public class OrderApplicationService {
@Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderMasterDataGuard masterDataGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional @Transactional
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request) { public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) {
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "CREATE_DRAFT", 0L, idempotencyKey,
requestHash("CREATE_DRAFT", request));
if (idempotency.replayed()) {
return idempotency.record().getResultOrderId();
}
Long orderId = createDraftInternal(tenantId, userId, request);
completeIdempotency(idempotency, orderId);
return orderId;
}
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
eligibilityGuard.requireApproved(tenantId); eligibilityGuard.requireApproved(tenantId);
validatePrototypeFields(request); validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request); var snapshots = masterDataGuard.validate(tenantId, request);
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setTenantId(tenantId); order.setOrderNo(nextNo()); order.setStatus(OrderStatus.DRAFT); long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT);
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId); apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId);
repository.insertOrder(order); repository.insertOrder(order);
saveChildren(tenantId, userId, order.getId(), request); saveChildren(tenantId, userId, order.getId(), request);
@@ -43,10 +59,10 @@ public class OrderApplicationService {
@Transactional @Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
OrderEntity order = require(tenantId, id);
eligibilityGuard.requireApproved(tenantId); eligibilityGuard.requireApproved(tenantId);
validatePrototypeFields(request); validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request); var snapshots = masterDataGuard.validate(tenantId, request);
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) { if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
throw new BizException("Only draft or rejected orders can be edited"); throw new BizException("Only draft or rejected orders can be edited");
} }
@@ -58,8 +74,8 @@ public class OrderApplicationService {
@Transactional @Transactional
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) { public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
OrderEntity order = require(tenantId, id); OrderEntity order = require(tenantId, id);
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
OrderStatus from = order.getStatus(); OrderStatus from = order.getStatus();
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target); from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
@@ -79,6 +95,18 @@ public class OrderApplicationService {
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target); if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
} }
/** 提交订单的幂等入口。 */
@Transactional
public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) {
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey,
requestHash("SUBMIT_ORDER", Map.of("orderId", id)));
if (idempotency.replayed()) {
return;
}
transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null);
completeIdempotency(idempotency, id);
}
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
public OrderDetailResponse detail(long tenantId, long id) { public OrderDetailResponse detail(long tenantId, long id) {
OrderEntity order = require(tenantId, id); OrderEntity order = require(tenantId, id);
@@ -149,9 +177,83 @@ public class OrderApplicationService {
private OrderEntity require(long tenantId, long id) { private OrderEntity require(long tenantId, long id) {
OrderEntity order = repository.findOwned(tenantId, id); OrderEntity order = repository.findOwned(tenantId, id);
if (order == null) throw new BizException("Order not found"); if (order == null) throw new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, "Order not found");
return order; return order;
} }
private IdempotencyContext beginIdempotency(long tenantId, long operatorId, String operation, long resourceId,
String rawKey, String requestHash) {
if (!StringUtils.hasText(rawKey)) {
throw new BizException(com.baiyue.common.core.api.ResultCode.BAD_REQUEST,
"必须提供 Idempotency-Key");
}
String key = rawKey.trim();
if (key.length() > 128) {
throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED,
"Idempotency-Key length must not exceed 128");
}
OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity();
record.setId(IdWorker.getId());
record.setTenantId(tenantId);
record.setOperatorId(operatorId);
record.setOperation(operation);
record.setResourceId(resourceId);
record.setIdempotencyKey(key);
record.setRequestHash(requestHash);
record.setStatus("PROCESSING");
record.setCreatedAt(LocalDateTime.now());
record.setUpdatedAt(LocalDateTime.now());
try {
repository.insertIdempotency(record);
return IdempotencyContext.created(record);
} catch (DuplicateKeyException duplicate) {
OrderIdempotencyRecordEntity existing = repository.findIdempotency(
tenantId, operatorId, operation, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"Idempotency-Key conflicts with a different request");
}
if (!"COMPLETED".equals(existing.getStatus())) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"The idempotent request is still being processed");
}
return IdempotencyContext.replayed(existing);
}
}
private void completeIdempotency(IdempotencyContext context, Long resultOrderId) {
if (context.enabled() && !context.replayed()) {
if (repository.completeIdempotency(context.record(), resultOrderId) != 1) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"幂等记录已被并发修改");
}
}
}
private String requestHash(String operation, Object request) {
try {
byte[] source = (operation + ":" + json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8);
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source);
StringBuilder result = new StringBuilder(64);
for (byte value : digest) {
result.append(String.format("%02x", value));
}
return result.toString();
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new BizException(com.baiyue.common.core.api.ResultCode.INTERNAL_ERROR,
"Cannot create idempotency request fingerprint");
}
}
private record IdempotencyContext(OrderIdempotencyRecordEntity record, boolean enabled, boolean replayed) {
static IdempotencyContext disabled() { return new IdempotencyContext(null, false, false); }
static IdempotencyContext created(OrderIdempotencyRecordEntity record) {
return new IdempotencyContext(record, true, false);
}
static IdempotencyContext replayed(OrderIdempotencyRecordEntity record) {
return new IdempotencyContext(record, true, true);
}
}
private boolean allowed(OrderStatus from, OrderStatus target) { private boolean allowed(OrderStatus from, OrderStatus target) {
return switch (from) { return switch (from) {
case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED; case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED;
@@ -182,8 +284,9 @@ public class OrderApplicationService {
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
} }
private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) { private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.addresses() == null || detail.addresses().isEmpty()) return null; if (detail.addresses() == null) return null;
var a = detail.addresses().get(0); var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null);
if (a == null) return null;
return java.util.stream.Stream.of(a.countryCode(), a.stateProvince(), a.city(), a.district(), return java.util.stream.Stream.of(a.countryCode(), a.stateProvince(), a.city(), a.district(),
a.addressLine1(), a.addressLine2(), a.postalCode()) a.addressLine1(), a.addressLine2(), a.postalCode())
.filter(StringUtils::hasText).collect(Collectors.joining(" ")); .filter(StringUtils::hasText).collect(Collectors.joining(" "));
@@ -191,7 +294,7 @@ public class OrderApplicationService {
private String formatReceivingTime(com.baiyue.order.client.WarehouseClient.Detail detail) { private String formatReceivingTime(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.capabilities() == null) return null; if (detail.capabilities() == null) return null;
return detail.capabilities().stream() return detail.capabilities().stream()
.filter(c -> "RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled())) .filter(c -> c != null && "RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled()))
.map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货") .map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货")
.findFirst().orElse(null); .findFirst().orElse(null);
} }
@@ -241,8 +344,7 @@ public class OrderApplicationService {
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now()); event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event); repository.insertOutbox(event);
} }
private String nextNo() { private String nextNo(long orderId) {
return "FL" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) return "FL" + orderId;
+ ThreadLocalRandom.current().nextInt(100, 1000);
} }
} }
@@ -8,6 +8,9 @@ import com.baiyue.order.dto.OrderRequests;
import feign.FeignException; import feign.FeignException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Objects;
@Service @Service
public class OrderMasterDataGuard { public class OrderMasterDataGuard {
@@ -23,10 +26,10 @@ public class OrderMasterDataGuard {
try { try {
var partner=loadPartner(tenantId,request.customerId()); var partner=loadPartner(tenantId,request.customerId());
var receiving=loadWarehouse(tenantId,request.receivingWarehouseId(),"收货仓库"); var receiving=loadWarehouse(tenantId,request.receivingWarehouseId(),"收货仓库");
if (receiving.addresses()==null || receiving.addresses().isEmpty()) if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull))
throw new BizException(ResultCode.VALIDATION_FAILED,"收货仓库未配置地址"); throw new BizException(ResultCode.VALIDATION_FAILED,"收货仓库未配置地址");
if (receiving.capabilities()==null || receiving.capabilities().stream().noneMatch(c -> if (receiving.capabilities()==null || receiving.capabilities().stream().noneMatch(c ->
"RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled()))) c != null && "RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled())))
throw new BizException(ResultCode.VALIDATION_FAILED,"仓库未启用收货能力"); throw new BizException(ResultCode.VALIDATION_FAILED,"仓库未启用收货能力");
WarehouseClient.Detail destination=null; WarehouseClient.Detail destination=null;
if (request.destinationWarehouseId()!=null) { if (request.destinationWarehouseId()!=null) {
@@ -37,6 +40,10 @@ public class OrderMasterDataGuard {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业"); throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业");
} catch (FeignException exception) { } catch (FeignException exception) {
throw unavailable("主数据"); throw unavailable("主数据");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable("主数据");
} }
} }
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) { public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) {
@@ -48,7 +55,13 @@ public class OrderMasterDataGuard {
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库"); if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
} catch (FeignException.NotFound exception) { } catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在"); throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在");
} catch (FeignException exception) { throw unavailable("主数据"); } } catch (FeignException exception) {
throw unavailable("主数据");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable("主数据");
}
} }
private PartnerClient.Partner loadPartner(long tenantId,long id) { private PartnerClient.Partner loadPartner(long tenantId,long id) {
var response=partners.active(token,tenantId,id); var response=partners.active(token,tenantId,id);
@@ -57,6 +70,7 @@ public class OrderMasterDataGuard {
var value=response.data(); var value=response.data();
if (!Long.valueOf(tenantId).equals(value.tenantId()) || !"ACTIVE".equals(value.status())) if (!Long.valueOf(tenantId).equals(value.tenantId()) || !"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业或已停用"); throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业或已停用");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位");
return value; return value;
} }
private WarehouseClient.Detail loadWarehouse(long tenantId,long id,String name) { private WarehouseClient.Detail loadWarehouse(long tenantId,long id,String name) {
@@ -66,6 +80,9 @@ public class OrderMasterDataGuard {
var value=response.data(); var value=response.data();
if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()) || !"ACTIVE".equals(value.warehouse().status())) if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()) || !"ACTIVE".equals(value.warehouse().status()))
throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业或已停用"); throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业或已停用");
if (!Long.valueOf(id).equals(value.warehouse().id()) || !StringUtils.hasText(value.warehouse().warehouseName())) {
throw unavailable(name);
}
return value; return value;
} }
private BizException unavailable(String name) { private BizException unavailable(String name) {
+19
View File
@@ -1,5 +1,6 @@
SET NAMES utf8mb4; SET NAMES utf8mb4;
-- 订单服务独占数据库。其他服务只能通过 OpenAPI 或领域事件获取订单数据。 -- 订单服务独占数据库。其他服务只能通过 OpenAPI 或领域事件获取订单数据。
CREATE DATABASE IF NOT EXISTS baiyue_order CREATE DATABASE IF NOT EXISTS baiyue_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_order; USE baiyue_order;
@@ -160,3 +161,21 @@ CREATE TABLE IF NOT EXISTS ord_outbox_event (
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_outbox_publish (publish_status, next_retry_at, created_at) KEY idx_outbox_publish (publish_status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'; ) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志';
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID',
operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型',
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID,创建草稿时为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING或COMPLETED',
result_order_id BIGINT UNSIGNED NULL COMMENT '成功结果订单ID',
completed_at DATETIME(3) NULL COMMENT '处理完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
KEY idx_order_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次';
@@ -1,12 +1,14 @@
package com.baiyue.order.service; package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse; 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.core.exception.BizException;
import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderRequests;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Collections;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -32,6 +34,41 @@ class OrderMasterDataGuardTest {
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L))) assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用"); .isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
} }
@Test
void 主数据响应含空元素时返回受控业务错误而不是空指针异常() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","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));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
@Test
void 缺失快照名称的主数据响应按服务不可用处理() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE")));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
verifyNoInteractions(warehouses);
}
@Test
void 下游非Feign运行时异常映射为服务不可用() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败"));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse) { private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
+5 -3
View File
@@ -14,11 +14,13 @@
| 内部仓库有效性校验 | `controller/InternalWarehouseController.java` | | 内部仓库有效性校验 | `controller/InternalWarehouseController.java` |
| 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` | | 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` |
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` | | 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` |
| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` |
| 仓库业务编排 | `service/WarehouseApplicationService.java` | | 仓库业务编排 | `service/WarehouseApplicationService.java` |
| 往来单位查询与停用过滤 | `service/PartnerQueryService.java` | | 往来单位查询与停用过滤 | `service/PartnerQueryService.java` |
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java` | | 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、伙伴持久化 | `entity/`、`mapper/`、`repository/` | | 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` |
| 表结构、伙伴与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-seed.sql` | | 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql` |
| 测试 | `src/test/java/com/baiyue/warehouse/` | | 测试 | `src/test/java/com/baiyue/warehouse/` |
## 修改与验证 ## 修改与验证
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PartnerQueryService; import com.baiyue.warehouse.service.PartnerQueryService;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -13,10 +14,14 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/warehouse-partners") @RequestMapping("/internal/warehouse-partners")
public class InternalPartnerController { public class InternalPartnerController {
private final PartnerQueryService service; private final byte[] expected; @Resource
public InternalPartnerController(PartnerQueryService service, private PartnerQueryService service;
private final byte[] expected;
public InternalPartnerController(
@Value("${baiyue.security.internal-service-token}") String token) { @Value("${baiyue.security.internal-service-token}") String token) {
this.service=service; this.expected=token.getBytes(StandardCharsets.UTF_8); this.expected=token.getBytes(StandardCharsets.UTF_8);
} }
@GetMapping("/{tenantId}/{id}/active") @GetMapping("/{tenantId}/{id}/active")
public ApiResponse<PartnerEntity> active(@RequestHeader(value="X-Internal-Service-Token",required=false) String token, public ApiResponse<PartnerEntity> active(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseDetailResponse;
import com.baiyue.warehouse.service.WarehouseApplicationService; import com.baiyue.warehouse.service.WarehouseApplicationService;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -13,11 +14,14 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/warehouses") @RequestMapping("/internal/warehouses")
public class InternalWarehouseController { public class InternalWarehouseController {
private final WarehouseApplicationService service; @Resource
private WarehouseApplicationService service;
private final byte[] expected; private final byte[] expected;
public InternalWarehouseController(WarehouseApplicationService service,
public InternalWarehouseController(
@Value("${baiyue.security.internal-service-token}") String token) { @Value("${baiyue.security.internal-service-token}") String token) {
this.service = service; this.expected = token.getBytes(StandardCharsets.UTF_8); this.expected = token.getBytes(StandardCharsets.UTF_8);
} }
@GetMapping("/{tenantId}/{id}/active") @GetMapping("/{tenantId}/{id}/active")
public ApiResponse<WarehouseDetailResponse> active( public ApiResponse<WarehouseDetailResponse> active(
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PartnerQueryService; import com.baiyue.warehouse.service.PartnerQueryService;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -11,8 +12,9 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/merchant/v1/partners") @RequestMapping("/api/merchant/v1/partners")
public class MerchantPartnerController { public class MerchantPartnerController {
private final PartnerQueryService service; @Resource
public MerchantPartnerController(PartnerQueryService service) { this.service=service; } private PartnerQueryService service;
@GetMapping("/candidates") @GetMapping("/candidates")
public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String keyword,@RequestParam(required=false) String type) { @RequestParam(required=false) String keyword,@RequestParam(required=false) String type) {
@@ -1,6 +1,9 @@
package com.baiyue.warehouse.repository; package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.mapper.PartnerMapper; import com.baiyue.warehouse.mapper.PartnerMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -24,4 +27,40 @@ public class PartnerRepository {
.eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id) .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id)
.eq(PartnerEntity::getStatus, "ACTIVE")); .eq(PartnerEntity::getStatus, "ACTIVE"));
} }
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery request) {
LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(request.type()), PartnerEntity::getPartnerType, request.type())
.eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status())
.and(StringUtils.hasText(request.keyword()), query -> query
.like(PartnerEntity::getPartnerCode, request.keyword())
.or().like(PartnerEntity::getPartnerName, request.keyword())
.or().like(PartnerEntity::getContactName, request.keyword())
.or().like(PartnerEntity::getContactPhone, request.keyword()))
.orderByAsc(PartnerEntity::getPartnerName)
.orderByAsc(PartnerEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper));
}
public PartnerEntity findOwned(long tenantId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(PartnerEntity::getId, id));
}
public boolean existsCode(long tenantId, String code, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(PartnerEntity::getPartnerCode, code)
.ne(excludedId != null, PartnerEntity::getId, excludedId)) > 0;
}
public void insert(PartnerEntity entity) {
mapper.insert(entity);
}
public void save(PartnerEntity entity) {
mapper.updateById(entity);
}
} }
@@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
import java.util.List; import java.util.List;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -32,4 +34,19 @@ class PartnerRepositoryTest {
verify(mapper).selectOne(c.capture()); c.getValue().getSqlSegment(); verify(mapper).selectOne(c.capture()); c.getValue().getSqlSegment();
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE");
} }
@Test void 平台分页强制租户并支持筛选() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
new PartnerRepository(mapper).page(10001L,
new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE"));
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectPage(any(Page.class), wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values())
.contains(10001L, "CUSTOMER", "ACTIVE", "%示例%");
}
} }