暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
@@ -0,0 +1,18 @@
package com.baiyue.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 订单微服务启动入口,负责装配订单领域组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.order.mapper")
@EnableFeignClients(basePackages = "com.baiyue.order.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueOrderApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueOrderApplication.class, args);
}
}
@@ -0,0 +1,14 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="warehouse-service", contextId="warehousePartnerClient",
url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners")
public interface PartnerClient {
@GetMapping("/{tenantId}/{id}/active")
ApiResponse<Partner> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
record Partner(Long id, Long tenantId, String partnerName, String status) { }
}
@@ -0,0 +1,17 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "iam-service", url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface TenantEligibilityClient {
@GetMapping("/tenants/{tenantId}/order-eligibility")
ApiResponse<EligibilityResponse> check(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("tenantId") long tenantId);
record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
}
@@ -0,0 +1,19 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.time.LocalTime;
import java.util.List;
@FeignClient(name="warehouse-service", url="${baiyue.clients.warehouse.url:}", path="/internal/warehouses")
public interface WarehouseClient {
@GetMapping("/{tenantId}/{id}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
record Detail(Warehouse warehouse, List<Address> addresses, List<Capability> capabilities) { }
record Warehouse(Long id, Long tenantId, String warehouseName, String status) { }
record Address(String countryCode, String stateProvince, String city, String district,
String postalCode, String addressLine1, String addressLine2) { }
record Capability(String capabilityCode, Boolean enabled, LocalTime cutoffTime) { }
}
@@ -0,0 +1,139 @@
package com.baiyue.order.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 商户端头程订单接口,按登录用户所属租户提供订单创建、编辑、查询及状态流转能力。
*/
@Tag(
name = "Merchant first-leg orders / 商户头程订单",
description = "Merchant order APIs restricted to the tenant in the authenticated JWT. / 商户头程订单接口,租户范围仅取自认证 JWT。"
)
@RestController
@RequestMapping("/api/merchant/v1/orders/first-leg")
public class MerchantFirstLegOrderController {
@Resource
private OrderApplicationService service;
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({
@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 = "401", description = "Authentication required / 需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
})
@PostMapping
@PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OrderRequests.SaveRequest request) {
return ApiResponse.success(service.createDraft(OrderScopeGuard.merchant(user), user.userId(), request));
}
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
@Operation(summary = "Update draft / 修改草稿", description = "Updates an editable draft owned by the authenticated merchant tenant. / 修改当前认证商户租户拥有且可编辑的订单草稿。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft updated / 草稿更新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Order is not editable / 订单当前不可编辑")
})
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) {
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request);
return ApiResponse.success(null);
}
@Operation(summary = "My order query / 我的订单查询", description = "Queries orders only in the authenticated merchant tenant with optional filters. / 仅查询当前认证商户租户下的订单,并支持条件筛选。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
})
@GetMapping
public ApiResponse<PageResult<OrderEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid OrderRequests.QueryRequest query) {
return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query));
}
@Operation(summary = "My order detail / 我的订单详情", description = "Returns the order aggregate only when it belongs to the authenticated merchant tenant. / 仅当订单属于当前认证商户租户时返回订单聚合详情。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
})
@GetMapping("/{id}")
public ApiResponse<OrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return ApiResponse.success(service.merchantDetail(OrderScopeGuard.merchant(user), id));
}
@BusinessAudit(event = "ORDER_SUBMIT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Submit order / 提交订单")
@Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"),
@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 / 状态流转无效")
})
@PostMapping("/{id}/submit")
@PreAuthorize("hasAuthority('merchant:order:submit')")
public ApiResponse<Void> submit(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return transit(user, id, OrderStatus.SUBMITTED, null);
}
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单")
@Operation(summary = "Cancel / 取消", description = "Cancels an order when the current lifecycle state allows merchant cancellation. / 在当前生命周期允许商户取消时取消订单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order cancelled / 订单取消成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Cancellation reason is invalid / 取消原因无效"),
@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 / 状态流转无效")
})
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('merchant:order:cancel')")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) {
return transit(user, id, OrderStatus.CANCELLED, request.reason());
}
private ApiResponse<Void> transit(AuthenticatedUser user, long id, OrderStatus target, String reason) {
service.transit(
OrderScopeGuard.merchant(user), user.userId(), user.username(), id, target, reason
);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,20 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 订单服务状态检查接口,供网关、部署平台或运维探针确认服务可用性。
*/
@RestController
@RequestMapping("/api/platform/v1/orders")
public class OrderHealthController {
@GetMapping("/status")
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "order-service", "status", "UP"));
}
}
@@ -0,0 +1,170 @@
package com.baiyue.order.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
/**
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。
*/
@Tag(
name = "Platform first-leg orders / 平台头程订单",
description = "Platform lifecycle management for orders in an explicit target tenant. / 平台端针对明确目标租户管理头程订单生命周期。"
)
@RestController
@RequestMapping("/api/platform/v1/orders/first-leg")
public class PlatformFirstLegOrderController {
@Resource
private OrderApplicationService service;
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid tenant or query / 租户或查询参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
})
@GetMapping
public ApiResponse<PageResult<OrderEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Valid OrderRequests.QueryRequest query) {
return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query));
}
@Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。")
@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 / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found in target tenant / 目标租户下订单不存在")
})
@GetMapping("/{id}")
public ApiResponse<OrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), id));
}
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order and records its status history. / 审核通过已提交订单,并记录状态历史。")
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
}
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
@PostMapping("/{id}/reject")
@PreAuthorize("hasAuthority('platform:order:reject')")
public ApiResponse<Void> reject(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) {
return transit(user, tenantId, id, OrderStatus.REJECTED, request.reason());
}
@BusinessAudit(event = "ORDER_ACCEPT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Accept order / 受理订单")
@Operation(summary = "Accept / 受理订单", description = "Accepts an approved order for fulfillment processing. / 受理已审核通过的订单,进入履约处理。")
@PostMapping("/{id}/accept")
@PreAuthorize("hasAuthority('platform:order:accept')")
public ApiResponse<Void> accept(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return transit(user, tenantId, id, OrderStatus.ACCEPTED, null);
}
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Platform cancel / 平台取消")
@Operation(summary = "Cancel / 取消订单", description = "Cancels an order from the platform with a required reason. / 平台端使用必填原因取消订单。")
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('platform:order:cancel')")
public ApiResponse<Void> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) {
return transit(user, tenantId, id, OrderStatus.CANCELLED, request.reason());
}
@BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理")
@Operation(summary = "Start processing / 开始处理", description = "Moves an accepted order into processing and records status history. / 将已受理订单流转为处理中,并记录状态历史。")
@PostMapping("/{id}/start")
@PreAuthorize("hasAuthority('platform:order:process')")
public ApiResponse<Void> start(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return transit(user, tenantId, id, OrderStatus.PROCESSING, null);
}
@BusinessAudit(event = "ORDER_COMPLETE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Complete order / 完成订单")
@Operation(summary = "Complete / 完成订单", description = "Marks a processing order as completed and publishes the integration event. / 将处理中订单标记为完成,并发布集成事件。")
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('platform:order:complete')")
public ApiResponse<Void> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
return transit(user, tenantId, id, OrderStatus.COMPLETED, null);
}
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Add internal note / 添加内部备注")
@Operation(summary = "Add internal note / 添加内部备注", description = "Adds a platform-only internal note to the target tenant order. / 为目标租户订单添加仅平台可见的内部备注。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Note added / 备注添加成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid note / 备注内容无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
})
@PostMapping("/{id}/internal-notes")
@PreAuthorize("hasAuthority('platform:order:note')")
public ApiResponse<Long> note(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.NoteRequest request) {
return ApiResponse.success(service.addNote(
OrderScopeGuard.platform(user, tenantId), user.userId(), id, request.content()
));
}
/** 仅履约权限可登记快递信息,商户端保持只读。 */
@PutMapping("/{id}/express")
@PreAuthorize("hasAuthority('platform:order:fulfillment')")
public ApiResponse<Void> updateExpress(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id,
@Valid @RequestBody OrderRequests.ExpressRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.updateExpress(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null);
}
private ApiResponse<Void> transit(AuthenticatedUser user, long tenantId, long id,
OrderStatus target, String reason) {
service.transit(
OrderScopeGuard.platform(user, tenantId), user.userId(), user.username(), id, target, reason
);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,14 @@
package com.baiyue.order.dto;
import com.baiyue.order.entity.*;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* 头程订单详情响应,聚合订单主信息、货物明细、收发货主体、内部备注和状态历史。
*/
@Schema(description = "Full order detail / 订单完整详情")
public record OrderDetailResponse(OrderEntity order, List<OrderCargoEntity> cargoes, List<OrderPartyEntity>
parties, List<OrderInternalNoteEntity> internalNotes, List<OrderStatusHistoryEntity> statusHistory) {
}
@@ -0,0 +1,167 @@
package com.baiyue.order.dto;
import com.baiyue.order.entity.OrderStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 头程订单接口请求模型容器,集中定义保存、查询、状态原因和内部备注等请求契约。
*/
public final class OrderRequests {
private OrderRequests() {
}
@Schema(name = "FirstLegOrderSaveRequest", description = "First-leg order create or update request / 头程订单新增或更新请求")
public record SaveRequest(
@Schema(description = "Customer ID / 客户 ID", example = "30001") Long customerId,
@Schema(description = "Customer name / 客户名称", example = "Shenzhen Example Trading Co., Ltd. / 深圳示例贸易有限公司", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String customerName,
@Schema(description = "Transport mode / 运输方式", example = "SEA", allowableValues = {"SEA", "AIR", "RAIL", "TRUCK"}, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Pattern(regexp = "SEA|AIR|RAIL|TRUCK") String transportMode,
@Schema(description = "Incoterm / 贸易术语", example = "FOB", maxLength = 16)
@Size(max = 16) String tradeTerm,
@Schema(description = "Destination ISO country code / 目的国 ISO 两位代码", example = "US", minLength = 2, maxLength = 2, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(min = 2, max = 2) String destinationCountryCode,
@Schema(description = "Destination warehouse ID, logical reference / 目的仓库 ID,逻辑引用", example = "190000000000000101")
Long destinationWarehouseId,
@Schema(description = "Destination warehouse name snapshot / 目的仓库名称快照", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String destinationWarehouseName,
@Schema(description = "Expected shipping date / 预计出运日期", example = "2026-08-15", type = "string", format = "date")
LocalDate expectedShipDate,
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
@Size(max = 1000) String customerRemark,
@Schema(description = "Sender information / 发货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Valid Party sender,
@Schema(description = "Receiver information / 收货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Valid Party receiver,
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@NotNull Long receivingWarehouseId,
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId
) {
}
@Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址")
public record Party(
@Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String companyName,
@Schema(description = "Contact name / 联系人", example = "Alex Chen", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String contactName,
@Schema(description = "Phone number / 联系电话", example = "+1-213-555-0100", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 32) String phone,
@Schema(description = "Email / 邮箱", example = "contact@example.com", maxLength = 128)
@Email @Size(max = 128) String email,
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(min = 2, max = 2) String countryCode,
@Schema(description = "Province or state / 省或州", example = "California", maxLength = 64)
@Size(max = 64) String province,
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String city,
@Schema(description = "District / 区县", example = "Commerce", maxLength = 64)
@Size(max = 64) String district,
@Schema(description = "Detailed address / 详细地址", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 255) String addressLine,
@Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20)
@Size(max = 20) String postalCode
) {
}
@Schema(name = "FirstLegOrderCargo", description = "First-leg order cargo line / 头程订单货物明细")
public record Cargo(
@Schema(description = "Cargo name / 货物名称", example = "Bluetooth speakers / 蓝牙音箱", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 128) String cargoName,
@Schema(description = "HS code / 海关编码", example = "8518210000", maxLength = 20)
@Size(max = 20) String hsCode,
@Schema(description = "Package type / 包装类型", example = "CARTON", maxLength = 32, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 32) String packageType,
@Schema(description = "Package count / 包裹数量", example = "100", minimum = "1", requiredMode = Schema.RequiredMode.REQUIRED)
@Min(1) int packageCount,
@Schema(description = "Gross weight in kilograms / 毛重(千克)", example = "1250.50", minimum = "0.001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @DecimalMin("0.001") BigDecimal grossWeightKg,
@Schema(description = "Volume in cubic meters / 体积(立方米)", example = "8.5000", minimum = "0.0001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @DecimalMin("0.0001") BigDecimal volumeCbm,
@Schema(description = "Package length in centimeters / 包装长度(厘米)", example = "60") BigDecimal lengthCm,
@Schema(description = "Package width in centimeters / 包装宽度(厘米)", example = "40") BigDecimal widthCm,
@Schema(description = "Package height in centimeters / 包装高度(厘米)", example = "35") BigDecimal heightCm,
@Schema(description = "Declared cargo value / 申报货值", example = "25000.00") BigDecimal declaredValue,
@Schema(description = "ISO 4217 currency / ISO 4217 币种", example = "USD", minLength = 3, maxLength = 3)
@Size(min = 3, max = 3) String currency,
@Schema(description = "Whether dangerous goods / 是否危险品", example = "false") boolean dangerousGoods,
@Schema(description = "Cargo remark / 货物备注", example = "Keep dry / 注意防潮", maxLength = 500)
@Size(max = 500) String cargoRemark,
@Size(max = 64) String skuNo,
@Size(max = 64) String upcNo,
@Size(max = 128) String cargoNameEn,
@Size(max = 20) String customsHsCode,
@Min(1) Integer unitsPerPackage,
@Min(1) Integer totalQuantity,
@DecimalMin("0.001") BigDecimal netWeightKg,
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String model,
@Size(max = 64) String brand
) {
}
@Schema(name = "FirstLegOrderQueryRequest", description = "First-leg order page query / 头程订单分页查询条件")
public record QueryRequest(
@Schema(description = "Order number or customer keyword / 订单号或客户关键字", example = "FL20260728") String keyword,
@Schema(description = "Order status / 订单状态", example = "SUBMITTED") OrderStatus status,
@Schema(description = "Customer ID filter / 客户 ID 筛选", example = "30001") Long customerId,
@Schema(description = "Destination warehouse ID filter / 目的仓库 ID 筛选", example = "190000000000000101") Long destinationWarehouseId,
@Schema(description = "Created from, inclusive / 创建时间起点(包含)", example = "2026-07-01T00:00:00") LocalDateTime createdFrom,
@Schema(description = "Created to, inclusive / 创建时间终点(包含)", example = "2026-07-31T23:59:59") LocalDateTime createdTo,
@Schema(description = "Page number / 页码", example = "1", minimum = "1", requiredMode = Schema.RequiredMode.REQUIRED)
@Min(1) long page,
@Schema(description = "Page size / 每页条数", example = "20", minimum = "1", maximum = "200", requiredMode = Schema.RequiredMode.REQUIRED)
@Min(1) @Max(200) long size
) {
}
@Schema(name = "OrderReasonRequest", description = "Order lifecycle reason / 订单生命周期操作原因")
public record ReasonRequest(
@Schema(description = "Reason / 原因", example = "Commercial terms need revision / 商务条款需要调整", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 500) String reason
) {
}
@Schema(name = "OrderInternalNoteRequest", description = "Platform internal order note / 平台内部订单备注")
public record NoteRequest(
@Schema(description = "Internal note content / 内部备注内容", example = "Customer confirmed the shipping schedule / 客户已确认船期", maxLength = 2000, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 2000) String content
) {
}
public record ExpressRequest(
@NotBlank @Size(max = 64)
@Pattern(regexp = "^[\\p{L}\\p{N} .()()\\-]{1,64}$", message = "快递公司格式不正确")
String expressCompany,
@NotBlank @Size(max = 128)
@Pattern(regexp = "^[A-Za-z0-9-]{4,128}$", message = "快递单号格式不正确")
String expressTrackingNo) { }
}
@@ -0,0 +1,18 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.*; import java.math.BigDecimal;
@Getter
@Setter
/**
* 订单货物明细持久化实体。
*
* <p>对应表 {@code ord_order_cargo},按租户和订单保存品名、包装、重量、体积、
* 申报价值及危险品标识等货物信息。</p>
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand;
}
@@ -0,0 +1,54 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.*;
import java.time.*;
/**
* 头程订单主表持久化实体。
*
* <p>对应表 {@code ord_order},保存租户内订单编号、客户、运输方案、目的地及
* 生命周期状态等订单核心信息。</p>
*/
@Getter
@Setter
@TableName("ord_order")
public class OrderEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String orderNo;
private Long customerId;
private String customerName;
private OrderStatus status;
private String transportMode;
private String tradeTerm;
private String destinationCountryCode;
private Long destinationWarehouseId;
private String destinationWarehouseName;
private LocalDate expectedShipDate;
private String customerRemark;
private String pickupMethod;
private Long receivingWarehouseId;
private String receivingWarehouseName;
private String receivingWarehouseAddress;
private String receivingTime;
private String channelCode;
private String channelName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private String amazonWarehouseCode;
private String shipmentId;
private String referenceId;
private String expressCompany;
private String expressTrackingNo;
private LocalDateTime submittedAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.*;
/**
* 订单内部备注持久化实体。
*
* <p>对应表 {@code ord_order_internal_note},保存平台内部针对租户订单记录的
* 非客户可见备注。</p>
*/
@Getter
@Setter
@TableName("ord_order_internal_note")
public class OrderInternalNoteEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long orderId;
private String content;
}
@@ -0,0 +1,18 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.*;
@Getter
@Setter
/**
* 订单参与方持久化实体。
*
* <p>对应表 {@code ord_order_party},按租户和订单保存发货方、收货方等主体的
* 联系人及地址信息。</p>
*/
@TableName("ord_order_party") public class OrderPartyEntity extends BaseEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private String partyType; private String companyName; private String contactName; private String phone; private String email; private String countryCode; private String province; private String city; private String district; private String addressLine; private String postalCode;
}
@@ -0,0 +1,5 @@
package com.baiyue.order.entity;
/**
* 头程订单生命周期状态,约束草稿、提交、审核、受理、处理、完成及取消等状态流转。
*/
public enum OrderStatus { DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED }
@@ -0,0 +1,32 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.*;
import java.time.LocalDateTime;
/**
* 订单状态流转历史持久化实体。
*
* <p>对应表 {@code ord_order_status_history},记录每次状态变更的前后状态、动作、
* 原因、操作人和发生时间,用于追溯订单生命周期。</p>
*/
@Getter
@Setter
@TableName("ord_order_status_history")
public class OrderStatusHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long orderId;
private OrderStatus fromStatus;
private OrderStatus toStatus;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.order.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.*;
import java.time.LocalDateTime;
/**
* 订单领域事件事务 Outbox 持久化实体。
*
* <p>对应表 {@code ord_outbox_event}。该表与订单业务变更在同一本地事务中写入,
* 由后台发布器异步投递至 RocketMQ,并通过失败重试实现数据库状态与领域事件的
* 最终一致性;它不是普通操作日志。</p>
*/
@Getter
@Setter
@TableName("ord_outbox_event")
public class OutboxEventEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String aggregateType;
private Long aggregateId;
private String eventType;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderCargoEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单货物明细 Mapper,提供 {@code ord_order_cargo} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderCargoMapper extends BaseMapper<OrderCargoEntity>{}
@@ -0,0 +1,9 @@
package com.baiyue.order.mapper;
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Param;
/**
* 订单子表清理 Mapper,在租户边界内删除订单货物和参与方数据,供草稿整体更新时重建明细。
*/
public interface OrderChildPurgeMapper {
@Delete("DELETE FROM ord_order_cargo WHERE tenant_id=#{tenantId} AND order_id=#{orderId}") int purgeCargo(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
@Delete("DELETE FROM ord_order_party WHERE tenant_id=#{tenantId} AND order_id=#{orderId}") int purgeParties(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单内部备注 Mapper,提供 {@code ord_order_internal_note} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderInternalNoteMapper extends BaseMapper<OrderInternalNoteEntity>{}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单主表 Mapper,提供 {@code ord_order} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderMapper extends BaseMapper<OrderEntity>{}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderPartyEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单参与方 Mapper,提供 {@code ord_order_party} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderPartyMapper extends BaseMapper<OrderPartyEntity>{}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OrderStatusHistoryEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单状态历史 Mapper,提供 {@code ord_order_status_history} 的 MyBatis-Plus 基础数据访问能力。
*/
public interface OrderStatusHistoryMapper extends BaseMapper<OrderStatusHistoryEntity>{}
@@ -0,0 +1,4 @@
package com.baiyue.order.mapper; import com.baiyue.order.entity.OutboxEventEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
* 订单事务 Outbox Mapper,提供 {@code ord_outbox_event} 待发布领域事件的持久化访问能力。
*/
public interface OutboxEventMapper extends BaseMapper<OutboxEventEntity>{}
@@ -0,0 +1,96 @@
package com.baiyue.order.repository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 订单聚合仓储,组合订单主表、货物、参与方、备注、状态历史与 Outbox 的持久化操作,
* 并统一落实租户查询边界。
*/
@Repository
public class OrderRepository {
private final OrderMapper orderMapper;
private final OrderCargoMapper cargoMapper;
private final OrderPartyMapper partyMapper;
private final OrderInternalNoteMapper noteMapper;
private final OrderStatusHistoryMapper historyMapper;
private final OutboxEventMapper outboxMapper;
private final OrderChildPurgeMapper childPurgeMapper;
public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper,
OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper,
OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper) {
this.orderMapper = orderMapper;
this.cargoMapper = cargoMapper;
this.partyMapper = partyMapper;
this.noteMapper = noteMapper;
this.historyMapper = historyMapper;
this.outboxMapper = outboxMapper;
this.childPurgeMapper = childPurgeMapper;
}
public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
public int saveOrder(OrderEntity order) { return orderMapper.updateById(order); }
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); }
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
public OrderEntity findOwned(long tenantId, long orderId) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getId, orderId));
}
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(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())
.ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo())
.and(StringUtils.hasText(query.keyword()), nested -> nested
.like(OrderEntity::getOrderNo, query.keyword())
.or().like(OrderEntity::getCustomerName, query.keyword()))
.orderByDesc(OrderEntity::getCreatedAt);
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public List<OrderCargoEntity> findCargo(long tenantId, long orderId) {
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId)
.orderByAsc(OrderCargoEntity::getItemNo));
}
public List<OrderPartyEntity> findParties(long tenantId, long orderId) {
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
.eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId));
}
public List<OrderInternalNoteEntity> findNotes(long tenantId, long orderId) {
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
.eq(OrderInternalNoteEntity::getTenantId, tenantId).eq(OrderInternalNoteEntity::getOrderId, orderId)
.orderByDesc(OrderInternalNoteEntity::getCreatedAt));
}
public List<OrderStatusHistoryEntity> findHistory(long tenantId, long orderId) {
return historyMapper.selectList(new LambdaQueryWrapper<OrderStatusHistoryEntity>()
.eq(OrderStatusHistoryEntity::getTenantId, tenantId).eq(OrderStatusHistoryEntity::getOrderId, orderId)
.orderByAsc(OrderStatusHistoryEntity::getOccurredAt));
}
public void replaceDraftChildren(long tenantId, long orderId) {
childPurgeMapper.purgeCargo(tenantId, orderId);
childPurgeMapper.purgeParties(tenantId, orderId);
}
}
@@ -0,0 +1,248 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
@Service
public class OrderApplicationService {
@Resource private OrderRepository repository;
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Transactional
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request) {
eligibilityGuard.requireApproved(tenantId);
validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request);
OrderEntity order = new OrderEntity();
order.setTenantId(tenantId); order.setOrderNo(nextNo()); order.setStatus(OrderStatus.DRAFT);
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId);
repository.insertOrder(order);
saveChildren(tenantId, userId, order.getId(), request);
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null);
return order.getId();
}
@Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
eligibilityGuard.requireApproved(tenantId);
validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request);
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
throw new BizException("Only draft or rejected orders can be edited");
}
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request);
}
@Transactional
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);
OrderStatus from = order.getStatus();
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
if (target == OrderStatus.SUBMITTED) {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required");
}
order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now());
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username);
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
}
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
public OrderDetailResponse detail(long tenantId, long id) {
OrderEntity order = require(tenantId, id);
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
repository.findNotes(tenantId, id), repository.findHistory(tenantId, id));
}
public OrderDetailResponse merchantDetail(long tenantId, long id) {
OrderEntity order = require(tenantId, id);
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
List.of(), repository.findHistory(tenantId, id));
}
public Long addNote(long tenantId, long userId, long id, String content) {
require(tenantId, id);
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
note.setTenantId(tenantId); note.setOrderId(id); note.setContent(content);
note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); return note.getId();
}
@Transactional
public void updateExpress(long tenantId, long userId, long id, OrderRequests.ExpressRequest request) {
OrderEntity order = require(tenantId, id);
if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED
|| order.getStatus() == OrderStatus.CANCELLED) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "当前订单状态不能登记快递信息");
}
order.setExpressCompany(request.expressCompany().trim());
order.setExpressTrackingNo(request.expressTrackingNo().trim());
order.setUpdatedBy(userId);
if (repository.saveOrder(order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改");
}
private void validatePrototypeFields(OrderRequests.SaveRequest request) {
if ("FBA".equals(request.destinationType())) {
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|| !StringUtils.hasText(request.referenceId())) {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
}
}
}
}
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
|| !StringUtils.hasText(order.getTaxMode()) || order.getCustomsInspection() == null
|| !StringUtils.hasText(order.getDestinationType())) {
throw new BizException("Order phase-one required fields are incomplete");
}
var cargoes = repository.findCargo(tenantId, order.getId());
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
if ("FBA".equals(order.getDestinationType())) {
if (!StringUtils.hasText(order.getAmazonWarehouseCode()) || !StringUtils.hasText(order.getShipmentId())
|| !StringUtils.hasText(order.getReferenceId())) {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : cargoes) {
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
}
}
}
}
private OrderEntity require(long tenantId, long id) {
OrderEntity order = repository.findOwned(tenantId, id);
if (order == null) throw new BizException("Order not found");
return order;
}
private boolean allowed(OrderStatus from, OrderStatus target) {
return switch (from) {
case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED;
case SUBMITTED -> target == OrderStatus.APPROVED || target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED;
case APPROVED -> target == OrderStatus.ACCEPTED || target == OrderStatus.CANCELLED;
case ACCEPTED -> target == OrderStatus.PROCESSING || target == OrderStatus.CANCELLED;
case PROCESSING -> target == OrderStatus.COMPLETED;
case REJECTED -> target == OrderStatus.DRAFT || target == OrderStatus.CANCELLED;
default -> false;
};
}
private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(snapshots.partner().id()); order.setCustomerName(snapshots.partner().partnerName());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(request.destinationCountryCode());
order.setDestinationWarehouseId(request.destinationWarehouseId());
order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod()); order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
order.setChannelName(request.channelName()); order.setTaxMode(request.taxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType()); order.setAmazonWarehouseCode(request.amazonWarehouseCode());
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
}
private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.addresses() == null || detail.addresses().isEmpty()) return null;
var a = detail.addresses().get(0);
return java.util.stream.Stream.of(a.countryCode(), a.stateProvince(), a.city(), a.district(),
a.addressLine1(), a.addressLine2(), a.postalCode())
.filter(StringUtils::hasText).collect(Collectors.joining(" "));
}
private String formatReceivingTime(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.capabilities() == null) return null;
return detail.capabilities().stream()
.filter(c -> "RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled()))
.map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货")
.findFirst().orElse(null);
}
private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request) {
int itemNo = 1;
for (var cargo : request.cargoes()) {
OrderCargoEntity item = new OrderCargoEntity();
item.setTenantId(tenantId); item.setOrderId(orderId); item.setItemNo(itemNo++);
item.setCargoName(cargo.cargoName()); item.setHsCode(cargo.hsCode()); item.setPackageType(cargo.packageType());
item.setPackageCount(cargo.packageCount()); item.setGrossWeightKg(cargo.grossWeightKg());
item.setVolumeCbm(cargo.volumeCbm()); item.setLengthCm(cargo.lengthCm()); item.setWidthCm(cargo.widthCm());
item.setHeightCm(cargo.heightCm()); item.setDeclaredValue(cargo.declaredValue()); item.setCurrency(cargo.currency());
item.setDangerousGoods(cargo.dangerousGoods()); item.setCargoRemark(cargo.cargoRemark());
item.setSkuNo(cargo.skuNo()); item.setUpcNo(cargo.upcNo()); item.setCargoNameEn(cargo.cargoNameEn());
item.setCustomsHsCode(cargo.customsHsCode()); item.setUnitsPerPackage(cargo.unitsPerPackage());
item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg());
item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material());
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
saveParty(tenantId, userId, orderId, "SENDER", request.sender());
saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
}
private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) {
OrderPartyEntity item = new OrderPartyEntity();
item.setTenantId(tenantId); item.setOrderId(orderId); item.setPartyType(type);
item.setCompanyName(party.companyName()); item.setContactName(party.contactName()); item.setPhone(party.phone());
item.setEmail(party.email()); item.setCountryCode(party.countryCode()); item.setProvince(party.province());
item.setCity(party.city()); item.setDistrict(party.district()); item.setAddressLine(party.addressLine());
item.setPostalCode(party.postalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
}
private void record(long tenantId, long orderId, OrderStatus from, OrderStatus to, String action,
String reason, long userId, String username) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
history.setTenantId(tenantId); history.setOrderId(orderId); history.setFromStatus(from); history.setToStatus(to);
history.setAction(action); history.setReason(reason); history.setOperatorId(userId);
history.setOperatorName(username); history.setOccurredAt(LocalDateTime.now()); repository.insertHistory(history);
}
private void writeOutbox(OrderEntity order, OrderStatus target) {
OutboxEventEntity event = new OutboxEventEntity();
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED");
try {
event.setPayloadJson(json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(),
"orderNo", order.getOrderNo(), "status", target.name())));
} catch (JsonProcessingException exception) { throw new BizException("Cannot serialize order event"); }
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event);
}
private String nextNo() {
return "FL" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ ThreadLocalRandom.current().nextInt(100, 1000);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.TenantEligibilityClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class OrderEligibilityGuard {
@Resource private TenantEligibilityClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireApproved(long tenantId) {
try {
var response = client.check(internalToken, tenantId);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify enterprise review status");
}
if (!response.data().eligible()) {
throw new BizException(ResultCode.FORBIDDEN,
response.data().reason() == null ? "Enterprise review approval is required" : response.data().reason());
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
}
}
}
@@ -0,0 +1,76 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class OrderMasterDataGuard {
private final WarehouseClient warehouses;
private final PartnerClient partners;
private final String token;
public OrderMasterDataGuard(WarehouseClient warehouses, PartnerClient partners,
@Value("${baiyue.security.internal-service-token}") String token) {
this.warehouses=warehouses; this.partners=partners; this.token=token;
}
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
if (request.customerId() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "必须选择往来单位");
try {
var partner=loadPartner(tenantId,request.customerId());
var receiving=loadWarehouse(tenantId,request.receivingWarehouseId(),"收货仓库");
if (receiving.addresses()==null || receiving.addresses().isEmpty())
throw new BizException(ResultCode.VALIDATION_FAILED,"收货仓库未配置地址");
if (receiving.capabilities()==null || receiving.capabilities().stream().noneMatch(c ->
"RECEIVING".equals(c.capabilityCode()) && Boolean.TRUE.equals(c.enabled())))
throw new BizException(ResultCode.VALIDATION_FAILED,"仓库未启用收货能力");
WarehouseClient.Detail destination=null;
if (request.destinationWarehouseId()!=null) {
destination=loadWarehouse(tenantId,request.destinationWarehouseId(),"目的仓库");
}
return new Snapshots(partner,receiving,destination);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业");
} catch (FeignException exception) {
throw unavailable("主数据");
}
}
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) {
try {
if (partnerId==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位已停用或不可用");
loadPartner(tenantId,partnerId);
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
loadWarehouse(tenantId,receivingWarehouseId,"收货仓库");
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在");
} catch (FeignException exception) { throw unavailable("主数据"); }
}
private PartnerClient.Partner loadPartner(long tenantId,long id) {
var response=partners.active(token,tenantId,id);
if (response==null || !response.successful() || response.data()==null)
throw unavailable("往来单位");
var value=response.data();
if (!Long.valueOf(tenantId).equals(value.tenantId()) || !"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业或已停用");
return value;
}
private WarehouseClient.Detail loadWarehouse(long tenantId,long id,String name) {
var response=warehouses.active(token,tenantId,id);
if (response==null || !response.successful() || response.data()==null || response.data().warehouse()==null)
throw unavailable(name);
var value=response.data();
if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()) || !"ACTIVE".equals(value.warehouse().status()))
throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业或已停用");
return value;
}
private BizException unavailable(String name) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE,name+"服务暂时不可用");
}
public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
WarehouseClient.Detail destination) { }
}
@@ -0,0 +1,10 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser;
/**
* 订单访问范围校验器,区分商户端与平台端身份并解析或校验目标租户,防止跨租户访问。
*/
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 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");}
}
+36
View File
@@ -0,0 +1,36 @@
server:
port: ${ORDER_PORT:9204}
spring:
application:
name: order-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config.namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${ORDER_DB_USERNAME:root}
password: ${ORDER_DB_PASSWORD:123456}
management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
clients:
iam:
url: ${IAM_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
+162
View File
@@ -0,0 +1,162 @@
SET NAMES utf8mb4;
-- 订单服务独占数据库。其他服务只能通过 OpenAPI 或领域事件获取订单数据。
CREATE DATABASE IF NOT EXISTS baiyue_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_order;
CREATE TABLE IF NOT EXISTS ord_order (
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态',
transport_mode VARCHAR(16) NOT NULL COMMENT '运输方式',
trade_term VARCHAR(16) NULL COMMENT '贸易条款',
destination_country_code CHAR(2) NOT NULL COMMENT '目的国家二字码',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照',
expected_ship_date DATE NULL COMMENT '预计发运日期',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID',
submitted_at DATETIME(3) NULL COMMENT '提交时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
KEY idx_order_tenant_customer (tenant_id, customer_id),
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
CREATE TABLE IF NOT EXISTS ord_order_party (
id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
party_type VARCHAR(16) NOT NULL COMMENT '参与方类型:SHIPPER或CONSIGNEE',
company_name VARCHAR(128) NOT NULL COMMENT '公司名称快照',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人快照',
phone VARCHAR(32) NOT NULL COMMENT '联系电话快照',
email VARCHAR(128) NULL COMMENT '联系邮箱快照',
country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省/州',
city VARCHAR(64) NOT NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(255) NOT NULL COMMENT '详细地址',
postal_code VARCHAR(20) NULL COMMENT '邮政编码',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_party_order_type (tenant_id, order_id, party_type),
CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单';
CREATE TABLE IF NOT EXISTS ord_order_cargo (
id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
item_no INT NOT NULL COMMENT '订单内明细序号',
cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
hs_code VARCHAR(20) NULL COMMENT '海关商品编码',
package_type VARCHAR(32) NOT NULL COMMENT '包装类型',
package_count INT UNSIGNED NOT NULL COMMENT '包装件数',
gross_weight_kg DECIMAL(18,3) NOT NULL COMMENT '毛重(千克)',
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '体积(立方米)',
length_cm DECIMAL(12,2) NULL COMMENT '单件长度(厘米)',
width_cm DECIMAL(12,2) NULL COMMENT '单件宽度(厘米)',
height_cm DECIMAL(12,2) NULL COMMENT '单件高度(厘米)',
declared_value DECIMAL(18,2) NULL COMMENT '申报价值',
currency CHAR(3) NULL COMMENT '币种三字码',
dangerous_goods TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否危险品',
cargo_remark VARCHAR(500) NULL COMMENT '货物备注',
sku_no VARCHAR(64) NULL COMMENT 'SKU',
upc_no VARCHAR(64) NULL COMMENT 'UPC',
cargo_name_en VARCHAR(128) NULL COMMENT '英文品名',
customs_hs_code VARCHAR(20) NULL COMMENT '目的国清关HS CODE',
units_per_package INT UNSIGNED NULL COMMENT '每箱件数',
total_quantity INT UNSIGNED NULL COMMENT '总件数',
net_weight_kg DECIMAL(18,3) NULL COMMENT '净重kg',
unit_declared_value DECIMAL(18,2) NULL COMMENT '单件申报价值',
material VARCHAR(128) NULL COMMENT '材质',
product_usage VARCHAR(128) NULL COMMENT '用途',
model VARCHAR(64) NULL COMMENT '型号',
brand VARCHAR(64) NULL COMMENT '品牌',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_item (tenant_id, order_id, item_no),
KEY idx_cargo_order (tenant_id, order_id),
CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性';
CREATE TABLE IF NOT EXISTS ord_order_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部人员可见的备注内容',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
KEY idx_note_order_time (tenant_id, order_id, created_at),
CONSTRAINT fk_note_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示';
CREATE TABLE IF NOT EXISTS ord_order_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态,首次创建可为空',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
action VARCHAR(24) NOT NULL COMMENT '触发状态变化的业务动作',
reason VARCHAR(500) NULL COMMENT '变更原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态变化时间',
PRIMARY KEY (id),
KEY idx_history_order_time (tenant_id, order_id, occurred_at),
CONSTRAINT fk_history_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单状态不可变历史表:按时间记录每次状态迁移、动作、原因和操作人';
CREATE TABLE IF NOT EXISTS ord_outbox_event (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID,同时作为消费幂等键',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型,例如 ORDER',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '发生变更的聚合根ID,例如订单ID',
event_type VARCHAR(64) NOT NULL COMMENT '领域事件类型,例如 ORDER_SUBMITTED',
payload_json JSON NOT NULL COMMENT '供下游消费的事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态:PENDING、PUBLISHED或FAILED',
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递失败后的累计重试次数',
next_retry_at DATETIME(3) NULL COMMENT '允许再次投递的最早时间',
published_at DATETIME(3) NULL COMMENT '成功发布到消息中间件的时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件在本地事务中的创建时间',
PRIMARY KEY (id),
KEY idx_outbox_publish (publish_status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志';
@@ -0,0 +1,16 @@
SET NAMES utf8mb4;
USE baiyue_order;
-- MySQL 8 可重复执行的订单快照及履约字段迁移。
SET @schema_name = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_warehouse_address')=0,
'ALTER TABLE ord_order ADD COLUMN receiving_warehouse_address VARCHAR(600) NULL COMMENT ''收货仓地址历史快照'' AFTER receiving_warehouse_name','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_time')=0,
'ALTER TABLE ord_order ADD COLUMN receiving_time VARCHAR(100) NULL COMMENT ''收货时间历史快照'' AFTER receiving_warehouse_address','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_company')=0,
'ALTER TABLE ord_order ADD COLUMN express_company VARCHAR(64) NULL COMMENT ''履约快递公司'' AFTER reference_id','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_tracking_no')=0,
'ALTER TABLE ord_order ADD COLUMN express_tracking_no VARCHAR(128) NULL COMMENT ''履约快递单号'' AFTER express_company','SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
@@ -0,0 +1,54 @@
-- Phase-one first-leg order fields. Safe to run repeatedly on MySQL 8.
USE baiyue_order;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS phase1_add_column;
DELIMITER $$
CREATE PROCEDURE phase1_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_definition VARCHAR(1000)
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND COLUMN_NAME = p_column_name
) THEN
SET @phase1_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE phase1_stmt FROM @phase1_sql;
EXECUTE phase1_stmt;
DEALLOCATE PREPARE phase1_stmt;
END IF;
END$$
DELIMITER ;
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'");
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'");
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'");
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'");
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'");
CALL phase1_add_column('ord_order_cargo', 'cargo_name_en', "VARCHAR(128) NULL COMMENT 'English cargo name'");
CALL phase1_add_column('ord_order_cargo', 'customs_hs_code', "VARCHAR(20) NULL COMMENT 'Destination customs HS code'");
CALL phase1_add_column('ord_order_cargo', 'units_per_package', "INT UNSIGNED NULL COMMENT 'Units per carton'");
CALL phase1_add_column('ord_order_cargo', 'total_quantity', "INT UNSIGNED NULL COMMENT 'Total units'");
CALL phase1_add_column('ord_order_cargo', 'net_weight_kg', "DECIMAL(18,3) NULL COMMENT 'Net weight in kilograms'");
CALL phase1_add_column('ord_order_cargo', 'unit_declared_value', "DECIMAL(18,2) NULL COMMENT 'Declared value per unit'");
CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT 'Material'");
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
DROP PROCEDURE phase1_add_column;
@@ -0,0 +1,29 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OrderApplicationServiceStateTest {
@Test
void 重复提交返回明确冲突且不重复写库() {
OrderRepository repository=mock(OrderRepository.class);
OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001,1)).thenReturn(order);
OrderEligibilityGuard eligibility=mock(OrderEligibilityGuard.class);
OrderApplicationService service=new OrderApplicationService();
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility);
ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
verify(repository,never()).saveOrder(any());
}
}
@@ -0,0 +1,41 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OrderMasterDataGuardTest {
@Test
void 正常校验返回服务端可信快照() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),List.of(address),List.of(capability));
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh));
var result=new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L));
assertThat(result.partner().partnerName()).isEqualTo("服务端客户名");
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void 停用或跨租户主数据被拒绝() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",null,"不可信目的仓",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道","BUY_DECLARATION","LCL",false,"OVERSEAS",null,null,null);
}
}