assign(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
+ @RequestParam long tenantId,
+ @Parameter(description = "First-leg fulfillment task ID / 头程履约任务 ID", example = "190000000000000101", required = true)
+ @PathVariable long taskId,
+ @Valid @RequestBody AssignRequest request) {
+ requirePlatform(user);
+ requireTenant(tenantId);
+ service.assign(
+ tenantId,
+ user.userId(),
+ taskId,
+ request.assigneeId(),
+ request.assigneeName(),
+ request.reason()
+ );
+ return ApiResponse.success(null);
+ }
+
+ private static void requirePlatform(AuthenticatedUser user) {
+ if (user == null || !"PLATFORM".equals(user.appCode())) {
+ throw new BizException(ResultCode.FORBIDDEN,
+ "Platform application required / 仅允许平台端访问");
+ }
+ }
+
+ private static void requireTenant(long tenantId) {
+ if (tenantId <= 0) {
+ throw new BizException(ResultCode.BAD_REQUEST,
+ "Target tenant is required / 必须指定有效目标租户");
+ }
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/AssignRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/AssignRequest.java
new file mode 100644
index 000000000..140a5c9cc
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/AssignRequest.java
@@ -0,0 +1,20 @@
+package com.baiyue.firstleg.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+/**
+ * 头程履约任务指派请求,承载被指派人标识、显示名称和指派原因。
+ */
+@Schema(name = "FirstlegAssignRequest", description = "First-leg task assignment request / 头程任务指派请求")
+public record AssignRequest(
+ @Schema(description = "Assignee user ID / 被指派人用户 ID", example = "20001", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotNull Long assigneeId,
+ @Schema(description = "Assignee display name / 被指派人显示名称", example = "Zhang San / 张三", maxLength = 64, requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotBlank @Size(max = 64) String assigneeName,
+ @Schema(description = "Assignment reason / 指派原因", example = "Responsible for booking and export customs declaration / 负责订舱及出口报关", maxLength = 500)
+ @Size(max = 500) String reason
+) {
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/AssignmentHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/AssignmentHistoryEntity.java
new file mode 100644
index 000000000..b728ba410
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/AssignmentHistoryEntity.java
@@ -0,0 +1,62 @@
+package com.baiyue.firstleg.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+
+/**
+ * 头程任务指派历史持久化实体。
+ *
+ * 对应表 {@code fl_assignment_history},记录租户内任务每次指派或改派的
+ * 前后负责人、原因、操作人和时间。
+ */
+@Schema(name = "FirstlegAssignmentHistory", description = "First-leg task assignment history / 头程任务指派历史")
+@Getter
+@Setter
+@TableName("fl_assignment_history")
+public class AssignmentHistoryEntity {
+ @Schema(description = "History ID / 历史记录 ID", example = "190000000000000111")
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ @Schema(description = "Tenant ID / 租户 ID", example = "10001")
+ private Long tenantId;
+ @Schema(description = "Task ID / 任务 ID", example = "190000000000000101")
+ private Long taskId;
+ @Schema(description = "Previous assignee ID / 原被指派人 ID", example = "20000")
+ private Long previousAssigneeId;
+ @Schema(description = "New assignee ID / 新被指派人 ID", example = "20001")
+ private Long assigneeId;
+ @Schema(description = "New assignee name / 新被指派人名称", example = "Zhang San / 张三")
+ private String assigneeName;
+ @Schema(description = "Assignment reason / 指派原因", example = "Responsible for booking / 负责订舱")
+ private String reason;
+ @Schema(description = "Operator user ID / 操作人用户 ID", example = "90001")
+ private Long operatorId;
+ @Schema(description = "Operation time / 操作时间", example = "2026-07-28T15:30:00")
+ private LocalDateTime createdAt;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FulfillmentTaskEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FulfillmentTaskEntity.java
new file mode 100644
index 000000000..ffdc74768
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FulfillmentTaskEntity.java
@@ -0,0 +1,75 @@
+package com.baiyue.firstleg.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baiyue.common.core.model.BaseEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+
+/**
+ * 头程履约任务持久化实体。
+ *
+ * 对应表 {@code fl_fulfillment_task},保存由订单驱动的租户履约任务、
+ * 订单快照、负责人及处理时间节点。
+ */
+@Schema(name = "FirstlegFulfillmentTask", description = "First-leg fulfillment task / 头程履约任务")
+@Getter
+@Setter
+@TableName("fl_fulfillment_task")
+public class FulfillmentTaskEntity extends BaseEntity {
+ @Schema(description = "Task ID / 任务 ID", example = "190000000000000101")
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ @Schema(description = "Tenant ID / 租户 ID", example = "10001")
+ private Long tenantId;
+ @Schema(description = "Source order ID, logical reference only / 来源订单 ID,仅逻辑引用", example = "190000000000000001")
+ private Long orderId;
+ @Schema(description = "Source order number / 来源订单号", example = "FL202607280001")
+ private String orderNo;
+ @Schema(description = "Order snapshot JSON / 订单快照 JSON", example = "{\"orderNo\":\"FL202607280001\"}")
+ private String orderSnapshotJson;
+ @Schema(
+ description = "Task status / 任务状态",
+ example = "ASSIGNED",
+ allowableValues = {"PENDING_ASSIGNMENT", "ASSIGNED", "PROCESSING", "COMPLETED", "CANCELLED"}
+ )
+ private String status;
+ @Schema(description = "Assignee user ID / 被指派人用户 ID", example = "20001")
+ private Long assigneeId;
+ @Schema(description = "Assignee display name / 被指派人显示名称", example = "Zhang San / 张三")
+ private String assigneeName;
+ @Schema(description = "Assignment time / 指派时间", example = "2026-07-28T15:30:00")
+ private LocalDateTime assignedAt;
+ @Schema(description = "Processing start time / 开始处理时间", example = "2026-07-28T16:00:00")
+ private LocalDateTime startedAt;
+ @Schema(description = "Completion time / 完成时间", example = "2026-07-29T12:00:00")
+ private LocalDateTime completedAt;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/AssignmentHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/AssignmentHistoryMapper.java
new file mode 100644
index 000000000..dde8754ec
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/AssignmentHistoryMapper.java
@@ -0,0 +1,4 @@
+package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
+ * 头程任务指派历史 Mapper,提供 {@code fl_assignment_history} 的 MyBatis-Plus 基础数据访问能力。
+ */
+public interface AssignmentHistoryMapper extends BaseMapper{}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/FulfillmentTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/FulfillmentTaskMapper.java
new file mode 100644
index 000000000..a62de8a21
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/FulfillmentTaskMapper.java
@@ -0,0 +1,4 @@
+package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /**
+ * 头程履约任务 Mapper,提供 {@code fl_fulfillment_task} 的 MyBatis-Plus 基础数据访问能力。
+ */
+public interface FulfillmentTaskMapper extends BaseMapper{}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java
new file mode 100644
index 000000000..89768edef
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java
@@ -0,0 +1,36 @@
+package com.baiyue.firstleg.repository;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
+import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
+import com.baiyue.firstleg.mapper.AssignmentHistoryMapper;
+import com.baiyue.firstleg.mapper.FulfillmentTaskMapper;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 头程履约任务仓储,按租户查询和更新任务,并持久化每次指派历史。
+ */
+@Repository
+public class FulfillmentTaskRepository {
+ private final FulfillmentTaskMapper tasks;
+ private final AssignmentHistoryMapper histories;
+
+ public FulfillmentTaskRepository(FulfillmentTaskMapper tasks, AssignmentHistoryMapper histories) {
+ this.tasks = tasks;
+ this.histories = histories;
+ }
+
+ public FulfillmentTaskEntity findOwned(long tenantId, long taskId) {
+ return tasks.selectOne(new LambdaQueryWrapper()
+ .eq(FulfillmentTaskEntity::getTenantId, tenantId)
+ .eq(FulfillmentTaskEntity::getId, taskId));
+ }
+
+ public boolean update(FulfillmentTaskEntity task) {
+ return tasks.updateById(task) == 1;
+ }
+
+ public void insertHistory(AssignmentHistoryEntity history) {
+ histories.insert(history);
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java
new file mode 100644
index 000000000..0118ac8a8
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java
@@ -0,0 +1,58 @@
+package com.baiyue.firstleg.service;
+import jakarta.annotation.Resource;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.firstleg.entity.AssignmentHistoryEntity;
+import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
+import com.baiyue.firstleg.repository.FulfillmentTaskRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+/**
+ * 头程履约任务应用服务,在事务内校验任务状态、更新负责人并记录指派历史。
+ */
+@Service
+public class FulfillmentTaskService {
+ @Resource
+ private FulfillmentTaskRepository repository;
+
+
+ @Transactional
+ public void assign(long tenantId, long operatorId, long taskId,
+ long assigneeId, String assigneeName, String reason) {
+ FulfillmentTaskEntity task = repository.findOwned(tenantId, taskId);
+ if (task == null) {
+ throw new BizException(ResultCode.NOT_FOUND,
+ "Fulfillment task not found / 头程履约任务不存在");
+ }
+ if ("COMPLETED".equals(task.getStatus()) || "CANCELLED".equals(task.getStatus())) {
+ throw new BizException(ResultCode.CONFLICT,
+ "Closed task cannot be assigned / 已关闭任务不能再次指派");
+ }
+
+ Long previousAssigneeId = task.getAssigneeId();
+ task.setAssigneeId(assigneeId);
+ task.setAssigneeName(assigneeName);
+ task.setAssignedAt(LocalDateTime.now());
+ task.setStatus("ASSIGNED");
+ task.setUpdatedBy(operatorId);
+ if (!repository.update(task)) {
+ throw new BizException(ResultCode.CONFLICT,
+ "Concurrent task update, please retry / 任务已被并发更新,请重试");
+ }
+
+ AssignmentHistoryEntity history = new AssignmentHistoryEntity();
+ history.setTenantId(tenantId);
+ history.setTaskId(taskId);
+ history.setPreviousAssigneeId(previousAssigneeId);
+ history.setAssigneeId(assigneeId);
+ history.setAssigneeName(assigneeName);
+ history.setReason(reason);
+ history.setOperatorId(operatorId);
+ history.setCreatedAt(LocalDateTime.now());
+ repository.insertHistory(history);
+ }
+}
diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml
new file mode 100644
index 000000000..f7fc61ca3
--- /dev/null
+++ b/firstleg/src/main/resources/application.yml
@@ -0,0 +1,29 @@
+server:
+ port: ${FIRSTLEG_PORT:9202}
+spring:
+ application:
+ name: firstleg-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: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
+ username: ${FIRSTLEG_DB_USERNAME:root}
+ password: ${FIRSTLEG_DB_PASSWORD:123456}
+baiyue:
+ security:
+ jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
+management.endpoints.web.exposure.include: health,info,prometheus
diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql
new file mode 100644
index 000000000..9a042f19f
--- /dev/null
+++ b/firstleg/src/main/resources/db/firstleg.sql
@@ -0,0 +1,49 @@
+-- 头程履约服务独占数据库。
+CREATE DATABASE IF NOT EXISTS baiyue_firstleg
+ DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+USE baiyue_firstleg;
+
+CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
+ id BIGINT UNSIGNED NOT NULL COMMENT '履约任务ID(雪花ID)',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
+ order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键',
+ order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
+ order_snapshot_json JSON NOT NULL COMMENT '创建履约任务时所需的订单业务快照',
+ status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约任务当前状态',
+ assignee_id BIGINT UNSIGNED NULL COMMENT '当前负责人ID',
+ assignee_name VARCHAR(64) NULL COMMENT '当前负责人名称快照',
+ assigned_at DATETIME(3) NULL COMMENT '最近指派时间',
+ started_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_task_tenant_order (tenant_id, order_id),
+ KEY idx_task_assignee_status (tenant_id, assignee_id, status, created_at)
+) ENGINE=InnoDB COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态';
+
+CREATE TABLE IF NOT EXISTS fl_assignment_history (
+ id BIGINT UNSIGNED NOT NULL COMMENT '指派历史ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
+ task_id BIGINT UNSIGNED NOT NULL COMMENT '所属履约任务ID',
+ previous_assignee_id BIGINT UNSIGNED NULL COMMENT '原负责人ID',
+ assignee_id BIGINT UNSIGNED NOT NULL COMMENT '新负责人ID',
+ assignee_name VARCHAR(64) NOT NULL COMMENT '新负责人名称快照',
+ reason VARCHAR(500) NULL COMMENT '指派或改派原因',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行指派的操作人ID',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '指派发生时间',
+ PRIMARY KEY (id),
+ KEY idx_assignment_task_time (tenant_id, task_id, created_at),
+ CONSTRAINT fk_assignment_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
+) ENGINE=InnoDB COMMENT='头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计';
+
+CREATE TABLE IF NOT EXISTS fl_consumed_event (
+ event_id BIGINT UNSIGNED NOT NULL COMMENT '已消费领域事件ID,与生产端Outbox事件ID一致',
+ event_type VARCHAR(64) NOT NULL COMMENT '已消费事件类型',
+ consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间',
+ PRIMARY KEY (event_id)
+) ENGINE=InnoDB COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表';
diff --git a/gateway/pom.xml b/gateway/pom.xml
new file mode 100644
index 000000000..86a5184a9
--- /dev/null
+++ b/gateway/pom.xml
@@ -0,0 +1,71 @@
+
+
+ 4.0.0
+
+ com.baiyue.firstleg
+ baiyue-firstleg-platform
+ 1.0.0-SNAPSHOT
+
+ gateway
+ gateway
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webflux-ui
+
+
+ com.baiyue.shared
+ baiyue-common-core${project.version}
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis-reactive
+
+
+ com.alibaba.cloud
+ spring-cloud-starter-alibaba-nacos-discovery
+
+
+ org.springframework.cloud
+ spring-cloud-starter-loadbalancer
+
+
+ com.alibaba.cloud
+ spring-cloud-starter-alibaba-nacos-config
+
+
+ com.alibaba.cloud
+ spring-cloud-starter-alibaba-sentinel
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.springframework.bootspring-boot-maven-plugin
+
diff --git a/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java b/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java
new file mode 100644
index 000000000..19f16f3d3
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java
@@ -0,0 +1,14 @@
+package com.baiyue.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
+ */
+@SpringBootApplication
+public class BaiyueGatewayApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(BaiyueGatewayApplication.class, args);
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java
new file mode 100644
index 000000000..5dae7304a
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java
@@ -0,0 +1,32 @@
+package com.baiyue.gateway.config;
+
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.ReactiveAuthorizationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.security.web.server.authorization.AuthorizationContext;
+import reactor.core.publisher.Mono;
+
+/**
+ * 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
+ */
+final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager {
+ private final String expectedAppCode;
+
+ AppScopeAuthorizationManager(String expectedAppCode) {
+ this.expectedAppCode = expectedAppCode;
+ }
+
+ @Override
+ public Mono check(Mono authentication,
+ AuthorizationContext context) {
+ return authentication.filter(Authentication::isAuthenticated)
+ .map(this::hasExpectedScope).map(AuthorizationDecision::new)
+ .defaultIfEmpty(new AuthorizationDecision(false));
+ }
+
+ private boolean hasExpectedScope(Authentication authentication) {
+ return authentication instanceof JwtAuthenticationToken jwt
+ && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsConfiguration.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsConfiguration.java
new file mode 100644
index 000000000..e49a07ccf
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsConfiguration.java
@@ -0,0 +1,30 @@
+package com.baiyue.gateway.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.reactive.CorsConfigurationSource;
+import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
+
+/**
+ * 统一生成网关响应式跨域规则,预检和实际请求均由同一份白名单约束。
+ */
+@Configuration
+@EnableConfigurationProperties(GatewayCorsProperties.class)
+public class GatewayCorsConfiguration {
+ @Bean
+ CorsConfigurationSource corsConfigurationSource(GatewayCorsProperties properties) {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(properties.allowedOrigins());
+ configuration.setAllowedMethods(properties.allowedMethods());
+ configuration.setAllowedHeaders(properties.allowedHeaders());
+ configuration.setExposedHeaders(properties.exposedHeaders());
+ configuration.setAllowCredentials(properties.allowCredentials());
+ configuration.setMaxAge(properties.maxAge());
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsProperties.java
new file mode 100644
index 000000000..8125fdd06
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewayCorsProperties.java
@@ -0,0 +1,39 @@
+package com.baiyue.gateway.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.List;
+
+/**
+ * 网关跨域白名单配置,只允许明确登记的商户端和后台端来源访问。
+ */
+@ConfigurationProperties(prefix = "baiyue.cors")
+public record GatewayCorsProperties(List allowedOrigins,
+ List allowedMethods,
+ List allowedHeaders,
+ List exposedHeaders,
+ boolean allowCredentials,
+ long maxAge) {
+ public GatewayCorsProperties {
+ allowedOrigins = normalize(allowedOrigins);
+ allowedMethods = normalize(allowedMethods);
+ allowedHeaders = normalize(allowedHeaders);
+ exposedHeaders = normalize(exposedHeaders);
+ maxAge = maxAge <= 0 ? 3600 : maxAge;
+ if (allowedOrigins.isEmpty()) {
+ throw new IllegalArgumentException("至少需要配置一个 CORS 允许来源");
+ }
+ if (allowedOrigins.contains("*")) {
+ throw new IllegalArgumentException("CORS 禁止使用通配符来源,请配置明确的前端地址");
+ }
+ }
+
+ private static List normalize(List values) {
+ if (values == null) return List.of();
+ return values.stream()
+ .filter(value -> value != null && !value.isBlank())
+ .map(String::trim)
+ .distinct()
+ .toList();
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java
new file mode 100644
index 000000000..0ffe1dbbd
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java
@@ -0,0 +1,79 @@
+package com.baiyue.gateway.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
+import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.web.cors.reactive.CorsConfigurationSource;
+import reactor.core.publisher.Mono;
+
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 网关响应式安全配置,建立 JWT 解码、双端应用范围授权规则及统一认证失败响应。
+ */
+@Configuration
+@EnableConfigurationProperties(GatewaySecurityProperties.class)
+public class GatewaySecurityConfig {
+ @Bean
+ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
+ GatewaySecurityProperties properties,
+ ObjectMapper objectMapper,
+ CorsConfigurationSource corsConfigurationSource) {
+ var exchanges = http.authorizeExchange();
+ exchanges.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll();
+ properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
+ exchanges.pathMatchers("/api/merchant/**")
+ .access(new AppScopeAuthorizationManager("MERCHANT"));
+ exchanges.pathMatchers("/api/platform/**")
+ .access(new AppScopeAuthorizationManager("PLATFORM"));
+ exchanges.anyExchange().authenticated();
+ return http.cors(cors -> cors.configurationSource(corsConfigurationSource))
+ .csrf(ServerHttpSecurity.CsrfSpec::disable)
+ .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
+ .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
+ .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
+ .authenticationEntryPoint((exchange, ex) -> writeError(exchange, objectMapper,
+ HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
+ .exceptionHandling(errors -> errors.accessDeniedHandler((exchange, ex) ->
+ writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
+ .build();
+ }
+
+ @Bean
+ ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
+ byte[] key = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
+ if (key.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
+ NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
+ .withSecretKey(new SecretKeySpec(key, "HmacSHA256"))
+ .macAlgorithm(MacAlgorithm.HS256).build();
+ decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer()));
+ return decoder;
+ }
+
+ static Mono writeError(org.springframework.web.server.ServerWebExchange exchange,
+ ObjectMapper mapper, HttpStatus status, String code, String message) {
+ exchange.getResponse().setStatusCode(status);
+ exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
+ Map body = new LinkedHashMap<>();
+ body.put("code", code); body.put("message", message); body.put("data", null);
+ body.put("requestId", exchange.getRequest().getId()); body.put("timestamp", Instant.now().toString());
+ try {
+ byte[] bytes = mapper.writeValueAsBytes(body);
+ return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
+ } catch (Exception e) { return exchange.getResponse().setComplete(); }
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java
new file mode 100644
index 000000000..01268b5d2
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java
@@ -0,0 +1,27 @@
+package com.baiyue.gateway.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.List;
+
+/**
+ * 网关安全配置模型,集中承载 JWT 密钥、签发方、Redis 令牌键前缀和匿名放行路径。
+ */
+@ConfigurationProperties(prefix = "baiyue.security")
+public record GatewaySecurityProperties(String jwtSecret, String issuer, String tokenKeyPrefix,
+ List permitPaths) {
+ public GatewaySecurityProperties {
+ issuer = issuer == null || issuer.isBlank() ? "auth-service" : issuer;
+ tokenKeyPrefix = normalize(tokenKeyPrefix);
+ permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
+ }
+
+ public String accessTokenKey(String jwtId) {
+ return tokenKeyPrefix + ":access:" + jwtId;
+ }
+
+ private static String normalize(String prefix) {
+ String value = prefix == null || prefix.isBlank() ? "baiyue:auth" : prefix.trim();
+ return value.endsWith(":") ? value.substring(0, value.length() - 1) : value;
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java
new file mode 100644
index 000000000..82ceca7a1
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java
@@ -0,0 +1,53 @@
+package com.baiyue.gateway.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+/**
+ * 网关访问令牌会话过滤器,通过 Redis 校验 JWT 会话仍然有效,以支持退出登录和令牌即时失效。
+ */
+@Component
+public class RedisTokenSessionWebFilter implements WebFilter {
+ private final ReactiveStringRedisTemplate redisTemplate;
+ private final GatewaySecurityProperties properties;
+ private final ObjectMapper objectMapper;
+
+ public RedisTokenSessionWebFilter(ReactiveStringRedisTemplate redisTemplate,
+ GatewaySecurityProperties properties,
+ ObjectMapper objectMapper) {
+ this.redisTemplate = redisTemplate;
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
+ return exchange.getPrincipal()
+ .ofType(JwtAuthenticationToken.class)
+ .flatMap(jwt -> validate(jwt, exchange, chain).thenReturn(Boolean.TRUE))
+ .switchIfEmpty(Mono.defer(() -> chain.filter(exchange).thenReturn(Boolean.TRUE)))
+ .then();
+ }
+
+ private Mono validate(JwtAuthenticationToken jwt, ServerWebExchange exchange,
+ WebFilterChain chain) {
+ String jwtId = jwt.getToken().getId();
+ if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange);
+ return redisTemplate.hasKey(properties.accessTokenKey(jwtId))
+ .onErrorReturn(false)
+ .flatMap(active -> Boolean.TRUE.equals(active)
+ ? chain.filter(exchange) : unauthorized(exchange));
+ }
+
+ private Mono unauthorized(ServerWebExchange exchange) {
+ return GatewaySecurityConfig.writeError(exchange, objectMapper, HttpStatus.UNAUTHORIZED,
+ "A0401", "登录会话已过期、已退出或 Redis 不可用");
+ }
+}
diff --git a/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java
new file mode 100644
index 000000000..80b2b320b
--- /dev/null
+++ b/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java
@@ -0,0 +1,73 @@
+package com.baiyue.gateway.filter;
+
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
+ */
+@Component
+public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
+ private static final List TRUSTED_HEADERS = List.of(
+ "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
+ "X-App-Code", "X-Client-Type"
+ );
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ return exchange.getPrincipal()
+ .cast(Authentication.class)
+ .defaultIfEmpty(new AnonymousAuthentication())
+ .flatMap(authentication -> {
+ var requestBuilder = exchange.getRequest().mutate();
+ requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
+
+ if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
+ put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
+ put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
+ put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
+ put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
+ put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
+ put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
+ }
+ return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
+ });
+ }
+
+ private static String claim(JwtAuthenticationToken jwt, String name) {
+ Object value = jwt.getToken().getClaims().get(name);
+ return value == null ? null : value.toString();
+ }
+
+ private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
+ String name,
+ String value) {
+ if (value != null && !value.isBlank()) {
+ builder.header(name, value);
+ }
+ }
+
+ @Override
+ public int getOrder() {
+ return -100;
+ }
+
+ private static final class AnonymousAuthentication implements Authentication {
+ @Override public java.util.Collection extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
+ @Override public Object getCredentials() { return ""; }
+ @Override public Object getDetails() { return null; }
+ @Override public Object getPrincipal() { return "anonymous"; }
+ @Override public boolean isAuthenticated() { return false; }
+ @Override public void setAuthenticated(boolean isAuthenticated) { }
+ @Override public String getName() { return "anonymous"; }
+ }
+}
+
diff --git a/gateway/src/main/resources/application-cn.yml b/gateway/src/main/resources/application-cn.yml
new file mode 100644
index 000000000..7c7928c0a
--- /dev/null
+++ b/gateway/src/main/resources/application-cn.yml
@@ -0,0 +1,95 @@
+spring:
+ config:
+ activate:
+ on-profile: cn
+ cloud:
+ gateway:
+ discovery:
+ locator:
+ enabled: false
+ routes:
+ - id: auth-service
+ uri: lb://auth-service
+ predicates: [Path=/api/auth/**]
+ - id: iam-service
+ uri: lb://iam-service
+ predicates: ["Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**"]
+ - id: order-service
+ uri: lb://order-service
+ predicates: ["Path=/api/merchant/v1/orders/**,/api/platform/v1/orders/**"]
+ - id: firstleg-service
+ uri: lb://firstleg-service
+ predicates: ["Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**"]
+ - id: warehouse-service
+ uri: lb://warehouse-service
+ predicates: ["Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
+ - id: file-service
+ uri: lb://file-service
+ predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
+ - id: print-service
+ uri: lb://print-service
+ predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
+ - id: auth-openapi
+ uri: lb://auth-service
+ predicates: [Path=/auth-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: iam-openapi
+ uri: lb://iam-service
+ predicates: [Path=/iam-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: order-openapi
+ uri: lb://order-service
+ predicates: [Path=/order-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: firstleg-openapi
+ uri: lb://firstleg-service
+ predicates: [Path=/firstleg-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: warehouse-openapi
+ uri: lb://warehouse-service
+ predicates: [Path=/warehouse-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: file-openapi
+ uri: lb://file-service
+ predicates: [Path=/file-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: print-openapi
+ uri: lb://print-service
+ predicates: [Path=/print-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: audit-openapi
+ uri: lb://audit-service
+ predicates: [Path=/audit-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: integration-openapi
+ uri: lb://integration-service
+ predicates: [Path=/integration-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+ - id: event-bridge-openapi
+ uri: lb://event-bridge-service
+ predicates: [Path=/event-bridge-service/v3/api-docs/**]
+ filters: [StripPrefix=1]
+
+baiyue:
+ deployment:
+ region-code: ${REGION_CODE:CN}
+ cell: ${DEPLOYMENT_CELL:prod-cn}
+ data-residency: ${DATA_RESIDENCY:CN}
+
+springdoc:
+ swagger-ui:
+ urls:
+ - { name: auth-platform, url: /auth-service/v3/api-docs/platform }
+ - { name: iam-platform, url: /iam-service/v3/api-docs/platform }
+ - { name: iam-merchant, url: /iam-service/v3/api-docs/merchant }
+ - { name: order-platform, url: /order-service/v3/api-docs/platform }
+ - { name: order-merchant, url: /order-service/v3/api-docs/merchant }
+ - { name: firstleg-platform, url: /firstleg-service/v3/api-docs/platform }
+ - { name: warehouse-merchant, url: /warehouse-service/v3/api-docs/merchant }
+ - { name: warehouse-platform, url: /warehouse-service/v3/api-docs/platform }
+ - { name: file-platform, url: /file-service/v3/api-docs/platform }
+ - { name: file-merchant, url: /file-service/v3/api-docs/merchant }
+ - { name: print-platform, url: /print-service/v3/api-docs/platform }
+ - { name: audit, url: /audit-service/v3/api-docs }
+ - { name: integration, url: /integration-service/v3/api-docs }
+ - { name: event-bridge, url: /event-bridge-service/v3/api-docs }
diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml
new file mode 100644
index 000000000..03e74896f
--- /dev/null
+++ b/gateway/src/main/resources/application.yml
@@ -0,0 +1,133 @@
+server:
+ port: ${GATEWAY_PORT:9000}
+
+spring:
+ profiles:
+ default: cn
+ application:
+ name: gateway
+ autoconfigure:
+ exclude:
+ - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
+ config:
+ import:
+ - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
+ - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
+ data:
+ redis:
+ host: ${REDIS_HOST:127.0.0.1}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:}
+ database: ${REDIS_DATABASE:0}
+ timeout: ${REDIS_TIMEOUT:3s}
+ 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}
+ gateway:
+ discovery:
+ locator:
+ enabled: true
+ lower-case-service-id: true
+ routes:
+ - id: auth-service
+ uri: lb://auth-service
+ predicates:
+ - Path=/api/auth/**
+ - id: iam-service
+ uri: lb://iam-service
+ predicates:
+ - Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**
+ - id: order-service
+ uri: lb://order-service
+ predicates:
+ - Path=/api/merchant/v1/orders/**,/api/platform/v1/orders/**
+ - id: warehouse-service
+ uri: lb://warehouse-service
+ predicates:
+ - Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**
+ - id: file-service
+ uri: lb://file-service
+ predicates:
+ - Path=/api/merchant/v1/files/**,/api/platform/v1/files/**
+ - id: print-service
+ uri: lb://print-service
+ predicates:
+ - Path=/api/merchant/v1/print/**,/api/platform/v1/print/**
+ - id: firstleg-service
+ uri: lb://firstleg-service
+ predicates:
+ - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**
+baiyue:
+ cors:
+ allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174}
+ allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
+ allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key
+ exposed-headers: X-Request-Id,X-Trace-Id,Location,Content-Disposition
+ allow-credentials: ${CORS_ALLOW_CREDENTIALS:false}
+ max-age: ${CORS_MAX_AGE:3600}
+ security:
+ jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
+ token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
+ permit-paths:
+ - /api/auth/login
+ - /api/auth/register
+ - /api/auth/refresh
+ - /actuator/health
+ - /swagger-ui/**
+ - /swagger-ui.html
+ - /webjars/**
+ - /v3/api-docs/**
+ - /auth-service/v3/api-docs/**
+ - /iam-service/v3/api-docs/**
+ - /order-service/v3/api-docs/**
+ - /warehouse-service/v3/api-docs/**
+ - /file-service/v3/api-docs/**
+ - /print-service/v3/api-docs/**
+ - /firstleg-service/v3/api-docs/**
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,prometheus
+ endpoint:
+ health:
+ probes:
+ enabled: true
+
+springdoc:
+ api-docs.enabled: ${SWAGGER_ENABLED:false}
+ swagger-ui:
+ enabled: ${SWAGGER_ENABLED:false}
+ urls:
+ - name: auth-platform
+ url: /auth-service/v3/api-docs/platform
+ - name: iam-platform
+ url: /iam-service/v3/api-docs/platform
+ - name: iam-merchant
+ url: /iam-service/v3/api-docs/merchant
+ - name: order-platform
+ url: /order-service/v3/api-docs/platform
+ - name: order-merchant
+ url: /order-service/v3/api-docs/merchant
+ - name: warehouse-merchant
+ url: /warehouse-service/v3/api-docs/merchant
+ - name: warehouse-platform
+ url: /warehouse-service/v3/api-docs/platform
+ - name: file-platform
+ url: /file-service/v3/api-docs/platform
+ - name: file-merchant
+ url: /file-service/v3/api-docs/merchant
+ - name: print-platform
+ url: /print-service/v3/api-docs/platform
+ - name: firstleg-platform
+ url: /firstleg-service/v3/api-docs/platform
diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
new file mode 100644
index 000000000..21e2371a7
--- /dev/null
+++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
@@ -0,0 +1,55 @@
+package com.baiyue.gateway.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.time.Instant;
+
+class AppScopeAuthorizationManagerTest {
+ @Test
+ void acceptsMatchingApplicationScope() {
+ var manager = new AppScopeAuthorizationManager("MERCHANT");
+ StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
+ .expectNextMatches(decision -> decision.isGranted())
+ .verifyComplete();
+ }
+
+ @Test
+ void rejectsDifferentApplicationScope() {
+ var manager = new AppScopeAuthorizationManager("PLATFORM");
+ StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
+ .expectNextMatches(decision -> !decision.isGranted())
+ .verifyComplete();
+ }
+
+ @Test
+ void rejectsMissingApplicationScope() {
+ var manager = new AppScopeAuthorizationManager("PLATFORM");
+ Jwt jwt = Jwt.withTokenValue("token")
+ .header("alg", "none")
+ .subject("1")
+ .issuedAt(Instant.now())
+ .expiresAt(Instant.now().plusSeconds(60))
+ .build();
+ StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
+ jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
+ .expectNextMatches(decision -> !decision.isGranted())
+ .verifyComplete();
+ }
+
+ private JwtAuthenticationToken authentication(String appCode) {
+ Jwt jwt = Jwt.withTokenValue("token")
+ .header("alg", "none")
+ .subject("1")
+ .issuedAt(Instant.now())
+ .expiresAt(Instant.now().plusSeconds(60))
+ .claim("app_code", appCode)
+ .build();
+ return new JwtAuthenticationToken(jwt,
+ java.util.List.of(new SimpleGrantedAuthority("authenticated")));
+ }
+}
diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java
new file mode 100644
index 000000000..a4631a245
--- /dev/null
+++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java
@@ -0,0 +1,65 @@
+package com.baiyue.gateway.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
+ "spring.cloud.nacos.discovery.enabled=false",
+ "spring.cloud.nacos.discovery.register-enabled=false",
+ "spring.cloud.nacos.config.enabled=false",
+ "spring.cloud.gateway.discovery.locator.enabled=false",
+ "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long",
+ "baiyue.cors.allowed-origins=http://localhost:5173"
+})
+@AutoConfigureWebTestClient
+class GatewayCorsConfigurationTest {
+ private static final String PROFILE_URL =
+ "http://localhost/api/merchant/v1/iam/tenant/profile";
+
+ @Autowired
+ private WebTestClient webTestClient;
+
+ @Test
+ void allowedOriginCanCompletePutPreflightWithoutToken() {
+ webTestClient.options()
+ .uri(PROFILE_URL)
+ .header(HttpHeaders.ORIGIN, "http://localhost:5173")
+ .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name())
+ .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173")
+ .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS,
+ value -> org.assertj.core.api.Assertions.assertThat(value).contains("PUT"))
+ .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
+ value -> org.assertj.core.api.Assertions.assertThat(value.toLowerCase())
+ .contains("authorization", "content-type"))
+ .expectHeader().doesNotExist(HttpHeaders.WWW_AUTHENTICATE);
+ }
+
+ @Test
+ void rejectedOriginDoesNotReceiveCorsPermission() {
+ webTestClient.options()
+ .uri(PROFILE_URL)
+ .header(HttpHeaders.ORIGIN, "https://untrusted.example.com")
+ .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name())
+ .exchange()
+ .expectStatus().isForbidden()
+ .expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
+ }
+
+ @Test
+ void corsDoesNotMakeProtectedActualRequestAnonymous() {
+ webTestClient.get()
+ .uri(PROFILE_URL)
+ .header(HttpHeaders.ORIGIN, "http://localhost:5173")
+ .exchange()
+ .expectStatus().isUnauthorized()
+ .expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173");
+ }
+}
diff --git a/iam/README-IAM.md b/iam/README-IAM.md
new file mode 100644
index 000000000..6bb91ca65
--- /dev/null
+++ b/iam/README-IAM.md
@@ -0,0 +1,29 @@
+# IAM 一期交付说明 / IAM Phase 1 Delivery
+
+## 数据库归属 / Database ownership
+
+全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。商户数据通过 `tenant_id` 强制隔离;平台租户使用 `tenant_id=0`,权限通过 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。
+
+All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. Every merchant query must constrain `tenant_id`. Platform scope uses `tenant_id=0` and `app_code=PLATFORM`; merchant scope uses `app_code=MERCHANT`.
+
+## 代码落位 / Code placement
+
+将 `src/main/java` 下的包合并到 `D:/baiyue/iam/src/main/java`;SQL 执行 `db/baiyue_iam.sql`。代码基于 Java 17、Spring Boot 3.2、MyBatis-Plus 3.5.7,并返回现有 `ApiResponse`。
+
+Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; execute `db/baiyue_iam.sql`. The skeleton targets Java 17, Spring Boot 3.2 and MyBatis-Plus 3.5.7, returning the existing `ApiResponse` envelope.
+
+## 接口清单 / API catalogue
+
+| Method | Path | App | Purpose / 用途 |
+|---|---|---|---|
+| POST | `/api/platform/v1/iam/tenants` | PLATFORM | Submit enterprise registration / 企业注册 |
+| GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 |
+| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 |
+| POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 |
+| GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 |
+| GET/POST | `/api/merchant/v1/iam/members` | MERCHANT | List/add members / 成员 |
+| GET/POST | `/api/{app}/v1/iam/roles` | PLATFORM/MERCHANT | Role management / 角色 |
+| GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 |
+
+OpenAPI is synchronized in `openapi/iam-v1.yaml`.
+
diff --git a/iam/pom.xml b/iam/pom.xml
new file mode 100644
index 000000000..5fd239738
--- /dev/null
+++ b/iam/pom.xml
@@ -0,0 +1,23 @@
+
+
+ 4.0.0
+ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
+ iam-service
+
+ com.baiyue.sharedbaiyue-common-audit${project.version}
+com.baiyue.sharedbaiyue-common-openapi${project.version}
+com.baomidoumybatis-plus-spring-boot3-starter
+ com.baiyue.sharedbaiyue-common-web${project.version}
+ com.baiyue.sharedbaiyue-common-security${project.version}
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config
+ org.springframework.cloudspring-cloud-starter-openfeign
+ org.springframework.cloudspring-cloud-starter-loadbalancer
+ org.springframework.bootspring-boot-starter-actuator
+ com.mysqlmysql-connector-jruntime
+ org.springframework.bootspring-boot-starter-testtest
+
+ org.springframework.bootspring-boot-maven-plugin
+
+
diff --git a/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java
new file mode 100644
index 000000000..0a42654e5
--- /dev/null
+++ b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java
@@ -0,0 +1,16 @@
+package com.baiyue.iam;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+
+/**
+ * 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。
+ */
+@MapperScan("com.baiyue.iam.mapper")
+@EnableFeignClients(basePackages = "com.baiyue.iam.client")
+@SpringBootApplication(scanBasePackages = "com.baiyue")
+public class BaiyueIamApplication {
+ public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); }
+}
diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java
new file mode 100644
index 000000000..19358f3aa
--- /dev/null
+++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java
@@ -0,0 +1,27 @@
+package com.baiyue.iam.client;
+
+import com.baiyue.common.core.api.ApiResponse;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.*;
+
+/** auth-service 内部账号管理客户端。 */
+@FeignClient(name = "auth-service", contextId = "authAccountClient",
+ url = "${baiyue.clients.auth.url:}", path = "/internal/auth/accounts")
+public interface AuthAccountClient {
+ @PostMapping ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token,
+ @RequestBody CreateAccountRequest request);
+ @GetMapping("/{id}") ApiResponse summary(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable Long id);
+ @PutMapping("/{id}/status") ApiResponse status(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable Long id, @RequestBody StatusRequest request);
+ @PutMapping("/{id}/password") ApiResponse reset(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable Long id, @RequestBody PasswordRequest request);
+ @DeleteMapping("/{id}/compensation") ApiResponse compensate(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable Long id);
+
+ record CreateAccountRequest(Long accountId, String username, String password, String displayName,
+ Long tenantId, Long membershipId) { }
+ record AccountSummary(Long accountId, String username, String displayName, String status) { }
+ record StatusRequest(String status) { }
+ record PasswordRequest(String newPassword) { }
+}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/IamHealthController.java b/iam/src/main/java/com/baiyue/iam/controller/IamHealthController.java
new file mode 100644
index 000000000..e985e767f
--- /dev/null
+++ b/iam/src/main/java/com/baiyue/iam/controller/IamHealthController.java
@@ -0,0 +1,20 @@
+package com.baiyue.iam.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;
+
+/**
+ * IAM 服务健康探针接口,用于确认身份与权限服务实例可用。
+ */
+@RestController
+@RequestMapping("/api/platform/v1/iam")
+public class IamHealthController {
+ @GetMapping("/status")
+ public ApiResponse