Compare commits

...
9 changed files with 366 additions and 5 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
exit 1
fi
fi
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4" SERVICE="$4"
ENV_FILE="$5" ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml" COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV" PROJECT_NAME="firstleg-$DEPLOY_ENV"
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2 echo "TEST deploy must run on the target host" >&2; exit 2
fi fi
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
fi
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } [ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
} }
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() { local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \ docker compose \
--project-name "$PROJECT_NAME" \ --project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \ --env-file "$ENV_FILE" \
@@ -6,6 +6,15 @@ import java.util.List;
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */ /** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
@Mapper @Mapper
public interface TripOperationsQueryMapper { public interface TripOperationsQueryMapper {
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
@ConstructorArgs({
@Arg(column = "trip_no", javaType = String.class),
@Arg(column = "status", javaType = String.class),
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
@Arg(column = "snapshot", javaType = String.class),
@Arg(column = "measured", javaType = String.class),
@Arg(column = "transport_facts", javaType = String.class)
})
@Select(""" @Select("""
<script> <script>
SELECT t.trip_no,t.status,t.completed_at, SELECT t.trip_no,t.status,t.completed_at,
@@ -27,7 +36,8 @@ public interface TripOperationsQueryMapper {
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END) WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
ELSE NULL END AS transport_facts ELSE NULL END AS transport_facts
FROM ll_fleet_trip t FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to} WHERE t.departed_at &gt;= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
AND t.departed_at &lt; #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if> <if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if> <if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001 ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.reporting;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
@Override
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
throws SQLException {
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
}
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
@Override
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
@Override
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
return toInstant(results.getObject(column, LocalDateTime.class));
}
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
@Override
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
return toInstant(statement.getObject(column, LocalDateTime.class));
}
/** 将数据库墙钟值还原为 UTC 时刻。 */
private Instant toInstant(LocalDateTime value) {
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "真实出车与自有外接运营统计", "title": "真实出车与自有外接运营统计",
"version": "1.0.0", "version": "1.0.0",
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" "description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或LOCAL_TRUCK_DEPART不可变发车事实,取货历史须带实际发车时间,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
}, },
"security": [ "security": [
{ {
@@ -0,0 +1,155 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
public class TripOperationsRepositoryTest {
private JdbcTemplate jdbc;
private TripOperationsRepository repository;
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
@BeforeEach void setUp() throws Exception {
var source = new DriverManagerDataSource(
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
jdbc.execute("CREATE TABLE ll_dispatch_order(trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(TripOperationsQueryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source);
factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
repository = new TripOperationsRepository(mapper);
}
/** 释放本例数据库,避免定向回归累积隔离状态。 */
@AfterEach void closeDatabase() {
if (jdbc != null) jdbc.execute("SHUTDOWN");
}
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
public static String jsonExtract(String document, String path) throws Exception {
if (document == null) return null;
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
return value.isMissingNode() ? null : value.toString();
}
/** 保留字符串及 JSON null 的解码语义。 */
public static String jsonUnquote(String document) throws Exception {
return document == null ? null : new ObjectMapper().readTree(document).asText();
}
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,'LOCAL_TRUCK',9)");
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
assertThat(result.summary().trips()).isEqualTo(3);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
assertThat(result.summary().ownPackages()).isEqualTo(27);
assertThat(result.summary().externalPackages()).isEqualTo(4);
assertThat(result.page().records().get(0).physicalPallets()).isNull();
assertThat(result.page().records().get(1).physicalPallets()).isNull();
}
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
assertThat(repository.departures(query(1L, "COMPLETED")))
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
}
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
assertThatThrownBy(() -> service().report(query(null, null), false))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
}
}
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
jdbc.update("DELETE FROM ll_fleet_trip_history");
jdbc.update("DELETE FROM ll_fleet_trip");
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
var result = service().report(query(null, null), false);
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
assertThat(result.summary().ownPackages()).isZero();
}
}
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
private String transportSnapshot(String number, String purpose) throws Exception {
var json = new ObjectMapper();
var snapshot = json.createObjectNode();
var trip = snapshot.putObject("trip");
trip.put("tripNo", number);
trip.put("tripPurpose", purpose);
trip.put("departedAt", "2026-10-01T00:00:00Z");
var stops = trip.putArray("stops");
stops.addObject().put("name", "起点");
stops.addObject().put("name", "终点");
return json.writeValueAsString(snapshot);
}
/** 创建本例隔离库中的发车事实。 */
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
}
/** 构造范围固定的只读查询。 */
private TripOperationsModels.Query query(Long warehouse, String status) {
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
}
/** 关闭成本查询,聚焦发车事实的持久化和投影。 */
private TripOperationsService service() {
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
}
}
+14 -2
View File
@@ -69,7 +69,8 @@ case "$command" in
esac esac
` `
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) { // 通过临时 Docker/SSH 替身验证环境选择、健康门禁及失败后的镜像恢复。
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, missingTestLimits = false, topicPrefix = `${environment}-`, remote = false }) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-'))
const mockBin = join(temporaryDirectory, 'bin') const mockBin = join(temporaryDirectory, 'bin')
const dockerPath = join(mockBin, 'docker') const dockerPath = join(mockBin, 'docker')
@@ -115,7 +116,7 @@ cp "$1" "\${2#*:}"
service, service,
envFile.replaceAll('\\', '/') envFile.replaceAll('\\', '/')
], { ], {
cwd: repositoryRoot, cwd: missingTestLimits ? temporaryDirectory : repositoryRoot,
encoding: 'utf8', encoding: 'utf8',
env: { env: {
...process.env, ...process.env,
@@ -158,6 +159,16 @@ test('successful deployment keeps the new healthy image', async (context) => {
assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/)
assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/) assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/)
assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood') assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood')
for (const command of result.dockerLog.split(/\r?\n/).filter(line => line.startsWith('compose '))) {
assert.match(command, /-f deploy\/docker-compose\.yml -f deploy\/docker-compose\.test-limits\.yml /)
}
})
test('TEST rejects a missing memory limits file before Docker', async () => {
const result = await runDeployment({ imageTag: 'newgood', missingTestLimits: true })
assert.equal(result.status, 2)
assert.equal(result.dockerLog, '')
assert.match(result.stderr, /TEST memory limits file missing/)
}) })
test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => { test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => {
@@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project',
assert.equal(result.status, 0, result.stderr) assert.equal(result.status, 0, result.stderr)
assert.match(result.dockerLog, /--project-name firstleg-production/) assert.match(result.dockerLog, /--project-name firstleg-production/)
assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/) assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/)
assert.doesNotMatch(result.dockerLog, /docker-compose\.test-limits\.yml/)
}) })
test('gray deployment rejects TEST message scope before any Docker operation', async () => { test('gray deployment rejects TEST message scope before any Docker operation', async () => {
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import test from 'node:test' import test from 'node:test'
const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8'))
@@ -15,6 +19,60 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile',
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n') .replaceAll('\r\n', '\n')
test('TEST rejects old source commits even when the workspace contains newer limits files', {
skip: process.platform === 'win32' && !existsSync('C:/Program Files/Git/bin/bash.exe')
}, async () => {
const directory = await mkdtemp(join(tmpdir(), 'test-memory-source-'))
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const guardStart = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then')
const guardEnd = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then', guardStart)
assert.ok(guardStart >= 0 && guardEnd > guardStart)
assert.ok(guardStart < pipeline.indexOf("stage('Maven 测试与打包')"))
const guard = pipeline.slice(guardStart, guardEnd)
// 在微型 Git 夹具中验证目标提交本身,避免工作区残留文件误放行旧版本。
const git = (...args) => {
const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
return result.stdout.trim()
}
// 构建提交沿用正常身份设置,但禁止夹具触发全局签名及提交钩子。
const commit = (message) => {
git('add', '.')
git('-c', 'user.name=Resource Contract', '-c', 'user.email=resource@example.invalid',
'-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-m', message)
return git('rev-parse', 'HEAD')
}
try {
git('init')
await writeFile(join(directory, 'README.md'), 'Source guard fixture\n')
const oldCommit = commit('old source')
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), '#!/bin/sh\nexit 0\n')
const incompleteCommit = commit('limits file without deployment integration')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
const protectedCommit = commit('complete memory protection')
git('checkout', '--detach', oldCommit)
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
for (const [environment, sourceCommit, status] of [
['test', oldCommit, 1], ['test', incompleteCommit, 1],
['test', protectedCommit, 0], ['production', oldCommit, 0]
]) {
const result = spawnSync(bash, ['-c', guard], {
cwd: directory, encoding: 'utf8', timeout: 10000,
env: { ...process.env, RESOLVED_ENVIRONMENT: environment, RESOLVED_SOURCE_COMMIT: sourceCommit }
})
assert.equal(result.status, status, result.stderr)
if (status !== 0) assert.match(result.stderr, /目标 TEST 提交缺少完整内存保护/)
}
} finally {
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
await rm(directory, { recursive: true, force: true })
}
})
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => { test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
for (const source of [coordinatorPipeline, pipeline]) { for (const source of [coordinatorPipeline, pipeline]) {
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/, assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,