This commit is contained in:
2026-08-04 12:57:58 +08:00
parent 667e0be7bd
commit 434715b938
12 changed files with 311 additions and 12 deletions
+1
View File
@@ -12,6 +12,7 @@
| 功能 | 入口 |
|---|---|
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` |
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
| 服务状态 | `controller/FileHealthController.java` |
+125
View File
@@ -0,0 +1,125 @@
# File Service 使用与运维说明
File Service 同时承载企业资质文件和公共导出下载中心。公共导出采用数据库任务表、事务 Outbox、RocketMQ 和业务 Provider;完整架构与接口契约见 [`../docs/公共导出与下载中心接入说明.md`](../docs/公共导出与下载中心接入说明.md)。
## 公共导出运行条件
创建导出任务只会写入 `file_export_task` 和 `file_export_outbox`。以下三个组件全部启用后,任务才会从 `PENDING` 继续执行:
```dotenv
FILE_EXPORT_RECONCILER_ENABLED=true
FILE_EXPORT_PUBLISHER_ENABLED=true
FILE_EXPORT_CONSUMER_ENABLED=true
FILE_EXPORT_CONSUMER_CONCURRENCY=4
```
三个开关默认均为 `false`,避免 RocketMQ 尚未就绪时 File Service 启动失败。Docker 容器只在创建时读取环境变量;修改 Jenkins 环境文件后必须重新部署 File Service,`docker restart` 不会更新配置。
当前测试环境由 Jenkins 文件凭据 `firstleg-test-env` 注入这些变量;凭据内容变更后重新执行 `file` 服务的部署阶段。
测试环境还必须配置:
```dotenv
INFRA_NETWORK=firstleg-test-infra-network
ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876
ROCKETMQ_BROKER_IP=rocketmq-broker
MQ_TOPIC_PREFIX=test-
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store
```
File 与 RocketMQ 不暴露 MQ 宿主机端口,二者通过 `infra-network` 和 Docker 服务名通信。`ROCKETMQ_BROKER_IP` 不得填写 `localhost`、`127.0.0.1` 或宿主机公网地址。
## 测试环境首次部署 RocketMQ
在服务器的稳定仓库目录执行,不要依赖会被 Jenkins 清理的临时工作区:
```bash
cd /www/deploy/baiyue-firstleg
mkdir -p /data/firstleg/rocketmq/store
chown -R 3000:3000 /data/firstleg/rocketmq/store
DEPLOY_ENV=test \
INFRA_NETWORK=firstleg-test-infra-network \
ROCKETMQ_BROKER_IP=rocketmq-broker \
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store \
MQ_TOPIC_PREFIX=test- \
docker compose -f deploy/docker-compose.rocketmq.yml up -d
```
官方 `apache/rocketmq:5.3.1` 镜像使用 UID/GID `3000` 的 `rocketmq` 用户。宿主机存储目录若属于 `root:root` 且不可写,Broker 会反复重启;不要把 Broker 改成 root 用户,应修正挂载目录所有权。
部署后的正常状态:
```text
rocketmq-namesrv Up
rocketmq-broker Up
rocketmq-topic-init Exited (0)
```
`rocketmq-topic-init` 是一次性初始化容器,`Exited (0)` 表示 Topic、消费组和 DLQ 初始化成功。
验证 Broker 注册结果:
```bash
docker run --rm \
--network firstleg-test-infra-network \
apache/rocketmq:5.3.1 \
sh mqadmin clusterList -n rocketmq-namesrv:9876
```
输出必须包含 `BaiyueCluster`、`baiyue-file-export-broker` 和可访问地址 `rocketmq-broker:10911`。
## 分阶段启用导出
首次上线按以下顺序逐步修改环境文件,每一步都重新部署 File Service 并确认容器 `healthy`、`restartCount=0`:
1. Reconciler:`true / false / false`
2. Reconciler + Publisher:`true / true / false`
3. Reconciler + Publisher + Consumer:`true / true / true`
顺序均为 `RECONCILER / PUBLISHER / CONSUMER`。第二阶段应观察到 Outbox 从 `PENDING` 变为 `SENT`;第三阶段任务应从 `PENDING` 进入 `RUNNING`,最终进入 `SUCCEEDED` 或带明确原因的 `FAILED`。已有的 `PENDING` 任务无需重建或手工改状态。
## 快速排查
任务状态查询:
```sql
SELECT id, task_no, status, dispatch_version, attempt_count,
processed_rows, next_retry_at, lease_owner, lease_until,
requested_at, started_at, completed_at, failure_message
FROM file_export_task
WHERE id = ?;
```
Outbox 查询:
```sql
SELECT id, task_id, dispatch_version, topic, status, attempt_count,
next_retry_at, lease_owner, lease_until, sent_at, last_error
FROM file_export_outbox
WHERE task_id = ?
ORDER BY id;
```
| 现象 | 判断与处理 |
|---|---|
| Task `PENDING`、`attempt_count=0`,Outbox `PENDING`、`attempt_count=0` | Publisher 未启用或未运行 |
| Outbox `PENDING` 且有 `last_error` | Publisher 已尝试,检查 NameServer、Broker、Topic 和网络 |
| Outbox `CLAIMED` 且租约过期 | Publisher 中断,确认 Reconciler 已启用 |
| Outbox `SENT`,Task 仍为 `PENDING` | Consumer 未启用、未连接或存在消费积压 |
| File 启动时报 `connect to null failed` | NameServer/Broker 未运行,或未连接同一 `infra-network` |
| Broker 反复重启,数据目录为 `root:root` | 将挂载目录递归调整为 `3000:3000`,不要使用 root 运行 Broker |
不要手工把 Task 改为 `RUNNING` 或把 Outbox 改为 `SENT`,否则会绕过租约、幂等和消息投递链路。
## 验收
测试环境主链路验收使用:
```powershell
$env:ADMIN_ACCESS_TOKEN = '<测试环境平台用户Token>'
.\scripts\export-center-test-env-acceptance.ps1 `
-GatewayBaseUrl 'http://8.163.30.128:9000'
```
脚本会创建真实 `CUSTOMER_LIST` 任务、轮询终态、下载文件并检查 XLSX ZIP 签名。Token 只通过当前进程环境变量注入,不写入仓库文件。
+3 -3
View File
@@ -9,12 +9,12 @@
## 接口隔离 / API separation
- 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs.
- 平台端 `/api/platform/v1/orders/first-leg`:租户订单查询、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform tenant query, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes.
- 平台端 `/api/platform/v1/orders/first-leg`:跨租户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-tenant list, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes.
- 平台指派 `/api/platform/v1/first-leg/tasks/{taskId}/assignment`。 / Platform fulfillment assignment.
商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台接口要求 `appCode=PLATFORM`,并显式传入目标 `tenantId`;所有服务层 SQL 都带租户条件。
商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台列表接口要求 `appCode=PLATFORM`,省略 `tenantId` 时查询全部客户租户,传入时按单个租户筛选;平台详情和写操作仍必须显式传入目标 `tenantId`。
Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. Platform APIs require `appCode=PLATFORM` and an explicit target `tenantId`; every service query includes the tenant predicate.
Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. The platform list requires `appCode=PLATFORM`: omitting `tenantId` queries all customer tenants, while supplying it filters one tenant. Platform detail and write operations still require an explicit target `tenantId`.
## 状态与审计 / State and audit
@@ -31,11 +31,11 @@ import org.springframework.web.bind.annotation.PutMapping;
import java.util.List;
/**
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。
* 平台端头程订单管理接口,提供跨租户列表查询,并在明确的目标租户范围内执行详情查询和状态流转。
*/
@Tag(
name = "Platform first-leg orders / 平台头程订单",
description = "Platform lifecycle management for orders in an explicit target tenant. / 平台端针对明确目标租户管理头程订单生命周期。"
description = "Platform review queue across tenants; detail and lifecycle actions require an explicit target tenant. / 平台端跨租户审核队列;详情和生命周期操作需要明确目标租户。"
)
@RestController
@RequestMapping("/api/platform/v1/orders/first-leg")
@@ -98,7 +98,7 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(null);
}
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。")
@Operation(summary = "Platform order query / 平台订单查询", description = "Queries all tenant orders when tenantId is omitted, or filters one tenant when supplied. / tenantId 省略时分页查询全部客户租户订单,传入时仅查询指定租户。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
@@ -107,9 +107,14 @@ public class PlatformFirstLegOrderController {
@PreAuthorize("hasAuthority('platform:order:list')")
public ApiResponse<PageResult<OrderEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Optional target tenant filter / 可选的目标租户筛选", example = "10001")
@RequestParam(required = false) Long tenantId,
@Valid OrderRequests.QueryRequest query) {
return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query));
OrderScopeGuard.platform(user);
if (tenantId != null) {
OrderScopeGuard.platform(user, tenantId);
}
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/tenant-counts")
@@ -73,9 +73,9 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId));
}
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) {
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.eq(query.status() != null, OrderEntity::getStatus, query.status())
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId())
@@ -106,7 +106,7 @@ public class OrderApplicationService {
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 List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) {
var counts = repository.countByTenantIds(tenantIds);
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exce
*/
public final class OrderScopeGuard { private OrderScopeGuard(){}
public static long merchant(AuthenticatedUser u){requireApp(u,"MERCHANT");if(u.tenantId()==null||u.tenantId()<=0)throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant is required");return u.tenantId();}
public static void platform(AuthenticatedUser u){requireApp(u,"PLATFORM");}
public static long platform(AuthenticatedUser u,long targetTenantId){requireApp(u,"PLATFORM");if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;}
private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");}
}
@@ -27,6 +27,18 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/platform/v1/orders/first-leg:
get:
summary: 平台分页查询头程订单
description: tenantId 省略时查询全部客户租户订单,传入时仅查询指定租户。
parameters:
- name: tenantId
in: query
required: false
description: 可选的目标租户筛选
schema: {type: integer, format: int64, minimum: 1}
responses:
'200': {description: 查询成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:list 权限和平台身份}
post:
summary: 平台代客创建订单草稿
parameters:
@@ -24,7 +24,7 @@ class OrderControllerPermissionTest {
@Test
void platformOrderListAndDetailUseConfiguredListPermission() throws Exception {
assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class);
AuthenticatedUser.class, Long.class, OrderRequests.QueryRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')",
@@ -0,0 +1,72 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.service.OrderApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class PlatformOrderPageControllerTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
void omittedTenantQueriesAcrossAllTenantsForPlatformUser() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
controller.page(user("PLATFORM"), null, query);
verify(service).page(null, query);
}
@Test
void suppliedTenantFiltersOneTenantForPlatformUser() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
controller.page(user("PLATFORM"), 2084455952401629185L, query);
verify(service).page(2084455952401629185L, query);
}
@Test
void invalidTenantFilterIsRejectedBeforeQuery() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
assertThatThrownBy(() -> controller.page(user("PLATFORM"), 0L, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Target tenant is required");
verifyNoInteractions(service);
}
@Test
void merchantCannotUseCrossTenantPlatformList() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
assertThatThrownBy(() -> controller.page(user("MERCHANT"), null, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Wrong application scope");
verifyNoInteractions(service);
}
private PlatformFirstLegOrderController controller(OrderApplicationService service) {
PlatformFirstLegOrderController controller = new PlatformFirstLegOrderController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
private AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(1L, null, null, null, "operator", appCode, "WEB", Set.of());
}
}
@@ -0,0 +1,56 @@
package com.baiyue.order.repository;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.mapper.OrderCargoMapper;
import com.baiyue.order.mapper.OrderChildPurgeMapper;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
import com.baiyue.order.mapper.OrderInternalNoteMapper;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.mapper.OrderPartyMapper;
import com.baiyue.order.mapper.OrderStatusHistoryMapper;
import com.baiyue.order.mapper.OutboxEventMapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PlatformOrderPageRepositoryTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void tenantPredicateIsAddedOnlyWhenFilterIsSupplied() {
OrderMapper mapper = mock(OrderMapper.class);
when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
OrderRepository repository = repository(mapper);
repository.page(null, query);
repository.page(20002L, query);
ArgumentCaptor<Wrapper<OrderEntity>> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper, org.mockito.Mockito.times(2)).selectPage(any(Page.class), wrapperCaptor.capture());
assertThat(wrapperCaptor.getAllValues().get(0).getExpression().getNormal()).isEmpty();
assertThat(wrapperCaptor.getAllValues().get(1).getExpression().getNormal()).isNotEmpty();
}
private OrderRepository repository(OrderMapper mapper) {
return new OrderRepository(
mapper,
mock(OrderCargoMapper.class),
mock(OrderPartyMapper.class),
mock(OrderInternalNoteMapper.class),
mock(OrderStatusHistoryMapper.class),
mock(OutboxEventMapper.class),
mock(OrderChildPurgeMapper.class),
mock(OrderIdempotencyRecordMapper.class)
);
}
}
@@ -0,0 +1,27 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class PlatformOrderPageServiceTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
void passesOptionalTenantScopeToRepository() {
OrderRepository repository = mock(OrderRepository.class);
OrderApplicationService service = new OrderApplicationService();
ReflectionTestUtils.setField(service, "repository", repository);
service.page(null, query);
service.page(20002L, query);
verify(repository).page(null, query);
verify(repository).page(20002L, query);
}
}