From 4b5b639adc7e79ffab5f9857e90c3127f7812876 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 14:10:17 +0800 Subject: [PATCH] feat(appointments): expose authoritative arrival forecast observations --- ...InternalAppointmentForecastController.java | 28 ++++++++++ .../mapper/AppointmentForecastMapper.java | 26 +++++++++ .../service/AppointmentForecastService.java | 26 +++++++++ .../appointment-forecasts-internal-v1.yaml | 54 +++++++++++++++++++ .../mapper/AppointmentForecastMapperTest.java | 31 +++++++++++ .../AppointmentForecastServiceTest.java | 34 ++++++++++++ 6 files changed, 199 insertions(+) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentForecastController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java create mode 100644 firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapperTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentForecastController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentForecastController.java new file mode 100644 index 000000000..fae6eb3f9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentForecastController.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.portschedule.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.portschedule.service.AppointmentForecastService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequestMapping("/internal/firstleg/appointment-forecasts") +public class InternalAppointmentForecastController { + public record Request(List containerBookingIds) { } + private final AppointmentForecastService service; + private final String token; + public InternalAppointmentForecastController(AppointmentForecastService service, + @Value("${baiyue.security.internal-service-token:}") String token){this.service=service;this.token=token;} + @PostMapping("/batch") + public ApiResponse> batch( + @RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,@RequestBody Request request) { + if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8))) + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效"); + return ApiResponse.success(service.batch(request==null?null:request.containerBookingIds())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapper.java new file mode 100644 index 000000000..6910eade1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentForecastMapper { + record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference, + LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { } + @Select(""" + + """) + List latest(@Param("ids") List containerIds); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java new file mode 100644 index 000000000..7488f1163 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.portschedule.mapper.AppointmentForecastMapper; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.stream.Collectors; + +/** Forecast facts are read from their owning service, never inferred from an order snapshot. */ +@Service +public class AppointmentForecastService { + public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest, + AppointmentForecastMapper.Fact previous,String timeBasis) { } + private final AppointmentForecastMapper mapper; + public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;} + public List batch(List ids) { + if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0) + || new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次"); + var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId)); + return ids.stream().map(id->{ + var rows=facts.getOrDefault(id,List.of()); + return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED"); + }).toList(); + } +} diff --git a/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml new file mode 100644 index 000000000..7f0304f16 --- /dev/null +++ b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.3 +info: + title: 预约筹划船期事实内部查询 + version: 1.0.0 +paths: + /internal/firstleg/appointment-forecasts/batch: + post: + operationId: readAppointmentForecastFacts + summary: 批量读取有效柜次最新两个到港船节点观察 + description: 仅内部服务令牌,不开放公网。最多200个不重复柜次,一次批量读取;按照观察时间及事实ID降序。最新观察没有预计时间时保持空,不沿用旧ETA;实际到港单独返回。缺失或非有效柜次返回空latest/previous。原来源时间没有可靠时区,明确SOURCE_LOCAL_UNSPECIFIED,不转成UTC或伪造备妥时间。 + security: [{internalToken: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [containerBookingIds] + properties: + containerBookingIds: {type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: ApiResponse;每个请求柜次恰好一行,按请求顺序 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: array + items: + type: object + properties: + containerBookingId: {type: string} + timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]} + latest: {$ref: '#/components/schemas/Fact'} + previous: {$ref: '#/components/schemas/Fact'} + '401': {description: 内部身份缺失、错误或服务端未配置} +components: + securitySchemes: + internalToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + schemas: + Fact: + type: object + nullable: true + properties: + containerBookingId: {type: string} + factId: {type: string, description: 技术关联标识,不展示为业务编号} + providerCode: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + observedAt: {type: string, nullable: true, description: 来源观察时间} + estimatedAt: {type: string, nullable: true, description: 来源当地预计到港时间,时区未核实} + actualAt: {type: string, nullable: true, description: 来源实际到港时间,与预测分开} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapperTest.java new file mode 100644 index 000000000..acb077fc1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentForecastMapperTest.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +class AppointmentForecastMapperTest { + @Test void keepsNewestTwoObservationsIncludingWithdrawnEtaAndExcludesOtherNodes() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR(32))"); + s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT PRIMARY KEY,container_booking_id BIGINT,standard_node VARCHAR(64),provider_code VARCHAR(64),source_reference VARCHAR(128),observed_at TIMESTAMP,estimated_at TIMESTAMP,actual_at TIMESTAMP)"); + s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE'),(2,'CANCELLED'),(3,'ACTIVE')"); + for(int id=1;id<=3;id++) s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES("+id+",1,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-10 12:00:00',"+(id==3?"NULL":"'2026-10-15 09:00:00'")+",NULL)"); + s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(4,1,'DEPARTED_ORIGIN_PORT','carrier','OTHER','2026-10-11',NULL,NULL),(5,2,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11','2026-10-15',NULL),(6,3,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11',NULL,'2026-10-11')"); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentForecastMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var rows=session.getMapper(AppointmentForecastMapper.class).latest(List.of(1L,2L,3L)); + assertThat(rows).extracting(AppointmentForecastMapper.Fact::factId).containsExactly(3L,2L,6L); + assertThat(rows.get(0).estimatedAt()).isNull();assertThat(rows.get(1).estimatedAt()).isNotNull(); + assertThat(rows.get(2).actualAt()).isNotNull(); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java new file mode 100644 index 000000000..f4992b9ca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.portschedule.mapper.AppointmentForecastMapper; +import com.baiyue.firstleg.portschedule.controller.InternalAppointmentForecastController; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentForecastServiceTest { + @Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() { + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + var observed=LocalDateTime.of(2026,10,10,12,0); + var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null); + var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null); + when(mapper.latest(List.of(1L,2L))).thenReturn(List.of(latest,previous)); + var result=service.batch(List.of(1L,2L)); + assertThat(result.get(0).latest()).isSameAs(latest);assertThat(result.get(0).previous()).isSameAs(previous); + assertThat(result.get(1).latest()).isNull();assertThat(result.get(1).timeBasis()).isEqualTo("SOURCE_LOCAL_UNSPECIFIED"); + verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper); + } + @Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() { + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + for(var ids:List.of(List.of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L))) + assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + var controller=new InternalAppointmentForecastController(service,"expected"); + assertThatThrownBy(()->controller.batch(null,new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThatThrownBy(()->controller.batch("wrong",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThatThrownBy(()->new InternalAppointmentForecastController(service,"").batch("",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(mapper); + } +}