commit 2f4e159356412481de0319ccde49acc291f9242a Author: liuyihe <2513762445@qq.com> Date: Thu Jul 30 18:01:36 2026 +0800 暂存提交 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..d54690bad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto +*.java text eol=lf +*.xml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.sql text eol=lf +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf +*.jar binary +*.png binary +*.jpg binary +*.jpeg binary +*.pdf binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..dd775881e --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +target/ +*.log +.idea/ +*.iml +.classpath +.project +.settings/ +.vscode/ +.DS_Store +Thumbs.db +logs/ +data/ +.env +.env.* +!.env.example +application-local.yml +bootstrap-local.yml +*.hprof +*.dump +dependency-reduced-pom.xml +reports/runtime*/ +reports/uploads/ +reports/db-backup*/ +uploads/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..4dd6604f7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,49 @@ +image: maven:3.9.9-eclipse-temurin-17 + +stages: + - verify + - package + +variables: + MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository -Dfile.encoding=UTF-8" + MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" + +cache: + key: "maven-$CI_COMMIT_REF_SLUG" + paths: + - .m2/repository/ + +校验与测试: + stage: verify + script: + - mvn $MAVEN_CLI_OPTS clean verify + artifacts: + when: always + expire_in: 7 days + reports: + junit: + - "**/target/surefire-reports/TEST-*.xml" + paths: + - "**/target/surefire-reports/" + +打包服务: + stage: package + needs: + - 校验与测试 + script: + - mvn $MAVEN_CLI_OPTS -DskipTests package + artifacts: + expire_in: 7 days + paths: + - gateway/target/*.jar + - auth/target/*.jar + - iam/target/*.jar + - order/target/*.jar + - firstleg/target/*.jar + - warehouse/target/*.jar + - file/target/*.jar + - print/target/*.jar + - audit-service/target/*.jar + - integration-service/target/*.jar + - event-bridge-service/target/*.jar + diff --git a/README.md b/README.md new file mode 100644 index 000000000..8f60c8784 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# 百悦头程平台 + +本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 + +## 模块 + +- `auth`:统一登录、令牌签发与刷新。 +- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。 +- `order`:订单主数据及订单状态编排。 +- `firstleg`:头程揽收、入仓、装载、发运和交接。 +- `warehouse`:国内仓储能力。 +- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。 +- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 +- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 + +## 本地构建 + +```powershell +mvn -DskipTests validate +mvn test +``` + +2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 + +本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 + +国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 + +## 网关跨域配置 + +本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: + +```text +CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com +``` + +当前登录态通过 Bearer Token 传递,`CORS_ALLOW_CREDENTIALS` 默认关闭。禁止把允许来源配置成 `*`;如果以后改为 Cookie 登录,必须使用 HTTPS 明确域名,并审查 Cookie 的 `SameSite` 和 `Secure` 设置。 + +## 仓库边界 + +详细规则见 `docs/repository-boundary.md`。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/audit-service/pom.xml b/audit-service/pom.xml new file mode 100644 index 000000000..87867c917 --- /dev/null +++ b/audit-service/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + audit-service + + + + com.baiyue.shared + baiyue-common-audit + ${project.version} + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.mysql + mysql-connector-j + runtime + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java new file mode 100644 index 000000000..3a9bd5c31 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java @@ -0,0 +1,16 @@ +package com.baiyue.audit; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 + */ +@MapperScan("com.baiyue.audit.mapper") +@SpringBootApplication +public class BaiyueAuditApplication { + public static void main(String[] args) { + SpringApplication.run(BaiyueAuditApplication.class, args); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java new file mode 100644 index 000000000..ff361f6ec --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java @@ -0,0 +1,45 @@ +package com.baiyue.audit.controller; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.service.AuditIngestService; +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +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.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 内部审计事件接收接口,校验服务间提交的审计数据并交由审计服务持久化。 + */ +@Tag( + name = "Internal audit ingestion / 内部审计写入", + description = "Internal service-to-service API for immutable business audit events. / 服务间写入不可变业务审计事件的内部接口。" +) +@RestController +@RequestMapping("/internal/audit/events") +public class AuditIngestController { + @Resource + private AuditIngestService service; + @Operation( + summary = "Ingest business audit event / 写入业务审计事件", + description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid event payload / 事件载荷无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Audit storage failed / 审计存储失败") + }) + @PostMapping + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse ingest(@Valid @RequestBody AuditIngestRequest event) { + service.ingest(event); + return ApiResponse.success(null); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java new file mode 100644 index 000000000..7948394c3 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java @@ -0,0 +1,63 @@ +package com.baiyue.audit.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +/** + * 内部审计写入请求契约,承载操作对象、租户与用户、请求链路、执行结果及脱敏快照。 + */ +@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件") +public record AuditIngestRequest( + @Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 64) String eventType, + @Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 64) String resourceType, + @Schema(description = "Resource identifier / 资源标识", example = "190000000000000001") + @Size(max = 128) String resourceId, + @Schema(description = "Bilingual event description / 中英文事件描述", example = "Update warehouse / 更新仓库") + @Size(max = 255) String description, + @Schema(description = "Request ID / 请求 ID", example = "req-20260728-001") + @Size(max = 128) String requestId, + @Schema(description = "Distributed trace ID / 分布式链路 ID", example = "trace-20260728-001") + @Size(max = 128) String traceId, + @Schema(description = "Business data tenant ID / 业务数据租户 ID", example = "10001") + Long tenantId, + @Schema(description = "Operator tenant ID / 操作人所属租户 ID", example = "0") + Long operatorTenantId, + @Schema(description = "Operator user ID / 操作人用户 ID", example = "90001") + Long userId, + @Schema(description = "Operator username / 操作人用户名", example = "platform.admin") + @Size(max = 128) String username, + @Schema(description = "Application scope / 应用权限域", example = "PLATFORM") + @Size(max = 64) String appCode, + @Schema(description = "Client type / 客户端类型", example = "WEB") + @Size(max = 32) String clientType, + @Schema(description = "HTTP method / HTTP 方法", example = "PUT") + @Size(max = 16) String httpMethod, + @Schema(description = "Request URI / 请求 URI", example = "/api/platform/v1/warehouse-admin/190000000000000001") + @Size(max = 512) String requestUri, + @Schema(description = "Client IP / 客户端 IP", example = "10.0.0.10") + @Size(max = 64) String clientIp, + @Schema(description = "Request duration in milliseconds / 请求耗时(毫秒)", example = "35") + @PositiveOrZero long durationMs, + @Schema(description = "Whether the business operation succeeded / 业务操作是否成功", example = "true") + boolean success, + @Schema(description = "Error code when failed / 失败时错误码", example = "WAREHOUSE_NOT_FOUND") + @Size(max = 64) String errorCode, + @Schema(description = "Sanitized error message / 脱敏后的错误信息", example = "Warehouse not found / 仓库不存在") + @Size(max = 512) String errorMessage, + @Schema(description = "Sanitized JSON snapshot before change / 变更前脱敏 JSON 快照", example = "{\"status\":\"INACTIVE\"}") + String beforeData, + @Schema(description = "Sanitized JSON snapshot after change / 变更后脱敏 JSON 快照", example = "{\"status\":\"ACTIVE\"}") + String afterData, + @Schema(description = "Event occurrence time in UTC / 事件发生时间(UTC)", example = "2026-07-28T07:30:00Z", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull Instant occurredAt, + @Schema(description = "Source service name / 来源服务名称", example = "warehouse-service") + @Size(max = 64) String serviceName +) { +} diff --git a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java new file mode 100644 index 000000000..ba85a9170 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java @@ -0,0 +1,101 @@ +package com.baiyue.audit.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.Instant; +import java.time.LocalDateTime; + +/** + * 业务审计日志持久化实体,对应 {@code sys_audit_log} 表。 + * 用于保存跨租户敏感操作的不可变审计证据,不用于领域事件投递。 + */ +@Schema(name = "AuditLog", description = "Stored business audit log / 已存储业务审计日志") +@Getter +@Setter +@TableName("sys_audit_log") +public class AuditLogEntity { + @Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201") + @TableId(type = IdType.AUTO) + private Long id; + private String eventType; + private String resourceType; + private String resourceId; + private String description; + private String requestId; + private String traceId; + private Long tenantId; + private Long operatorTenantId; + private Long userId; + private String username; + private String appCode; + private String clientType; + private String httpMethod; + private String requestUri; + private String clientIp; + private Long durationMs; + private Boolean success; + private String errorCode; + private String errorMessage; + private String beforeData; + private String afterData; + private String serviceName; + private Instant occurredAt; + private LocalDateTime createdAt; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java new file mode 100644 index 000000000..612c0e5cc --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java @@ -0,0 +1,10 @@ +package com.baiyue.audit.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.audit.entity.AuditLogEntity; + +/** + * 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。 + */ +public interface AuditLogMapper extends BaseMapper { +} diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java new file mode 100644 index 000000000..acc5669a4 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java @@ -0,0 +1,50 @@ +package com.baiyue.audit.repository; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.entity.AuditLogEntity; +import com.baiyue.audit.mapper.AuditLogMapper; +import org.springframework.stereotype.Repository; + +/** + * 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。 + */ +@Repository +public class AuditEventRepository { + private final AuditLogMapper mapper; + + public AuditEventRepository(AuditLogMapper mapper) { + this.mapper = mapper; + } + + public void save(AuditIngestRequest event) { + AuditLogEntity entity = new AuditLogEntity(); + entity.setEventType(event.eventType()); + entity.setResourceType(event.resourceType()); + entity.setResourceId(event.resourceId()); + entity.setDescription(event.description()); + entity.setRequestId(event.requestId()); + entity.setTraceId(event.traceId()); + entity.setTenantId(event.tenantId()); + entity.setOperatorTenantId(event.operatorTenantId()); + entity.setUserId(event.userId()); + entity.setUsername(event.username()); + entity.setAppCode(event.appCode()); + entity.setClientType(event.clientType()); + entity.setHttpMethod(event.httpMethod()); + entity.setRequestUri(event.requestUri()); + entity.setClientIp(event.clientIp()); + entity.setDurationMs(event.durationMs()); + entity.setSuccess(event.success()); + entity.setErrorCode(event.errorCode()); + entity.setErrorMessage(event.errorMessage()); + entity.setBeforeData(jsonOrNull(event.beforeData())); + entity.setAfterData(jsonOrNull(event.afterData())); + entity.setOccurredAt(event.occurredAt()); + entity.setServiceName(event.serviceName()); + mapper.insert(entity); + } + + private static String jsonOrNull(String value) { + return value == null ? "null" : value; + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java new file mode 100644 index 000000000..a75e4177b --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java @@ -0,0 +1,22 @@ +package com.baiyue.audit.service; +import jakarta.annotation.Resource; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.repository.AuditEventRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。 + */ +@Service +public class AuditIngestService { + @Resource + private AuditEventRepository repository; + + + @Transactional + public void ingest(AuditIngestRequest event) { + repository.save(event); + } +} diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml new file mode 100644 index 000000000..0244bf667 --- /dev/null +++ b/audit-service/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: ${AUDIT_PORT:9700} + +spring: + application: + name: audit-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} + + # 本地 IDEA 启动默认连接本机 MySQL;生产环境请通过 AUDIT_DB_* 环境变量覆盖。 + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUDIT_DB_USERNAME:root} + password: ${AUDIT_DB_PASSWORD:123456} + +baiyue.audit.enabled: false +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/audit-service/src/main/resources/db/audit.sql b/audit-service/src/main/resources/db/audit.sql new file mode 100644 index 000000000..985b89b32 --- /dev/null +++ b/audit-service/src/main/resources/db/audit.sql @@ -0,0 +1,36 @@ +CREATE DATABASE IF NOT EXISTS baiyue_audit + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_audit; + +CREATE TABLE IF NOT EXISTS sys_audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID', + event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码', + resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型', + resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID', + description VARCHAR(255) DEFAULT NULL COMMENT '操作说明', + request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', + trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID', + tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID', + operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID', + user_id BIGINT DEFAULT NULL COMMENT '操作用户ID', + username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照', + app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端', + client_type VARCHAR(32) DEFAULT NULL COMMENT '客户端类型', + http_method VARCHAR(16) DEFAULT NULL COMMENT 'HTTP请求方法', + request_uri VARCHAR(512) DEFAULT NULL COMMENT '请求URI', + client_ip VARCHAR(64) DEFAULT NULL COMMENT '客户端IP', + duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '请求耗时(毫秒)', + success TINYINT NOT NULL DEFAULT 0 COMMENT '操作是否成功', + error_code VARCHAR(64) DEFAULT NULL COMMENT '失败时的错误码', + error_message VARCHAR(512) DEFAULT NULL COMMENT '脱敏后的失败信息', + before_data JSON DEFAULT NULL COMMENT '变更前数据快照(已脱敏)', + after_data JSON DEFAULT NULL COMMENT '变更后数据快照(已脱敏)', + service_name VARCHAR(64) DEFAULT NULL COMMENT '产生日志的服务名', + occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间', + PRIMARY KEY (id), + KEY idx_audit_tenant_time (tenant_id, occurred_at), + KEY idx_audit_event_time (event_type, occurred_at), + KEY idx_audit_user_time (user_id, occurred_at), + KEY idx_audit_request (request_id) +) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; diff --git a/auth/pom.xml b/auth/pom.xml new file mode 100644 index 000000000..d8d25d7f3 --- /dev/null +++ b/auth/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + auth-service + + com.baiyue.sharedbaiyue-common-audit${project.version} +com.baiyue.sharedbaiyue-common-openapi${project.version} +com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.baomidoumybatis-plus-spring-boot3-starter + org.springframework.bootspring-boot-starter-security + org.springframework.securityspring-security-oauth2-jose + 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-data-redis + org.springframework.bootspring-boot-starter-actuator + com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-starter-testtest + org.springframework.securityspring-security-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java b/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java new file mode 100644 index 000000000..ad3d69c33 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java @@ -0,0 +1,16 @@ +package com.baiyue.auth; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。 + */ +@MapperScan("com.baiyue.auth.mapper") +@EnableFeignClients(basePackages = "com.baiyue.auth.client") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueAuthApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueAuthApplication.class, args); } +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java new file mode 100644 index 000000000..a48bc5303 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -0,0 +1,43 @@ +package com.baiyue.auth.client; + +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 + */ +@Component +public class AuthorizationLoader { + private final IamAuthorizationClient client; + private final String internalToken; + + public AuthorizationLoader(IamAuthorizationClient client, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + this.client = client; + this.internalToken = internalToken; + } + + public AuthorizationSnapshot load(AuthAccount account) { + try { + var response = client.load(internalToken, new AuthorizationSnapshotRequest( + account.getId(), account.getDefaultTenantId(), account.getDefaultMembershipId(), + account.getDefaultAppCode())); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); + } + return response.data(); + } catch (FeignException exception) { + if (exception.status() == 400 || exception.status() == 403 || exception.status() == 404) { + throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); + } + } +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java new file mode 100644 index 000000000..4e7361ecf --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -0,0 +1,23 @@ +package com.baiyue.auth.client; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +/** + * IAM 授权快照响应模型,承载用户身份、租户、角色、按钮/接口权限及数据范围。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthorizationSnapshot { + private Long userId; + private Long tenantId; + private Long membershipId; + private String appCode; + private String clientType; + private Set roles; + private Set permissions; +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshotRequest.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshotRequest.java new file mode 100644 index 000000000..77cb34ecd --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshotRequest.java @@ -0,0 +1,18 @@ +package com.baiyue.auth.client; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IAM 授权快照查询请求契约,描述待解析的认证账号、租户及应用权限域。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthorizationSnapshotRequest { + private Long userId; + private Long tenantId; + private Long membershipId; + private String appCode; +} diff --git a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java new file mode 100644 index 000000000..5dca79307 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java @@ -0,0 +1,19 @@ +package com.baiyue.auth.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** + * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 + */ +@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamAuthorizationClient { + @PostMapping("/authorization-snapshot") + ApiResponse load( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody AuthorizationSnapshotRequest request); +} diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java new file mode 100644 index 000000000..704653bb8 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -0,0 +1,22 @@ +package com.baiyue.auth.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "iam-service", contextId = "iamOnboardingClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamOnboardingClient { + @PostMapping("/merchant-registrations") + ApiResponse register( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody OnboardingRequest request); + + record OnboardingRequest(Long authUserId, String displayName, String companyName, + String legalPerson, String legalPhone, String contactEmail, + String countryCode, String address) { } + + record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java new file mode 100644 index 000000000..31a2e7d6e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java @@ -0,0 +1,57 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.baiyue.common.security.JwtAuthenticationFilter; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.nio.charset.StandardCharsets; + +/** + * 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。 + */ +@Configuration +@EnableConfigurationProperties(AuthSecurityProperties.class) +public class AuthSecurityConfig { + @Bean + PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } + + @Bean + JwtEncoder jwtEncoder(AuthSecurityProperties properties) { + byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); + if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); + return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); + } + + @Bean + SecurityFilterChain authSecurityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtFilter, + AuthenticationEntryPoint entryPoint, + AccessDeniedHandler deniedHandler) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(form -> form.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(errors -> errors + .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout", + "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/internal/auth/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java new file mode 100644 index 000000000..ba0804300 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -0,0 +1,14 @@ +package com.baiyue.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 + */ +@ConfigurationProperties(prefix = "baiyue.security") +public record AuthSecurityProperties(String jwtSecret, String issuer, + Duration accessTokenTtl, Duration refreshTokenTtl, + int maxLoginFailures, Duration lockDuration) { +} diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java new file mode 100644 index 000000000..2fd921852 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -0,0 +1,89 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.service.AuthService; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.dto.RefreshRequest; +import com.baiyue.auth.dto.TokenResponse; +import com.baiyue.auth.dto.MerchantRegisterRequest; +import com.baiyue.auth.dto.MerchantRegisterResponse; +import com.baiyue.auth.service.MerchantRegistrationService; +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; +import com.baiyue.auth.service.AccountManagementService; +import org.springframework.security.access.prepost.PreAuthorize; + +/** + * 统一认证接口,向平台端与商户端提供登录、刷新令牌和退出登录能力。 + */ +@RestController +@RequestMapping("/api/auth") +@Tag(name = "Authentication / 统一认证", description = "Unified login, token refresh and logout. / 统一登录、令牌刷新与退出。") +public class AuthController { + @Resource + private AuthService authService; + @Resource + private MerchantRegistrationService registrationService; + @Resource + private AccountManagementService accountManagementService; + + @PutMapping("/password") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "修改当前用户密码") + public ApiResponse changePassword(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ChangePasswordRequest request) { + accountManagementService.changePassword(user.userId(), request); + return ApiResponse.success(null); + } + + @SecurityRequirements + @PostMapping("/register") + @Operation(summary = "Merchant registration / 客户注册", + description = "Create a login account and an enterprise profile awaiting one business-license upload.") + public ApiResponse register(@Valid @RequestBody MerchantRegisterRequest request) { + return ApiResponse.success(registrationService.register(request)); + } + + @SecurityRequirements + @Operation(summary = "Login / 登录", description = "Validate credentials and issue access and refresh tokens. / 校验账号密码并签发访问令牌和刷新令牌。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Login succeeded / 登录成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数错误"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Invalid username or password / 用户名或密码错误"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "Account temporarily locked / 账号暂时锁定") + }) + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.success(authService.login(request)); + } + + @SecurityRequirements + @Operation(summary = "Refresh token / 刷新令牌", description = "Rotate a valid refresh token and issue a new token pair. / 轮换有效刷新令牌并签发新的令牌对。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token refreshed / 刷新成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Refresh token invalid or expired / 刷新令牌无效或已过期") + }) + @PostMapping("/refresh") + public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) { + return ApiResponse.success(authService.refresh(request.refreshToken())); + } + + @Operation(summary = "Logout / 退出登录", description = "Revoke the supplied refresh token. / 吊销指定刷新令牌。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Logged out / 退出成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录") + }) + @PostMapping("/logout") + public ApiResponse logout(@Valid @RequestBody RefreshRequest request) { + authService.logout(request.refreshToken()); + return ApiResponse.success(null); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java new file mode 100644 index 000000000..01c56a0ec --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -0,0 +1,62 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.dto.AccountManagementDtos.*; +import com.baiyue.auth.service.AccountManagementService; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** 仅供可信内部服务调用的账号管理接口。 */ +@RestController +@RequestMapping("/internal/auth/accounts") +public class InternalAccountController { + private final AccountManagementService service; + private final String configuredToken; + private byte[] tokenBytes; + + public InternalAccountController(AccountManagementService service, + @Value("${baiyue.security.internal-service-token}") String configuredToken) { + this.service = service; + this.configuredToken = configuredToken; + } + + @PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); } + + @PostMapping + public ApiResponse create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @Valid @RequestBody CreateAccountRequest request) { + verify(token); return ApiResponse.success(service.create(request)); + } + @GetMapping("/{id}") + public ApiResponse summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id) { + verify(token); return ApiResponse.success(service.summary(id)); + } + @PutMapping("/{id}/status") + public ApiResponse status(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) { + verify(token); service.updateStatus(id, request); return ApiResponse.success(null); + } + @PutMapping("/{id}/password") + public ApiResponse reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { + verify(token); service.resetPassword(id, request); return ApiResponse.success(null); + } + @DeleteMapping("/{id}/compensation") + public ApiResponse compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id) { + verify(token); service.compensateDelete(id); return ApiResponse.success(null); + } + + private void verify(String supplied) { + byte[] value = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(tokenBytes, value)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java new file mode 100644 index 000000000..bd6763fe5 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java @@ -0,0 +1,25 @@ +package com.baiyue.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; + +/** 账号安全及内部账号管理契约。 */ +public final class AccountManagementDtos { + private AccountManagementDtos() { } + + public record ChangePasswordRequest(@NotBlank String oldPassword, + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } + public record CreateAccountRequest(@NotNull Long accountId, + @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, + @NotBlank String displayName, @NotNull Long tenantId, + @NotNull Long membershipId) { } + public record UpdateAccountStatusRequest(@NotBlank String status) { } + public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } + public record AccountSummary(Long accountId, String username, String displayName, String status) { } +} diff --git a/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java new file mode 100644 index 000000000..db5b95596 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java @@ -0,0 +1,14 @@ +package com.baiyue.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求契约,接收账号、密码和目标应用域等统一登录参数。 + */ +@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据") +public record LoginRequest( + @Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username, + @Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password +) { +} \ No newline at end of file diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java new file mode 100644 index 000000000..695e36832 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java @@ -0,0 +1,20 @@ +package com.baiyue.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration") +public record MerchantRegisterRequest( + @NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username, + @NotBlank @Size(min = 8, max = 72) String password, + @NotBlank @Size(max = 64) String displayName, + @NotBlank @Size(max = 128) String companyName, + @NotBlank @Size(max = 64) String legalPerson, + @NotBlank @Size(max = 32) String legalPhone, + @Email @Size(max = 128) String contactEmail, + @Size(min = 2, max = 2) String countryCode, + @Size(max = 255) String address +) { } diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java new file mode 100644 index 000000000..3c357b850 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java @@ -0,0 +1,9 @@ +package com.baiyue.auth.dto; + +public record MerchantRegisterResponse( + Long accountId, + Long tenantId, + Long membershipId, + String tenantStatus, + String nextStep +) { } diff --git a/auth/src/main/java/com/baiyue/auth/dto/RefreshRequest.java b/auth/src/main/java/com/baiyue/auth/dto/RefreshRequest.java new file mode 100644 index 000000000..44223e3f9 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/RefreshRequest.java @@ -0,0 +1,13 @@ +package com.baiyue.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 刷新或注销请求契约,承载客户端提交的刷新令牌。 + */ +@Schema(name = "RefreshRequest", description = "Refresh token request / 刷新令牌请求") +public record RefreshRequest( + @Schema(description = "Refresh token returned by login or refresh / 登录或刷新接口返回的刷新令牌", example = "eyJ...refresh-token", format = "password") @NotBlank String refreshToken +) { +} \ No newline at end of file diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java new file mode 100644 index 000000000..eeb8d43ca --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -0,0 +1,16 @@ +package com.baiyue.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。 + */ +@Schema(name = "TokenResponse", description = "Access and refresh token pair / 访问令牌与刷新令牌") +public record TokenResponse( + @Schema(description = "Token type / 令牌类型", example = "Bearer") String tokenType, + @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, + @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, + @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, + @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn +) { +} \ No newline at end of file diff --git a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java new file mode 100644 index 000000000..d2b6a4a67 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java @@ -0,0 +1,51 @@ +package com.baiyue.auth.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +/** + * 认证账号持久化实体,对应表 {@code auth_account};保存统一登录账号、密码摘要、账号类型、租户归属和启停状态,是身份凭证的数据源。 + */ +@Getter +@Setter +@TableName("auth_account") +public class AuthAccount { + @TableId private Long id; + private String username; + private String passwordHash; + private String displayName; + private String accountStatus; + private Integer failedLoginCount; + private LocalDateTime lockedUntil; + private Integer tokenVersion; + private Long defaultTenantId; + private Long operatorTenantId; + private Long defaultMembershipId; + private String defaultAppCode; + @TableLogic private Integer deleted; + private LocalDateTime lastLoginAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + + + + + + + + + + + + + + + +} diff --git a/auth/src/main/java/com/baiyue/auth/entity/AuthRefreshToken.java b/auth/src/main/java/com/baiyue/auth/entity/AuthRefreshToken.java new file mode 100644 index 000000000..27b78410a --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/entity/AuthRefreshToken.java @@ -0,0 +1,45 @@ +package com.baiyue.auth.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +/** + * 刷新令牌持久化实体,对应表 {@code auth_refresh_token};保存刷新令牌摘要、访问令牌标识、有效期和撤销状态,用于令牌轮换、追踪与失效控制。 + */ +@Getter +@Setter +@TableName("auth_refresh_token") +public class AuthRefreshToken { + @TableId private Long id; + private String tokenHash; + private Long userId; + private Long tenantId; + private Long operatorTenantId; + private String appCode; + private LocalDateTime expiresAt; + private LocalDateTime revokedAt; + private LocalDateTime createdAt; + + + + + + + + + + + + + + + + + + +} diff --git a/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java b/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java new file mode 100644 index 000000000..5b43f3d1d --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.auth.mapper; + +import com.baiyue.auth.entity.AuthAccount; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。 + */ +public interface AuthAccountMapper extends BaseMapper { } diff --git a/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java b/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java new file mode 100644 index 000000000..7704add04 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.auth.mapper; + +import com.baiyue.auth.entity.AuthRefreshToken; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。 + */ +public interface AuthRefreshTokenMapper extends BaseMapper { } diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java new file mode 100644 index 000000000..31551dda9 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.auth.repository; + +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.mapper.AuthAccountMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; + +/** + * 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。 + */ +@Repository +public class AuthAccountRepository { + private final AuthAccountMapper mapper; + + public AuthAccountRepository(AuthAccountMapper mapper) { + this.mapper = mapper; + } + + public AuthAccount findByUsername(String username) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(AuthAccount::getUsername, username) + .last("LIMIT 1")); + } + + public boolean existsByUsername(String username) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(AuthAccount::getUsername, username)) > 0; + } + + public void insert(AuthAccount account) { mapper.insert(account); } + + public AuthAccount findById(Long id) { + return mapper.selectById(id); + } + + public void save(AuthAccount account) { + mapper.updateById(account); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java new file mode 100644 index 000000000..8f93c0d71 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java @@ -0,0 +1,33 @@ +package com.baiyue.auth.repository; + +import com.baiyue.auth.entity.AuthRefreshToken; +import com.baiyue.auth.mapper.AuthRefreshTokenMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; + +/** + * 刷新令牌仓储,封装令牌摘要查询、加锁读取、创建和状态更新操作。 + */ +@Repository +public class AuthRefreshTokenRepository { + private final AuthRefreshTokenMapper mapper; + + public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) { + this.mapper = mapper; + } + + public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) { + String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; + return mapper.selectOne(new LambdaQueryWrapper() + .eq(AuthRefreshToken::getTokenHash, tokenHash) + .last(limit)); + } + + public void insert(AuthRefreshToken token) { + mapper.insert(token); + } + + public void save(AuthRefreshToken token) { + mapper.updateById(token); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java new file mode 100644 index 000000000..8b0426157 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -0,0 +1,111 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.dto.AccountManagementDtos.*; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baiyue.common.audit.BusinessAudit; + +/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */ +@Service +public class AccountManagementService { + @Resource private AuthAccountRepository repository; + @Resource private PasswordEncoder passwordEncoder; + @Resource private TokenSessionStore tokenSessionStore; + + @Transactional + @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) + public void changePassword(Long accountId, ChangePasswordRequest request) { + AuthAccount account = required(accountId); + if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "原密码错误"); + } + if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同"); + } + setPasswordAndRevoke(account, request.newPassword()); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT", + resourceId = "#request.accountId", description = "创建认证账号") + public AccountSummary create(CreateAccountRequest request) { + if (repository.existsByUsername(request.username()) || repository.findById(request.accountId()) != null) { + throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + } + AuthAccount account = new AuthAccount(); + account.setId(request.accountId()); + account.setUsername(request.username()); + account.setPasswordHash(passwordEncoder.encode(request.password())); + account.setDisplayName(request.displayName()); + account.setAccountStatus("ACTIVE"); + account.setFailedLoginCount(0); + account.setTokenVersion(1); + account.setDefaultTenantId(request.tenantId()); + account.setOperatorTenantId(request.tenantId()); + account.setDefaultMembershipId(request.membershipId()); + account.setDefaultAppCode("MERCHANT"); + account.setDeleted(0); + repository.insert(account); + return summary(account); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_STATUS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改账号状态", captureAfter = false) + public void updateStatus(Long accountId, UpdateAccountStatusRequest request) { + if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "账号状态只允许 ACTIVE 或 DISABLED"); + } + AuthAccount account = required(accountId); + account.setAccountStatus(request.status()); + bumpVersion(account); + } + + @Transactional + @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) + public void resetPassword(Long accountId, ResetPasswordRequest request) { + setPasswordAndRevoke(required(accountId), request.newPassword()); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT", + resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false) + public void compensateDelete(Long accountId) { + AuthAccount account = repository.findById(accountId); + if (account != null) { + account.setAccountStatus("DISABLED"); + account.setDeleted(1); + bumpVersion(account); + } + } + + public AccountSummary summary(Long accountId) { return summary(required(accountId)); } + + private void setPasswordAndRevoke(AuthAccount account, String password) { + account.setPasswordHash(passwordEncoder.encode(password)); + account.setFailedLoginCount(0); + account.setLockedUntil(null); + bumpVersion(account); + } + + private void bumpVersion(AuthAccount account) { + account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1); + repository.save(account); + tokenSessionStore.revokeAll(account.getId()); + } + + private AuthAccount required(Long id) { + AuthAccount account = repository.findById(id); + if (account == null) throw new BizException(ResultCode.NOT_FOUND, "账号不存在"); + return account; + } + + private AccountSummary summary(AuthAccount a) { + return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus()); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java new file mode 100644 index 000000000..9fb486383 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -0,0 +1,137 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.AuthSecurityProperties; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.dto.TokenResponse; +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + + +import jakarta.annotation.Resource; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.UUID; + +/** + * 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 + */ +@Service +public class AuthService { + @Resource + private AuthAccountRepository accountRepository; + @Resource + private PasswordEncoder passwordEncoder; + @Resource + private JwtEncoder jwtEncoder; + @Resource + private AuthSecurityProperties properties; + @Resource + private AuthorizationLoader authorizationLoader; + @Resource + private TokenSessionStore tokenSessionStore; + + private final SecureRandom secureRandom = new SecureRandom(); + + + @Transactional + @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username", + description = "User login / 用户登录", captureAfter = false) + public TokenResponse login(LoginRequest request) { + AuthAccount account = accountRepository.findByUsername(request.username()); + if (account == null) throw invalidCredentials(); + LocalDateTime now = LocalDateTime.now(); + if (!"ACTIVE".equals(account.getAccountStatus())) + throw new BizException(ResultCode.FORBIDDEN, "账号已禁用"); + if (account.getLockedUntil() != null && account.getLockedUntil().isAfter(now)) + throw new BizException(ResultCode.TOO_MANY_REQUESTS, "账号暂时锁定,请稍后重试"); + if (!passwordEncoder.matches(request.password(), account.getPasswordHash())) { + recordFailure(account, now); + throw invalidCredentials(); + } + account.setFailedLoginCount(0); + account.setLockedUntil(null); + account.setLastLoginAt(now); + accountRepository.save(account); + return issue(account); + } + + @Transactional + @BusinessAudit(event = "AUTH_TOKEN_REFRESH", resourceType = "REFRESH_TOKEN", + description = "Refresh access token / 刷新访问令牌", captureBefore = false, captureAfter = false) + public TokenResponse refresh(String rawRefreshToken) { + TokenSessionStore.RefreshSession session = tokenSessionStore.consume(rawRefreshToken); + if (session == null) { + throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌无效或已过期"); + } + AuthAccount account = accountRepository.findById(session.userId()); + if (account == null || !"ACTIVE".equals(account.getAccountStatus())) + throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); + return issue(account); + } + + @Transactional + @BusinessAudit(event = "AUTH_LOGOUT", resourceType = "REFRESH_TOKEN", + description = "User logout / 用户退出", captureBefore = false, captureAfter = false) + public void logout(String rawRefreshToken) { + tokenSessionStore.revoke(rawRefreshToken); + } + + private void recordFailure(AuthAccount account, LocalDateTime now) { + int failures = account.getFailedLoginCount() == null ? 1 : account.getFailedLoginCount() + 1; + account.setFailedLoginCount(failures); + if (failures >= properties.maxLoginFailures()) account.setLockedUntil(now.plus(properties.lockDuration())); + accountRepository.save(account); + } + + private TokenResponse issue(AuthAccount account) { + AuthorizationSnapshot authorization = authorizationLoader.load(account); + Instant issuedAt = Instant.now(); + String jwtId = UUID.randomUUID().toString(); + Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) + .subject(account.getId().toString()) + .claim("user_id", account.getId()) + .claim("username", account.getUsername()) + .claim("tenant_id", account.getDefaultTenantId()) + .claim("operator_tenant_id", account.getOperatorTenantId()) + .claim("membership_id", account.getDefaultMembershipId()) + .claim("app_code", account.getDefaultAppCode()) + .claim("client_type", authorization.getClientType()) + .claim("roles", authorization.getRoles()) + .claim("permissions", authorization.getPermissions()) + .claim("token_version", account.getTokenVersion()) + .build(); + String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); + + byte[] random = new byte[48]; + secureRandom.nextBytes(random); + String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); + tokenSessionStore.store(jwtId, account.getId(), rawRefresh, + properties.accessTokenTtl(), properties.refreshTokenTtl()); + return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), + rawRefresh, properties.refreshTokenTtl().toSeconds()); + } + + private static BizException invalidCredentials() { + return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); + } + +} diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java new file mode 100644 index 000000000..1b9ff3769 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -0,0 +1,66 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.IamOnboardingClient; +import com.baiyue.auth.dto.MerchantRegisterRequest; +import com.baiyue.auth.dto.MerchantRegisterResponse; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.audit.BusinessAudit; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MerchantRegistrationService { + @Resource private AuthAccountRepository accountRepository; + @Resource private PasswordEncoder passwordEncoder; + @Resource private IamOnboardingClient onboardingClient; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @Transactional + @BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT", + resourceId = "#request.username", description = "商户注册", captureAfter = false) + public MerchantRegisterResponse register(MerchantRegisterRequest request) { + if (accountRepository.existsByUsername(request.username())) { + throw new BizException(ResultCode.CONFLICT, "Username already exists"); + } + long accountId = IdWorker.getId(); + IamOnboardingClient.OnboardingResponse onboarding; + try { + var response = onboardingClient.register(internalToken, + new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), + request.companyName(), request.legalPerson(), request.legalPhone(), + request.contactEmail(), request.countryCode(), request.address())); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); + } + onboarding = response.data(); + } catch (FeignException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "Enterprise already exists"); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable"); + } + + AuthAccount account = new AuthAccount(); + account.setId(accountId); + account.setUsername(request.username()); + account.setPasswordHash(passwordEncoder.encode(request.password())); + account.setDisplayName(request.displayName()); + account.setAccountStatus("ACTIVE"); + account.setFailedLoginCount(0); + account.setTokenVersion(1); + account.setDefaultTenantId(onboarding.tenantId()); + account.setOperatorTenantId(onboarding.tenantId()); + account.setDefaultMembershipId(onboarding.membershipId()); + account.setDefaultAppCode("MERCHANT"); + account.setDeleted(0); + accountRepository.insert(account); + return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), + onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE"); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java new file mode 100644 index 000000000..2a7a75953 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -0,0 +1,134 @@ +package com.baiyue.auth.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.RedisTokenKeys; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Set; +import java.util.ArrayList; +import java.util.List; + +/** + * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 + */ +@Component +public class TokenSessionStore { + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final String keyPrefix; + + public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + } + + public void store(String jwtId, Long userId, String rawRefreshToken, + Duration accessTtl, Duration refreshTtl) { + String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); + String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); + String userKey = userSessionsKey(userId); + try { + redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl); + String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); + redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); + SetOperations sets = redisTemplate.opsForSet(); + if (sets != null) sets.add(userKey, accessKey, refreshKey); + redisTemplate.expire(userKey, refreshTtl); + } catch (Exception exception) { + try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { } + throw unavailable(exception); + } + } + + public RefreshSession consume(String rawRefreshToken) { + try { + String value = redisTemplate.opsForValue().getAndDelete( + RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken))); + if (value == null || value.isBlank()) return null; + RefreshSession session = objectMapper.readValue(value, RefreshSession.class); + redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); + return session; + } catch (DataAccessException exception) { + throw unavailable(exception); + } catch (Exception exception) { + throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌会话无效"); + } + } + + public void revoke(String rawRefreshToken) { + consume(rawRefreshToken); + } + + /** 撤销该账号当前已登记的全部访问令牌和刷新令牌。 */ + public void revokeAll(Long userId) { + String userKey = userSessionsKey(userId); + try { + Set keys = redisTemplate.opsForSet().members(userKey); + if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); + // 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。 + List legacyKeys = new ArrayList<>(); + collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false); + collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true); + if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys); + redisTemplate.delete(userKey); + } catch (DataAccessException exception) { + throw unavailable(exception); + } + } + + private void collectLegacyKeys(String pattern, Long userId, List result, boolean refresh) { + Cursor cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build()); + if (cursor == null) return; + try (cursor) { + while (cursor.hasNext()) { + String key = cursor.next(); + String value = redisTemplate.opsForValue().get(key); + if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) { + result.add(key); + } + } + } catch (Exception exception) { + throw unavailable(exception); + } + } + + private boolean belongsTo(String value, Long userId) { + if (value == null || value.isBlank()) return false; + try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } + catch (Exception ignored) { return false; } + } + + private String userSessionsKey(Long userId) { + return keyPrefix + ":user-sessions:" + userId; + } + + private static BizException unavailable(Exception cause) { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, + "Redis 令牌会话服务暂时不可用: " + cause.getClass().getSimpleName()); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + public record RefreshSession(Long userId, String accessJwtId) { } +} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml new file mode 100644 index 000000000..162844ec6 --- /dev/null +++ b/auth/src/main/resources/application.yml @@ -0,0 +1,48 @@ +server: + port: ${AUTH_PORT:9100} +spring: + application: + name: auth-service + 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} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUTH_DB_USERNAME:root} + password: ${AUTH_DB_PASSWORD:123456} + +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} + token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} + issuer: ${JWT_ISSUER:auth-service} + access-token-ttl: ${ACCESS_TOKEN_TTL:30m} + refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} + max-login-failures: ${MAX_LOGIN_FAILURES:5} + lock-duration: ${LOGIN_LOCK_DURATION:15m} + +baiyue.clients: + iam: + url: ${IAM_SERVICE_URL:} + +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/auth/src/main/resources/db/auth.sql b/auth/src/main/resources/db/auth.sql new file mode 100644 index 000000000..594484e66 --- /dev/null +++ b/auth/src/main/resources/db/auth.sql @@ -0,0 +1,44 @@ +SET NAMES utf8mb4; +CREATE DATABASE IF NOT EXISTS baiyue_auth + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_auth; + +CREATE TABLE IF NOT EXISTS auth_account ( + id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)', + username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', + password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', + display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', + account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED', + failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', + token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', + default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', + default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', + default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_auth_account_username (username), + KEY idx_auth_account_status (account_status, locked_until) +) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; + +CREATE TABLE IF NOT EXISTS auth_refresh_token ( + id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', + token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', + user_id BIGINT NOT NULL COMMENT '认证账号ID', + tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID', + app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端', + expires_at DATETIME(3) NOT NULL COMMENT '到期时间', + revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', + PRIMARY KEY (id), + UNIQUE KEY uk_auth_refresh_hash (token_hash), + KEY idx_auth_refresh_user (user_id, revoked_at, expires_at), + KEY idx_auth_refresh_expire (expires_at, revoked_at) +) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态'; + +-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。 diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql new file mode 100644 index 000000000..b84c3e956 --- /dev/null +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -0,0 +1,26 @@ +-- Phase-one local administrator accounts. +-- Development-only initial password: ChangeMe123! +-- The BCrypt hash is intentionally stored instead of plaintext. +USE baiyue_auth; +SET NAMES utf8mb4; + +INSERT INTO auth_account ( + id, username, password_hash, display_name, account_status, + failed_login_count, token_version, default_tenant_id, + operator_tenant_id, default_membership_id, default_app_code, deleted +) VALUES + (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', + '平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0), + (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', + '一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0) +ON DUPLICATE KEY UPDATE + password_hash = VALUES(password_hash), + display_name = VALUES(display_name), + account_status = 'ACTIVE', + failed_login_count = 0, + locked_until = NULL, + default_tenant_id = VALUES(default_tenant_id), + operator_tenant_id = VALUES(operator_tenant_id), + default_membership_id = VALUES(default_membership_id), + default_app_code = VALUES(default_app_code), + deleted = 0; diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java new file mode 100644 index 000000000..bce35b8b3 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -0,0 +1,52 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AccountManagementServiceTest { + private final AuthAccountRepository repository = mock(AuthAccountRepository.class); + private final TokenSessionStore sessions = mock(TokenSessionStore.class); + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); + private final AccountManagementService service = new AccountManagementService(); + private AuthAccount account; + + @BeforeEach void setUp() { + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "passwordEncoder", encoder); + ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); + account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1")); + account.setTokenVersion(2); account.setAccountStatus("ACTIVE"); + when(repository.findById(10L)).thenReturn(account); + } + + @Test void 修改密码校验旧密码并撤销全部会话() { + service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")); + assertTrue(encoder.matches("NewPass2", account.getPasswordHash())); + assertEquals(3, account.getTokenVersion()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + + @Test void 原密码错误不得写入() { + assertThrows(BizException.class, + () -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2"))); + verify(repository, never()).save(any()); + verify(sessions, never()).revokeAll(any()); + } + + @Test void 停用账号撤销全部会话() { + service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); + assertEquals("DISABLED", account.getAccountStatus()); + verify(sessions).revokeAll(10L); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java new file mode 100644 index 000000000..8eb6ed0ac --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -0,0 +1,80 @@ +package com.baiyue.auth.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.SetOperations; + +import java.time.Duration; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; + +class TokenSessionStoreTest { + @Test + void storesAccessAndHashedRefreshWithIndependentTtls() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:"); + + store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + + verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30)); + verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"), + org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); + } + + @Test + void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + String json = new ObjectMapper().writeValueAsString( + new TokenSessionStore.RefreshSession(42L, "jti-1")); + when(values.getAndDelete(anyString())).thenReturn(json); + TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth"); + + TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); + + assertThat(session.userId()).isEqualTo(42L); + assertThat(session.accessJwtId()).isEqualTo("jti-1"); + verify(values).getAndDelete(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:")); + verify(redis).delete("baiyue:auth:access:jti-1"); + } + + @Test + void treatsAlreadyConsumedRefreshAsInvalid() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.getAndDelete(anyString())).thenReturn(null); + + assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth") + .consume("raw-refresh")).isNull(); + } + + @Test + void revokesAllIndexedSessionsForUser() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + SetOperations sets = mock(SetOperations.class); + when(redis.opsForSet()).thenReturn(sets); + when(sets.members("baiyue:auth:user-sessions:42")) + .thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")); + + new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L); + + verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"))); + verify(redis).delete("baiyue:auth:user-sessions:42"); + } +} diff --git a/common-audit/pom.xml b/common-audit/pom.xml new file mode 100644 index 000000000..7b8224ae1 --- /dev/null +++ b/common-audit/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + + com.baiyue.shared + baiyue-common-audit + + com.baiyue.sharedbaiyue-common-security${project.version} + org.springframework.bootspring-boot-starter-aop + org.springframework.bootspring-boot-starter-web + + diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java new file mode 100644 index 000000000..75a59edb2 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java @@ -0,0 +1,15 @@ +package com.baiyue.common.audit; + +import java.time.Instant; + +/** + * 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。 + */ +public record AuditEvent( + String eventType, String resourceType, String resourceId, String description, + String requestId, String traceId, Long tenantId, Long operatorTenantId, + Long userId, String username, String appCode, String clientType, + String httpMethod, String requestUri, String clientIp, long durationMs, + boolean success, String errorCode, String errorMessage, + String beforeData, String afterData, Instant occurredAt, String serviceName +) { } diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditProperties.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditProperties.java new file mode 100644 index 000000000..6e9aab0f1 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditProperties.java @@ -0,0 +1,23 @@ +package com.baiyue.common.audit; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 业务审计配置模型,控制审计开关、输出方式、远程地址、服务名和快照长度限制。 + */ +@ConfigurationProperties("baiyue.audit") +public class AuditProperties { + private boolean enabled = true; + private String endpoint; + private int maxPayloadLength = 16000; + private String serviceName = "unknown-service"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getEndpoint() { return endpoint; } + public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public int getMaxPayloadLength() { return maxPayloadLength; } + public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; } + public String getServiceName() { return serviceName; } + public void setServiceName(String serviceName) { this.serviceName = serviceName; } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditSink.java new file mode 100644 index 000000000..de9ae87d3 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditSink.java @@ -0,0 +1,8 @@ +package com.baiyue.common.audit; + +/** + * 业务审计事件输出接口,用于解耦审计事件采集与日志或远程服务等落地方式。 + */ +public interface AuditSink { + void publish(AuditEvent event); +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java b/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java new file mode 100644 index 000000000..367266286 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java @@ -0,0 +1,23 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** + * 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。 + */ +@AutoConfiguration +@EnableConfigurationProperties(AuditProperties.class) +@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true) +public class BaiyueAuditAutoConfiguration { + @Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) { + return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint()); + } + @Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); } + @Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java new file mode 100644 index 000000000..9a67d641e --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java @@ -0,0 +1,20 @@ +package com.baiyue.common.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 业务审计方法注解,声明事件类型、资源标识、描述以及是否采集变更前后数据。 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BusinessAudit { + String event(); + String resourceType(); + String resourceId() default ""; // SpEL, e.g. #request.orderId + String description() default ""; + boolean captureBefore() default true; + boolean captureAfter() default true; +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java new file mode 100644 index 000000000..cb1136966 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java @@ -0,0 +1,88 @@ +package com.baiyue.common.audit; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.web.CorrelationContext; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.Instant; +import java.util.Arrays; + +/** + * 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。 + */ +@Aspect +public class BusinessAuditAspect { + private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class); + private final AuditSink sink; + private final SensitiveDataSanitizer sanitizer; + private final String serviceName; + private final ExpressionParser parser = new SpelExpressionParser(); + + public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) { + this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName; + } + + @Around("@annotation(audit)") + public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable { + long start = System.nanoTime(); + String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null; + Object result = null; Throwable failure = null; + try { result = pjp.proceed(); return result; } + catch (Throwable ex) { failure = ex; throw ex; } + finally { + try { publish(pjp, audit, before, result, failure, (System.nanoTime() - start) / 1_000_000); } + catch (RuntimeException ex) { log.warn("Audit capture failed", ex); } + } + } + + private void publish(ProceedingJoinPoint pjp, BusinessAudit a, String before, Object result, + Throwable failure, long duration) { + HttpServletRequest req = request(); + AuthenticatedUser user = currentUser(); + String resourceId = evaluateResourceId(pjp, a.resourceId()); + sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(), + req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req), + user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(), + user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(), + user == null ? null : user.userId(), user == null ? null : user.username(), + user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(), + req == null ? null : req.getMethod(), req == null ? null : req.getRequestURI(), + req == null ? null : clientIp(req), duration, failure == null, + failure == null ? null : failure.getClass().getSimpleName(), + failure == null ? null : failure.getMessage(), before, + a.captureAfter() ? sanitizer.sanitize(result) : null, Instant.now(), + serviceName)); + } + + private HttpServletRequest request() { + var attrs = RequestContextHolder.getRequestAttributes(); + return attrs instanceof ServletRequestAttributes s ? s.getRequest() : null; + } + private AuthenticatedUser currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null; + } + private String evaluateResourceId(ProceedingJoinPoint p, String expression) { + if (expression == null || expression.isBlank()) return null; + MethodSignature s = (MethodSignature) p.getSignature(); + var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer()); + Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString(); + } + private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); } + private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } } + private static String clientIp(HttpServletRequest r) { String x = r.getHeader("X-Forwarded-For"); return x == null ? r.getRemoteAddr() : x.split(",")[0].trim(); } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java new file mode 100644 index 000000000..0a8aa2a19 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java @@ -0,0 +1,27 @@ +package com.baiyue.common.audit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.client.RestClient; + +/** + * HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。 + */ +public class HttpAuditSink implements AuditSink { + private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT"); + private final RestClient client; + private final String endpoint; + + public HttpAuditSink(RestClient.Builder builder, String endpoint) { + this.client = builder.build(); + this.endpoint = endpoint; + } + + @Override public void publish(AuditEvent event) { + try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); } + catch (RuntimeException ex) { + auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}", + ex.getClass().getSimpleName(), ex.getMessage(), event); + } + } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/LoggingAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/LoggingAuditSink.java new file mode 100644 index 000000000..d9e2a38cf --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/LoggingAuditSink.java @@ -0,0 +1,12 @@ +package com.baiyue.common.audit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 日志审计输出实现,在未配置远程审计服务时将结构化审计事件写入应用日志。 + */ +public class LoggingAuditSink implements AuditSink { + private static final Logger log = LoggerFactory.getLogger("BAIYUE_AUDIT"); + @Override public void publish(AuditEvent event) { log.info("audit={}", event); } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java new file mode 100644 index 000000000..9bbe9d633 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java @@ -0,0 +1,52 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 审计数据脱敏器,在记录请求快照前递归屏蔽密码、令牌、密钥等敏感字段。 + */ +public class SensitiveDataSanitizer { + private static final Set SENSITIVE = Set.of("password", "passwordhash", "token", "accesstoken", + "refreshtoken", "authorization", "secret", "idcard", "bankcard", "mobile", "phone", "email"); + private final ObjectMapper mapper; + private final int maxLength; + + public SensitiveDataSanitizer(ObjectMapper mapper, int maxLength) { + this.mapper = mapper; + this.maxLength = maxLength; + } + + public String sanitize(Object value) { + if (value == null) return null; + try { + JsonNode root = mapper.valueToTree(value); + redact(root); + String json = mapper.writeValueAsString(root); + return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]"; + } catch (IllegalArgumentException | JsonProcessingException e) { + return "\"[unserializable]\""; + } + } + + private void redact(JsonNode node) { + if (node == null) return; + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String normalized = field.getKey().replace("_", "").toLowerCase(Locale.ROOT); + if (SENSITIVE.stream().anyMatch(normalized::contains)) { + ((ObjectNode) node).put(field.getKey(), "******"); + } else redact(field.getValue()); + } + } else if (node.isArray()) node.forEach(this::redact); + } +} diff --git a/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..279053f24 --- /dev/null +++ b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.baiyue.common.audit.BaiyueAuditAutoConfiguration diff --git a/common-core/pom.xml b/common-core/pom.xml new file mode 100644 index 000000000..faff84fd8 --- /dev/null +++ b/common-core/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + com.baiyue.shared + baiyue-common-core + baiyue-common-core + + + org.springframeworkspring-web + + com.baomidou + mybatis-plus-spring-boot3-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java new file mode 100644 index 000000000..d88fd5b64 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java @@ -0,0 +1,37 @@ +package com.baiyue.common.core.api; + +import java.time.Instant; + +/** + * 统一 API 响应封装,包含结果码、消息、业务数据、请求标识和响应时间。 + */ +public record ApiResponse( + String code, + String message, + T data, + String requestId, + String traceId, + Instant timestamp) { + + public static ApiResponse success(T data) { + return of(ResultCode.SUCCESS, ResultCode.SUCCESS.message(), data, null, null); + } + + public static ApiResponse success(T data, String requestId, String traceId) { + return of(ResultCode.SUCCESS, ResultCode.SUCCESS.message(), data, requestId, traceId); + } + + public static ApiResponse failure(ResultCode resultCode, String message, + String requestId, String traceId) { + return of(resultCode, message, null, requestId, traceId); + } + + public static ApiResponse of(ResultCode resultCode, String message, T data, + String requestId, String traceId) { + return new ApiResponse<>(resultCode.code(), message, data, requestId, traceId, Instant.now()); + } + + public boolean successful() { + return ResultCode.SUCCESS.code().equals(code); + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/api/PageResult.java b/common-core/src/main/java/com/baiyue/common/core/api/PageResult.java new file mode 100644 index 000000000..26ce509ed --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/api/PageResult.java @@ -0,0 +1,22 @@ +package com.baiyue.common.core.api; + +import com.baomidou.mybatisplus.core.metadata.IPage; + +import java.util.List; + +/** + * 统一分页响应模型,将 MyBatis-Plus 分页结果转换为稳定的接口契约。 + */ +public record PageResult(List records, long page, long size, long total, long pages) { + public PageResult { + records = records == null ? List.of() : List.copyOf(records); + if (page < 1 || size < 1 || total < 0 || pages < 0) { + throw new IllegalArgumentException("Invalid pagination metadata"); + } + } + + public static PageResult from(IPage source) { + return new PageResult<>(source.getRecords(), source.getCurrent(), source.getSize(), + source.getTotal(), source.getPages()); + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java new file mode 100644 index 000000000..edda90feb --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java @@ -0,0 +1,34 @@ +package com.baiyue.common.core.api; + +import org.springframework.http.HttpStatus; + +/** + * 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。 + */ +public enum ResultCode { + SUCCESS("0", "success", HttpStatus.OK), + BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST), + UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED), + FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN), + NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND), + CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT), + VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY), + TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS), + BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST), + INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR), + SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE); + + private final String code; + private final String message; + private final HttpStatus httpStatus; + + ResultCode(String code, String message, HttpStatus httpStatus) { + this.code = code; + this.message = message; + this.httpStatus = httpStatus; + } + + public String code() { return code; } + public String message() { return message; } + public HttpStatus httpStatus() { return httpStatus; } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/exception/BizException.java b/common-core/src/main/java/com/baiyue/common/core/exception/BizException.java new file mode 100644 index 000000000..f0e3bee9b --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/exception/BizException.java @@ -0,0 +1,27 @@ +package com.baiyue.common.core.exception; + +import com.baiyue.common.core.api.ResultCode; + +/** + * 统一业务异常,携带标准结果码供全局异常处理器转换为 API 错误响应。 + */ +public class BizException extends RuntimeException { + private final ResultCode resultCode; + + public BizException(String message) { + this(ResultCode.BUSINESS_ERROR, message); + } + + public BizException(ResultCode resultCode) { + this(resultCode, resultCode.message()); + } + + public BizException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } + + public ResultCode getResultCode() { + return resultCode; + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/model/BaseEntity.java b/common-core/src/main/java/com/baiyue/common/core/model/BaseEntity.java new file mode 100644 index 000000000..11716e507 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/model/BaseEntity.java @@ -0,0 +1,44 @@ +package com.baiyue.common.core.model; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.Version; + +import java.time.LocalDateTime; + +/** + * 业务持久化实体公共基类,统一维护创建/更新审计字段、逻辑删除标记和乐观锁版本号。 + */ +@Getter +@Setter +public abstract class BaseEntity { + @TableField(fill = FieldFill.INSERT) + private Long createdBy; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updatedBy; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + @TableLogic + private Boolean deleted; + @Version + private Integer version; + + + + + + + + + + + + + +} diff --git a/common-core/src/main/java/com/baiyue/common/core/mybatis/AuditorProvider.java b/common-core/src/main/java/com/baiyue/common/core/mybatis/AuditorProvider.java new file mode 100644 index 000000000..a84cb4b91 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/mybatis/AuditorProvider.java @@ -0,0 +1,9 @@ +package com.baiyue.common.core.mybatis; + +/** + * 审计操作者提供接口,为 MyBatis-Plus 自动填充字段提供当前用户标识。 + */ +@FunctionalInterface +public interface AuditorProvider { + Long currentUserId(); +} diff --git a/common-core/src/main/java/com/baiyue/common/core/mybatis/MybatisPlusConfiguration.java b/common-core/src/main/java/com/baiyue/common/core/mybatis/MybatisPlusConfiguration.java new file mode 100644 index 000000000..7cddc7586 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/mybatis/MybatisPlusConfiguration.java @@ -0,0 +1,59 @@ +package com.baiyue.common.core.mybatis; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 公共配置,注册分页、乐观锁、防全表更新删除和审计字段自动填充能力。 + */ +@Configuration(proxyBeanMethods = false) +public class MybatisPlusConfiguration { + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); + pagination.setOverflow(false); + pagination.setMaxLimit(500L); + interceptor.addInnerInterceptor(pagination); + interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); + return interceptor; + } + + @Bean + @ConditionalOnMissingBean + public AuditorProvider auditorProvider() { + return () -> null; + } + + @Bean + @ConditionalOnMissingBean(MetaObjectHandler.class) + public MetaObjectHandler auditMetaObjectHandler(AuditorProvider auditorProvider) { + return new MetaObjectHandler() { + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now); + strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now); + strictInsertFill(metaObject, "createdBy", Long.class, auditorProvider.currentUserId()); + strictInsertFill(metaObject, "updatedBy", Long.class, auditorProvider.currentUserId()); + strictInsertFill(metaObject, "deleted", Boolean.class, false); + strictInsertFill(metaObject, "version", Integer.class, 0); + } + + @Override + public void updateFill(MetaObject metaObject) { + strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); + strictUpdateFill(metaObject, "updatedBy", Long.class, auditorProvider.currentUserId()); + } + }; + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java new file mode 100644 index 000000000..1ba26fbea --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java @@ -0,0 +1,30 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApiResponseTest { + @Test + void createsSuccessfulResponseWithCorrelationIds() { + ApiResponse response = ApiResponse.success("ok", "request-1", "trace-1"); + assertThat(response.code()).isEqualTo(ResultCode.SUCCESS.code()); + assertThat(response.successful()).isTrue(); + assertThat(response.requestId()).isEqualTo("request-1"); + assertThat(response.timestamp()).isNotNull(); + } + + @Test + void pageResultIsImmutableAndRejectsInvalidMetadata() { + PageResult result = new PageResult<>(List.of("a"), 1, 20, 1, 1); + assertThat(result.records()).containsExactly("a"); + assertThatThrownBy(() -> new PageResult<>(List.of(), 0, 20, 0, 0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-event-contract/pom.xml b/common-event-contract/pom.xml new file mode 100644 index 000000000..4941f821a --- /dev/null +++ b/common-event-contract/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + com.baiyue.shared + common-event-contract + common-event-contract + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java new file mode 100644 index 000000000..d4cc2775b --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java @@ -0,0 +1,55 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.Instant; + +/** + * 跨服务和跨区域传输使用的不可变事件信封。 + * + *

租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 + * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。

+ * + * @param eventId 事件全局唯一 ID,使用标准 UUID 或 26 位 ULID + * @param eventType 事件类型,不包含版本号 + * @param eventVersion 事件契约版本,从 1 开始 + * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 + * @param sourceRegion 事件来源业务单元 + * @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同 + * @param tenantId 事件所属客户租户 ID + * @param aggregateType 聚合类型 + * @param aggregateId 聚合 ID + * @param aggregateVersion 聚合版本,用于阻止乱序事件覆盖新状态 + * @param handoverVersion 跨区交接版本;非交接事件可为空 + * @param traceId 分布式链路 ID + * @param correlationId 同一业务流程的关联 ID + * @param causationId 直接触发当前事件的上游事件 ID;根事件可为空 + * @param payload 事件业务负载 + * @param payloadHash 负载摘要,格式为 sha256 加冒号和 64 位十六进制字符 + */ +public record EventEnvelope( + String eventId, + String eventType, + int eventVersion, + Instant occurredAt, + RegionCode sourceRegion, + RegionCode targetRegion, + String tenantId, + String aggregateType, + String aggregateId, + long aggregateVersion, + Long handoverVersion, + String traceId, + String correlationId, + String causationId, + JsonNode payload, + String payloadHash +) { + /** + * 校验事件信封,不符合基础契约时抛出异常。 + */ + public EventEnvelope requireValid() { + EventEnvelopeValidator.requireValid(this); + return this; + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java new file mode 100644 index 000000000..980c7b5b1 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java @@ -0,0 +1,89 @@ +package com.baiyue.common.event; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 事件信封基础校验器。 + * + *

这里只校验所有事件共有的传输约束,具体事件负载由对应业务消费者按事件版本校验。

+ */ +public final class EventEnvelopeValidator { + private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]{1,63}$"); + private static final Pattern AGGREGATE_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{1,63}$"); + private static final int MAX_ID_LENGTH = 128; + + private EventEnvelopeValidator() { + } + + /** + * 返回全部基础校验错误;合法时返回空列表。 + */ + public static List validate(EventEnvelope envelope) { + List errors = new ArrayList<>(); + if (envelope == null) { + return List.of("事件信封不能为空"); + } + if (!EventIdentifiers.isSupported(envelope.eventId())) { + errors.add("eventId 必须是标准 UUID 或 26 位 ULID"); + } + if (envelope.eventType() == null || !EVENT_TYPE_PATTERN.matcher(envelope.eventType()).matches()) { + errors.add("eventType 必须以大写字母开头且只能包含字母和数字,长度为 2 至 64 位"); + } + if (envelope.eventVersion() < 1) { + errors.add("eventVersion 必须大于等于 1"); + } + if (envelope.occurredAt() == null) { + errors.add("occurredAt 不能为空"); + } + if (envelope.sourceRegion() == null) { + errors.add("sourceRegion 不能为空"); + } + if (envelope.targetRegion() == null) { + errors.add("targetRegion 不能为空"); + } + requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH); + if (envelope.aggregateType() == null + || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { + errors.add("aggregateType 必须使用大写字母、数字或下划线,长度为 2 至 64 位"); + } + requireText(errors, "aggregateId", envelope.aggregateId(), MAX_ID_LENGTH); + if (envelope.aggregateVersion() < 0) { + errors.add("aggregateVersion 不能小于 0"); + } + if (envelope.handoverVersion() != null && envelope.handoverVersion() < 1) { + errors.add("handoverVersion 存在时必须大于等于 1"); + } + requireText(errors, "traceId", envelope.traceId(), MAX_ID_LENGTH); + requireText(errors, "correlationId", envelope.correlationId(), MAX_ID_LENGTH); + if (envelope.causationId() != null && !EventIdentifiers.isSupported(envelope.causationId())) { + errors.add("causationId 存在时必须是标准 UUID 或 26 位 ULID"); + } + if (envelope.payload() == null || envelope.payload().isNull()) { + errors.add("payload 不能为空"); + } + if (!EventPayloadHash.hasValidFormat(envelope.payloadHash())) { + errors.add("payloadHash 必须使用 sha256 加冒号和 64 位十六进制字符格式"); + } + return List.copyOf(errors); + } + + /** + * 校验事件信封,存在错误时抛出包含全部原因的异常。 + */ + public static void requireValid(EventEnvelope envelope) { + List errors = validate(envelope); + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join(";", errors)); + } + } + + private static void requireText(List errors, String field, String value, int maxLength) { + if (value == null || value.isBlank()) { + errors.add(field + " 不能为空"); + } else if (value.length() > maxLength) { + errors.add(field + " 长度不能超过 " + maxLength + " 位"); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventIdentifiers.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventIdentifiers.java new file mode 100644 index 000000000..c45973467 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventIdentifiers.java @@ -0,0 +1,31 @@ +package com.baiyue.common.event; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * 事件标识生成与格式判断工具。 + */ +public final class EventIdentifiers { + private static final Pattern UUID_PATTERN = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"); + private static final Pattern ULID_PATTERN = Pattern.compile( + "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", Pattern.CASE_INSENSITIVE); + + private EventIdentifiers() { + } + + /** + * 生成标准 UUID 字符串。 + */ + public static String newUuid() { + return UUID.randomUUID().toString(); + } + + /** + * 判断字符串是否为标准 UUID 或 26 位 ULID。 + */ + public static boolean isSupported(String value) { + return value != null && (UUID_PATTERN.matcher(value).matches() || ULID_PATTERN.matcher(value).matches()); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java new file mode 100644 index 000000000..57d173010 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java @@ -0,0 +1,48 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * 将 JSON 负载转换为稳定字节序列,避免对象字段顺序不同导致摘要不一致。 + */ +public final class EventPayloadCanonicalizer { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + private EventPayloadCanonicalizer() { + } + + /** + * 递归排序对象字段并输出 UTF-8 JSON 字节。 + */ + public static byte[] canonicalize(JsonNode payload) { + if (payload == null || payload.isNull()) { + throw new IllegalArgumentException("事件负载不能为空"); + } + try { + return OBJECT_MAPPER.writeValueAsBytes(sort(payload)); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("事件负载无法规范化", exception); + } + } + + private static JsonNode sort(JsonNode node) { + if (node.isObject()) { + var names = new java.util.ArrayList(); + node.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + var result = OBJECT_MAPPER.createObjectNode(); + names.forEach(name -> result.set(name, sort(node.get(name)))); + return result; + } + if (node.isArray()) { + var result = OBJECT_MAPPER.createArrayNode(); + node.forEach(child -> result.add(sort(child))); + return result; + } + return node; + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadHash.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadHash.java new file mode 100644 index 000000000..6c4cd1427 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadHash.java @@ -0,0 +1,52 @@ +package com.baiyue.common.event; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * 事件负载 SHA-256 摘要工具。 + */ +public final class EventPayloadHash { + private static final Pattern HASH_PATTERN = Pattern.compile("^sha256:[0-9a-fA-F]{64}$"); + + private EventPayloadHash() { + } + + /** + * 计算调用方提供的规范化负载字节摘要。 + */ + public static String sha256(byte[] canonicalPayload) { + if (canonicalPayload == null) { + throw new IllegalArgumentException("规范化负载字节不能为空"); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonicalPayload); + return "sha256:" + HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前运行环境不支持 SHA-256", exception); + } + } + + /** + * 判断摘要是否符合约定格式。 + */ + public static boolean hasValidFormat(String value) { + return value != null && HASH_PATTERN.matcher(value).matches(); + } + + /** + * 使用常量时间比较校验规范化负载字节与摘要是否一致。 + */ + public static boolean matches(byte[] canonicalPayload, String expectedHash) { + if (canonicalPayload == null || !hasValidFormat(expectedHash)) { + return false; + } + byte[] actual = sha256(canonicalPayload).getBytes(StandardCharsets.US_ASCII); + byte[] expected = expectedHash.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII); + return MessageDigest.isEqual(actual, expected); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java new file mode 100644 index 000000000..eb12cc898 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -0,0 +1,20 @@ +package com.baiyue.common.event; + +/** + * 跨服务和跨区域事件类型常量。 + * + *

事件版本由事件信封的 {@code eventVersion} 单独表达,类型名称本身不携带版本号。

+ */ +public final class EventTypes { + public static final String ORDER_APPROVED = "OrderApproved"; + public static final String ORDER_CANCELLED = "OrderCancelled"; + public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; + public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; + public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; + public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; + public static final String LAST_MILE_COMPLETED = "LastMileCompleted"; + public static final String TRACKING_MILESTONE_PROJECTED = "TrackingMilestoneProjected"; + + private EventTypes() { + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java b/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java new file mode 100644 index 000000000..f70c608c2 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java @@ -0,0 +1,13 @@ +package com.baiyue.common.event; + +/** + * 事件来源与目标部署区域。 + * + *

区域表示独立业务单元,不表示用户当前所在地。

+ */ +public enum RegionCode { + /** 国内业务单元。 */ + CN, + /** 海外业务单元。 */ + OVERSEAS +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java b/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java new file mode 100644 index 000000000..785251b9d --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java @@ -0,0 +1,4 @@ +/** + * 国内与海外业务单元共享的稳定事件契约。 + */ +package com.baiyue.common.event; diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java new file mode 100644 index 000000000..8cf5ac3a3 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -0,0 +1,104 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EventEnvelopeTest { + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception { + EventEnvelope source = validEnvelope(EventIdentifiers.newUuid()); + + source.requireValid(); + String json = objectMapper.writeValueAsString(source); + EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class); + + assertThat(restored).isEqualTo(source); + assertThat(objectMapper.readTree(json).path("eventType").asText()) + .isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED); + } + + @Test + void 合法Ulid可以作为事件标识() { + EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W"); + + assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty(); + } + + @Test + void 校验器一次返回全部基础错误() { + EventEnvelope envelope = new EventEnvelope( + "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", + -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); + + List errors = EventEnvelopeValidator.validate(envelope); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(10); + assertThatThrownBy(envelope::requireValid) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eventId") + .hasMessageContaining("payloadHash"); + } + + @Test + void 负载摘要可计算并校验() { + byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8); + String hash = EventPayloadHash.sha256(payload); + + assertThat(hash).matches("sha256:[0-9a-f]{64}"); + assertThat(EventPayloadHash.matches(payload, hash)).isTrue(); + assertThat(EventPayloadHash.matches("{}".getBytes(StandardCharsets.UTF_8), hash)).isFalse(); + } + + @Test + void 对象字段顺序不同仍产生相同规范摘要() throws Exception { + JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}"); + JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}"); + + assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first))) + .isEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second))); + } + + @Test + void 数组顺序仍作为业务语义保留() throws Exception { + JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}"); + JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}"); + + assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first))) + .isNotEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second))); + } + + private EventEnvelope validEnvelope(String eventId) { + JsonNode payload = objectMapper.createObjectNode() + .put("containerNo", "MSCU1234567") + .put("orderId", "90001"); + byte[] canonicalPayload = payload.toString().getBytes(StandardCharsets.UTF_8); + return new EventEnvelope( + eventId, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + 1, + Instant.parse("2026-07-29T08:30:00Z"), + RegionCode.CN, + RegionCode.OVERSEAS, + "20001", + "FIRST_LEG_SHIPMENT", + "90001", + 7, + 1L, + "trace-90001", + "handover-90001", + null, + payload, + EventPayloadHash.sha256(canonicalPayload)); + } +} diff --git a/common-openapi/pom.xml b/common-openapi/pom.xml new file mode 100644 index 000000000..8ddfe901a --- /dev/null +++ b/common-openapi/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + + com.baiyue.shared + baiyue-common-openapi + + org.springdocspringdoc-openapi-starter-webmvc-ui + org.springframework.bootspring-boot-starter-webtrue + + diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java new file mode 100644 index 000000000..04055e3b5 --- /dev/null +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -0,0 +1,67 @@ +package com.baiyue.common.openapi; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.parameters.HeaderParameter; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * OpenAPI 公共自动配置,根据服务配置生成统一的接口文档元数据和 JWT 安全方案。 + */ +@AutoConfiguration +@EnableConfigurationProperties(OpenApiProperties.class) +@ConditionalOnProperty(prefix = "baiyue.openapi", name = "enabled", havingValue = "true", matchIfMissing = true) +public class BaiyueOpenApiAutoConfiguration { + public static final String BEARER = "bearerAuth"; + + @Bean + OpenAPI baiyueOpenApi(OpenApiProperties p) { + return new OpenAPI() + .info(new Info().title(p.getTitle()).description(p.getDescription()).version(p.getVersion())) + .components(new Components().addSecuritySchemes(BEARER, + new SecurityScheme().name(BEARER).type(SecurityScheme.Type.HTTP) + .scheme("bearer").bearerFormat("JWT"))) + .addSecurityItem(new SecurityRequirement().addList(BEARER)); + } + + @Bean + OperationCustomizer tenantHeaderCustomizer() { + return (operation, method) -> operation + .addParametersItem(header("X-Tenant-Id", "Current tenant ID / 当前租户 ID", false)) + .addParametersItem(header("X-Operator-Tenant-Id", "Operator tenant ID / 运营方租户 ID", false)) + .addParametersItem(header("X-App-Code", "Application code / 应用编码", false)) + .addParametersItem(header("X-Request-Id", "Request correlation ID / 请求关联 ID", false)) + .addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false)); + } + + @Bean + GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) { + return GroupedOpenApi.builder().group("platform") + .displayName("Platform APIs / 平台端接口") + .pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**") + .addOperationCustomizer(tenantHeaderCustomizer).build(); + } + + @Bean + GroupedOpenApi merchantApi(OperationCustomizer tenantHeaderCustomizer) { + return GroupedOpenApi.builder().group("merchant") + .displayName("Merchant APIs / 商户端接口") + .pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**", + "/api/firstleg/**", "/api/lastleg/**") + .addOperationCustomizer(tenantHeaderCustomizer).build(); + } + + private Parameter header(String name, String description, boolean required) { + return new HeaderParameter().name(name).description(description).required(required) + .schema(new io.swagger.v3.oas.models.media.StringSchema()); + } +} diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/OpenApiProperties.java b/common-openapi/src/main/java/com/baiyue/common/openapi/OpenApiProperties.java new file mode 100644 index 000000000..8526918a0 --- /dev/null +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/OpenApiProperties.java @@ -0,0 +1,28 @@ +package com.baiyue.common.openapi; + +import lombok.Getter; +import lombok.Setter; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * OpenAPI 文档配置模型,定义文档开关、服务信息、联系人、许可证和服务器地址。 + */ +@Getter +@Setter +@ConfigurationProperties("baiyue.openapi") +public class OpenApiProperties { + private String title = "Baiyue Platform API / 柏悦平台接口"; + private String description = "Logistics SaaS API / 物流 SaaS 接口"; + private String version = "v1"; + private boolean enabled = true; + + + + + + + + + +} diff --git a/common-openapi/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-openapi/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..3e907bbcb --- /dev/null +++ b/common-openapi/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.baiyue.common.openapi.BaiyueOpenApiAutoConfiguration diff --git a/common-security/pom.xml b/common-security/pom.xml new file mode 100644 index 000000000..5e0462486 --- /dev/null +++ b/common-security/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + com.baiyue.shared + baiyue-common-security + baiyue-common-security + + + + com.baiyue.shared + baiyue-common-core + ${project.version} + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-oauth2-resource-server + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + diff --git a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java new file mode 100644 index 000000000..6b431251e --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java @@ -0,0 +1,11 @@ +package com.baiyue.common.security; + +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。 + */ +@FunctionalInterface +public interface AccessTokenSessionValidator { + void validate(Jwt jwt); +} diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java new file mode 100644 index 000000000..27d1c0e0e --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -0,0 +1,21 @@ +package com.baiyue.common.security; + +import java.security.Principal; +import java.util.Set; + +/** + * 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。 + */ +public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, + Set permissions) implements Principal { + public AuthenticatedUser { + permissions = permissions == null ? Set.of() : Set.copyOf(permissions); + } + + @Override + public String getName() { + return username; + } +} + diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueJwtAuthenticationToken.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueJwtAuthenticationToken.java new file mode 100644 index 000000000..f2f4c8955 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueJwtAuthenticationToken.java @@ -0,0 +1,28 @@ +package com.baiyue.common.security; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +/** + * 系统 JWT 认证令牌实现,封装已认证用户主体、原始令牌和权限集合。 + */ +public final class BaiyueJwtAuthenticationToken extends AbstractAuthenticationToken { + private final AuthenticatedUser principal; + private final String tokenValue; + + public BaiyueJwtAuthenticationToken(AuthenticatedUser principal, String tokenValue, + Collection authorities) { + super(authorities); + this.principal = principal; + this.tokenValue = tokenValue; + setAuthenticated(true); + } + + @Override + public String getCredentials() { return tokenValue; } + + @Override + public AuthenticatedUser getPrincipal() { return principal; } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java new file mode 100644 index 000000000..f7b81b803 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -0,0 +1,89 @@ +package com.baiyue.common.security; + +import com.baiyue.common.core.mybatis.AuditorProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 + */ +@AutoConfiguration( + after = HmacJwtDecoderAutoConfiguration.class, + before = SecurityAutoConfiguration.class) +@EnableMethodSecurity +public class BaiyueSecurityAutoConfiguration { + @Bean + @ConditionalOnMissingBean + JwtClaimsMapper jwtClaimsMapper() { return new JwtClaimsMapper(); } + + @Bean + @ConditionalOnMissingBean(AuthenticationEntryPoint.class) + AuthenticationEntryPoint jsonAuthenticationEntryPoint(ObjectMapper objectMapper) { + return new JsonAuthenticationEntryPoint(objectMapper); + } + + @Bean + @ConditionalOnMissingBean(AccessDeniedHandler.class) + AccessDeniedHandler jsonAccessDeniedHandler(ObjectMapper objectMapper) { + return new JsonAccessDeniedHandler(objectMapper); + } + + @Bean + @ConditionalOnMissingBean + JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, + AuthenticationEntryPoint entryPoint, + StringRedisTemplate redisTemplate, + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { + return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, + new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix)); + } + + @Bean + FilterRegistrationBean disableContainerRegistration( + JwtAuthenticationFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + + @Bean + @ConditionalOnMissingBean(SecurityFilterChain.class) + SecurityFilterChain baiyueSecurityFilterChain( + HttpSecurity http, JwtAuthenticationFilter jwtFilter, + AuthenticationEntryPoint entryPoint, AccessDeniedHandler deniedHandler) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(form -> form.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(errors -> errors + .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers("/internal/**").permitAll() + .requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT") + .requestMatchers("/api/platform/**").hasAuthority("APP_PLATFORM") + .anyRequest().authenticated()) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + @Primary + AuditorProvider securityAuditorProvider() { return new SecurityAuditorProvider(); } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java new file mode 100644 index 000000000..4cc24909d --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java @@ -0,0 +1,33 @@ +package com.baiyue.common.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + +/** + * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 + */ +@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) +@ConditionalOnProperty(name = "baiyue.security.jwt-secret") +public class HmacJwtDecoderAutoConfiguration { + @Bean + @ConditionalOnMissingBean(JwtDecoder.class) + JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret, + @Value("${baiyue.security.issuer:auth-service}") String issuer) { + byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8); + if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")) + .macAlgorithm(MacAlgorithm.HS256).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)); + return decoder; + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/InvalidJwtAuthenticationException.java b/common-security/src/main/java/com/baiyue/common/security/InvalidJwtAuthenticationException.java new file mode 100644 index 000000000..1a9ed13e0 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/InvalidJwtAuthenticationException.java @@ -0,0 +1,12 @@ +package com.baiyue.common.security; + +import org.springframework.security.core.AuthenticationException; + +/** + * JWT 认证失败异常,表示令牌无效、过期或对应登录会话已失效。 + */ +public class InvalidJwtAuthenticationException extends AuthenticationException { + public InvalidJwtAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JsonAccessDeniedHandler.java b/common-security/src/main/java/com/baiyue/common/security/JsonAccessDeniedHandler.java new file mode 100644 index 000000000..f7ca9e8b0 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JsonAccessDeniedHandler.java @@ -0,0 +1,34 @@ +package com.baiyue.common.security; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 访问拒绝处理器,以统一 JSON 结构返回 HTTP 403 错误。 + */ +public class JsonAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + public JsonAccessDeniedHandler(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException exception) throws IOException { + ResultCode code = ResultCode.FORBIDDEN; + response.setStatus(code.httpStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), ApiResponse.failure(code, code.message(), + CorrelationContext.requestId(request), CorrelationContext.traceId(request))); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JsonAuthenticationEntryPoint.java b/common-security/src/main/java/com/baiyue/common/security/JsonAuthenticationEntryPoint.java new file mode 100644 index 000000000..21e055a39 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JsonAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.baiyue.common.security; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 未认证请求处理器,以统一 JSON 结构返回 HTTP 401 错误。 + */ +public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + public JsonAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + ResultCode code = ResultCode.UNAUTHORIZED; + response.setStatus(code.httpStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), ApiResponse.failure(code, code.message(), + CorrelationContext.requestId(request), CorrelationContext.traceId(request))); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java new file mode 100644 index 000000000..ececf08fa --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,70 @@ +package com.baiyue.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。 + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String BEARER = "Bearer "; + private final JwtDecoder jwtDecoder; + private final JwtClaimsMapper claimsMapper; + private final AuthenticationEntryPoint authenticationEntryPoint; + private final AccessTokenSessionValidator sessionValidator; + + public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, + AuthenticationEntryPoint authenticationEntryPoint) { + this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { }); + } + + public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, + AuthenticationEntryPoint authenticationEntryPoint, + AccessTokenSessionValidator sessionValidator) { + this.jwtDecoder = jwtDecoder; + this.claimsMapper = claimsMapper; + this.authenticationEntryPoint = authenticationEntryPoint; + this.sessionValidator = sessionValidator; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null || !header.regionMatches(true, 0, BEARER, 0, BEARER.length())) { + filterChain.doFilter(request, response); + return; + } + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + String tokenValue = header.substring(BEARER.length()).trim(); + try { + Jwt jwt = jwtDecoder.decode(tokenValue); + sessionValidator.validate(jwt); + Authentication authentication = claimsMapper.map(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } catch (JwtException | IllegalArgumentException exception) { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence(request, response, + new InvalidJwtAuthenticationException("Invalid or expired access token", exception)); + } finally { + SecurityContextHolder.clearContext(); + } + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java new file mode 100644 index 000000000..19d798d17 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -0,0 +1,77 @@ +package com.baiyue.common.security; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * JWT 声明映射器,将令牌中的用户、租户、端类型和权限声明转换为认证主体与授权集合。 + */ +public class JwtClaimsMapper { + public BaiyueJwtAuthenticationToken map(Jwt jwt) { + Set permissions = new LinkedHashSet<>(); + addClaimValues(permissions, jwt.getClaims().get("authorities")); + addClaimValues(permissions, jwt.getClaims().get("permissions")); + addClaimValues(permissions, jwt.getClaims().get("scope")); + addRoles(permissions, jwt.getClaims().get("roles")); + + String appCode = jwt.getClaimAsString("app_code"); + if (appCode != null && !appCode.isBlank()) { + permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT)); + } + String username = firstNonBlank(jwt.getClaimAsString("username"), + jwt.getClaimAsString("preferred_username"), jwt.getSubject()); + Long userId = longClaim(jwt, "user_id"); + AuthenticatedUser user = new AuthenticatedUser( + userId == null ? longValue(jwt.getSubject()) : userId, + longClaim(jwt, "tenant_id"), + longClaim(jwt, "operator_tenant_id"), + longClaim(jwt, "membership_id"), + username, + jwt.getClaimAsString("app_code"), + jwt.getClaimAsString("client_type"), + permissions); + List authorities = permissions.stream() + .map(SimpleGrantedAuthority::new).toList(); + return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); + } + + private void addClaimValues(Set target, Object value) { + if (value instanceof Collection collection) { + collection.stream().map(Object::toString).filter(s -> !s.isBlank()).forEach(target::add); + } else if (value instanceof String text) { + for (String item : text.split("[ ,]+")) { + if (!item.isBlank()) target.add(item); + } + } + } + + private void addRoles(Set target, Object value) { + Set roles = new LinkedHashSet<>(); + addClaimValues(roles, value); + roles.stream().map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role).forEach(target::add); + } + + private Long longValue(String value) { + if (value == null || value.isBlank()) return null; + try { return Long.valueOf(value); } + catch (NumberFormatException ignored) { return null; } + } + + private Long longClaim(Jwt jwt, String name) { + Object value = jwt.getClaims().get(name); + if (value instanceof Number number) return number.longValue(); + if (value instanceof String text) return longValue(text); + return null; + } + + private String firstNonBlank(String... values) { + for (String value : values) if (value != null && !value.isBlank()) return value; + return "unknown"; + } +} + diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java new file mode 100644 index 000000000..66098791f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -0,0 +1,32 @@ +package com.baiyue.common.security; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。 + */ +public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator { + private final StringRedisTemplate redisTemplate; + private final String keyPrefix; + + public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { + this.redisTemplate = redisTemplate; + this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + } + + @Override + public void validate(Jwt jwt) { + String jwtId = jwt.getId(); + if (jwtId == null || jwtId.isBlank()) { + throw new IllegalArgumentException("Access token has no jti"); + } + try { + if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) { + throw new IllegalArgumentException("Access token session is revoked or expired"); + } + } catch (org.springframework.dao.DataAccessException exception) { + throw new IllegalArgumentException("Token session store is unavailable", exception); + } + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisTokenKeys.java b/common-security/src/main/java/com/baiyue/common/security/RedisTokenKeys.java new file mode 100644 index 000000000..234e77599 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/RedisTokenKeys.java @@ -0,0 +1,24 @@ +package com.baiyue.common.security; + +/** + * Redis 令牌键工具,规范访问令牌会话键前缀及键名拼装规则。 + */ +public final class RedisTokenKeys { + private RedisTokenKeys() { } + + public static String access(String prefix, String jwtId) { + return normalize(prefix) + ":access:" + jwtId; + } + + public static String refresh(String prefix, String refreshHash) { + return normalize(prefix) + ":refresh:" + refreshHash; + } + + public static String normalize(String prefix) { + String value = prefix == null || prefix.isBlank() ? "baiyue:auth" : prefix.trim(); + while (value.endsWith(":")) { + value = value.substring(0, value.length() - 1); + } + return value; + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/SecurityAuditorProvider.java b/common-security/src/main/java/com/baiyue/common/security/SecurityAuditorProvider.java new file mode 100644 index 000000000..eabf3e959 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/SecurityAuditorProvider.java @@ -0,0 +1,17 @@ +package com.baiyue.common.security; + +import com.baiyue.common.core.mybatis.AuditorProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Spring Security 审计操作者提供器,从当前认证主体提取用户 ID 供持久化审计字段自动填充。 + */ +public class SecurityAuditorProvider implements AuditorProvider { + @Override + public Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user + ? user.userId() : null; + } +} diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..09441e4a6 --- /dev/null +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.baiyue.common.security.HmacJwtDecoderAutoConfiguration +com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..65967aeac --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,42 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtAuthenticationFilterTest { + @AfterEach + void clearContext() { SecurityContextHolder.clearContext(); } + + @Test + void authenticatesValidBearerDuringFilterChain() throws Exception { + Jwt jwt = Jwt.withTokenValue("valid").header("alg", "none").subject("alice") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) + .claim("user_id", 1L).build(); + JwtDecoder decoder = token -> jwt; + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(), + new JsonAuthenticationEntryPoint(new ObjectMapper())); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + filter.doFilter(request, response, (request1, response1) -> { + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + invoked.set(true); + }); + assertThat(invoked).isTrue(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java new file mode 100644 index 000000000..dd5000696 --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -0,0 +1,50 @@ +package com.baiyue.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtClaimsMapperTest { + @Test + void mapsTenantUserRolesAndPermissions() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("subject") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("user_id", 42L) + .claim("tenant_id", "7") + .claim("username", "alice") + .claim("app_code", "MERCHANT") + .claim("client_type", "MERCHANT") + .claim("permissions", List.of("order:read", "order:create")) + .claim("roles", List.of("ADMIN")) + .build(); + + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt); + assertThat(authentication.getPrincipal().userId()).isEqualTo(42L); + assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L); + assertThat(authentication.getAuthorities()).extracting("authority") + .contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT"); + } + + @Test + void fallsBackToNumericSubjectForUserId() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("99") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("tenant_id", 0L) + .claim("app_code", "PLATFORM") + .build(); + + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt); + assertThat(authentication.getPrincipal().userId()).isEqualTo(99L); + assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM"); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java new file mode 100644 index 000000000..556bf7426 --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -0,0 +1,53 @@ +package com.baiyue.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RedisAccessTokenSessionValidatorTest { + @Test + void acceptsActiveSession() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true); + + assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))).doesNotThrowAnyException(); + } + + @Test + void rejectsRevokedSession() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("revoked or expired"); + } + + @Test + void failsClosedWhenRedisIsUnavailable() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + when(redis.hasKey("baiyue:auth:access:jti-1")) + .thenThrow(new RedisConnectionFailureException("offline")); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unavailable"); + } + + private Jwt jwt(String jwtId) { + return Jwt.withTokenValue("token").header("alg", "none") + .subject("1").claim("jti", jwtId).issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)).build(); + } +} diff --git a/common-web/pom.xml b/common-web/pom.xml new file mode 100644 index 000000000..a9e635d84 --- /dev/null +++ b/common-web/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + com.baiyue.shared + baiyue-common-web + baiyue-common-web + + + + com.baiyue.shared + baiyue-common-core + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.security + spring-security-core + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java new file mode 100644 index 000000000..976ab0b30 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java @@ -0,0 +1,29 @@ +package com.baiyue.common.web; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; + +/** + * 请求关联标识上下文工具,从请求属性或日志 MDC 中读取当前请求 ID。 + */ +public final class CorrelationContext { + public static final String REQUEST_ID = "requestId"; + public static final String TRACE_ID = "traceId"; + public static final String REQUEST_ID_HEADER = "X-Request-Id"; + public static final String TRACE_ID_HEADER = "X-Trace-Id"; + + private CorrelationContext() {} + + public static String requestId(HttpServletRequest request) { + return value(request, REQUEST_ID); + } + + public static String traceId(HttpServletRequest request) { + return value(request, TRACE_ID); + } + + private static String value(HttpServletRequest request, String key) { + Object attribute = request.getAttribute(key); + return attribute == null ? MDC.get(key) : attribute.toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java new file mode 100644 index 000000000..b08f7c832 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java @@ -0,0 +1,48 @@ +package com.baiyue.common.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * 请求关联标识过滤器,校验或生成请求 ID,并同步写入响应头和日志 MDC。 + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorrelationIdFilter extends OncePerRequestFilter { + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); + String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); + request.setAttribute(CorrelationContext.REQUEST_ID, requestId); + request.setAttribute(CorrelationContext.TRACE_ID, traceId); + response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); + response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); + + try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); + MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { + filterChain.doFilter(request, response); + } + } + + private String acceptedOrGenerated(String candidate, boolean compact) { + if (candidate != null && SAFE_ID.matcher(candidate).matches()) { + return candidate; + } + String id = UUID.randomUUID().toString(); + return compact ? id.replace("-", "") : id; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java new file mode 100644 index 000000000..aaa5095fe --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -0,0 +1,102 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Servlet Web 全局异常处理器,将业务异常、参数校验异常和系统异常转换为统一 API 响应。 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BizException.class) + public ResponseEntity> handleBusiness(BizException exception, HttpServletRequest request) { + return failure(exception.getResultCode(), exception.getMessage(), request); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidation( + MethodArgumentNotValidException exception, HttpServletRequest request) { + return validationResponse(fieldErrors(exception.getBindingResult().getFieldErrors()), request); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity>> handleBinding( + BindException exception, HttpServletRequest request) { + return validationResponse(fieldErrors(exception.getBindingResult().getFieldErrors()), request); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity>> handleConstraint( + ConstraintViolationException exception, HttpServletRequest request) { + Map errors = new LinkedHashMap<>(); + exception.getConstraintViolations().forEach(violation -> + errors.put(violation.getPropertyPath().toString(), violation.getMessage())); + return validationResponse(errors, request); + } + + @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class}) + public ResponseEntity> handleBadRequest(Exception exception, HttpServletRequest request) { + return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthentication( + AuthenticationException exception, HttpServletRequest request) { + return failure(ResultCode.UNAUTHORIZED, ResultCode.UNAUTHORIZED.message(), request); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied( + AccessDeniedException exception, HttpServletRequest request) { + return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { + log.error("Unhandled request failure requestId={} traceId={}", + CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception); + return failure(ResultCode.INTERNAL_ERROR, ResultCode.INTERNAL_ERROR.message(), request); + } + + private Map fieldErrors(Iterable fieldErrors) { + Map errors = new LinkedHashMap<>(); + fieldErrors.forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage())); + return errors; + } + + private ResponseEntity>> validationResponse( + Map errors, HttpServletRequest request) { + ResultCode code = ResultCode.VALIDATION_FAILED; + ApiResponse> body = ApiResponse.of(code, code.message(), errors, + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + return ResponseEntity.status(code.httpStatus()).body(body); + } + + private ResponseEntity> failure(ResultCode code, String message, + HttpServletRequest request) { + ApiResponse body = ApiResponse.failure(code, message, + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + return ResponseEntity.status(code.httpStatus()).body(body); + } +} + diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java new file mode 100644 index 000000000..ee9683390 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +class CorrelationIdFilterTest { + private final CorrelationIdFilter filter = new CorrelationIdFilter(); + + @Test + void preservesSafeIncomingIds() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); + assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); + } + + @Test + void replacesUnsafeIncomingIds() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..ae2d02ae9 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -0,0 +1,23 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void businessExceptionKeepsBusinessCodeAndCorrelation() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "r1"); + request.setAttribute(CorrelationContext.TRACE_ID, "t1"); + var response = handler.handleBusiness(new BizException("inventory is locked"), request); + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code()); + assertThat(response.getBody().requestId()).isEqualTo("r1"); + } +} diff --git a/config/nacos/cn/README.md b/config/nacos/cn/README.md new file mode 100644 index 000000000..704d7ffbf --- /dev/null +++ b/config/nacos/cn/README.md @@ -0,0 +1,5 @@ +# 国内 Nacos 配置 + +将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。 + +尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。 diff --git a/config/nacos/cn/audit-service.yml b/config/nacos/cn/audit-service.yml new file mode 100644 index 000000000..1ac49f251 --- /dev/null +++ b/config/nacos/cn/audit-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${AUDIT_DB_URL} + username: ${AUDIT_DB_USERNAME} + password: ${AUDIT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml new file mode 100644 index 000000000..38e4be250 --- /dev/null +++ b/config/nacos/cn/auth-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${AUTH_DB_URL} + username: ${AUTH_DB_USERNAME} + password: ${AUTH_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/cn/baiyue-common.yml new file mode 100644 index 000000000..3b6187a66 --- /dev/null +++ b/config/nacos/cn/baiyue-common.yml @@ -0,0 +1,61 @@ +baiyue: + deployment: + region-code: ${REGION_CODE:CN} + cell: ${DEPLOYMENT_CELL:prod-cn} + data-residency: ${DATA_RESIDENCY:CN} + security: + jwt-secret: ${JWT_SECRET} + issuer: ${JWT_ISSUER:auth-service} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} + openapi.enabled: ${SWAGGER_ENABLED:false} + audit.enabled: true +spring: + jackson: + default-property-inclusion: non_null + deserialization: + fail-on-unknown-properties: false + serialization: + write-dates-as-timestamps: false + data: + redis: + host: ${REDIS_HOST:redis-cn.internal} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: ${REDIS_TIMEOUT:3s} + cloud: + nacos: + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-cn} + openfeign: + circuitbreaker.enabled: true + client.config.default: + connect-timeout: 3000 + read-timeout: 5000 +server: + shutdown: graceful +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes.enabled: true + show-details: never +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + call-setters-on-nulls: true + global-config: + banner: false + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml new file mode 100644 index 000000000..a5fbba087 --- /dev/null +++ b/config/nacos/cn/event-bridge-service.yml @@ -0,0 +1,10 @@ +spring: + datasource: + url: ${EVENT_BRIDGE_DB_URL} + username: ${EVENT_BRIDGE_DB_USERNAME} + password: ${EVENT_BRIDGE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + region: ${REGION_CODE:CN} + deployment-cell: ${DEPLOYMENT_CELL:prod-cn} + data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml new file mode 100644 index 000000000..e7d9a989e --- /dev/null +++ b/config/nacos/cn/file-service.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: ${FILE_DB_URL} + username: ${FILE_DB_USERNAME} + password: ${FILE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +file: + storage: + provider: ${FILE_STORAGE_PROVIDER:oss} + endpoint: ${FILE_STORAGE_ENDPOINT:} + bucket: ${FILE_STORAGE_BUCKET:} + access-key: ${FILE_STORAGE_ACCESS_KEY:} + secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml new file mode 100644 index 000000000..4baf82f98 --- /dev/null +++ b/config/nacos/cn/firstleg-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${FIRSTLEG_DB_URL} + username: ${FIRSTLEG_DB_USERNAME} + password: ${FIRSTLEG_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/gateway.yml b/config/nacos/cn/gateway.yml new file mode 100644 index 000000000..e16f76e10 --- /dev/null +++ b/config/nacos/cn/gateway.yml @@ -0,0 +1,3 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml new file mode 100644 index 000000000..43082d56b --- /dev/null +++ b/config/nacos/cn/iam-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${IAM_DB_URL} + username: ${IAM_DB_USERNAME} + password: ${IAM_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/cn/integration-service.yml new file mode 100644 index 000000000..a0de026fb --- /dev/null +++ b/config/nacos/cn/integration-service.yml @@ -0,0 +1,8 @@ +baiyue: + integration: + baidu-ocr: + enabled: ${BAIDU_OCR_ENABLED:false} + api-key: ${BAIDU_OCR_API_KEY:} + secret-key: ${BAIDU_OCR_SECRET_KEY:} + amazon-sp-api: + enabled: ${AMAZON_SP_API_ENABLED:false} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/cn/order-service.yml new file mode 100644 index 000000000..390e43a77 --- /dev/null +++ b/config/nacos/cn/order-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${ORDER_DB_URL} + username: ${ORDER_DB_USERNAME} + password: ${ORDER_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/print-service.yml b/config/nacos/cn/print-service.yml new file mode 100644 index 000000000..613433e3f --- /dev/null +++ b/config/nacos/cn/print-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${PRINT_DB_URL} + username: ${PRINT_DB_USERNAME} + password: ${PRINT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/cn/warehouse-service.yml b/config/nacos/cn/warehouse-service.yml new file mode 100644 index 000000000..a05d57b33 --- /dev/null +++ b/config/nacos/cn/warehouse-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md new file mode 100644 index 000000000..acfe702a9 --- /dev/null +++ b/config/nacos/local/README.md @@ -0,0 +1,13 @@ +# 头程本地 Nacos 配置 + +本目录配置用于本机开发环境,发布目标为 `public` 命名空间和 `BAIYUE` 分组。 + +配置通过环境变量保留覆盖能力,并提供当前本机可直接启动的默认地址。生产环境必须使用 `config/nacos/cn`,不得使用本目录中的本地密码和本地密钥。 + +发布命令: + +```powershell +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 +``` + +配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 diff --git a/config/nacos/local/audit-service.yml b/config/nacos/local/audit-service.yml new file mode 100644 index 000000000..f1d414ec6 --- /dev/null +++ b/config/nacos/local/audit-service.yml @@ -0,0 +1,5 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUDIT_DB_USERNAME:root} + password: ${AUDIT_DB_PASSWORD:123456} diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml new file mode 100644 index 000000000..8843875b3 --- /dev/null +++ b/config/nacos/local/auth-service.yml @@ -0,0 +1,5 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUTH_DB_USERNAME:root} + password: ${AUTH_DB_PASSWORD:123456} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml new file mode 100644 index 000000000..0869c1028 --- /dev/null +++ b/config/nacos/local/baiyue-common.yml @@ -0,0 +1,54 @@ +# 头程本地公共配置。生产环境不得直接使用本文件中的本地默认密钥。 +baiyue: + deployment: + region-code: ${REGION_CODE:CN} + cell: ${DEPLOYMENT_CELL:local-cn} + data-residency: ${DATA_RESIDENCY:CN} + security: + jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} + issuer: ${JWT_ISSUER:auth-service} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} + openapi.enabled: ${SWAGGER_ENABLED:true} + audit.enabled: true +spring: + jackson: + default-property-inclusion: non_null + deserialization.fail-on-unknown-properties: false + serialization.write-dates-as-timestamps: false + 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.discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + openfeign: + circuitbreaker.enabled: true + client.config.default: + connect-timeout: 3000 + read-timeout: 5000 +server.shutdown: graceful +management: + endpoints.web.exposure.include: health,info,prometheus + endpoint.health: + probes.enabled: true + show-details: always +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + call-setters-on-nulls: true + global-config: + banner: false + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:true} + swagger-ui.enabled: ${SWAGGER_ENABLED:true} diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml new file mode 100644 index 000000000..2f3d5dd51 --- /dev/null +++ b/config/nacos/local/event-bridge-service.yml @@ -0,0 +1,9 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} + username: ${EVENT_BRIDGE_DB_USERNAME:root} + password: ${EVENT_BRIDGE_DB_PASSWORD:123456} +baiyue: + region: ${REGION_CODE:CN} + deployment-cell: ${DEPLOYMENT_CELL:local-cn} + data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml new file mode 100644 index 000000000..bfc359e34 --- /dev/null +++ b/config/nacos/local/file-service.yml @@ -0,0 +1,6 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${FILE_DB_USERNAME:root} + password: ${FILE_DB_PASSWORD:123456} +baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml new file mode 100644 index 000000000..2597cf547 --- /dev/null +++ b/config/nacos/local/firstleg-service.yml @@ -0,0 +1,5 @@ +spring.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} diff --git a/config/nacos/local/gateway.yml b/config/nacos/local/gateway.yml new file mode 100644 index 000000000..38bedb7c3 --- /dev/null +++ b/config/nacos/local/gateway.yml @@ -0,0 +1,3 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:true} + swagger-ui.enabled: ${SWAGGER_ENABLED:true} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml new file mode 100644 index 000000000..fba851416 --- /dev/null +++ b/config/nacos/local/iam-service.yml @@ -0,0 +1,5 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${IAM_DB_USERNAME:root} + password: ${IAM_DB_PASSWORD:123456} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml new file mode 100644 index 000000000..f41753f73 --- /dev/null +++ b/config/nacos/local/integration-service.yml @@ -0,0 +1,3 @@ +baiyue.integration: + baidu-ocr.enabled: ${BAIDU_OCR_ENABLED:false} + amazon-sp-api.enabled: ${AMAZON_SP_API_ENABLED:false} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml new file mode 100644 index 000000000..ca0bf449e --- /dev/null +++ b/config/nacos/local/order-service.yml @@ -0,0 +1,7 @@ +spring.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} +baiyue.clients: + warehouse.url: ${WAREHOUSE_SERVICE_URL:http://127.0.0.1:9205} diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml new file mode 100644 index 000000000..0a3a09dc5 --- /dev/null +++ b/config/nacos/local/print-service.yml @@ -0,0 +1,5 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${PRINT_DB_USERNAME:root} + password: ${PRINT_DB_PASSWORD:123456} diff --git a/config/nacos/local/warehouse-service.yml b/config/nacos/local/warehouse-service.yml new file mode 100644 index 000000000..b0e8a969c --- /dev/null +++ b/config/nacos/local/warehouse-service.yml @@ -0,0 +1,5 @@ +spring.datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/docker-compose-infra.yml b/docker-compose-infra.yml new file mode 100644 index 000000000..b8cfe398c --- /dev/null +++ b/docker-compose-infra.yml @@ -0,0 +1,73 @@ +name: baiyue-infra + +services: + nacos: + image: nacos/nacos-server:v2.3.2 + container_name: baiyue-nacos + environment: + MODE: standalone + PREFER_HOST_MODE: hostname + NACOS_AUTH_ENABLE: "false" + JVM_XMS: 256m + JVM_XMX: 512m + JVM_XMN: 128m + ports: + - "8848:8848" + - "9848:9848" + - "9849:9849" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8848/nacos/v1/console/health/readiness"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + restart: unless-stopped + networks: [baiyue] + + mysql: + image: mysql:8.0.39 + container_name: baiyue-mysql + profiles: [database] + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-me-before-use} + MYSQL_DATABASE: ${MYSQL_DATABASE:-baiyue} + MYSQL_USER: ${MYSQL_USER:-baiyue} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me-before-use} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + - --default-time-zone=+08:00 + ports: ["3306:3306"] + volumes: [baiyue-mysql-data:/var/lib/mysql] + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + restart: unless-stopped + networks: [baiyue] + + redis: + image: redis:7.2.5-alpine + container_name: baiyue-redis + profiles: [cache] + command: ["redis-server", "--appendonly", "yes"] + ports: ["6379:6379"] + volumes: [baiyue-redis-data:/data] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: [baiyue] + +volumes: + baiyue-mysql-data: + baiyue-redis-data: + +networks: + baiyue: + name: baiyue-network diff --git a/docs/business-domain-and-phase1-plan.md b/docs/business-domain-and-phase1-plan.md new file mode 100644 index 000000000..959c8cbd1 --- /dev/null +++ b/docs/business-domain-and-phase1-plan.md @@ -0,0 +1,166 @@ +# 业务域梳理与一期任务实施审核稿 + +更新时间:2026-07-30 +审核状态:一期前端联调能力已验收,完整头程生产闭环待审核 +目标:一期在国内服务器形成“注册与审核、下单、国内仓、头程交接、事件通知”的可演示闭环,并为后期尾程迁移海外保留稳定边界。 + +## 1. 一期业务主链路 + +```text +商户注册 + -> 提交企业资料和资质文件 + -> 平台审核并激活租户 + -> 商户管理员登录并配置成员权限 + -> 商户创建、提交头程订单 + -> 平台审核、受理订单 + -> 生成头程履约任务 + -> 国内仓预约到货、收货测量和差异处理 + -> 排柜、订舱、拖车、装柜、报关 + -> 装船发运并固化交接快照 + -> Outbox 可靠发布交接事件 + -> 一期国内尾程 Inbox 幂等接收并回执 + -> 国内统一轨迹投影 + -> 异步订单通知 +``` + +一期不要求立刻把服务物理部署到海外,但要求尾程交接不依赖跨库联表、同步跨区 Feign 或共享 Redis,以便后期只迁移尾程执行单元。 + +## 2. 业务域边界 + +| 业务域 | 唯一主数据 | 对外输入 | 对外输出 | 明确不负责 | 当前状态 | +| --- | --- | --- | --- | --- | --- | +| 租户权限 | 租户、成员、角色、菜单、按钮、数据范围、资质审核 | 注册资料、成员和授权变更 | 授权快照、租户下单资格 | 密码校验、订单业务状态 | 个人资料、成员、角色、按钮权限和租户隔离已通过真实回归 | +| 统一登录 | 账号、密码哈希、刷新令牌、登录会话 | 注册、登录、刷新、退出 | JWT 和会话状态 | 菜单权限主数据 | 登录、刷新轮换、退出、改密和会话撤销已通过真实回归;海外本地验签待完成 | +| 订单 | 商户委托、货物、收发方、订单状态和历史 | 商户下单、平台审核和受理 | 订单事件、履约创建指令、查询视图 | 仓内作业、头程执行细节、尾程事实 | 主要接口与状态机已存在,可靠事件发布未闭环 | +| 国内仓 | 国内仓资料、往来单位配置、联系人地址、库位及仓内作业事实 | 到仓预约、收货、测量、装柜指令 | 有效仓库与往来单位候选、入库结果、差异、装柜证据 | 订单审核、订舱、报关、尾程 | 仓库和往来单位资料能力已有,仓内作业待实现 | +| 头程 | 头程履约任务、订舱、柜、航次、报关和发运交接 | 已受理订单、仓储作业结果 | 发运交接事件和头程里程碑 | 商户订单主档、海外派送 | 任务分配基础已有,完整履约待实现 | +| 尾程交接 | 尾程聚合、交接版本、执行区域、到港至签收事实 | 不可变发运交接事件 | 接收/拒绝回执、尾程里程碑、完成事件 | 修改国内订单和头程事实 | 当前主要为骨架,Inbox 与业务流程未完成 | +| 文件 | 文件元数据、存储定位、版本、摘要、下载授权和日志 | 文件上传或受控复制 | 文件标识、短期下载授权 | 在事件中传输文件正文、业务审批 | 资质上传下载已有,本地磁盘需迁对象存储 | +| 打印 | 模板、打印任务、状态历史 | 业务单据数据和模板版本 | 生成文件标识、打印结果 | 保存订单或仓储主数据 | 表结构与健康接口已有,执行能力待实现 | +| 审计 | 操作人、租户、动作、对象、结果和追踪号 | 各服务异步或内部审计事件 | 审计查询与留痕 | 代替业务状态历史 | 采集落库基础已有,关键写动作覆盖需核对 | +| 集成 | 提供方配置、外部请求映射、回调幂等键 | 业务服务抽象调用、第三方回调 | 标准化结果和外部事件 | 持有订单、头程、尾程主状态 | 提供方状态骨架已有,真实适配器待实现 | +| 事件通知 | Outbox、Inbox、投递、重试、死信、通知订阅和发送记录 | 领域事件 | 站内信、短信/邮件/回调结果和统一轨迹 | 直接推进源聚合状态 | 公共事件契约已存在,可靠桥和通知链路未完成 | + +## 3. 服务间协作规则 + +1. `auth-service` 登录时向 `iam-service` 获取授权快照;JWT 携带可信租户、端和权限信息。 +2. `order-service` 只通过 IAM 内部接口判断租户是否允许下单,不跨库查询 IAM 表。 +3. `order-service` 通过 `warehouse-service` 内部接口校验仓库和往来单位;二者均属于 `baiyue_warehouse`,订单只保存不可变业务快照,不跨库读取配置表。 +4. 订单受理后通过事件或明确的内部契约创建头程任务,头程不得修改订单表。 +5. 国内仓回传测量、差异和装柜结果;头程维护履约编排,仓储维护仓内事实。 +6. 只有“装船发运且交接快照固化”才能产生尾程交接事件,订单审核通过事件不能替代尾程交接。 +7. 尾程以 `event_id + event_version` 幂等接收,以聚合版本拒绝旧事件覆盖新状态。 +8. 海外里程碑通过事件回到国内只读轨迹投影,客户端只查询国内统一时间线。 +9. 文件事件只传 `file_id`、版本、摘要和用途;打印任务只保存业务引用和 `file_id`。 +10. 通知消费者失败不能回滚订单或履约事务,必须重试、死信并可人工补投。 +11. 每个写操作同时校验登录身份、端、租户和按钮权限,不能只依赖前端隐藏按钮。 + +## 4. 一期范围 + +### 4.1 必须完成 + +- 平台端和商户端统一登录、租户隔离、菜单与按钮权限。 +- 商户注册、资质提交、平台审核、租户激活、允许下单联动。 +- 普通头程订单创建、编辑、提交、审核、受理和取消。 +- 国内仓到仓、收货、测量、差异确认、装柜证据的最小闭环。 +- 头程排柜、订舱、装柜、报关、装船发运和交接快照。 +- 国内回环的 Outbox、事件桥、Inbox、回执、统一轨迹和订单通知。 +- 文件对象存储抽象、审计、监控、自动化验收和部署手册。 + +### 4.2 延后到后续迭代 + +- 完整快递、换标、海外仓增值服务产品线。 +- 全量 Amazon SP-API、各国海关、港务和多承运商适配。 +- 海外正式生产切流、多区域高可用和历史执行中聚合迁移。 +- 高级智能排柜、智能计费、复杂结算和财务对账。 + +## 5. 一期详细任务节点 + +状态取值:`已有基础` 表示代码或表已存在但仍需按验收标准补齐;`待实现` 表示尚未形成可用闭环。 + +| 编号 | 任务 | 输入 | 输出 | 依赖 | 验收标准 | 优先级 | 建议迭代 | 当前状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| P1-001 | 固化一期状态机与跨服务契约 | 原型、订单状态、头尾程节点 | 状态表、接口契约、事件目录 | 产品审核 | 非法流转有明确错误码;契约评审通过并版本化 | P0 | 迭代 0 | 待审核 | +| P1-002 | 统一环境与数据库初始化 | 服务清单、SQL、Nacos 模板 | 可重复初始化脚本和环境检查 | MySQL、Redis、Nacos | 空环境一键初始化;重复执行无副作用;密码不入库 | P0 | 迭代 0 | 已有基础 | +| P1-003 | 统一登录回归 | 账号、密码、端类型 | Access Token、Refresh Token、退出结果 | Auth、IAM、Redis | 平台/商户登录刷新退出全通过;错误端和禁用账号被拒绝 | P0 | 迭代 1 | 已完成真实回归 | +| P1-004 | 注册审核激活闭环 | 注册资料、资质文件 | 待审核租户、审核记录、激活账号 | Auth、IAM、File | 注册后不可下单;审核通过自动激活;拒绝原因可查;重复请求幂等 | P0 | 迭代 1 | 部分实现,需闭环回归 | +| P1-005 | 两端菜单按钮授权 | 角色、资源、成员 | 授权快照和 JWT 权限 | IAM、Auth | 平台与商户资源完全隔离;菜单和按钮权限正确下发 | P0 | 迭代 1 | 已有基础并有验收记录 | +| P1-006 | 写接口后端鉴权全覆盖 | 按钮权限码、写接口清单 | `@PreAuthorize` 或等效策略 | P1-005 | 未授权直调所有写接口均返回 403;跨租户访问为 403/404 | P0 | 迭代 1 | 本次成员、角色、仓库、往来单位和订单接口已通过;其余后续业务写接口随功能实现验收 | +| P1-007 | 首次登录改密和凭据治理 | 初始账号、密码策略 | 强制改密状态、修改密码接口 | Auth | 初始账号未改密不能进入业务;密码不记录日志 | P1 | 迭代 1 | 待实现 | +| P1-008 | 商户头程订单最小闭环 | 可下单租户、货物和收发方 | 草稿、提交、审核、受理、取消状态 | P1-004、P1-006 | 正常路径和拒绝/取消路径通过;状态历史完整;租户隔离通过 | P0 | 迭代 2 | 商户草稿创建、编辑、提交、快照和异常路径已通过;平台审核、受理、取消仍待闭环 | +| P1-009 | 订单 Outbox 状态机 | 订单本地事务和领域事件 | 待投递、投递中、成功、重试、死信记录 | P1-001、P1-008 | 业务提交与 Outbox 同事务;进程崩溃后可恢复;无事件丢失 | P0 | 迭代 2 | 有表和写入基础,发布未完成 | +| P1-010 | 头程任务创建与幂等消费 | 订单受理事件 | 唯一头程任务、消费记录 | P1-009 | 同一事件重复 10 次只创建一个任务;失败可重试 | P0 | 迭代 2 | 任务、消费表和分配基础已有 | +| P1-011 | 国内仓到仓预约与收货 | 头程任务、预约信息、实收件 | 入库单、收货时间和操作人 | Warehouse、Firstleg | 可登记预约和实收;租户/订单引用有效;完整审计 | P0 | 迭代 2 | 待实现 | +| P1-012 | 测量与差异处理 | 实收货物、实测重量体积 | 测量记录、差异结果、确认记录 | P1-011 | 原始值和实测值可追溯;超阈值必须人工确认后继续 | P0 | 迭代 2 | 待实现 | +| P1-013 | 仓库及往来单位基础数据完善 | 仓库、地址、能力、往来单位联系人与地址、状态 | 可供商户选择的有效仓库和往来单位列表 | Warehouse | 租户隔离、停用过滤、商户只读查询、订单快照校验通过 | P1 | 迭代 2 | 已补查询、过滤、样本和订单校验 | +| P1-014 | 头程排柜与订舱 | 合格入库货物、航线和柜型 | 柜计划、订舱单、航次信息 | P1-012、Integration | 容量校验、重复分配保护、订舱失败可重试且不重复 | P0 | 迭代 3 | 待实现 | +| P1-015 | 拖车、装柜和证据链 | 柜计划、装柜货物 | 装柜明细、柜号、封条号、照片文件引用 | P1-014、File | 装柜数量一致;照片摘要可校验;柜号封条唯一;留审计 | P0 | 迭代 3 | 待实现 | +| P1-016 | 报关与放行 | 货物、企业资质、装柜数据 | 报关单、申报结果、放行状态 | File、Integration | 资料版本可追溯;失败可重提;未放行禁止发运完成 | P0 | 迭代 3 | 待实现 | +| P1-017 | 装船发运与不可变交接快照 | 放行状态、航次、柜和文件清单 | `FirstLegShipmentDeparted.v1` 与快照 | P1-015、P1-016 | 仅满足前置条件可发运;重复操作同一结果;快照有版本和摘要 | P0 | 迭代 3 | 待实现 | +| P1-018 | 事件信封与兼容性测试 | 事件目录、字段定义 | 版本化契约、示例、校验测试 | P1-001 | 必填字段、区域码、版本、摘要校验通过;旧消费者兼容新增字段 | P0 | 迭代 3 | 基础契约已实现 | +| P1-019 | 事件桥可靠发送基础 | Outbox 事件 | 签名请求、投递状态、重试和死信 | P1-009、P1-018 | 超时重试有上限;签名失败拒绝;支持人工补投和监控 | P0 | 迭代 4 | 内部令牌、目标区域和 Inbox 幂等已实现并通过 8 项测试;mTLS、跨区发送、RocketMQ、Outbox 扫描待实现 | +| P1-020 | 尾程 Inbox 与交接回执 | 发运交接事件 | 幂等 Inbox、尾程聚合、接受/拒绝回执 | P1-017、P1-019 | 重复 10 次只创建一次;坏摘要拒绝;旧版本不覆盖新版本 | P0 | 迭代 4 | 待实现 | +| P1-021 | 尾程一期最小阶段 | 已接受交接 | 到港前、到港、到仓、出仓、签收状态 | P1-020 | 五阶段正常流转;非法逆序拒绝;异常原因和操作者可查 | P0 | 迭代 4 | 待实现 | +| P1-022 | 国内统一轨迹投影 | 订单、头程和尾程里程碑事件 | 商户订单时间线 | P1-018、P1-021 | 最终一致;乱序不回退;重复不新增;展示最后同步时间 | P0 | 迭代 4 | 待实现 | +| P1-023 | 异步订单通知 | 领域事件、订阅规则、模板 | 站内通知及发送记录 | P1-022 | 通知失败不回滚业务;可重试、死信、去重;租户隔离 | P0 | 迭代 4 | 待实现 | +| P1-024 | 文件对象存储适配 | 现有本地文件接口 | 存储抽象、对象键、短期下载授权 | 对象存储 | 本地与对象存储实现可切换;SHA-256、大小、类型和下载审计通过 | P0 | 迭代 3 | 当前本地磁盘,待改造 | +| P1-025 | 打印最小闭环 | 模板、订单/仓储数据 | 打印任务、生成文件、结果历史 | File、Print | 可创建模板版本和任务;失败可重试;任务关联 `file_id` | P1 | 迭代 4 | 表结构已有,执行待实现 | +| P1-026 | 关键动作审计覆盖 | 写接口和领域事件清单 | 审计记录 | Audit、各业务服务 | 登录、授权、审核、状态推进、交接、补投均记录租户和追踪号 | P0 | 迭代 1-4 | 采集基础已有,覆盖待核验 | +| P1-027 | 可观测性和告警 | 服务、Outbox、Inbox、数据库指标 | 仪表盘和告警规则 | 全部核心服务 | 事件积压、死信、5xx、鉴权失败、数据库连接异常可告警 | P0 | 迭代 4 | 待实现 | +| P1-028 | 一期自动化业务验收 | 测试账号、测试订单和故障场景 | 验收报告与证据 | P1-003 至 P1-027 | 主链路、重复、乱序、断网 30 分钟、跨租户、回滚场景全部通过 | P0 | 迭代 5 | 前端联调范围 105 项真实 API 检查通过;完整履约、跨区断网和乱序验收待补 | +| P1-029 | 国内生产部署与回滚演练 | 镜像、配置、SQL、备份 | 国内上线版本和回滚记录 | P1-028 | 标准端口无冲突;密钥不入库;备份恢复和旧版本回滚演练通过 | P0 | 迭代 5 | 待实现 | +| P1-030 | 海外迁移准备包 | 区域配置、数据分类和事件链路 | 海外资源清单、切流门槛、回滚手册 | P1-019 至 P1-029 | 不依赖国内 MySQL/Redis/Nacos;影子环境可重复部署 | P1 | 迭代 5 | 结构已拆,生产准备未完成 | + +## 6. 建议迭代与出口条件 + +| 迭代 | 目标 | 出口条件 | +| --- | --- | --- | +| 迭代 0:边界冻结 | 冻结状态机、事件、数据库和部署边界 | 审核本文及区域方案;契约有负责人和版本 | +| 迭代 1:账号权限 | 完成注册审核、激活、登录和按钮鉴权 | 两端权限与跨租户自动化通过,关键动作有审计 | +| 迭代 2:订单到入仓 | 订单受理后创建头程任务并完成到仓测量 | 正常、拒绝、取消、重复事件和差异处理通过 | +| 迭代 3:头程发运 | 完成订舱、装柜、报关、对象存储和发运快照 | 可稳定生成唯一且可校验的交接事件 | +| 迭代 4:尾程回环 | 在国内打通事件桥、Inbox、尾程五阶段、轨迹和通知 | 断网恢复、重复、乱序、死信补投和跨租户测试通过 | +| 迭代 5:上线准备 | 自动化验收、容量、安全、备份和回滚 | 国内一期上线审批通过;海外准备包可审核 | + +## 7. 一期验收数据与场景 + +至少准备两个商户租户、一个平台管理员、一个审核员、一个订单运营、两个企业成员、两个国内仓、一条正常订单、一条拒绝订单和一条差异订单。 + +必须自动化覆盖: + +1. 商户甲不能读取或操作商户乙的成员、订单、货物、文件和轨迹。 +2. 无按钮权限的用户直接调用写接口返回 403。 +3. 未审核、被拒绝、禁用租户不能提交订单。 +4. 同一订单事件重复投递 10 次只生成一个头程任务。 +5. 同一发运交接事件重复投递 10 次只生成一个尾程聚合。 +6. 尾程里程碑乱序到达时,旧版本不能覆盖新状态。 +7. 事件通道中断 30 分钟不影响国内订单和头程提交,恢复后自动补投。 +8. 通知发送失败不回滚履约状态,并能重试或进入死信。 +9. 文件下载必须短期授权、校验租户并写下载审计。 +10. 关键状态推进、人工覆盖和人工补投均可按追踪号还原操作链。 + +## 8. 当前可复用的验收基线 + +已有一期管理员、角色、菜单和按钮初始化记录,以及登录、权限查询、跨端隔离等 19 项自动化冒烟记录;根工程曾有 20 个测试通过记录。这些结果可以作为权限基线,但不能替代本方案中的注册审核、仓内作业、头程发运、尾程交接、可靠事件、轨迹和通知验收。 + +一期在国内同时运行尾程时,应使用已提供的国内兼容 Profile 构建: + +```powershell +cd D:\baiyue\deployments\cn +.\build.ps1 -IncludePhase1Lastleg +``` + +默认国内聚合为 18 个 Reactor 模块;上述命令启用 `phase1-domestic-lastleg` 后为 19 个 Reactor 模块并包含 `lastleg`。国内和海外聚合均已包含 `common-event-contract` 与 `event-bridge-service`,不需要另行手工加入。 + +执行新一轮验收前,应先清理旧端口进程并以最新代码、正确数据库密码和标准端口重新启动,避免把临时 9001/9301 验收环境当作正式运行配置。 + +## 9. 审核后立即执行的 P0 顺序 + +1. 审核并冻结 `P1-001` 的业务状态与事件交接点。 +2. 完成 `P1-004`、`P1-006`,关闭注册审核与后端按钮鉴权缺口。 +3. 完成 `P1-009`、`P1-010`,先把订单到头程任务的可靠链路跑通。 +4. 完成 `P1-011` 至 `P1-017`,形成真实发运交接快照。 +5. 完成 `P1-019` 至 `P1-023`,在国内回环验证尾程、轨迹和通知。 +6. 完成 `P1-027` 至 `P1-029`,通过故障演练后部署国内一期。 + +海外服务器、海外数据库和正式切流放在国内回环可靠性验收之后,避免在业务闭环尚未形成时同时引入跨国网络问题。 diff --git a/docs/cross-region-firstleg-lastleg-deployment-plan.md b/docs/cross-region-firstleg-lastleg-deployment-plan.md new file mode 100644 index 000000000..e108a3dd9 --- /dev/null +++ b/docs/cross-region-firstleg-lastleg-deployment-plan.md @@ -0,0 +1,494 @@ +# 头程国内、尾程海外的跨区域微服务部署方案 + +更新时间:2026-07-29 +依据:当前 `D:/baiyue` Spring Cloud Alibaba 微服务、`悦/头程物流流程图.svg`、头程下单原型和货代后台原型。 + +> 审核状态:待用户审核。本文是项目拆分、部署和迁移的实施方案;文中目标架构、海外资源和切流步骤不代表已经完成生产部署。当前生成的部署目录仅是同一 Git 仓库内的 Maven 聚合和配置骨架。 + +## 1. 结论 + +推荐采用“国内业务单元 + 海外业务单元 + 跨区域可靠事件桥”,不要把同一套 Nacos、MySQL、Redis 或一个微服务调用链直接横跨国内外服务器。 + +这里的“国内业务单元、海外业务单元”首先是代码、数据库和数据所有权边界,不等于一期必须立即购买海外服务器。当前项目应立即按两个部署单元拆分配置、服务职责和事件契约,但一期允许所有服务都部署在国内同一机房:`lastleg-service`、海外仓实例和海外集成实例先以 `REGION_CODE=CN` 运行。待事件链路、鉴权和对象存储满足迁移门槛后,再把尾程写入单元迁到海外,不需要重新拆项目。 + +```text +国内单元:客户、订单、头程、国内仓、认证和权限的主数据 +海外单元:尾程、海外仓、清关、提柜、派送、POD、Amazon SP-API +跨区交接:本地事务 Outbox -> HTTPS/mTLS 事件中继 -> Inbox 去重 -> 处理回执 +统一轨迹:海外发布里程碑事件,国内保存只读投影供客户查询 +``` + +这一方案符合原型的业务边界: + +- 头程在国内完成“下单 → 到仓 → 出仓 → 装船发运”。 +- 尾程在海外完成“到港前 → 到港 → 到仓 → 出仓 → 签收”。 +- “装船发运”是两个区域的数据所有权交接点。 + +### 1.1 当前已生成的部署骨架 + +| 路径 | 当前用途 | 当前状态 | +| --- | --- | --- | +| `D:/baiyue/deployments/cn` | 国内部署单元 Maven 聚合、环境变量示例和构建脚本 | 已生成,待审核;一期实际部署入口 | +| `D:/baiyue/deployments/overseas` | 海外部署单元 Maven 聚合、环境变量示例和构建脚本 | 已生成,待审核;暂不代表海外服务器已部署 | +| `D:/baiyue/common-event-contract` | 国内外共享的事件信封、类型和区域契约 | 已生成基础源码,6 个单元测试通过;尚未接入业务事件链路,不得视为可用于生产 | +| `D:/baiyue/event-bridge-service` | 跨区事件接收幂等、待发送事件和投递尝试的独立服务边界 | 已生成基础骨架,8 项测试通过;内部令牌、目标区域校验和 Inbox 幂等接收已实现,生产传输开关为关闭 | + +两个部署目录引用根仓库现有模块,不复制业务源码,也不建立第二个 Git 仓库。构建命令为: + +国内和海外聚合均已包含 `common-event-contract` 与 `event-bridge-service`。国内默认构建为 18 个 Reactor 模块;一期尾程临时在国内运行时,使用 `phase1-domestic-lastleg` Profile 构建 19 个 Reactor 模块并包含 `lastleg`。 + +```powershell +cd D:\baiyue\deployments\cn +.\build.ps1 + +# 一期尾程临时部署在国内 +.\build.ps1 -IncludePhase1Lastleg + +cd D:\baiyue\deployments\overseas +.\build.ps1 +``` + +只校验默认聚合关系时,在对应目录执行 `mvn -f pom.xml -DskipTests validate`;校验一期国内尾程兼容聚合时执行 `mvn -f pom.xml -Pphase1-domestic-lastleg -DskipTests validate`。事件契约可在根目录执行 `mvn -pl common-event-contract -am test`。以上构建通过只说明代码依赖和测试通过,不等于生产部署、网络连通或数据迁移验收通过。 + +## 2. 当前代码现状判断 + +| 模块 | 当前情况 | 对跨区部署的影响 | +| --- | --- | --- | +| `gateway` | 已按服务名走 Nacos 和 `lb://` 路由 | 可以分别部署国内、海外 Gateway,但必须连接各自区域的 Nacos | +| `auth-service` | 国内统一登录,HS256 JWT | 可先放国内;正式跨区前建议改为 RS256/ES256 + JWKS | +| `iam-service` | 租户、角色、菜单、按钮权限 | 主库建议放国内,海外通过 Token 权限快照工作,不应每次跨区查询 IAM | +| `order-service` | 已有下单和状态机,也有 `ord_outbox_event` | Outbox 只是表和写入逻辑,尚缺可靠发布器、MQ/中继和跨区消费者 | +| `firstleg-service` | 目前只有健康检查、任务分配等少量能力 | 原型中的到仓、测量、排柜、订舱、装柜、报关等还需实现 | +| `lastleg-service` | 当前基本只有健康检查 | 还没有到港前、清关、提柜、海外仓、派送、POD 的业务闭环 | +| `warehouse-service` | 当前是一个服务边界 | 需要区分国内仓实例和海外仓实例,各自独占区域数据库 | +| `file-service` | 当前默认使用本地磁盘 | 跨区域部署不能继续依赖单机 `D:/...`,需要迁移到 OSS/S3 类对象存储 | +| `integration-service` | 已预留百度 OCR、Amazon SP-API 配置 | 国内实例启用百度 OCR;海外实例启用 Amazon SP-API 和当地承运商接口 | +| `event-bridge-service` | 已有端口 9300、独立库和三张基础表;内部服务令牌、目标区域、事件信封与摘要校验及 Inbox 幂等已实现,8 项测试通过 | mTLS、来源白名单、RocketMQ、真实跨公网 HTTP 投递、Outbox 扫描调度和业务消费者尚未实现,不能用于生产交接 | + +因此现在可以先搭好部署骨架和事件契约,但不能把 `lastleg-service` 部署到海外后就认定尾程业务已经完成。 + +## 3. 推荐部署拓扑 + +```mermaid +flowchart LR + FE[客户/平台前端] --> EDGE[统一域名 + CDN/WAF/全局路由] + + subgraph CN[国内业务单元] + CNGW[gateway-cn] + AUTH[auth-service] + IAM[iam-service] + ORDER[order-service] + FIRST[firstleg-service] + CNWMS[warehouse-service CN] + CNFILE[file-service CN] + CNOCR[integration-service Baidu OCR] + CNDB[(国内独立 MySQL)] + CNREDIS[(国内 Redis)] + CNNACOS[Nacos prod-cn] + CNOUT[Outbox Publisher] + CNPROJ[统一轨迹投影] + end + + subgraph OS[海外业务单元] + OSGW[gateway-overseas] + LAST[lastleg-service] + OSWMS[warehouse-service OVERSEAS] + OSFILE[file-service OVERSEAS] + OSINT[integration-service Amazon/海关/承运商] + OSDB[(海外独立 MySQL)] + OSREDIS[(海外 Redis)] + OSNACOS[Nacos prod-overseas] + OSIN[Inbox Consumer] + OSOUT[Outbox Publisher] + end + + EDGE -->|Auth/IAM/Order/First-leg| CNGW + EDGE -->|Last-leg/海外仓作业| OSGW + CNGW --> AUTH & IAM & ORDER & FIRST & CNWMS & CNFILE + OSGW --> LAST & OSWMS & OSFILE & OSINT + FIRST --> CNOUT + CNOUT -->|HTTPS 443 + mTLS| OSIN + OSIN --> LAST + LAST --> OSOUT + OSOUT -->|里程碑/回执 HTTPS 443 + mTLS| CNPROJ +``` + +### 3.1 国内部署的服务 + +| 服务 | 是否国内主部署 | 说明 | +| --- | --- | --- | +| `gateway` | 是 | 国内客户、平台后台入口 | +| `auth-service` | 是 | 账号登录、刷新、退出的唯一写入口 | +| `iam-service` | 是 | 企业、成员、角色、菜单和按钮权限主数据 | +| `order-service` | 是 | 商户下单、审核、受理以及订单主档 | +| `firstleg-service` | 是 | 到国内仓、测量差异、排柜、订舱、拖车、装柜和国内报关 | +| `warehouse-service` | 是 | 国内仓资料和国内仓作业数据 | +| `file-service` | 是 | 企业资质、国内报关资料、装柜照片等国内文件入口 | +| `integration-service` | 是 | 百度 OCR、国内报关代理或港务接口适配 | +| `audit-service` | 是 | 国内业务审计主记录 | +| `print-service` | 按需 | 国内标签、箱唛、作业单打印 | + +### 3.2 海外部署的服务 + +| 服务 | 是否海外部署 | 说明 | +| --- | --- | --- | +| `gateway` | 是 | 海外运营后台、海外 PDA 和承运商回调入口 | +| `lastleg-service` | 是 | 到港前、清关、提柜、到仓、派送、签收和异常主服务 | +| `warehouse-service` | 是 | 海外仓预约、开柜、清点、打托、拣货和装车 | +| `file-service` | 是 | BOL、DO/PU、开柜照片、POD 等海外文件 | +| `integration-service` | 是 | Amazon SP-API、FBA 预约、海关、码头和海外承运商适配 | +| `audit-service` | 是 | 海外操作本地审计;摘要可异步回传国内 | +| `print-service` | 按需 | BOL、派送单、海外标签和仓内作业单 | + +海外不部署 `order-service` 和 `firstleg-service` 的写实例,国内也不部署 `lastleg-service` 的写实例,避免同一业务聚合在两个区域同时写入。 + +### 3.3 一期与目标物理归属 + +| 服务 | 一期物理位置 | 后期主写位置 | 迁移说明 | +| --- | --- | --- | --- | +| `auth-service`、`iam-service` | 国内 | 国内 | 统一登录和权限主数据保持国内唯一写入 | +| `order-service`、`firstleg-service` | 国内 | 国内 | 订单和头程事实保持国内唯一写入 | +| `lastleg-service` | 国内 | 海外 | 一期先在国内运行;后期只把新创建的尾程聚合切到海外,旧单在国内排空 | +| `warehouse-service` | 国内单实例 | 国内仓实例在国内,海外仓实例在海外 | 同一代码按区域配置部署,数据库完全隔离 | +| `file-service` | 国内 | 国内、海外各一套 | 文件元数据和对象桶按数据产生区域归属,按业务需要复制 | +| `integration-service` | 国内 | 国内、海外各一套 | 国内启用 OCR/港务适配,海外启用 Amazon/海关/承运商适配 | +| `audit-service`、`print-service` | 国内 | 按区域各自部署 | 本地操作本地留痕和打印,不跨区同步调用 | +| `gateway`、Nacos、Redis | 国内 | 国内、海外各一套 | 两区互不注册、互不直接访问 Redis | + +## 4. 原型节点与服务归属 + +### 4.1 国内头程 + +| 原型阶段 | 原型动作 | 主要服务 | +| --- | --- | --- | +| 下单 | 客户下单、委托下单、快速下单、换标下单 | `order-service`;一期仅实现普通头程订单,不扩展快速/换标完整产品 | +| 到仓 | 预约取货、送货上门 | `firstleg-service` + 国内 `warehouse-service` | +| 到仓 | 入库测量、差异核对、指定库位 | 国内 `warehouse-service`,结果事件回写 `firstleg-service` | +| 出仓 | 智能排柜、订舱、预约拖车 | `firstleg-service` | +| 出仓 | PDA 拣货、装柜、柜照和铅封照 | 国内 `warehouse-service` + `file-service` | +| 出仓 | 报关资料、代理报关、回执 | `firstleg-service` + 国内 `integration-service` + `file-service` | +| 出仓 | 港务信息、预计开船、到港 | `firstleg-service` + 船司/港务适配器 | +| 交接 | 装船发运 | `firstleg-service` 生成不可变交接快照并写 Outbox | + +### 4.2 海外尾程 + +| 原型阶段 | 原型动作 | 主要服务 | +| --- | --- | --- | +| 到港前 | FBA 仓预约、BOL 预清货、清关资料、货物中转备注 | `lastleg-service` + 海外 `integration-service` | +| 到港 | 清关放行、DO/PU、通知码头车队提柜 | `lastleg-service` | +| 到仓 | 开柜拍照比对、PDA 打托、派送车队预约 | `lastleg-service` + 海外 `warehouse-service` + `file-service` | +| 出仓 | BOL 送货、PDA 拣货装车、整柜派送、自提出仓 | `lastleg-service` + 海外 `warehouse-service` | +| 签收 | 私人仓/FBA 签收回执、送仓异常 | `lastleg-service` + `file-service` + Amazon/承运商适配器 | + +## 5. 跨区交接设计 + +### 5.1 正确交接点 + +当前 `order-service` 在订单 `APPROVED` 时写 `FIRST_LEG_ORDER_APPROVED`,这不是尾程交接事件。尾程只能在真实“装船发运”完成后建立预到港任务。 + +建议新增: + +```text +FirstLegShipmentDeparted.v1 +LastMileHandoffAccepted.v1 +LastMileHandoffRejected.v1 +LastMileMilestoneChanged.v1 +LastMileCompleted.v1 +``` + +### 5.2 交接事件示例 + +```json +{ + "eventId": "01J...ULID", + "eventType": "FirstLegShipmentDeparted", + "eventVersion": 1, + "occurredAt": "2026-07-29T16:30:00+08:00", + "traceId": "trace-xxx", + "sourceRegion": "CN", + "operatorTenantId": 10001, + "customerTenantId": 20001, + "aggregateId": 90001, + "aggregateVersion": 7, + "handoverVersion": 1, + "payload": { + "firstLegOrderIds": [80001, 80002], + "containerNo": "MSCU1234567", + "sealNo": "SEAL123456", + "vesselName": "EXAMPLE VESSEL", + "voyageNo": "V001", + "mblNo": "MBL001", + "hblNos": ["HBL001"], + "etd": "2026-07-30T10:00:00+08:00", + "eta": "2026-08-18T09:00:00-07:00", + "destinationPortCode": "USLAX", + "destinationWarehouse": { + "type": "FBA", + "code": "LGB8" + }, + "cargoSnapshot": [], + "documentRefs": [], + "checksum": "sha256:..." + } +} +``` + +文件正文不要放进消息,只传文件 ID、版本、SHA-256 和一次性下载凭证。海外确认保存后再返回 `LastMileHandoffAccepted`。 + +### 5.3 可靠性规则 + +1. 头程状态更新、状态历史和 Outbox 必须在一个国内 MySQL 本地事务中完成。 +2. 国内发布器轮询 `PENDING/RETRY` Outbox,使用 HTTPS 443 + mTLS 投递到海外事件入口。 +3. 海外先按 `eventId` 写 Inbox,唯一索引去重,再创建尾程交接记录。 +4. 消费失败时不确认;重复投递必须返回相同业务结果。 +5. 海外校验通过后写本地 Outbox,异步回传 Accepted/Rejected 回执。 +6. 国内收到 Accepted 后才把交接状态标记为 `ACCEPTED`,但不能回滚已经真实发生的装船事实。 +7. 修改交接数据要发布更高 `handoverVersion` 的新事件,不能覆盖旧快照。 +8. 跨区断网时,两个区域本地业务继续运行;Outbox 积压告警,网络恢复后自动补投。 + +不建议让 `firstleg-service` 通过 OpenFeign 跨公网同步调用 `lastleg-service` 创建尾程单。国际链路一次超时就会把真实装船操作拖入不确定状态。 + +### 5.4 Topic、事件桥和订单通知 + +一期不强制引入跨区 MQ,可由 Outbox 发布器直接通过 HTTPS/mTLS 投递;引入 RocketMQ 后,国内和海外各使用独立集群,禁止一个 Broker 集群横跨两区。建议 Topic 如下: + +| Topic | 方向 | 主要事件 | +| --- | --- | --- | +| `shipment-handover-cn` | 国内 → 海外 | `FirstLegShipmentDeparted.v1` | +| `shipment-handover-result-os` | 海外 → 国内 | `LastMileHandoffAccepted.v1`、`LastMileHandoffRejected.v1` | +| `shipment-milestone-os` | 海外 → 国内 | `LastMileMilestoneChanged.v1`、`LastMileCompleted.v1` | +| `security-session-cn` | 国内 → 海外 | 会话撤销、账号禁用、权限版本变化 | + +同一尾程聚合以 `aggregateId` 作为顺序键;只能保证同一聚合有序,不追求全局有序。事件入 Inbox 并提交本地事务后才返回成功,失败采用有上限的指数退避,超过阈值进入死信并告警。 + +短信、邮件、站内信和企业微信等客户通知不得放在订单事务内同步发送。国内 `notification-service` 或通知消费者订阅国内订单事件及海外回传的里程碑事件,按 `eventId + channel + recipient` 幂等发送;承运商回调在海外本地处理,再通过里程碑事件回传国内。 + +## 6. Gateway 与流量路由 + +建议对外保持一个域名,例如 `api.example.com`,由 CDN/WAF/全局流量入口按路径转发: + +| 路径 | 目标区域 | +| --- | --- | +| `/api/auth/**` | 国内 Gateway | +| `/api/platform/v1/iam/**`、`/api/merchant/v1/iam/**` | 国内 Gateway | +| `/api/merchant/v1/orders/**`、`/api/platform/v1/orders/**` | 国内 Gateway | +| `/api/merchant/v1/first-leg/**`、`/api/platform/v1/first-leg/**` | 国内 Gateway | +| `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**` | 海外 Gateway | +| 海外仓/PDA 专用路径 | 海外 Gateway | + +国内 Gateway 只通过国内 Nacos 发现本地服务;海外 Gateway 只通过海外 Nacos 发现本地服务。跨区内部事件入口不暴露在普通业务 Gateway 上,应使用独立内部域名、WAF 白名单、mTLS 和消息签名。 + +如果暂时没有全局流量产品,可以先使用两个域名: + +```text +https://api-cn.example.com +https://api-us.example.com +``` + +前端通过同一个 API SDK 根据业务域选择基础地址,不要让浏览器知道 Nacos 服务名。 + +一期即固定对外域名和证书,域名先指向国内 WAF/负载均衡;计划切换前至少 7 天把 DNS TTL 调整到 300 秒。后期统一域名由全局流量入口按路径转发,`auth/iam/order/first-leg` 固定国内,`last-leg` 才允许按灰度规则转发海外。DNS 只负责稳定入口,不用 DNS 权重承担租户或订单级灰度,因为 DNS 缓存无法保证同一业务聚合始终进入同一区域。 + +灰度期可先使用 `api-us-canary.example.com` 供指定运营账号和租户验证,正式流量由 Gateway 根据可信的 `execution_region` 路由。Gateway 必须删除客户端伪造的区域头。若前端需要访问两个域名,CORS 只允许明确的生产前端域名、必要方法和请求头;启用 Cookie 时禁止 `Access-Control-Allow-Origin: *`,并统一处理预检请求。优先使用同一对外域名,减少浏览器跨域和登录态问题。 + +## 7. Nacos 设计 + +不要使用一个 Nacos 集群横跨国内和海外,也不要让海外服务连接国内 `8848`。 + +| 区域 | Nacos | Namespace | Group | 注册服务示例 | +| --- | --- | --- | --- | --- | +| 国内 | `nacos-cn.internal` | `prod-cn` | `BAIYUE` | gateway、auth、iam、order、firstleg、国内 warehouse/file/integration | +| 海外 | `nacos-overseas.internal` | `prod-overseas` | `BAIYUE` | gateway、lastleg、海外 warehouse/file/integration | + +两个区域可以保留相同服务名,例如都叫 `warehouse-service`,因为它们位于不同 Nacos 集群。配置必须增加: + +```text +REGION_CODE=CN / OVERSEAS +DEPLOYMENT_CELL=prod-cn / prod-overseas +DATA_RESIDENCY=CN / OVERSEAS +``` + +当前各服务的 `spring.config.import` 固定读取 `group=BAIYUE`,所以两个区域先保持相同 Group,通过独立集群和 Namespace 隔离。若后续希望使用 `BAIYUE-CN/BAIYUE-US`,必须先把所有服务配置导入 URI 中的 Group 改成环境变量。 + +生产密钥不得放进 Nacos 明文配置,应进入云 KMS、Secret Manager 或主机受限环境变量。 + +## 8. 数据库、Redis 和文件边界 + +### 8.1 数据库 + +- 国内 MySQL:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_firstleg`、`baiyue_warehouse_cn`、`baiyue_file_cn`、`baiyue_integration_cn`、`baiyue_audit_cn`、`baiyue_print_cn`。 +- 一期尾程仍在国内使用 `baiyue_lastleg_cn`,库名提前带区域后缀,避免迁移时混淆数据所有权。 +- 海外 MySQL:`baiyue_lastleg_os`、`baiyue_warehouse_os`、`baiyue_file_os`、`baiyue_integration_os`、`baiyue_audit_os`、`baiyue_print_os`。 +- 禁止跨区数据库外键、跨区事务和双写。 +- 国内只保存海外状态的只读轨迹投影;海外仍是尾程事实的唯一写入者。 +- ID 继续使用应用生成的雪花 ID/ULID,避免区域数据库自增 ID 冲突。 + +数据库迁移优先采用“旧单排空、新单切换”,不移动执行中的尾程聚合。确需迁移历史数据时,先全量复制,再用 Binlog/CDC 追平;切换前短暂停止对应聚合写入,确认延迟为零、行数和关键字段校验一致后,才允许海外库转为主写。国内旧库在回滚观察期保持只读,不得与海外库同时写入。 + +### 8.2 Redis 与登录会话 + +当前各 Servlet 服务会访问 Redis 检查 Access Token 的 `jti` 会话键。若海外 `lastleg-service` 直接读取国内 Redis,跨区断网会导致海外所有请求鉴权失败。 + +推荐目标方案: + +1. Auth 使用 RS256/ES256 签发短期 Access Token,海外 Gateway/服务通过 JWKS 本地缓存验签。 +2. Access Token 有效期缩短到 10~15 分钟。 +3. 登录、撤销、禁用账号发布 `SessionIssued/SessionRevoked/AccountDisabled` 事件,复制到海外本地 Redis。 +4. 海外服务只访问海外 Redis,不访问国内 Redis。 + +以上改造是海外影子环境上线前置条件,不应放到海外正式切流之后。当前代码仍使用 HS256,并要求通过 Redis 中存在 `jti` 才允许访问;迁移时先支持带 `kid` 的 RS256/ES256 Token 和 JWKS 本地缓存,再将海外校验改为“本地验签 + 短有效期 + 本地撤销集合”。私钥只保存在国内 Auth,海外只持有公钥。过渡期国内可短暂兼容旧 Token,但海外灰度账号必须重新登录取得新 Token,稳定后移除 HS256。 + +PoC 阶段可以通过专线/VPN临时读取国内 Redis,但不能作为正式生产方案;Redis 的 `6379` 绝不能暴露公网。 + +### 8.3 文件 + +当前本地磁盘文件存储不适合双区域。建议: + +- 国内文件进入国内 OSS/COS/S3 兼容桶。 +- 海外文件进入海外区域对象存储桶。 +- 只复制尾程履约必需的报关资料、BOL、箱单和授权照片。 +- 文件下载使用短期预签名 URL,跨区域复制前做病毒扫描、MIME/魔数和 SHA-256 校验。 +- POD 等海外原始文件保留在海外,国内保存合规副本或文件引用。 + +跨境传输企业、联系人、收货地址和报关资料前,应完成数据分类、最小化、访问审计和适用地区的合规评估。 + +## 9. 统一客户轨迹 + +客户不应为了看完整物流轨迹分别查询国内、海外两套数据库。建议国内新增“统一轨迹只读投影”,可以先放在 `order-service`,后续独立为 `tracking-query-service`。 + +```text +头程本地状态 -> 国内轨迹投影 +海外 LastMileMilestoneChanged -> 跨区事件 -> 国内轨迹投影 +客户 GET /api/merchant/v1/orders/{id}/timeline -> 只查国内投影 +``` + +轨迹投影允许最终一致,目标延迟 P95 小于 30 秒。页面应展示“最后同步时间”,跨区中断时显示“海外轨迹同步延迟”,不能把旧状态误报成业务失败。 + +## 10. integration-service 的区域部署 + +现有 `integration-service` 不需要再增加 `common-sdk`。同一模块按区域配置不同适配器: + +| 国内实例 | 海外实例 | +| --- | --- | +| `BAIDU_OCR_ENABLED=true` | `BAIDU_OCR_ENABLED=false` | +| `AMAZON_SP_API_ENABLED=false` | `AMAZON_SP_API_ENABLED=true` | +| 国内港务/报关代理适配器 | Amazon SP-API、海外海关、码头、车队/承运商适配器 | + +各业务服务只调用内部抽象接口,不直接依赖百度或 Amazon SDK。第三方回调必须校验签名、保存原始回调摘要,并按第三方事件 ID 幂等处理。 + +## 11. 网络和安全 + +1. 国内与海外通过站点到站点 VPN、云企业网或专线连接;跨区业务事件只开放 HTTPS 443。 +2. `3306`、`6379`、`8848`、MQ Broker 端口不得暴露公网。 +3. 跨区事件入口使用 mTLS、IP 白名单、时间戳、nonce 和 HMAC/数字签名,防重放。 +4. Gateway 清除外部伪造的 `X-User-Id`、`X-Tenant-Id`、`X-App-Code` 等内部头。 +5. 国内和海外使用不同数据库账号、Redis密码、内部服务 Token 和对象存储凭证。 +6. 所有交接、清关放行、提柜、装车、签收和人工覆盖动作写不可篡改审计日志。 +7. 跨区链路设置独立限流、超时、熔断和重试策略,禁止无限重试。 + +## 12. 高可用与灾备规格 + +### 12.1 正式生产推荐 + +每个区域至少: + +- 2 个应用节点,前置负载均衡/WAF。 +- 3 节点 Nacos 集群或云托管注册配置中心。 +- MySQL 主备/托管高可用,开启 Binlog 和时间点恢复。 +- Redis 高可用或托管版。 +- 区域本地 MQ/事件中继服务。 +- 区域对象存储和跨可用区备份。 + +目标建议:核心写接口可用性 99.9%,数据库 RPO ≤ 5 分钟、RTO ≤ 60 分钟,跨区事件 P95 ≤ 30 秒。 + +### 12.2 低成本一期过渡 + +可以先使用国内、海外各一台应用服务器加托管 MySQL/Redis/对象存储,但必须接受单机故障风险,并至少做到: + +- Docker Compose 或 systemd 自动拉起。 +- 每日全备、持续 Binlog、异地备份。 +- HTTPS、WAF/安全组、只开放 80/443。 +- Outbox/Inbox、失败重试和人工补投工具先完成。 +- 上线前做跨区断网 30 分钟并恢复补投的演练。 + +## 13. 分阶段实施顺序 + +### 阶段 A:国内完成逻辑拆分并上线 + +1. 所有服务仍可只部署国内,但按 `CN/OVERSEAS` 部署单元拆分 Nacos 配置、库名、对象存储目录和服务启动清单。 +2. 为尾程聚合增加不可变的 `execution_region`,创建时确定归属,执行过程中不允许改变。 +3. 实现尾程五个阶段的国内版本,并将本地磁盘文件迁到国内对象存储。 +4. 国内先使用独立 `baiyue_lastleg_cn`,禁止尾程表与订单、头程或仓储跨库联表。 + +### 阶段 B:在国内打通可靠事件 + +1. 实现 `FirstLegShipmentDeparted.v1`、交接回执和里程碑事件。 +2. 完成 Outbox 发布器、Inbox 幂等、聚合版本检查、重试、死信、人工补投和通知消费者。 +3. 事件桥先在国内回环运行,验证跨服务契约,不把第一次验证留到跨国网络。 +4. 完成重复投递、乱序、30 分钟断网、文件复制失败和恢复补投演练。 + +### 阶段 C:建立海外影子环境 + +1. 建立独立海外 Nacos、Gateway、MySQL、Redis、对象存储和监控,不注册到国内 Nacos。 +2. 在切流前完成 RS256/ES256 + JWKS、海外本地撤销集合以及对象存储按需复制。 +3. 海外服务先只消费脱敏测试事件或生产镜像事件,禁止产生真实外部操作和业务回执。 +4. 对比国内结果与海外影子结果,连续 7 天满足数据、权限、性能和事件延迟门槛后才能灰度。 + +### 阶段 D:按新尾程聚合灰度 + +1. 先选择内部测试租户,再按 `1% → 10% → 30% → 100%` 放量,每档至少观察一个完整业务周期。 +2. 只有新创建的尾程聚合进入海外并永久写入 `execution_region=OVERSEAS`;已经在国内执行的旧单继续留在国内直至完成。 +3. Gateway 按聚合归属路由,不按一次请求随机分流;国内订单和统一轨迹查询入口保持不变。 +4. 每档验证交接成功率、事件积压、鉴权失败率、文件可用率和租户隔离后再继续放量。 + +### 阶段 E:旧单排空与正式收口 + +1. 国内停止创建新的尾程聚合,只保留旧单处理、查询和应急能力。 +2. 旧单全部完成并超过售后观察期后,将 `baiyue_lastleg_cn` 改为只读归档。 +3. 停止临时兼容路由和 HS256,海外成为尾程唯一写入单元;完成灾备和回滚演练后收口。 + +## 14. 上线验收门槛 + +- 国内 Nacos 停止海外注册后,国内服务仍能正常下单和执行头程。 +- 国际链路断开时,真实装船操作成功且事件保留在 Outbox,不产生半完成尾程单。 +- 网络恢复后,同一事件重复投递 10 次只创建一条尾程交接记录。 +- 海外拒绝不完整交接时,国内能收到结构化拒绝原因并进入人工处理。 +- 海外里程碑乱序到达时,较旧版本不能覆盖较新状态。 +- 海外 MySQL/Redis短暂不可用时,不影响国内订单和头程。 +- 商户 A 不能访问商户 B 的交接、柜、货物、文件和 POD。 +- 未授权用户即使直接调用清关放行、装车、签收接口也返回 403。 +- POD、清关资料和装柜照片下载均使用短期授权并留下审计记录。 +- 跨区事件积压、死信、延迟和回执缺失均有告警和补投手册。 + +### 14.1 灰度继续和回滚阈值 + +- 继续放量:跨区事件 P95 小于 30 秒、P99 小于 2 分钟,交接成功率不低于 99.9%,Inbox 重复创建业务记录为 0,必需文件 SHA-256 校验通过率 100%。 +- 暂停放量:最老 Outbox 积压超过 5 分钟、海外接口 P95 比国内基线升高 30%、新增 401/403 比基线升高 0.2 个百分点,或出现无法自动补投的死信。 +- 立即回滚:连续 5 分钟 5xx 超过 1%、发现跨租户访问、数据丢失/重复写入、错误业务状态推进或关键文件不可恢复。 +- 回滚先停止创建新的海外聚合,已有海外聚合原则上继续由海外处理,不能直接路由到国内旧库。若海外无法继续写入,必须先冻结写入,通过反向事件或 CDC 补齐国内应急库并校验后再切回;禁止两区同时写同一聚合。 +- 国内旧库、旧版本应用和路由配置至少保留 7 天回滚观察期。每次切换和回滚均记录审批人、时间点、数据校验结果和路由版本。 + +## 15. 近期 P0 清单 + +当前 P0 基线:部署聚合入口、公共事件契约和事件桥 Inbox 幂等基础骨架已经生成;头程真实发运、尾程聚合、可靠发布传输、统一轨迹和通知仍未闭环。 + +1. `P0-01`:冻结装船发运交接点和 `FirstLegShipmentDeparted.v1` 字段,给尾程聚合增加不可变 `execution_region`、聚合版本和唯一幂等键。验收:重复交接只创建一个聚合,旧版本不能覆盖新版本。 +2. `P0-02`:完成头程到仓、测量、排柜、订舱、装柜、报关和装船发运最小流程,生成带版本与摘要的不可变交接快照。验收:前置条件不满足不能发运,关键证据可审计。 +3. `P0-03`:在现有 `event-bridge-service` 基础上补齐 Outbox 扫描和发布状态机、有限重试、死信、人工补投及指标。验收:业务提交和事件不丢失,进程重启后可继续投递。 +4. `P0-04`:实现 Inbox 到尾程业务消费者和接受/拒绝回执,并完成尾程到港前、到港、到仓、出仓、签收五阶段。验收:重复、乱序、坏摘要和非法状态流转均有自动化测试。 +5. `P0-05`:先在国内回环打通事件桥,完成事件签名、来源白名单和 mTLS;RocketMQ 发布消费与真实跨区 HTTPS 投递按最终传输选型实现。验收:断网 30 分钟后可自动恢复补投,链路无跨库和同步跨区 Feign。 +6. `P0-06`:将文件存储抽象为对象存储,完成版本、病毒扫描、SHA-256 和短期下载授权。验收:交接所需文件可按清单受控复制且下载有租户审计。 +7. `P0-07`:把 JWT 改为 RS256/ES256 + JWKS,移除海外服务对国内 Redis 正向会话键的依赖。验收:海外断开国内 Redis 后仍可校验短期令牌和本地撤销信息。 +8. `P0-08`:建立 `CN/OVERSEAS` 配置模板、区域化数据库名和 `REGION_CODE/DEPLOYMENT_CELL/DATA_RESIDENCY` 启动校验。验收:区域错配时服务拒绝启动,两区不共享 MySQL、Redis 和 Nacos。 +9. `P0-09`:实现国内统一轨迹投影和异步订单通知。验收:重复不新增、乱序不回退、通知失败不回滚业务且可死信补投。 +10. `P0-10`:建立覆盖断网、重复、乱序、跨租户、文件失败和回滚的自动化验收。验收:国内回环全部通过后才允许建设海外影子环境。 + +## 16. 最终建议 + +现在最合适的落地路线不是立即购买很多服务器,而是先完成三个基础能力: + +1. 把 `firstleg-service` 的真实“装船发运”节点做出来。 +2. 把现有 Outbox 表补成可发布、可重试、可告警的可靠事件链路。 +3. 在国内先为 `lastleg-service` 实现 Inbox + 交接回执并验证五个尾程阶段,再将同一部署单元迁到海外影子环境。 + +完成这三项后,国内和海外才能真正成为两个可以独立运行、网络中断后仍可恢复的业务单元。 diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 000000000..eed58a2ad --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,128 @@ +# 数据库结构与表用途 + +## 数据归属原则 + +本项目采用单 Git 仓库、多 Maven 模块、多服务数据库架构。每个业务服务只访问自己的数据库,禁止跨库查询、跨库外键和共享业务表。服务之间只交换业务 ID,并通过 OpenAPI 或领域事件协作。开发环境可以共用一个 MySQL 8 实例,但 schema、数据库账号和迁移历史应保持独立。 + +商户业务数据必须包含 `tenant_id`。商户端接口只能从已认证 JWT 获取可信租户 ID,并将其加入每个数据查询条件;调用方不能通过请求参数自行选择租户。平台后台接口使用独立路由及 `PLATFORM` 应用身份,并显式指定目标租户。 + +## 数据库与表总览 + +| 服务 | 数据库 | 表 | 数据所有权 | +| --- | --- | --- | --- | +| `auth-service` | `baiyue_auth` | `auth_account`、`auth_refresh_token` | 登录凭据、账号安全状态、刷新令牌生命周期 | +| `iam-service` | `baiyue_iam` | `iam_*` | 租户、企业资料、用户、成员、双端角色、菜单/页面/按钮/API权限、数据范围 | +| `order-service` | `baiyue_order` | `ord_*` | 订单聚合、状态历史和待发布领域事件 | +| `warehouse-service` | `baiyue_warehouse` | `wh_*` | 仓库主数据、地址和服务能力 | +| `firstleg-service` | `baiyue_firstleg` | `fl_*` | 头程履约任务、指派过程和消息消费幂等 | +| `file-service` | `baiyue_file` | `file_*` | 文件元数据、临时下载授权和下载审计 | +| `print-service` | `baiyue_print` | `print_*` | 打印模板、打印任务和任务状态历史 | +| `event-bridge-service` | `baiyue_event_bridge` | `bridge_*` | 跨区事件接收幂等、待发送事件和投递尝试记录 | +| `audit-service` | `baiyue_audit` | `sys_audit_log` | 集中式、追加写入的业务审计轨迹 | +| `lastleg-service` | 后续阶段创建 | 暂无业务表 | 尾程派单、配送轨迹和签收凭证 | + +SQL 入口位于各服务的 `src/main/resources/db` 目录。 + +## 认证服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `auth_account` | 平台端与商户端共用的统一登录账号;保存密码哈希、锁定状态、令牌版本和默认租户/应用上下文。IAM用户资料与本表通过逻辑ID关联,本表不保存角色权限。 | `username`、`password_hash`、`account_status`、`locked_until`、`token_version`、`default_tenant_id`、`operator_tenant_id`、`default_app_code` | +| `auth_refresh_token` | 保存刷新令牌摘要以及签发时的租户和应用上下文,用于刷新令牌轮换、吊销和到期清理;不保存令牌明文。 | `token_hash`、`user_id`、`tenant_id`、`app_code`、`expires_at`、`revoked_at` | + +## IAM服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `iam_tenant` | SaaS租户主表,是商户业务数据隔离的根边界,保存企业基本资料、审核结果和启停状态。 | `tenant_code`、`tenant_type`、`status`、`unified_social_credit_code` | +| `iam_tenant_review` | 保存企业注册或资料变更的审核队列、审核人、意见和结果。 | `tenant_id`、`review_type`、`status`、`reviewer_id`、`reviewed_at` | +| `iam_tenant_address` | 保存企业注册地址、办公地址等租户级地址资料。 | `tenant_id`、`address_type`、`is_default` | +| `iam_tenant_qualification` | 保存企业证照、文件地址、有效期和资质审核状态。 | `tenant_id`、`qualification_type`、`document_url`、`valid_to`、`status` | +| `iam_user` | IAM用户资料,连接认证账号与业务身份;不保存登录密码。 | `auth_user_id`、`user_type`、`display_name`、`status` | +| `iam_membership` | 表示某用户在某企业租户中的成员身份,是分配商户端角色的主体。一个用户可加入多个租户。 | `tenant_id`、`user_id`、`member_type`、`status` | +| `iam_role` | 按 `tenant_id + app_code` 隔离角色,支持商户端 `MERCHANT` 与后台端 `PLATFORM` 两套角色体系。 | `tenant_id`、`app_code`、`role_code`、`role_type`、`status` | +| `iam_membership_role` | 成员与角色的多对多关系,描述成员在当前租户和应用端拥有的角色。 | `membership_id`、`role_id`、`tenant_id` | +| `iam_permission` | 统一权限资源树;`permission_type` 支持 `MENU`、`PAGE`、`BUTTON`、`API`,实现权限控制到按钮并将前端权限与后端API权限关联。 | `tenant_id`、`app_code`、`permission_type`、`parent_id`、`permission_code`、`route_path`、`http_method` | +| `iam_role_permission` | 角色与菜单、页面、按钮、API权限的多对多授权关系。 | `role_id`、`permission_id`、`tenant_id` | +| `iam_data_scope` | 在功能权限之外定义角色可查看/操作的数据范围。 | `role_id`、`tenant_id`、`scope_type`、`scope_expression` | + +## 订单服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `ord_order` | 头程订单聚合根,保存客户、仓库名称与地址、收货时间等下单快照,以及只读履约快递信息和当前状态。 | `tenant_id`、`order_no`、`customer_id`、`customer_name`、`receiving_warehouse_address`、`receiving_time`、`express_company`、`express_tracking_no`、`status`、`version` | +| `ord_order_party` | 固化下单时的发货人和收货人信息,避免外部主数据变化影响历史订单。 | `tenant_id`、`order_id`、`party_type` | +| `ord_order_cargo` | 保存订单货物行、包装件数、重量、体积、申报价值和危险品属性。 | `tenant_id`、`order_id`、`item_no`、`gross_weight_kg`、`volume_cbm` | +| `ord_order_internal_note` | 保存平台运营或履约人员的内部协作备注,不作为客户公开备注。 | `tenant_id`、`order_id`、`content`、`created_by` | +| `ord_order_status_history` | 以不可变记录保存订单每次状态迁移、业务动作、原因和操作人。 | `tenant_id`、`order_id`、`from_status`、`to_status`、`action`、`occurred_at` | +| `ord_outbox_event` | **事务 Outbox 可靠消息表**。订单服务在订单状态变更的同一本地事务中写入待发布领域事件,后台发布器再投递 RocketMQ,从而避免“数据库提交成功但消息发送失败”。它不是普通操作日志,也不是消费者幂等表。 | `aggregate_type`、`aggregate_id`、`event_type`、`payload_json`、`publish_status`、`retry_count`、`next_retry_at`、`published_at` | + +## 头程履约服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `fl_fulfillment_task` | 接收订单领域事件后形成独立头程履约任务,保存订单快照并管理指派、执行和完成状态。 | `tenant_id`、`order_id`、`order_snapshot_json`、`status`、`assignee_id`、`version` | +| `fl_assignment_history` | 不可变记录头程任务每次负责人指派/改派、原因和操作人。 | `tenant_id`、`task_id`、`previous_assignee_id`、`assignee_id`、`operator_id` | +| `fl_consumed_event` | **消费者幂等去重表**。记录已成功处理的领域事件ID,防止 RocketMQ 重复投递造成重复创建履约任务;它不是消息发布表。 | `event_id`、`event_type`、`consumed_at` | + +## 仓库服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `wh_warehouse` | 租户维度仓库聚合根,保存仓库编码、名称、类型、国家、时区、联系人和状态。 | `tenant_id`、`warehouse_code`、`warehouse_type`、`country_code`、`status` | +| `wh_warehouse_address` | 保存仓库运营、退货等不同用途的地址和可选经纬度。 | `tenant_id`、`warehouse_id`、`address_type` | +| `wh_service_capability` | 声明仓库可提供的收货、仓储、拣包、退货等服务能力及截单时间。 | `tenant_id`、`warehouse_id`、`capability_code`、`enabled`、`cutoff_time` | +| `wh_partner` | 仓储域内的租户往来单位配置,供订单选择客户、联系人和地址;商户候选查询强制过滤停用数据。 | `tenant_id`、`partner_code`、`partner_name`、`partner_type`、`contact_name`、`contact_phone`、`address_line`、`status` | + +## 文件中心服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `file_object` | 保存文件元数据、对象存储定位信息、校验值和生命周期状态;文件二进制存放在对象存储中。 | `tenant_id`、`original_name`、`storage_provider`、`object_key`、`content_type`、`size_bytes`、`sha256`、`status` | +| `file_download_token` | 保存临时下载授权及其有效期、使用次数和吊销状态,用于生成受控下载链接。 | `tenant_id`、`file_id`、`token_hash`、`expires_at`、`max_downloads`、`download_count`、`revoked_at` | +| `file_download_log` | 记录文件下载申请与执行结果,供安全追踪和下载审计使用。 | `tenant_id`、`file_id`、`user_id`、`request_id`、`client_ip`、`result`、`failure_reason`、`downloaded_at` | + +## 打印服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `print_template` | 保存租户级打印模板、模板类型、版本、内容和启停状态。 | `tenant_id`、`template_code`、`template_type`、`content`、`version_no`、`status` | +| `print_job` | 保存打印任务、业务来源、模板快照、打印参数、生成文件标识和当前状态。 | `tenant_id`、`job_no`、`template_id`、`business_type`、`business_id`、`payload_json`、`output_file_id`、`status` | +| `print_job_history` | 追加记录打印任务的状态迁移、执行结果、失败原因和操作主体。 | `tenant_id`、`print_job_id`、`from_status`、`to_status`、`message`、`operator_id`、`occurred_at` | + +## 跨区事件桥服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `bridge_outbox` | 保存事件桥待发送事件及发布状态,为后续扫描、重试、死信和人工补投保留持久化边界。当前仅有基础表结构,真实扫描调度和跨区发送尚未实现。 | `event_id`、`idempotency_key`、`event_type`、`event_version`、`source_region`、`target_region`、`payload_hash`、`payload_json`、`status`、`retry_count`、`next_retry_at`、`lease_until` | +| `bridge_inbox` | 保存事件桥已接收的事件信封和摘要,用 `event_id` 和 `idempotency_key` 唯一约束实现幂等;相同事件且相同摘要返回重复,相同事件但摘要不同拒绝覆盖。 | `event_id`、`idempotency_key`、`event_type`、`event_version`、`source_region`、`target_region`、`payload_hash`、`payload_json`、`status`、`received_at`、`processed_at` | +| `bridge_delivery_attempt` | 追加记录每一次投递尝试、目标区域、结果、响应码和错误信息,为重试审计与故障定位提供依据。当前真实投递器尚未实现。 | `event_id`、`source_region`、`target_region`、`attempt_no`、`status`、`response_code`、`error_message`、`started_at`、`finished_at` | + +`event-bridge-service` 当前是可靠事件桥基础骨架,不代表跨区链路可用于生产。内部服务令牌、目标区域校验和 Inbox 幂等接收已实现并通过事件桥 8 项测试;mTLS、来源白名单、RocketMQ 发布消费、真实跨公网 HTTP 投递、Outbox 扫描调度和业务消费者仍待实现。 + +## 审计服务 + +| 表名 | 用途 | 关键字段 | +| --- | --- | --- | +| `sys_audit_log` | **集中业务审计表**。追加记录敏感操作的主体、租户、资源、请求链路、结果及脱敏后的前后快照,用于合规审计和问题追踪;它不同于 Outbox 和消费幂等表。 | `event_type`、`resource_type`、`resource_id`、`tenant_id`、`operator_tenant_id`、`user_id`、`success`、`before_data`、`after_data`、`occurred_at` | + +## 三类事件/日志表的区别 + +| 表 | 写入方 | 核心目标 | 数据生命周期 | +| --- | --- | --- | --- | +| `ord_outbox_event` | 订单业务事务 | 保证业务数据与待发布领域事件最终一致 | 成功发布后保留一段时间,再归档或清理 | +| `fl_consumed_event` | 头程消息消费者 | 对重复投递进行幂等去重 | 至少覆盖消息可能重复投递的最长时间 | +| `bridge_outbox` | 事件桥生产端 | 保存等待投递、重试或死信的版本化跨区事件 | 成功投递并超过审计与补偿窗口后归档 | +| `bridge_inbox` | 事件桥接收端 | 按事件标识和摘要幂等接收,拒绝同标识不同内容 | 至少覆盖跨区重试与人工补投窗口 | +| `sys_audit_log` | 各服务审计组件/审计服务 | 记录谁在何时对什么资源做了什么以及结果 | 按合规要求长期留存,原则上只追加不修改 | + +## 服务边界 + +- `order-service` 拥有客户委托、参与方、货物、商业状态和权威订单号。 +- `firstleg-service` 拥有受理后的指派、订舱、集货、干线运输和清关节点。 +- `warehouse-service` 拥有仓库主数据和能力;后续入库、库存、库位和出库记录也归属该服务。 +- `lastleg-service` 后续负责派单、配送轨迹和签收凭证。 +- `file-service` 拥有文件元数据、下载授权和下载审计;对象二进制存放在对象存储中。 +- `print-service` 拥有打印模板和打印任务;生成文件只保存 `file_id`,不跨库读取文件表。 +- `event-bridge-service` 只负责跨区事件信封的接收幂等、投递记录和可靠传输边界,不拥有订单、头程或尾程业务状态,也不能直接推进业务聚合。 +- `order_id`、`warehouse_id` 等跨服务字段只是逻辑标识,不建立跨数据库外键。 diff --git a/docs/gitlab-repository-creation.md b/docs/gitlab-repository-creation.md new file mode 100644 index 000000000..8a8f039c4 --- /dev/null +++ b/docs/gitlab-repository-creation.md @@ -0,0 +1,98 @@ +# 头程 GitLab 建仓清单 + +## 1. 建仓结论 + +头程项目创建一个 GitLab 仓库,不按服务拆成多个 Git 仓库。 + +原因:当前是一个 Maven Reactor 多模块工程,公共组件、平台 BOM、事件契约和 11 个可运行服务共享根父工程并统一构建。此时按服务拆仓会破坏父 POM、公共版本管理和现有流水线。服务仍然可以独立打包、独立镜像和独立部署,Git 仓库数量不等于微服务数量。 + +GitLab 创建参数: + +| 字段 | 填写内容 | +| --- | --- | +| 项目名称 | 百悦头程平台 | +| 项目标识 | `baiyue-firstleg` | +| 项目描述 | 百悦物流国内头程微服务平台,包含统一登录、租户权限、订单、头程履约、国内仓储、文件、打印、审计、外部集成与跨区域事件桥。 | +| 可见性 | 私有 | +| 默认分支 | `main` | +| 使用 README 初始化 | 否,本地项目已有 README | +| Git 忽略模板 | 不选择,本地已有 `.gitignore` | +| 许可证 | 不选择,内部商业项目 | + +本地目录:`D:\baiyue-firstleg`。 + +## 2. 每个可运行服务的描述 + +以下 11 个模块都是独立运行服务,但属于同一个 GitLab 仓库。 + +| 模块 | 建议制品或镜像名 | 默认端口 | 中文描述 | +| --- | --- | ---: | --- | +| `gateway` | `firstleg-gateway` | 9000 | 头程统一 API 入口,负责路由、跨域、统一鉴权入口、限流与 Swagger 聚合。 | +| `auth` | `auth-service` | 9100 | 统一认证服务,负责注册、登录、Access Token、Refresh Token、退出、修改密码和会话撤销。 | +| `iam` | `iam-service` | 9201 | 租户与权限服务,负责企业、成员、角色、菜单、按钮权限、数据隔离和企业资质审核。 | +| `firstleg` | `firstleg-service` | 9202 | 头程履约服务,负责揽收、入仓、装载、发运和国内到海外的交接事实。 | +| `order` | `order-service` | 9204 | 订单服务,负责头程订单草稿、提交、状态编排、主数据快照、快递履约信息和订单事件。 | +| `warehouse` | `warehouse-service` | 9205 | 国内仓储服务,负责仓库、地址、收货能力和往来单位配置;往来单位表位于 `baiyue_warehouse.wh_partner`。 | +| `file` | `file-service` | 9206 | 文件中心,负责企业资质和业务文件的上传、下载、元数据、授权与后续对象存储适配。 | +| `print` | `print-service` | 9207 | 打印中心,负责打印模板、打印任务、状态记录及生成文件关联。 | +| `audit-service` | `audit-service` | 按配置 | 审计服务,负责关键操作、租户、操作者、业务对象和追踪号的审计留痕。 | +| `integration-service` | `integration-service` | 按配置 | 外部集成服务,负责承运商、渠道和第三方接口的适配、映射、回调与幂等。 | +| `event-bridge-service` | `event-bridge-service` | 按配置 | 跨区域事件桥,负责版本化事件、Inbox 幂等、投递记录、重试和后续国内外通信。 | + +## 3. 不单独创建 GitLab 仓库的公共模块 + +| 模块 | 说明 | +| --- | --- | +| `platform-bom` | 统一公共制品版本清单。 | +| `common-core` | 统一返回、错误码和核心基础模型。 | +| `common-web` | 全局异常、请求追踪和 Web 公共能力。 | +| `common-security` | JWT、安全上下文、租户和审计操作者能力。 | +| `common-openapi` | Swagger/OpenAPI 公共配置。 | +| `common-audit` | 审计事件公共模型与切面。 | +| `common-event-contract` | 国内与海外共享的版本化事件契约。 | + +这些模块随头程仓发布到 Maven 私有仓库,尾程只能按固定版本引用,不能复制源码或再次发布同一坐标。 + +## 4. GitLab 仓库设置建议 + +1. 保护 `main` 分支,禁止直接推送和强制推送。 +2. 合并必须通过流水线,并至少由一名维护者审核。 +3. 开启提交记录、合并请求和发布标签保护。 +4. CI/CD 变量中保存测试或部署环境密码,不提交 `.env`、本地配置或密钥。 +5. 制品保留 7 天;正式发布通过版本标签长期保留。 +6. 服务使用独立镜像标签和部署版本,但共同关联同一个 Git 提交号。 + +## 5. 第一次推送命令 + +先在 GitLab 创建空项目,不要勾选 README 初始化。将下面地址中的组织名替换为实际 GitLab 组: + +```powershell +Set-Location D:\baiyue-firstleg +git status +git add . +git commit -m "初始化百悦头程微服务平台" +git branch -M main +git remote add origin https://gitlab.example.com/你的组织/baiyue-firstleg.git +git push -u origin main +``` + +如果本地已经存在 `origin`: + +```powershell +git remote set-url origin https://gitlab.example.com/你的组织/baiyue-firstleg.git +git push -u origin main +``` + +推送前必须确认 `git status` 中没有 `target`、`.idea`、日志、上传文件、数据库导出或包含密码的本地配置。 + +## 6. 后续确需按服务拆仓时的前置条件 + +只有满足以下条件后,才建议把 11 个服务拆成独立 Git 仓库: + +1. `platform-bom` 和全部 `common-*` 已发布到可用的 Maven 私有仓库。 +2. 每个服务拥有独立父 POM 或只依赖已发布父 POM。 +3. 服务间 DTO 已替换为版本化 API 或事件契约,不存在源码级依赖。 +4. 每个服务都有独立流水线、镜像、配置、数据库迁移和负责人。 +5. 已完成跨仓版本兼容和回滚演练。 + +当前不满足全部前置条件,因此本次只创建 `baiyue-firstleg` 一个 GitLab 仓库。 diff --git a/docs/local-runtime.md b/docs/local-runtime.md new file mode 100644 index 000000000..fda8c1e9d --- /dev/null +++ b/docs/local-runtime.md @@ -0,0 +1,24 @@ +# 头程本地运行环境 + +运行目录为 `D:\baiyue-firstleg-runtime`。 + +| 组件 | 地址 | +|---|---| +| Redis | `127.0.0.1:6379` | +| Nacos | `127.0.0.1:8848` | +| Nacos 控制台 | `http://127.0.0.1:8848/nacos/` | + +先启动基础设施: + +```powershell +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg-runtime\start-infra.ps1 +``` + +在项目 PowerShell 中加载本地变量: + +```powershell +cd D:\baiyue-firstleg +. .\scripts\local-env.ps1 +``` + +头程业务端口保持项目现有默认值,文件上传目录改为 `D:\baiyue-firstleg-runtime\uploads`。 diff --git a/docs/order-warehouse-real-api-regression.md b/docs/order-warehouse-real-api-regression.md new file mode 100644 index 000000000..ca2d6c234 --- /dev/null +++ b/docs/order-warehouse-real-api-regression.md @@ -0,0 +1,37 @@ +# 订单、仓库与往来单位真实 API 回归说明 + +脚本:`scripts/order-warehouse-real-api-regression.ps1`。 + +## 运行环境 + +- Gateway:`19002` +- Order:`19204` +- Warehouse:`19205` +- Auth、IAM:本地已启动服务,由 Gateway 发现 +- MySQL:`127.0.0.1:3306` +- 默认本地管理员密码和 MySQL 密码仅通过脚本参数传入,生成报告不会记录密码或令牌 + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\order-warehouse-real-api-regression.ps1 ` + -GatewayBaseUrl http://127.0.0.1:19002 ` + -OrderBaseUrl http://127.0.0.1:19204 ` + -WarehouseBaseUrl http://127.0.0.1:19205 +``` + +## 覆盖范围 + +脚本真实访问 HTTP 和 MySQL,覆盖: + +1. 仓库与往来单位租户隔离、启用过滤和停用详情限制。 +2. 仓库地址、收货能力和截单时间回显。 +3. 订单草稿创建、编辑、重新保存、详情和提交。 +4. 企业审核未通过时禁止创建订单。 +5. 创建、编辑和提交阶段的仓库及往来单位停用校验。 +6. 客户名称、仓库名称、仓库地址、收货时间和参与方地址历史快照。 +7. 重复提交返回 HTTP 409 和统一错误码 `A0409`。 +8. 平台履约权限写入快递信息,商户身份禁止写入且详情只读回显。 +9. Order、Warehouse 直连 `api-docs`,并静态审查 Gateway 聚合路由源配置。备用 Gateway 不要求实际加载 docs 转发路由。 + +脚本使用固定高位 ID 初始化仓库和往来单位测试数据,可重复执行。无论成功或失败,`finally` 清理都会恢复演示企业、仓库和往来单位状态,删除临时仓库、往来单位及测试订单;若履约权限原本不存在,也会删除临时权限和角色关系。报告只保留脱敏的测试证据。 + +这是本地测试联调脚本,不创建数据库备份,不执行生产迁移或发布操作。 diff --git a/docs/phase1-business-maturity-regression-report.md b/docs/phase1-business-maturity-regression-report.md new file mode 100644 index 000000000..f68d6bec6 --- /dev/null +++ b/docs/phase1-business-maturity-regression-report.md @@ -0,0 +1,71 @@ +# 一期业务成熟度回归测试报告 + +测试时间:2026-07-30 +测试环境:本地标准端口、MySQL、Redis、Nacos +结论:本次前端联调报告列出的认证、个人资料、成员、角色、仓库、往来单位、订单草稿及快递信息缺口已通过成熟度验收。 + +本次属于本地测试联调,不执行数据库备份或生产发布操作。脚本只创建可识别的测试数据,并在 `finally` 中恢复状态、删除可删除数据;不能物理删除的成员和租户按测试数据规则停用保留。 + +## 1. 验收结果 + +| 验收层级 | 结果 | 证据 | +| --- | --- | --- | +| 全仓构建 | 通过 | 19 个 Reactor 项目全部 `SUCCESS` | +| 单元及组件测试 | 通过 | 75 项通过,失败 0,错误 0,跳过 0 | +| 认证、个人资料、成员、角色、租户隔离 | 通过 | 68 项真实 API 检查通过,失败 0 | +| 仓库、往来单位、订单、快递 | 通过 | 37 项真实 API 检查通过,失败 0 | +| 服务健康 | 通过 | Gateway、Auth、IAM、Firstleg、Order、Warehouse、File、Print 共 8 个服务均为 `UP` | +| Swagger | 通过 | Gateway UI、配置和 11 个业务分组共 13 个入口全部 HTTP 200 | +| CORS | 通过 | 白名单来源 HTTP 200,非白名单来源 HTTP 403 | + +结构化报告: + +- `reports/phase1-business-maturity-regression-20260730171959885.json` +- `reports/订单仓库往来单位真实API回归-20260730-171805.json` + +## 2. 已通过的业务能力 + +- 统一登录、刷新令牌轮换、退出、过期会话和旧令牌失效。 +- 本人资料查询、编辑、回显和恢复;错误旧密码被拒绝,正确修改密码后全部旧会话失效。 +- 成员新增、编辑、列表、详情、姓名联系方式及 `roleIds` 回显、启停、重置密码。 +- 普通成员不能停用本人,企业所有者不能被停用,停用账号不能登录。 +- 角色新增、编辑、启停、删除、`permissionIds` 回显;已分配成员的角色禁止删除。 +- 平台端与商户端隔离、按钮权限、跨租户成员/仓库/往来单位访问隔离。 +- 启用仓库查询、地址及收货截单时间回显,停用仓库不可选择。 +- 往来单位位于 `warehouse-service` 和 `baiyue_warehouse.wh_partner`,启用过滤和租户隔离通过。 +- 订单草稿创建、编辑后重新保存、提交、重复提交 409、审核未通过禁止下单。 +- 仓库和往来单位停用后禁止保存,订单使用服务端可信主数据快照,不信任客户端伪造名称。 +- 订单历史参与方地址快照、仓库地址及收货时间快照通过。 +- 平台写入履约快递信息,商户详情只读展示,商户不能调用平台写接口。 + +## 3. 回归中发现并修复的问题 + +1. Auth 修改密码接口使用了错误的认证主体类型,已统一使用公共 JWT 认证过滤器。 +2. 权限列表同时返回租户权限和全局模板,已按权限码合并并优先当前租户。 +3. 成员审计字段可能为空,安全上下文审计提供器已设为主提供器。 +4. 企业所有者停用校验顺序错误,已优先执行所有者保护。 +5. `/members/roles` 被 `/members/{membershipId}` 动态路由吞掉,成员路径已限制为数字。 +6. Windows PowerShell 5 对错误响应、UTF-8 请求和响应处理不稳定,两套回归脚本已统一修复。 +7. MySQL 短参数在 PowerShell 下解析异常,回归脚本已改为长参数并使用进程内密码环境变量。 + +## 4. 测试数据清理 + +- 临时自定义角色均已删除,未删除角色数为 0。 +- 临时测试成员因当前没有成员物理删除接口,已全部停用并清空角色,保留可审计记录。 +- 跨租户注册测试会保留停用前的租户与账号数据;后续可增加测试数据归档任务。 +- 订单、临时仓库、临时往来单位和临时权限关系已由脚本 `finally` 恢复或删除。 + +## 5. 尚未达到生产成熟的范围 + +本报告只确认前端联调清单对应能力成熟,不代表整个国内外头尾程平台已经可以直接生产上线。以下仍需后续迭代: + +- 注册和成员创建的跨服务写操作仍为同步调用加补偿,需要 Outbox/Saga 进一步保证极端失败一致性。 +- 内部服务凭据仍为共享静态密钥,生产应使用密钥轮换和 mTLS。 +- 营业执照真实图片的桌面端、移动端浏览器查看与替换仍需人工浏览器验收。 +- 文件服务仍为本地磁盘,生产需切换对象存储并完成短期授权与下载审计。 +- 完整头程仓内作业、订舱、装柜、报关、发运交接、跨区域 Outbox/Inbox、轨迹和通知尚未形成生产闭环。 +- 往来单位一期为单联系人、单地址平面配置,复杂联系人和地址簿属于后续范围。 + +## 6. 验收结论 + +本次前端联调缺口可以进入稳定浏览器联调和一期演示验收。生产上线审批仍应以跨服务可靠性、文件对象存储、完整头程履约、跨区域事件、备份恢复和安全演练全部完成为前提。 diff --git a/docs/phase1-business-maturity-regression.md b/docs/phase1-business-maturity-regression.md new file mode 100644 index 000000000..7fb0610ab --- /dev/null +++ b/docs/phase1-business-maturity-regression.md @@ -0,0 +1,23 @@ +# 一期业务成熟度真实 API 回归脚本 + +脚本位置:`scripts/phase1-business-maturity-regression.ps1`。 + +脚本覆盖登录、刷新、退出、个人资料、修改密码、企业成员新增/编辑/查询/角色回显/启停/重置密码、角色新增/编辑/启停/删除保护/权限回显、本人和企业所有者保护、按钮权限、应用端隔离和跨租户越权。 + +运行前应完成数据库迁移并启动网关、认证服务、IAM 服务、MySQL、Redis 和 Nacos。建议使用专用测试环境,不要在生产环境执行。脚本会修改并恢复商户管理员密码,创建带 `p1m_时间戳` 前缀的成员、角色和第二租户。由于当前没有删除成员和租户接口,脚本会停用新成员、清空其角色并保留成员和租户记录,以便审计和人工清理;临时自定义角色会自动删除。 + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\phase1-business-maturity-regression.ps1 ` + -RepositoryRoot "D:\baiyue-firstleg" ` + -GatewayBaseUrl "http://127.0.0.1:9000" ` + -MerchantAdminPassword "实际商户管理员密码" ` + -PlatformAdminPassword "实际平台管理员密码" +``` + +可通过 `-TemporaryAdminPassword`、`-MemberInitialPassword` 和 `-MemberResetPassword` 传入符合策略的测试密码。密码及令牌只保存在进程内存中,不会写入控制台或 JSON 报告。 + +默认报告输出到仓库的 `reports` 目录。报告包含每个检查项的分类、通过状态、HTTP 状态、业务码、脱敏测试数据标识和汇总结果。任一关键检查失败时脚本返回退出码 `1`,便于接入流水线。 + +无论主流程成功或失败,脚本都会在 `finally` 阶段再次登录商户管理员并执行最佳努力清理。历史成员只有同时满足成员编号为 `P1-*` 且显示名为 `一期验收成员*` 才会被停用和清空角色;历史角色只有编码为 `P1_SELF_*` 且类型为 `CUSTOM` 才会被删除。脚本不会按单一模糊条件处理正常业务数据。无法通过现有 API 删除的测试成员和第二测试租户会记录在报告的 `residualData` 中。 + + diff --git a/docs/phase1-frontend-integration-tasks.md b/docs/phase1-frontend-integration-tasks.md new file mode 100644 index 000000000..0b2b439b5 --- /dev/null +++ b/docs/phase1-frontend-integration-tasks.md @@ -0,0 +1,145 @@ +# 一期前端本地联调任务 + +## 1. 文档范围 + +本文只用于本地开发联调,不包含测试环境、生产环境、国内外部署和跨区域事件同步。 + +一期业务闭环:商户注册 → 登录 → 完善企业资料 → 上传营业执照 → 平台审核 → 审核通过后创建头程订单 → 编辑草稿 → 提交订单。 + +## 2. 本地联调入口 + +| 项目 | 地址 | +|---|---| +| 前端统一 API 地址 | `http://127.0.0.1:9000` | +| 局域网前端统一 API 地址 | `http://192.168.110.149:9000` | +| Swagger | `http://127.0.0.1:9000/swagger-ui.html` | +| MySQL | `127.0.0.1:3306`,本地密码 `123456` | +| Redis | `127.0.0.1:6379` | +| Nacos | `http://127.0.0.1:8848/nacos` | + +前端不得直连 9100、9201 等业务端口,所有请求统一经过 Gateway 的 9000 端口。 + +当前允许的前端 Origin: + +- `http://localhost:5173`、`http://localhost:5174` +- `http://127.0.0.1:5173`、`http://127.0.0.1:5174` +- `http://192.168.110.149:5173`、`http://192.168.110.149:5174` + +如果前端实际 IP 或端口不同,修改 `scripts/local-env.ps1` 中的 `CORS_ALLOWED_ORIGINS`,然后只重启 Gateway。 + +## 3. 本地启动顺序 + +先启动 MySQL、Redis、Nacos,再在 IDEA 中启动以下应用: + +1. `BaiyueIamApplication`,端口 9201 +2. `BaiyueAuthApplication`,端口 9100 +3. `BaiyueFirstlegApplication`,端口 9202 +4. `BaiyueOrderApplication`,端口 9204 +5. `BaiyueWarehouseApplication`,端口 9205 +6. `FileServiceApplication`,端口 9206 +7. `PrintServiceApplication`,端口 9207 +8. `BaiyueGatewayApplication`,端口 9000,最后启动 + +一期最小主流程必须启动 Gateway、Auth、IAM、Order、File。为了让一期前端菜单、仓储选择、打印与完整 Swagger 不出现缺失,日常联调建议启动上面 8 个应用。 + +启动后检查: + +```text +http://127.0.0.1:9000/actuator/health +http://127.0.0.1:9100/actuator/health +http://127.0.0.1:9201/actuator/health +http://127.0.0.1:9202/actuator/health +http://127.0.0.1:9204/actuator/health +http://127.0.0.1:9205/actuator/health +http://127.0.0.1:9206/actuator/health +http://127.0.0.1:9207/actuator/health +``` + +预期均返回 `status=UP`。 + +## 4. 联调账号 + +| 端 | 用户名 | 密码 | +|---|---|---| +| 平台端 | `platform_admin` | `ChangeMe123!` | +| 商户端 | `merchant_admin` | `ChangeMe123!` | + +两端共用登录接口,但 JWT 中 `app_code`、租户、角色和按钮权限不同。平台 Token 不能访问商户接口,商户 Token 不能访问平台接口。 + +## 5. 前端公共能力任务 + +- 配置统一请求基地址,不在页面中写死业务服务端口。 +- 成功登录后保存 `accessToken` 和 `refreshToken`,业务请求发送 `Authorization: Bearer `。 +- 遇到 401 时尝试刷新一次 Token;刷新失败则清理会话并回到登录页,禁止无限重试。 +- 统一解析 `{code,message,data,requestId,traceId,timestamp}`。 +- HTTP 200 仍需判断业务 `code` 是否为 `0`。 +- 统一处理 400、401、403、404、409、422、429、500、503。 +- 根据登录返回的角色和权限生成菜单、按钮;按钮隐藏不是安全边界,后端仍执行权限校验。 +- 商户页面不允许传入或切换 `tenantId`,租户从 JWT 获取。 +- 平台查询租户业务时必须显式传目标 `tenantId`。 +- 文件上传使用 `multipart/form-data`,不要手工设置 multipart boundary。 + +## 6. 一期页面任务 + +### 6.1 公共登录与注册 + +- 完成商户注册表单及前端校验。 +- 完成平台、商户统一登录页。 +- 完成 Token 刷新、退出登录和登录态恢复。 +- 根据 `app_code` 进入平台端或商户端,禁止跨端复用页面权限。 + +### 6.2 商户端企业入驻 + +- 企业资料详情页:查询并编辑企业名称、法定名称、联系人、电话、邮箱。 +- 资质页:上传营业执照,显示上传结果和资质审核状态。 +- 资质图片预览/下载必须携带商户 Token。 +- 企业未审核通过时,头程下单入口置灰,并展示后端返回的禁止原因。 + +状态展示:`PROFILE_REQUIRED`/`PENDING` 表示待完善或待审,`ACTIVE` 表示审核通过,`REJECTED` 表示已驳回。 + +### 6.3 平台端企业审核 + +- 待审核列表。 +- 查询指定租户资质列表。 +- 携带平台 Token 预览资质图片。 +- 审核通过、审核驳回及审核意见输入。 +- 按钮权限:`platform:customer:review`、`platform:customer:approve`、`platform:customer:reject`。 + +### 6.4 商户端头程订单 + +- 订单列表:关键字、状态、客户、目的仓、时间和分页筛选。 +- 订单详情:订单、货物、发收货方、状态历史。 +- 新建草稿、编辑草稿、提交订单。 +- 仅 `DRAFT`、`REJECTED` 可编辑;一期主流程状态为 `DRAFT → SUBMITTED`。 +- 商户详情不得展示 `internalNotes`;前端模型即使收到未知字段也不得渲染平台内部备注。 +- 按钮权限:`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit`。 + +## 7. 一期验收清单 + +- 8 个应用健康检查全部 `UP`。 +- 平台账号、商户账号均能通过 Gateway 登录。 +- 两类 Token 跨端访问返回 403。 +- 商户能查询、编辑企业资料。 +- 商户能上传真实 JPG/PNG 营业执照,非图片或超过 5MB 被拒绝。 +- 平台能查询待审核记录、查看资质并通过或驳回。 +- 企业审核前创建订单被拒绝,审核后可创建草稿。 +- 商户可查询、编辑和提交自己的订单,不能读取其他租户订单。 +- 商户订单详情不返回平台内部备注。 +- 局域网前端 Origin 的 OPTIONS 预检返回 200,非白名单 Origin 返回 403。 +- Swagger UI 和一期服务 API 文档均可通过 Gateway 打开。 + +## 8. 本次本地实测结果 + +2026-07-30 已完成本机验证:8 个健康检查均为 `UP`;平台/商户登录成功;IAM、Order、Firstleg、Warehouse、File、Print 状态接口及商户资料、订单列表、仓库列表均能通过 Gateway 返回成功;13 个 Swagger 页面/分组返回 200;`http://192.168.110.149:5173` 跨域预检返回 200,非白名单返回 403。 + +另创建本地联调订单并添加 1 条平台内部备注:平台详情可见 1 条,商户详情返回 0 条,租户端数据隔离验证通过。 + +2026-07-30 17:20 使用最新代码重新完成成熟度回归: + +- 全仓 19 个 Reactor 项目构建成功,75 项单元及组件测试全部通过。 +- 认证、个人资料、修改密码、成员、角色、按钮权限、租户隔离共 68 项真实 API 检查全部通过。 +- 仓库、往来单位、订单草稿、可信快照、停用限制、重复提交、快递只读共 37 项真实 API 检查全部通过。 +- Gateway Swagger UI、配置及 11 个业务分组共 13 个入口全部返回 200。 +- 白名单 CORS 预检返回 200,非白名单返回 403。 + +详细报告见 `docs/phase1-business-maturity-regression-report.md`。营业执照真实图片的桌面端和移动端浏览器查看、替换仍需前端执行人工验收。 diff --git a/docs/phase1-local-api.md b/docs/phase1-local-api.md new file mode 100644 index 000000000..1f11e78bc --- /dev/null +++ b/docs/phase1-local-api.md @@ -0,0 +1,267 @@ +# 一期本地联调 API 文档 + +## 1. 通用约定 + +统一基地址:`http://127.0.0.1:9000`;局域网前端使用 `http://192.168.110.149:9000`。除登录、注册、刷新和 Swagger 外,接口均携带: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +普通成功响应: + +```json +{ + "code": "0", + "message": "success", + "data": {}, + "requestId": null, + "traceId": null, + "timestamp": "2026-07-30T12:00:00" +} +``` + +分页数据: + +```json +{"records": [], "page": 1, "size": 20, "total": 0, "pages": 0} +``` + +常用业务错误码:`A0400` 参数错误、`A0401` 未登录、`A0403` 无权限、`A0404` 不存在、`A0409` 冲突、`A0422` 业务校验失败、`A0429` 请求过多、`B0001` 业务失败、`S0500` 服务异常、`S0503` 服务不可用。 + +## 2. 认证接口 + +| 方法 | 路径 | 登录要求 | 说明 | +|---|---|---|---| +| POST | `/api/auth/register` | 匿名 | 注册商户及租户 | +| POST | `/api/auth/login` | 匿名 | 平台、商户统一登录 | +| POST | `/api/auth/refresh` | 匿名 | 刷新令牌 | +| POST | `/api/auth/logout` | Access Token | 注销刷新令牌 | + +注册请求: + +```json +{ + "username": "merchant_demo", + "password": "ChangeMe123!", + "displayName": "张三", + "companyName": "深圳示例贸易有限公司", + "legalPerson": "张三", + "legalPhone": "13800138000", + "contactEmail": "demo@example.com", + "countryCode": "CN", + "address": "广东省深圳市南山区测试路1号" +} +``` + +用户名为 4 至 64 位字母、数字或下划线,密码为 8 至 72 位。注册响应 `data`:`accountId,tenantId,membershipId,tenantStatus,nextStep`。 + +登录请求: + +```json +{"username":"merchant_admin","password":"ChangeMe123!"} +``` + +登录/刷新响应 `data`:`tokenType,accessToken,expiresIn,refreshToken,refreshExpiresIn`。刷新和退出请求均为: + +```json +{"refreshToken":""} +``` + +## 3. IAM 商户端 + +| 方法 | 路径 | 请求/响应 | +|---|---|---| +| GET | `/api/merchant/v1/iam/tenant/profile` | 返回当前 JWT 租户资料 | +| PUT | `/api/merchant/v1/iam/tenant/profile` | 更新当前租户资料 | +| GET | `/api/merchant/v1/iam/tenant/qualifications` | 返回当前租户资质列表 | + +个人资料与修改密码: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/merchant/v1/iam/profile` | 查询当前登录成员资料 | +| PUT | `/api/merchant/v1/iam/profile` | 编辑姓名、电话和邮箱 | +| PUT | `/api/auth/password` | 校验旧密码并修改本人密码,成功后撤销全部旧会话 | + +成员与角色: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET/POST | `/api/merchant/v1/iam/members` | 成员列表、创建成员和认证账号 | +| GET/PUT | `/api/merchant/v1/iam/members/{membershipId}` | 成员详情、编辑资料和角色 | +| PUT | `/api/merchant/v1/iam/members/{membershipId}/status` | 启用或停用普通成员 | +| PUT | `/api/merchant/v1/iam/members/{membershipId}/password` | 管理员重置成员密码 | +| GET/POST | `/api/merchant/v1/iam/roles` | 角色列表、新建角色 | +| GET/PUT/DELETE | `/api/merchant/v1/iam/roles/{roleId}` | 角色详情、编辑、删除 | +| PUT | `/api/merchant/v1/iam/roles/{roleId}/status` | 启用或停用自定义角色 | +| GET | `/api/merchant/v1/iam/permissions` | 查询去重后的权限资源 | +| PUT | `/api/merchant/v1/iam/roles/permissions` | 全量保存角色权限 | +| GET | `/api/merchant/v1/iam/members/{membershipId}/roles` | 查询成员当前角色 | +| PUT | `/api/merchant/v1/iam/members/roles` | 全量保存或清空成员角色 | + +成员列表和详情均返回 `displayName,phone,email,roleIds`;角色列表和详情均返回 `permissionIds`。企业所有者和系统角色受保护,已分配成员的自定义角色不能删除。 + +资料更新请求: + +```json +{ + "tenantName": "深圳示例贸易有限公司", + "legalName": "深圳示例贸易有限公司", + "contactName": "李四", + "contactPhone": "13900139000", + "contactEmail": "service@example.com" +} +``` + +资料响应字段:`id,tenantCode,tenantName,status,legalName,unifiedSocialCreditCode,contactName,contactPhone,contactEmail`。 + +## 4. IAM 平台审核 + +| 方法 | 路径 | 权限 | 说明 | +|---|---|---|---| +| GET | `/api/platform/v1/iam/reviews` | 平台身份 | 待审核记录 | +| GET | `/api/platform/v1/iam/tenants/{tenantId}/qualifications` | `platform:customer:review` | 指定租户资质 | +| POST | `/api/platform/v1/iam/reviews/{id}/approve` | `platform:customer:approve` | 审核通过 | +| POST | `/api/platform/v1/iam/reviews/{id}/reject` | `platform:customer:reject` | 审核驳回 | + +审核请求: + +```json +{"comment":"资料完整,审核通过"} +``` + +审核通过后租户状态为 `ACTIVE`、资质为 `APPROVED`;驳回后租户和资质为 `REJECTED`。 + +## 5. 资质文件 + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/merchant/v1/files/qualifications` | 上传当前租户营业执照 | +| GET | `/api/merchant/v1/files/qualifications/{fileName}` | 商户下载自己的资质 | +| GET | `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` | 平台查看指定租户资质 | + +上传采用 `multipart/form-data`:`file` 必填,`certificateNo` 可选。只接受文件内容真实为 JPG/PNG 的图片,最大 5MB。上传成功 `data`: + +```json +{ + "qualificationId": 1, + "reviewId": 1, + "tenantStatus": "PENDING", + "documentUrl": "/api/merchant/v1/files/qualifications/文件名.png" +} +``` + +下载接口直接返回图片二进制,不使用统一 JSON 包装。 + +## 6. 商户头程订单 + +基路径:`/api/merchant/v1/orders/first-leg`。 + +| 方法 | 路径 | 权限 | 说明 | +|---|---|---|---| +| POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,`data` 为订单 ID | +| PUT | `/api/merchant/v1/orders/first-leg/{id}` | `merchant:order:edit` | 编辑草稿 | +| GET | `/api/merchant/v1/orders/first-leg` | 商户身份 | 分页查询自己的订单 | +| GET | `/api/merchant/v1/orders/first-leg/{id}` | 商户身份 | 查询自己的订单详情 | +| POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单 | + +列表参数:`keyword,status,customerId,destinationWarehouseId,createdFrom,createdTo,page,size`;`page` 从 1 开始,`size` 为 1 至 200。 + +创建/编辑请求示例: + +```json +{ + "customerId": 10001, + "customerName": "深圳示例贸易有限公司", + "transportMode": "SEA", + "tradeTerm": "FOB", + "destinationCountryCode": "US", + "destinationWarehouseId": 1001, + "destinationWarehouseName": "ONT8", + "expectedShipDate": "2026-08-15", + "customerRemark": "提货前联系", + "pickupMethod": "PICKUP", + "receivingWarehouseId": 2001, + "receivingWarehouseName": "深圳收货仓", + "channelCode": "SEA-US-FBA", + "channelName": "美国海运渠道", + "taxMode": "BUY_DECLARATION", + "containerMode": "LCL", + "customsInspection": false, + "destinationType": "FBA", + "amazonWarehouseCode": "ONT8", + "shipmentId": "FBA20260730001", + "referenceId": "REF20260730001", + "sender": { + "companyName": "深圳示例贸易有限公司", + "contactName": "张三", + "phone": "13800138000", + "email": "sender@example.com", + "countryCode": "CN", + "province": "广东", + "city": "深圳", + "district": "南山", + "addressLine": "测试路1号", + "postalCode": "518000" + }, + "receiver": { + "companyName": "Amazon", + "contactName": "ONT8", + "phone": "1234567890", + "email": "ont8@example.com", + "countryCode": "US", + "province": "CA", + "city": "Moreno Valley", + "district": "Riverside", + "addressLine": "24300 Nandina Ave", + "postalCode": "92551" + }, + "cargoes": [{ + "cargoName": "蓝牙音箱", + "hsCode": "8518210000", + "packageType": "CARTON", + "packageCount": 2, + "grossWeightKg": 20.5, + "volumeCbm": 0.2, + "declaredValue": 200, + "currency": "USD", + "dangerousGoods": false, + "totalQuantity": 20, + "netWeightKg": 18 + }] +} +``` + +枚举约束:`transportMode=SEA|AIR|RAIL|TRUCK`;`pickupMethod=SELF_EXPRESS|PICKUP|SELF_TRUCK`;`taxMode=BUY_DECLARATION|TAX_REFUND`;`containerMode=FULL_CONTAINER|LCL`;`destinationType=FBA|OVERSEAS|PRIVATE`。 + +订单详情 `data` 包含 `order,cargoes,parties,internalNotes,statusHistory`。商户详情的 `internalNotes` 固定为空;平台内部备注不得向商户展示。企业必须为 `ACTIVE` 且至少有一项 `APPROVED` 资质,才能创建和提交订单。 + +## 7. 一期相关查询接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/merchant/v1/warehouses?page=1&size=20` | 商户仓库列表 | +| GET | `/api/merchant/v1/warehouses/{warehouseId}` | 启用仓库详情、地址、能力和收货截单时间 | +| GET | `/api/merchant/v1/partners/candidates` | 当前租户启用往来单位候选 | +| GET | `/api/merchant/v1/partners/{partnerId}` | 当前租户启用往来单位详情 | +| GET | `/api/platform/v1/warehouse-admin?tenantId={tenantId}&page=1&size=20` | 平台仓储列表 | +| GET | `/api/platform/v1/iam/status` | IAM 网关路由状态 | +| GET | `/api/platform/v1/orders/status` | Order 网关路由状态 | +| GET | `/api/platform/v1/first-leg/status` | Firstleg 网关路由状态 | +| GET | `/api/platform/v1/warehouses/status` | Warehouse 网关路由状态 | +| GET | `/api/platform/v1/files/status` | File 网关路由状态 | +| GET | `/api/platform/v1/print/status` | Print 网关路由状态 | + +往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。 + +平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。 + +## 8. Swagger 分组 + +入口:`http://127.0.0.1:9000/swagger-ui.html`。当前可用分组:Auth 平台、IAM 平台/商户、Order 平台/商户、Firstleg 平台、Warehouse 平台/商户、File 平台/商户、Print 平台。 + +## 9. 一期明确不纳入 + +订单平台审核、受理、履约、完成、取消;尾程业务;跨区域事件通知;测试/生产部署,均不属于本地一期前端联调验收范围。 diff --git a/docs/project-structure-after-region-split.md b/docs/project-structure-after-region-split.md new file mode 100644 index 000000000..3ebbdbdb9 --- /dev/null +++ b/docs/project-structure-after-region-split.md @@ -0,0 +1,191 @@ +# 国内外区域拆分后的项目结构审核稿 + +更新时间:2026-07-29 +审核状态:待审核 +适用仓库:`D:\baiyue` + +## 1. 拆分结论 + +项目继续使用一个 Git 仓库和一套业务源码,通过“国内部署聚合入口、海外部署聚合入口、区域独立配置”形成两个部署单元。当前拆分没有复制或移动业务源码,也没有把国内、海外拆成两个 Git 仓库。 + +一期全部服务可以先部署在国内服务器,但源码、数据库命名、配置和事件契约从现在起按国内与海外边界约束。后期迁移时只迁移尾程及海外作业单元,新尾程聚合切到海外主写,国内旧尾程单排空;严禁同一聚合在两个区域同时写入。 + +## 2. 拆分后的目录树 + +```text +D:\baiyue +├─ pom.xml 根 Maven 聚合,一个 Git 仓库 +├─ common-core 统一返回、异常码等公共模型 +├─ common-web 统一异常处理和 Web 公共能力 +├─ common-security JWT、租户和权限上下文 +├─ common-openapi Swagger/OpenAPI 公共配置 +├─ common-audit 审计事件采集客户端 +├─ common-event-contract 国内外共享的版本化事件信封和类型 +├─ event-bridge-service 跨区事件接收幂等和投递记录基础骨架 +├─ gateway 区域网关源码,按区域独立部署 +├─ auth 统一登录与令牌服务 +├─ iam 租户、成员、角色、菜单和按钮权限 +├─ order 订单主档、状态历史和订单 Outbox +├─ firstleg 国内头程履约 +├─ lastleg 尾程履约,后期迁移到海外主写 +├─ warehouse 仓储源码,国内仓和海外仓分别部署 +├─ file 文件服务源码,按区域分别部署 +├─ print 打印服务源码,按区域分别部署 +├─ audit-service 审计落库服务,按区域分别部署 +├─ integration-service 第三方适配,按区域启用不同提供方 +├─ deployments +│ ├─ cn +│ │ ├─ pom.xml 国内部署聚合入口 +│ │ ├─ env.example 国内环境变量清单 +│ │ ├─ build.ps1 国内单元构建脚本 +│ │ └─ README.md 国内部署说明 +│ └─ overseas +│ ├─ pom.xml 海外部署聚合入口 +│ ├─ env.example 海外环境变量清单 +│ ├─ build.ps1 海外单元构建脚本 +│ └─ README.md 海外部署说明 +├─ config\nacos Nacos 配置模板 +└─ docs 架构、数据和实施审核文档 +``` + +`event-bridge-service` 已生成基础骨架,默认端口 `9300`,独立数据库 `baiyue_event_bridge`。它已实现事件信封校验、规范化摘要、内部服务令牌校验、目标区域校验和 Inbox 幂等接收。事件桥自身 8 项测试、公共事件契约 6 项测试通过;基础骨架不等于真实跨区链路已可用。 + +## 3. 模块职责与部署归属 + +| 模块 | 核心职责 | 一期物理位置 | 目标主写位置 | +| --- | --- | --- | --- | +| `gateway` | 统一入口、路由、鉴权前置校验、Swagger 聚合 | 国内 | 国内和海外各一套 | +| `auth` | 注册、登录、刷新、退出、令牌会话 | 国内 | 国内 | +| `iam` | 租户、成员、角色、菜单、按钮、数据范围和资质审核 | 国内 | 国内 | +| `order` | 商户订单主档、平台审核、状态机、订单 Outbox | 国内 | 国内 | +| `firstleg` | 国内头程任务与履约交接 | 国内 | 国内 | +| `lastleg` | 到港前、清关、提柜、海外仓、派送、签收 | 国内运行一期版本 | 海外 | +| `warehouse` | 仓库资料与仓内作业边界 | 国内 | 国内仓、海外仓各自区域主写 | +| `file` | 文件元数据、授权下载、区域对象存储适配 | 国内 | 国内和海外各一套 | +| `print` | 模板、打印任务和状态历史 | 国内 | 按区域各一套 | +| `audit-service` | 不可篡改操作审计的落库边界 | 国内 | 按区域本地记录 | +| `integration-service` | OCR、港务、海关、Amazon 和承运商适配 | 国内 | 国内和海外各一套 | +| `common-event-contract` | 事件信封、类型、区域码、校验规则 | 仅参与编译 | 两区共享同一版本 | +| `event-bridge-service` | 跨区事件接收幂等、待发送记录和投递尝试 | 国内回环验证 | 国内和海外各部署一套 | + +## 4. 国内与海外启动清单 + +### 4.1 一期国内服务器 + +一期核心闭环建议启动:`gateway`、`auth-service`、`iam-service`、`order-service`、`warehouse-service`、`firstleg-service`、`audit-service`、`integration-service`。涉及资质附件时启动 `file-service`;涉及标签和作业单时启动 `print-service`;开发尾程交接闭环后再启动 `lastleg-service`。 + +当前 `deployments/cn/pom.xml` 已聚合公共库、`common-event-contract`、`event-bridge-service`,以及 `gateway/auth/iam/order/firstleg/warehouse/file/print/audit-service/integration-service`。默认构建为 18 个 Reactor 模块,不包含 `lastleg`,用于表达长期区域职责;启用 `phase1-domestic-lastleg` Profile 后为 19 个 Reactor 模块并包含 `lastleg`,用于一期尾程临时部署在国内的兼容场景。 + +一期包含尾程的国内构建命令: + +```powershell +cd D:\baiyue\deployments\cn +.\build.ps1 -IncludePhase1Lastleg + +# 等效 Maven 命令 +mvn -f pom.xml -Pphase1-domestic-lastleg -DskipTests package +``` + +默认构建不带 `-IncludePhase1Lastleg`。长期迁移完成后停止使用该 Profile,国内单元恢复默认 18 模块,旧国内尾程单按迁移方案排空。 + +### 4.2 后期海外服务器 + +`deployments/overseas/pom.xml` 当前聚合公共库、`common-event-contract`、`event-bridge-service`,以及 `gateway/lastleg/warehouse/file/print/audit-service/integration-service`。海外不部署 `auth`、`iam`、`order`、`firstleg` 的写实例。 + +海外正式上线前必须具备:独立 Nacos、独立 MySQL、独立 Redis、独立对象存储、独立域名和证书、跨区可靠事件、海外本地鉴权、监控告警和回滚能力。当前目录聚合只代表结构已拆分,不代表这些生产资源已经创建。 + +## 5. 共享源码规则 + +1. 国内与海外共用同一 Git 仓库、同一模块源码和同一版本号,不复制 `warehouse/file/print/integration/audit` 源码。 +2. 区域差异必须通过环境变量、Spring Profile、Nacos Namespace 和适配器开关表达,禁止在业务代码中散落主机地址判断。 +3. 公共模块只提供无区域主写状态的能力,不保存业务数据,不单独启动。 +4. 事件契约必须向后兼容;新增字段优先可选,破坏性变更必须升级事件版本。 +5. 国内和海外可使用相同服务注册名,但只能注册到各自 Nacos,不能互相发现。 +6. 跨区调用只使用版本化事件或受控文件复制,不通过 OpenFeign 直接跨公网调用业务服务。 + +## 6. 构建与校验命令 + +根工程完整校验: + +```powershell +cd D:\baiyue +mvn clean test +``` + +国内部署单元: + +```powershell +cd D:\baiyue\deployments\cn +.\build.ps1 +mvn -f pom.xml -DskipTests validate +``` + +上面是默认 18 模块构建;一期需要连同国内尾程一起构建时执行 `.\build.ps1 -IncludePhase1Lastleg`,对应 19 模块。 + +海外部署单元: + +```powershell +cd D:\baiyue\deployments\overseas +.\build.ps1 +mvn -f pom.xml -DskipTests validate +``` + +事件契约: + +```powershell +cd D:\baiyue +mvn -pl common-event-contract -am test +``` + +构建成功只证明依赖、编译和自动化测试通过,不代表数据库初始化、Nacos 配置、网络连通、跨区事件或生产部署已验收。 + +## 7. 数据与基础设施边界 + +### 7.1 MySQL + +- 国内主库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_firstleg`,以及带区域归属的国内仓储、文件、打印、集成和审计数据库。 +- 一期尾程位于国内时使用独立的 `baiyue_lastleg_cn`,不与订单、头程或仓储跨库联表。 +- 海外目标库:尾程、海外仓储、海外文件、海外打印、海外集成和海外审计各自独立数据库。 +- 禁止跨区外键、跨区事务、共享数据库账号和双写;订单号等公共标识由应用生成,避免区域自增冲突。 + +现有各模块默认配置仍主要使用 `baiyue_warehouse`、`baiyue_file`、`baiyue_print`、`baiyue_lastleg` 等未带区域后缀的本地库名。区域化库名属于目标配置,必须在部署环境变量或 Nacos 中显式覆盖,不能把目标名称写成当前已经生效。 + +### 7.2 Nacos + +国内使用独立集群和 `prod-cn` Namespace,海外使用独立集群和 `prod-overseas` Namespace,现阶段 Group 保持 `BAIYUE`。两区的 `8848` 不暴露公网,海外服务不连接国内 Nacos。 + +### 7.3 Redis + +国内登录和网关当前会检查国内 Redis 中的令牌会话。海外正式运行前必须改为短期非对称签名令牌、本地 JWKS 缓存和海外本地撤销信息,不能把跨公网读取国内 Redis 当作生产方案。两区 Redis 的密码、库和键空间独立。 + +### 7.4 对象存储 + +当前文件资质上传仍使用本地磁盘目录,适合本地开发,不适合双区域。目标方案是国内文件进入国内对象存储,海外文件进入海外对象存储;跨区只复制履约所需文件,事件仅携带文件标识、版本和摘要,不携带文件正文。 + +## 8. 当前真实落地状态 + +| 能力 | 状态 | 说明 | +| --- | --- | --- | +| 单 Git 仓库、多模块 Maven 工程 | 已实现 | 根工程统一管理源码和依赖 | +| 国内、海外部署聚合目录 | 已生成,待部署验证 | 已有 POM、环境变量示例、构建脚本和说明 | +| 事件信封和类型公共契约 | 已实现基础契约 | 有源码和单元测试,尚未接入完整业务链路 | +| 事件桥服务 | 已实现基础骨架 | 内部令牌、目标区域、信封与摘要校验及 Inbox 幂等已实现,8 项测试通过;mTLS、来源白名单、RocketMQ、真实跨区发送、Outbox 扫描和业务消费者未实现 | +| 登录、租户、菜单和按钮权限模型 | 已实现一期基础能力 | 管理员与权限初始化已有验收记录;写接口权限仍需系统覆盖 | +| 订单主档和状态机 | 已实现一期主要接口 | 已有订单 Outbox 表和本地事务写入;可靠发布未闭环 | +| 头程服务 | 部分实现 | 已有任务、分配历史和消费记录,完整到仓、排柜、订舱、装柜、报关未完成 | +| 尾程服务 | 骨架 | 当前主要为健康接口,尾程业务闭环和 Inbox 尚未完成 | +| 仓储服务 | 部分实现 | 仓库资料维护和查询已具备,仓内入库、测量、拆货、打托等作业未闭环 | +| 文件服务 | 部分实现 | 资质上传下载和表结构已存在;当前仍依赖本地磁盘,对象存储未落地 | +| 打印服务 | 基础骨架 | 有表结构和健康接口,模板渲染、任务执行和打印机适配未完成 | +| 审计服务 | 基础能力已实现 | 有内部采集接口和审计表;跨区审计汇总未完成 | +| 集成服务 | 基础骨架 | 可查询提供方状态;真实 OCR、Amazon、海关、港务、承运商调用待实现 | +| 跨区 Outbox/Inbox 可靠链路 | 部分骨架,未完成 | Inbox 持久化基础已存在;mTLS、真实跨区发送、RocketMQ 发布消费、重试死信和人工补投仍待实现 | +| 海外独立基础设施与灰度 | 未完成 | 尚未进入海外生产部署和切流阶段 | + +## 9. 审核决策点 + +1. 是否确认长期归属为“订单和头程国内唯一主写,尾程海外唯一主写”。 +2. 是否确认一期尾程允许先在国内运行,但数据库和聚合从第一天保留 `execution_region`。 +3. 是否确认仓储、文件、打印、审计、集成采用同源码双区域部署,而不是复制出海外模块。 +4. 是否确认跨区只走可靠事件和受控文件复制,不开放 MySQL、Redis、Nacos 公网互通。 +5. 是否确认先在国内回环验证事件可靠性,再建设海外影子环境和灰度切流。 diff --git a/docs/repository-boundary.md b/docs/repository-boundary.md new file mode 100644 index 000000000..e35f87346 --- /dev/null +++ b/docs/repository-boundary.md @@ -0,0 +1,27 @@ +# 头程仓库边界 + +## 业务所有权 + +头程仓库拥有统一身份入口和国内主数据,包括租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约、文件、打印、审计和国内渠道集成。`auth` 负责认证,`iam` 负责授权与租户隔离;任何海外服务都不能直接写入这两个服务的数据库。 + +尾程仓库拥有海外尾程履约及海外本地支撑能力。头程仓库不包含尾程服务源码,尾程仓库也不复制认证、权限、角色和租户服务。 + +## 技术边界 + +- 公共代码由本仓以 `com.baiyue.shared` 坐标发布,尾程仓通过 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT` 锁定版本。 +- 两仓保留各自独立的父工程、构建流水线、配置中心命名空间和数据库实例。 +- 跨区域通信只使用版本化事件信封,通过事件桥、HTTPS 与双向证书完成。 +- 禁止跨公网共享 MySQL、Redis、Nacos,禁止跨区域直接使用 Feign 调用内部服务。 +- 文件二进制存入区域对象存储;跨区仅传递文件标识、签名地址或经批准的复制事件。 + +## 身份与权限 + +统一登录、租户、用户、角色、菜单及按钮权限全部归属头程仓。权限体系按应用端严格分为商户端 `MERCHANT` 和平台后台端 `PLATFORM` 两套,角色、菜单、页面、按钮和接口权限均按 `tenant_id + app_code` 隔离,后端写接口必须校验对应权限码,不能只依赖前端隐藏按钮。海外网关和服务只验证由头程认证中心签发的非对称令牌,通过只读密钥集合校验签名,不连接国内认证数据库。令牌需携带租户、用户、端类型、角色、权限及区域声明;海外服务仍必须执行租户隔离和本地资源授权校验。 + +## 数据与事件 + +订单在头程仓主写,头程完成后发布交接事件。尾程里程碑通过事件桥回传,头程订单服务生成国内只读轨迹投影并触发通知。发送端使用 Outbox,接收端使用 Inbox,事件必须具备全局事件编号、聚合编号、版本、源区域、目标区域、链路编号和摘要。 + +## 发布原则 + +公共制品先发布 BOM 与依赖模块,再构建尾程仓。公共事件契约只允许向后兼容扩展;破坏性变更必须提升主版本并安排双版本消费窗口。两个仓库独立发布,任何一方失败不得要求另一方同时回滚。 diff --git a/docs/two-repository-split.md b/docs/two-repository-split.md new file mode 100644 index 000000000..f7b6320cc --- /dev/null +++ b/docs/two-repository-split.md @@ -0,0 +1,400 @@ +# 头程与尾程双仓拆分审核方案 + +## 一、拆分结论 + +项目拆分为两个完全独立的 Git 仓库: + +- 头程仓负责国内业务、统一登录、租户、用户、角色、菜单、按钮权限、订单主数据和头程履约。 +- 尾程仓负责海外尾程履约以及海外本地仓储、文件、打印、审计、外部渠道适配。 +- 登录和权限数据只有头程仓可以写入,尾程仓不得包含 `auth`、`iam`、`order`、`firstleg` 源码。 +- 两仓不得通过源码依赖、跨库查询或跨区域 Feign 调用协作,只能通过版本化事件契约交换业务事实。 +- 公共组件由头程仓维护并发布到 Maven 私有仓库,尾程仓只引用明确版本的制品。 + +当前目录是待审核的拆分暂存稿,审核通过后再分别落到正式仓库目录并初始化独立 Git 仓库。 + +## 二、拆分后的目录树 + +```text +baiyue-firstleg +├─ pom.xml +├─ platform-bom +├─ common-core +├─ common-web +├─ common-security +├─ common-openapi +├─ common-audit +├─ common-event-contract +├─ gateway +├─ auth +├─ iam +├─ order +├─ firstleg +├─ warehouse +├─ file +├─ print +├─ audit-service +├─ integration-service +├─ event-bridge-service +├─ config +│ └─ nacos +│ └─ cn +├─ docs +├─ scripts +└─ docker-compose-infra.yml + +baiyue-lastleg +├─ pom.xml +├─ gateway +├─ lastleg +├─ warehouse +├─ file +├─ print +├─ audit-service +├─ integration-service +├─ event-bridge-service +├─ config +│ └─ nacos +├─ docs +├─ scripts +└─ env.example +``` + +## 三、模块归属和职责 + +### 3.1 头程仓 + +| 模块 | 职责 | 数据归属 | +|---|---|---| +| `gateway` | 国内统一入口、鉴权、路由和接口文档聚合 | 不保存业务数据 | +| `auth` | 统一登录、令牌签发、刷新、注销和登录会话 | 国内认证库和国内 Redis | +| `iam` | 租户、用户、成员、角色、菜单、按钮权限和数据范围 | 国内权限库 | +| `order` | 订单主数据、状态编排和跨区订单事实发布 | 国内订单库 | +| `firstleg` | 揽收、入仓、装载、发运和跨区交接 | 国内头程库 | +| `warehouse` | 国内仓库、库位和出入库能力 | 国内仓储库 | +| `file` | 国内文件元数据和对象存储适配 | 国内文件库和国内对象存储 | +| `print` | 国内标签、单据和打印任务 | 国内打印库 | +| `audit-service` | 国内审计记录 | 国内审计库 | +| `integration-service` | 国内外部渠道适配 | 配置和必要的本地状态 | +| `event-bridge-service` | 国内跨区事件发送、接收、重试和幂等 | 国内事件桥库 | +| `common-*` | 公共基础能力 | 发布为 Maven 制品 | +| `common-event-contract` | 跨区事件信封、事件类型和校验规则 | 发布为 Maven 制品 | +| `platform-bom` | 统一公共制品版本 | 发布为 Maven 物料清单 | + +### 3.2 尾程仓 + +| 模块 | 职责 | 数据归属 | +|---|---|---| +| `gateway` | 海外统一入口、本地验签、路由和访问控制 | 不保存业务数据 | +| `lastleg` | 到港、清关、拆柜、海外仓入库、派送和签收 | 海外尾程库 | +| `warehouse` | 海外仓库、库位和出入库作业 | 海外仓储库 | +| `file` | 海外业务文件、面单和签收证明 | 海外文件库和海外对象存储 | +| `print` | 海外标签、面单和打印任务 | 海外打印库 | +| `audit-service` | 海外本地操作审计 | 海外审计库 | +| `integration-service` | 海外仓、承运商、清关和平台渠道适配 | 配置和必要的本地状态 | +| `event-bridge-service` | 海外跨区事件接收、发送、重试和幂等 | 海外事件桥库 | + +尾程仓明确禁止出现以下源码目录和服务制品依赖: + +```text +auth +iam +order +firstleg +common-core +common-web +common-security +common-openapi +common-audit +common-event-contract +``` + +其中公共组件不是禁止使用,而是禁止复制源码;尾程仓必须通过 Maven 私有仓库引用。 + +## 四、登录和权限边界 + +1. 账号、密码、租户、用户、角色、菜单、按钮权限和数据范围只允许由头程仓的 `auth` 与 `iam` 写入。 +2. 用户通过国内统一入口登录,由 `auth` 使用非对称算法签发短时效令牌。 +3. 海外网关只缓存公开密钥并在本地验签,不保存签名私钥,不跨区读取国内 Redis 登录会话。 +4. 令牌携带用户、租户、应用端、权限快照版本和必要的授权声明;海外服务仍需校验租户边界。 +5. 用户停用、租户冻结、权限版本变化和高风险封禁通过事件同步为海外本地投影。 +6. 正式迁往海外前,公共安全组件必须完成公开密钥验签和本地权限投影校验改造;当前基于共享密钥和 Redis 会话的实现不能直接作为跨区域生产方案。 + +## 五、公共制品发布清单 + +头程仓发布以下公共制品,尾程仓只消费,不得再次发布同一坐标: + +| 制品坐标 | 类型 | 用途 | +|---|---|---| +| `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT` | `pom` | 统一公共组件版本 | +| `com.baiyue.shared:baiyue-common-core:1.0.0-SNAPSHOT` | `jar` | 返回结构、异常、基础实体和数据库公共配置 | +| `com.baiyue.shared:baiyue-common-web:1.0.0-SNAPSHOT` | `jar` | 统一异常、链路标识和网络层公共能力 | +| `com.baiyue.shared:baiyue-common-security:1.0.0-SNAPSHOT` | `jar` | 认证信息解析和接口权限校验 | +| `com.baiyue.shared:baiyue-common-openapi:1.0.0-SNAPSHOT` | `jar` | 接口文档公共配置 | +| `com.baiyue.shared:baiyue-common-audit:1.0.0-SNAPSHOT` | `jar` | 业务审计切面和审计发送能力 | +| `com.baiyue.shared:common-event-contract:1.0.0-SNAPSHOT` | `jar` | 跨区事件信封、类型、摘要和校验规则 | + +两个聚合父工程使用不同坐标: + +```text +com.baiyue.firstleg:baiyue-firstleg-platform:1.0.0-SNAPSHOT +com.baiyue.lastleg:baiyue-lastleg-platform:1.0.0-SNAPSHOT +``` + +正式环境不得长期引用快照版本。审核和联调阶段可以使用快照版本,上线时应发布不可覆盖的正式版本,例如 `1.0.0`,并保留制品校验值和变更记录。 + +## 六、构建和发布顺序 + +### 6.1 本地构建 + +第一次构建或公共版本发生变化时,必须先构建头程仓: + +```powershell +cd D:\baiyue-firstleg +mvn clean install +``` + +确认公共制品已经安装到本地 Maven 仓库后,再构建尾程仓: + +```powershell +cd D:\baiyue-lastleg +.\scripts\check-boundary.ps1 +mvn clean verify +``` + +### 6.2 私有仓库发布 + +用于团队开发、流水线或服务器构建时,头程仓先发布公共制品: + +```powershell +cd D:\baiyue-firstleg +mvn clean deploy +``` + +尾程流水线确认指定的 `baiyue.shared.version` 已经存在且校验通过,然后执行自身构建。尾程仓不得通过相对路径访问头程仓,也不得依赖开发人员本地残留的公共源码。 + +### 6.3 正确的拓扑顺序 + +```text +头程父工程 + → 公共核心 + → 公共网络层 + → 公共安全 + → 公共接口文档 + → 公共审计 + → 公共事件契约 + → 公共版本清单发布 + → 头程业务服务构建 + → 尾程边界检查 + → 尾程业务服务构建 +``` + +## 七、跨仓业务协作 + +### 7.1 强制规则 + +- 跨仓只传递业务事件,不共享源码模型。 +- 不通过公网 Feign 直接调用对方内部服务。 +- 不跨区域查询对方数据库、Redis、Nacos 或对象存储。 +- 事件发送采用本地事务与发件箱,接收采用收件箱幂等。 +- 同一事件重复到达只能产生一次业务效果。 +- 失败事件必须支持指数退避、死信、告警和人工补投。 + +### 7.2 推荐事件链路 + +```text +业务服务本地事务 + → 本地发件箱 + → 本区域事件桥 + → 加密的跨区传输 + → 对方区域事件桥 + → 本地收件箱去重 + → 本地业务消费者 + → 本地投影或状态机更新 +``` + +### 7.3 首批事件 + +| 方向 | 事件 | 用途 | +|---|---|---| +| 国内到海外 | 订单已创建 | 建立尾程订单投影 | +| 国内到海外 | 订单已取消 | 阻止未开始的尾程履约 | +| 国内到海外 | 头程已交接 | 建立尾程接货任务 | +| 国内到海外 | 货物已发运 | 提供预计到港和装载信息 | +| 国内到海外 | 权限版本已变化 | 刷新海外授权投影 | +| 国内到海外 | 租户已冻结 | 海外本地立即拒绝访问 | +| 海外到国内 | 尾程已接货 | 更新国内订单轨迹 | +| 海外到国内 | 已到港或已清关 | 更新节点并触发通知 | +| 海外到国内 | 已派送 | 更新订单状态和客户通知 | +| 海外到国内 | 已签收 | 完成尾程并回传签收证明引用 | +| 海外到国内 | 尾程异常 | 触发国内客服和订单异常流程 | + +文件二进制不放入事件,只传文件标识、校验值、区域和受控下载引用。 + +## 八、数据库、Nacos 和 Redis 边界 + +### 8.1 数据库 + +- 每个微服务拥有独立数据库账号和独立逻辑库。 +- 国内数据库只允许国内服务网络访问,海外数据库只允许海外服务网络访问。 +- 同名逻辑库也必须部署在不同实例或不同区域资源中,不能把同一个 MySQL 实例暴露给两区。 +- 服务之间不建立跨库外键,不使用跨库联表,不复制对方业务表。 +- 跨区所需数据建立最小化本地投影,并记录来源事件、聚合版本和最后更新时间。 +- 订单主数据归国内,尾程状态主数据归海外;国内只保存用于查询和通知的尾程轨迹投影。 + +### 8.2 Nacos + +```text +国内命名空间:prod-cn +海外命名空间:prod-overseas +``` + +- 两区使用独立 Nacos 集群、独立地址和独立凭据。 +- 服务发现仅在本区域内生效。 +- 配置项可以使用相同的数据标识,但配置内容、密钥和资源地址必须按区域隔离。 +- 网关关闭自动服务路由,只开放区域清单中明确声明的服务。 + +### 8.3 Redis + +- 国内 Redis 保存国内登录会话、限流和国内业务缓存。 +- 海外 Redis 只保存海外缓存、幂等辅助状态和本地权限投影,不保存或远程读取国内登录会话。 +- 两区 Redis 不做公网主从复制,不复用密码和数据库编号作为隔离手段。 +- 需要跨区同步的冻结或撤销事实通过事件传递,并在海外形成可过期的本地拒绝投影。 + +## 九、持续集成和发布流程 + +### 9.1 头程仓流水线 + +1. 检查仓库边界、敏感信息和禁止文件。 +2. 校验全部 POM、代码格式、单元测试和契约测试。 +3. 构建公共制品和头程业务服务。 +4. 公共组件变更时先发布候选版本到 Maven 私有仓库。 +5. 使用候选公共版本触发尾程兼容流水线。 +6. 两仓都通过后,将公共候选版本提升为不可覆盖的正式版本。 +7. 构建带提交号和版本号的国内服务镜像。 +8. 部署国内测试环境,完成数据库迁移、健康检查和一期验收。 +9. 通过审批后灰度发布国内生产环境。 + +### 9.2 尾程仓流水线 + +1. 执行仓库边界脚本,禁止头程业务源码和公共组件源码进入仓库。 +2. 从私有仓库解析锁定的公共版本,不使用动态版本范围。 +3. 在干净 Maven 缓存中执行编译、单元测试、契约测试和集成测试。 +4. 校验海外配置中不存在国内数据库、Redis、Nacos 和内部服务地址。 +5. 构建带提交号、尾程版本和公共组件版本标签的镜像。 +6. 部署海外测试环境并执行事件往返、幂等、重试和断网恢复测试。 +7. 通过审批后按租户或新订单灰度,不迁移在途订单的写入归属。 + +公共制品发布失败或尾程兼容流水线失败时,禁止头程公共版本进入正式仓库。 + +## 十、版本兼容规则 + +1. 公共制品和事件契约使用语义化版本。 +2. 补丁版本只允许缺陷修复;次版本允许向后兼容的字段和能力增加;主版本用于不兼容变更。 +3. 事件必须携带事件类型、事件标识、契约版本、生产区域、目标区域、聚合标识、聚合版本、时间和负载摘要。 +4. 新增字段必须可选或具有默认语义,消费者必须忽略不认识的附加字段。 +5. 不允许在同一主版本中删除字段、改变字段含义或复用旧事件类型表达新语义。 +6. 生产者至少兼容当前和上一版消费者;消费者至少支持当前和上一版事件契约。 +7. 公共版本升级必须同时通过头程与尾程测试,尾程仓在自己的父 POM 中明确锁定版本。 +8. 每个服务镜像记录业务版本、公共制品版本、契约版本和源代码提交号,便于回滚和审计。 + +## 十一、迁移步骤 + +1. 审核并冻结本方案中的模块归属、数据主权和首批事件清单。 +2. 在原仓库冻结结构性新增,只允许必要缺陷修复,避免拆分期间双向漂移。 +3. 完成暂存仓的中文乱码检查、父 POM、模块坐标和区域配置校验。 +4. 建立 Maven 私有仓库的正式版、候选版和快照版发布策略。 +5. 先发布头程公共物料清单、公共组件和事件契约。 +6. 在干净环境构建尾程仓,证明其不依赖原单体目录和本地源码。 +7. 分别创建 `D:\baiyue-firstleg` 与 `D:\baiyue-lastleg`,各自初始化独立 Git 仓库和远程仓库。 +8. 先在国内部署头程仓全部一期服务,尾程一期仍可在国内独立环境运行,但使用尾程仓代码和独立资源。 +9. 完成发件箱、事件桥、收件箱、业务消费者和轨迹通知闭环。 +10. 事件链路先影子运行,对比原链路结果,不立即切换业务主写。 +11. 海外基础设施准备完成后,仅将新创建、未履约的尾程聚合灰度到海外。 +12. 已在途订单保持原区域完成,不在履约过程中迁移数据库写入权。 +13. 灰度稳定后扩大流量,保留事件回放、数据核对和版本回滚能力。 +14. 双仓稳定运行并完成审计后,再归档原 `D:\baiyue`,不得提前删除。 + +## 十二、验收清单 + +### 12.1 仓库和构建 + +- [ ] 两个目录分别存在且各自只有一个独立 `.git`。 +- [ ] 尾程仓不存在 `auth`、`iam`、`order`、`firstleg` 和公共组件源码目录。 +- [ ] 两个聚合父工程及重复区域服务不存在相同可发布坐标冲突。 +- [ ] 头程仓可执行 `mvn clean verify`。 +- [ ] 头程公共制品可执行 `mvn deploy` 并从私有仓库重新下载。 +- [ ] 清空本地相关制品后,尾程仓仍可独立执行 `mvn clean verify`。 +- [ ] 两仓均未提交 `target`、密钥、密码、日志和开发工具缓存。 + +### 12.2 登录权限 + +- [ ] 登录、租户、用户、角色、菜单、按钮权限只有国内写入口。 +- [ ] 海外网关使用公开密钥本地验签,不保存国内签名私钥。 +- [ ] 海外服务不连接国内 Redis 会话。 +- [ ] 租户冻结、用户停用和权限版本变化可在规定时间内同步到海外。 +- [ ] 平台端与商户端权限、按钮权限和租户数据边界均有自动化测试。 + +### 12.3 跨区事件 + +- [ ] 订单、头程交接、尾程节点和异常事件均有明确契约版本。 +- [ ] 发件箱与业务写入属于同一本地事务。 +- [ ] 收件箱按事件标识幂等,重复事件不重复推进状态。 +- [ ] 乱序、延迟、重复、摘要冲突和非法目标区域均有测试。 +- [ ] 断网恢复、指数退避、死信告警和人工补投可用。 +- [ ] 跨区链路启用传输加密、双向认证或等效的服务身份校验。 +- [ ] 两仓不存在跨区 Feign 地址和对方内部数据库连接。 + +### 12.4 数据和基础设施 + +- [ ] 国内与海外 MySQL、Redis、Nacos、消息系统和对象存储物理隔离。 +- [ ] 每个服务使用独立数据库账号且权限最小化。 +- [ ] 没有跨库查询、跨库外键和公网数据库访问。 +- [ ] 国内与海外网关仅暴露本区域允许的路由。 +- [ ] 备份、恢复、数据保留、审计和敏感信息脱敏策略按区域验证。 + +### 12.5 发布迁移 + +- [ ] 镜像可追溯到代码提交、业务版本、公共版本和契约版本。 +- [ ] 数据库脚本支持升级校验和回滚预案。 +- [ ] 新订单灰度、在途订单保持原区域和失败回切规则已演练。 +- [ ] 国内订单轨迹与海外尾程事实完成自动核对。 +- [ ] 原仓库只在双仓稳定验收后归档,归档前保留只读备份。 + +## 十三、当前完成情况 + +### 13.1 已完成 + +- 已在可写暂存目录形成头程和尾程两个独立 Maven 聚合工程草案。 +- 已将 `auth`、`iam`、`order`、`firstleg` 和全部公共组件源码仅放入头程草案。 +- 已确认尾程草案不包含上述头程业务源码目录和编译期业务包引用。 +- 已为两个父工程使用不同的组织坐标,降低聚合制品冲突风险。 +- 已建立公共物料清单,尾程通过 `com.baiyue.shared` 公共制品引用公共能力。 +- 已按国内和海外分别整理 Gateway 与 Nacos 配置草案。 +- 已为尾程草案提供仓库边界检查脚本和构建脚本。 +- 已从尾程文件服务草案中移除同步调用国内权限服务的资格上传链路。 +- 已保留跨区事件桥和公共事件契约骨架。 + +### 13.2 待完成 + +- 待用户审核本方案后,才创建两个正式 Git 仓库;当前暂存目录不是最终交付仓库。 +- 待修复暂存文件中已有的中文编码显示问题,并统一以无字节顺序标记的 UTF-8 保存。 +- 待配置 Maven 私有仓库地址、凭据、发布权限和不可覆盖的正式版本策略。 +- 待检查并消除两个仓中重复区域服务的最终发布坐标冲突。 +- 待将公共安全组件从共享密钥与国内 Redis 会话校验改造为公开密钥验签和海外本地授权投影。 +- 待实现真实的发件箱扫描、跨区发送、收件箱消费、重试、死信和人工补投。 +- 待为订单、头程、尾程、权限冻结和轨迹通知接入真实事件生产者与消费者。 +- 待补齐尾程业务表、仓储作业、文件对象存储、打印和渠道适配的实际业务能力。 +- 待在干净 Maven 仓库中验证“头程发布公共制品后,尾程完全独立构建”。 +- 待执行两仓全部单元测试、契约测试、集成测试、安全测试和区域故障演练。 +- 待初始化独立 Git 历史、远程仓库保护规则和双仓持续集成流水线。 + +## 十四、审核决策点 + +本次审核需要确认以下内容: + +1. 登录、租户、用户、角色、菜单和按钮权限永久归头程仓唯一写入。 +2. 订单主数据归头程,尾程只维护事件投影和尾程状态主数据。 +3. 公共组件由头程仓发布,尾程不复制公共源码。 +4. 两仓跨区只使用事件,不开放跨区内部 Feign 和数据库访问。 +5. 一期先在国内完成独立双仓部署验证,后期只迁移新尾程聚合到海外。 +6. 当前 `D:\baiyue` 保留为拆分来源和回滚备份,双仓验收前不删除。 diff --git a/event-bridge-service/pom.xml b/event-bridge-service/pom.xml new file mode 100644 index 000000000..c1cd6fedc --- /dev/null +++ b/event-bridge-service/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + + event-bridge-service + event-bridge-service + + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedcommon-event-contract${project.version} + com.baomidoumybatis-plus-spring-boot3-starter + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + 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/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java new file mode 100644 index 000000000..dd0e0b068 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java @@ -0,0 +1,16 @@ +package com.baiyue.eventbridge; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 跨区域事件桥启动入口。 + */ +@MapperScan("com.baiyue.eventbridge.mapper") +@SpringBootApplication +public class EventBridgeApplication { + public static void main(String[] args) { + SpringApplication.run(EventBridgeApplication.class, args); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java new file mode 100644 index 000000000..4b4f4fec9 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -0,0 +1,57 @@ +package com.baiyue.eventbridge.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.eventbridge.dto.BridgeStatus; +import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.service.EventInboxService; +import com.baiyue.eventbridge.security.InternalServiceTokenGuard; +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 lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 + */ +@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态") +@RestController +@RequestMapping("/internal/event-bridge") +@RequiredArgsConstructor +public class EventBridgeController { + private final EventInboxService eventInboxService; + private final InternalServiceTokenGuard internalServiceTokenGuard; + + @Value("${baiyue.region:CN}") + private String region; + + @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "内部服务令牌未配置,接收接口关闭") + }) + @PostMapping("/events") + public ApiResponse receive( + @Parameter(description = "内部服务共享令牌") + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope envelope) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(eventInboxService.receive(envelope)); + } + + @Operation(summary = "查询事件桥能力状态") + @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) + @GetMapping("/status") + public ApiResponse status() { + return ApiResponse.success(new BridgeStatus("运行中", region, true, false)); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java new file mode 100644 index 000000000..4094be14e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java @@ -0,0 +1,36 @@ +package com.baiyue.eventbridge.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; +import java.time.Instant; + +/** + * 事件桥入站幂等记录。 + */ +@Data +@TableName("bridge_inbox") +public class BridgeInbox { + @TableId + private String eventId; + private String idempotencyKey; + private String eventType; + private Integer eventVersion; + private String sourceRegion; + private String targetRegion; + private String tenantId; + private String aggregateType; + private String aggregateId; + private Long aggregateVersion; + private Long handoverVersion; + private String traceId; + private String correlationId; + private String causationId; + private Instant occurredAt; + private String payloadHash; + private String payloadJson; + private String status; + private LocalDateTime receivedAt; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java new file mode 100644 index 000000000..ff9d7efbc --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java @@ -0,0 +1,14 @@ +package com.baiyue.eventbridge.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 事件桥能力状态,不代表生产网络链路已经开通。 + */ +@Schema(description = "事件桥能力状态") +public record BridgeStatus( + @Schema(description = "服务状态") String status, + @Schema(description = "当前区域") String region, + @Schema(description = "数据库幂等接收是否启用") boolean inboxEnabled, + @Schema(description = "生产级跨区传输是否启用") boolean productionTransportEnabled) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java new file mode 100644 index 000000000..5d84a09df --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java @@ -0,0 +1,15 @@ +package com.baiyue.eventbridge.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +/** + * 事件接收结果。 + */ +@Schema(description = "事件接收结果") +public record EventReceiveResult( + @Schema(description = "事件标识") String eventId, + @Schema(description = "接收结果:已接收或重复") String result, + @Schema(description = "接收时间") LocalDateTime receivedAt) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java new file mode 100644 index 000000000..5ec53602f --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -0,0 +1,12 @@ +package com.baiyue.eventbridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.apache.ibatis.annotations.Mapper; + +/** + * 入站事件持久化映射。 + */ +@Mapper +public interface BridgeInboxMapper extends BaseMapper { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..ba4717409 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java @@ -0,0 +1,32 @@ +package com.baiyue.eventbridge.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * 内部事件接收接口的共享令牌校验器;未配置令牌时默认关闭接收。 + */ +@Component +public class InternalServiceTokenGuard { + private final String expectedToken; + + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { + this.expectedToken = expectedToken; + } + + public void verify(String actualToken) { + if (expectedToken == null || expectedToken.isBlank()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "内部事件接收令牌未配置,接口已关闭"); + } + byte[] expected = expectedToken.getBytes(StandardCharsets.UTF_8); + byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效"); + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java new file mode 100644 index 000000000..8803d0a1a --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -0,0 +1,93 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 入站事件校验与幂等落库服务。 + */ +@Service +public class EventInboxService { + private final InboxStore inboxStore; + private final ObjectMapper objectMapper; + private final String localRegion; + + public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper, + @Value("${baiyue.region:CN}") String localRegion) { + this.inboxStore = inboxStore; + this.objectMapper = objectMapper; + this.localRegion = localRegion; + } + + @Transactional + public EventReceiveResult receive(EventEnvelope envelope) { + envelope.requireValid(); + if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { + throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); + } + BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); + if (existing != null) { + return duplicateOrConflict(existing, envelope); + } + BridgeInbox inbox = toInbox(envelope); + try { + inboxStore.insert(inbox); + return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); + } catch (DuplicateKeyException exception) { + BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); + if (concurrent == null) { + throw exception; + } + return duplicateOrConflict(concurrent, envelope); + } + } + + private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { + if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); + } + return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); + } + + private BridgeInbox toInbox(EventEnvelope envelope) { + BridgeInbox inbox = new BridgeInbox(); + inbox.setEventId(envelope.eventId()); + inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId()); + inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); + inbox.setSourceRegion(envelope.sourceRegion().name()); + inbox.setTargetRegion(envelope.targetRegion().name()); + inbox.setTenantId(envelope.tenantId()); + inbox.setAggregateType(envelope.aggregateType()); + inbox.setAggregateId(envelope.aggregateId()); + inbox.setAggregateVersion(envelope.aggregateVersion()); + inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setTraceId(envelope.traceId()); + inbox.setCorrelationId(envelope.correlationId()); + inbox.setCausationId(envelope.causationId()); + inbox.setOccurredAt(envelope.occurredAt()); + inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); + try { + inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("事件负载无法序列化", exception); + } + inbox.setStatus("RECEIVED"); + inbox.setReceivedAt(LocalDateTime.now()); + return inbox; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java new file mode 100644 index 000000000..e96519c13 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java @@ -0,0 +1,11 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +/** + * 入站幂等存储边界,便于隔离数据库实现并进行单元测试。 + */ +public interface InboxStore { + BridgeInbox findByEventId(String eventId); + void insert(BridgeInbox inbox); +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java new file mode 100644 index 000000000..b397cae4c --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -0,0 +1,25 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的入站幂等存储。 + */ +@Repository +@RequiredArgsConstructor +public class MybatisInboxStore implements InboxStore { + private final BridgeInboxMapper mapper; + + @Override + public BridgeInbox findByEventId(String eventId) { + return mapper.selectById(eventId); + } + + @Override + public void insert(BridgeInbox inbox) { + mapper.insert(inbox); + } +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml new file mode 100644 index 000000000..03dd17a36 --- /dev/null +++ b/event-bridge-service/src/main/resources/application.yml @@ -0,0 +1,40 @@ +server: + port: ${EVENT_BRIDGE_PORT:9300} + +spring: + application: + name: event-bridge-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} + username: ${EVENT_BRIDGE_DB_USERNAME:root} + password: ${EVENT_BRIDGE_DB_PASSWORD:123456} + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:} + config: + namespace: ${NACOS_NAMESPACE:} + +baiyue: + region: ${REGION_CODE:CN} + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + +springdoc: + api-docs: + path: /v3/api-docs diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql new file mode 100644 index 000000000..3fd4368b7 --- /dev/null +++ b/event-bridge-service/src/main/resources/db/event-bridge.sql @@ -0,0 +1,80 @@ +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), + KEY idx_bridge_attempt_event (event_id, started_at) +) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..1b8dfb9d2 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,31 @@ +package com.baiyue.eventbridge.security; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + @Test + void 正确令牌允许访问() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); + assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); + } + + @Test + void 未配置令牌时默认关闭接收接口() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); + assertThatThrownBy(() -> guard.verify("anything")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("接口已关闭"); + } + + @Test + void 错误令牌被拒绝() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); + assertThatThrownBy(() -> guard.verify("wrong")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("令牌无效"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java new file mode 100644 index 000000000..97fc88360 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; + +import java.time.Instant; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class EventInboxServiceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final InboxStore store = mock(InboxStore.class); + private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS"); + + @Test + void 首次事件落库并返回已接收() { + EventEnvelope envelope = envelope("sha256:placeholder"); + envelope = withHash(envelope); + + assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + verify(store).insert(any(BridgeInbox.class)); + } + + @Test + void 相同事件和摘要重复接收不再次写入() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + BridgeInbox existing = existing(envelope.payloadHash()); + when(store.findByEventId(envelope.eventId())).thenReturn(existing); + + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + } + + @Test + void 相同事件但摘要不同时拒绝覆盖() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); + + assertThatThrownBy(() -> service.receive(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("拒绝覆盖"); + } + + @Test + void 并发插入冲突后按既有记录判定为重复() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); + doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); + + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + } + + @Test + void 目标区域与当前部署区域不一致时拒绝接收() { + EventInboxService cnService = new EventInboxService(store, objectMapper, "CN"); + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + + assertThatThrownBy(() -> cnService.receive(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("目标区域"); + } + + private EventEnvelope envelope(String hash) { + return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1, + Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS, + "20001", "ORDER", "90001", 1, null, "trace-1", "flow-1", null, + objectMapper.createObjectNode().put("orderId", "90001"), hash); + } + + private EventEnvelope withHash(EventEnvelope source) { + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); + return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), + source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), + source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), + source.correlationId(), source.causationId(), source.payload(), hash); + } + + private BridgeInbox existing(String hash) { + BridgeInbox inbox = new BridgeInbox(); + inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); + inbox.setPayloadHash(hash); + inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); + return inbox; + } +} diff --git a/file/pom.xml b/file/pom.xml new file mode 100644 index 000000000..e9b118423 --- /dev/null +++ b/file/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + file-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 + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + 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/file/src/main/java/com/baiyue/file/FileServiceApplication.java b/file/src/main/java/com/baiyue/file/FileServiceApplication.java new file mode 100644 index 000000000..d4de34b4b --- /dev/null +++ b/file/src/main/java/com/baiyue/file/FileServiceApplication.java @@ -0,0 +1,16 @@ +package com.baiyue.file; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 文件中心服务启动入口。 + */ +@SpringBootApplication(scanBasePackages = "com.baiyue") +@EnableFeignClients(basePackages = "com.baiyue.file.client") +public class FileServiceApplication { + public static void main(String[] args) { + SpringApplication.run(FileServiceApplication.class, args); + } +} diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java new file mode 100644 index 000000000..def2eeef7 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -0,0 +1,20 @@ +package com.baiyue.file.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "iam-service", contextId = "iamQualificationClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamQualificationClient { + @PostMapping("/qualifications") + ApiResponse submit( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody QualificationRequest request); + + record QualificationRequest(Long tenantId, String qualificationType, + String certificateNo, String documentUrl) { } + record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { } +} diff --git a/file/src/main/java/com/baiyue/file/controller/FileHealthController.java b/file/src/main/java/com/baiyue/file/controller/FileHealthController.java new file mode 100644 index 000000000..3c5db74e7 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/FileHealthController.java @@ -0,0 +1,28 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +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/files") +@Tag(name = "文件中心状态", description = "文件中心服务可用性接口") +public class FileHealthController { + @GetMapping("/status") + @Operation(summary = "查询服务状态", description = "返回文件中心服务的逻辑运行状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功") + }) + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "file-service", "status", "UP")); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java new file mode 100644 index 000000000..9792adab9 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -0,0 +1,36 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.LocalQualificationStorage; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/v1/files/qualifications") +public class PlatformQualificationFileController { + @Resource private LocalQualificationStorage storage; + + @GetMapping("/{tenantId}/{fileName}") + @PreAuthorize("hasAuthority('platform:customer:review')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, @PathVariable String fileName) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); + } + var resource = storage.load(tenantId, fileName); + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(mediaType)).body(resource); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java new file mode 100644 index 000000000..3dea234c6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -0,0 +1,74 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.client.IamQualificationClient; +import com.baiyue.file.service.LocalQualificationStorage; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/merchant/v1/files/qualifications") +public class QualificationFileController { + @Resource private LocalQualificationStorage storage; + @Resource private IamQualificationClient iamClient; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file, + @RequestParam(value = "certificateNo", required = false) String certificateNo) { + long tenantId = merchantTenant(user); + var stored = storage.store(tenantId, file); + try { + var response = iamClient.submit(internalToken, + new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE", + certificateNo, stored.documentUrl())); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed"); + } + var data = response.data(); + return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(), + data.tenantStatus(), stored.documentUrl())); + } catch (RuntimeException exception) { + storage.delete(stored); + if (exception instanceof FeignException feign && feign.status() == 409) { + throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review"); + } + if (exception instanceof BizException business) throw business; + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); + } + } + + @GetMapping("/{fileName}") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { + var resource = storage.load(merchantTenant(user), fileName); + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(mediaType)).body(resource); + } + + private long merchantTenant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required"); + } + return user.tenantId(); + } + + public record UploadResponse(Long qualificationId, Long reviewId, + String tenantStatus, String documentUrl) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java new file mode 100644 index 000000000..830394a15 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -0,0 +1,81 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Service +public class LocalQualificationStorage { + private static final long MAX_SIZE = 5L * 1024 * 1024; + private static final Set TYPES = Set.of("image/jpeg", "image/png"); + private final Path root; + + public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) { + this.root = Path.of(root).toAbsolutePath().normalize(); + } + + public StoredFile store(long tenantId, MultipartFile file) { + if (file == null || file.isEmpty()) throw new BizException("One business-license image is required"); + if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB"); + String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!TYPES.contains(contentType)) throw new BizException("Only JPG and PNG images are supported"); + if (!matchesImageSignature(file, contentType)) { + throw new BizException("The uploaded file content is not a valid JPG or PNG image"); + } + String extension = "image/png".equals(contentType) ? ".png" : ".jpg"; + String fileName = UUID.randomUUID() + extension; + Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + Path target = directory.resolve(fileName).normalize(); + if (!target.startsWith(directory)) throw new BizException("Invalid upload path"); + try { + Files.createDirectories(directory); + file.transferTo(target); + return new StoredFile(target, fileName, + "/api/merchant/v1/files/qualifications/" + fileName); + } catch (IOException exception) { + throw new BizException("Unable to store enterprise qualification"); + } + } + + private boolean matchesImageSignature(MultipartFile file, String contentType) { + byte[] header = new byte[8]; + try (InputStream input = file.getInputStream()) { + int read = input.read(header); + if ("image/jpeg".equals(contentType)) { + return read >= 3 && (header[0] & 0xff) == 0xff && (header[1] & 0xff) == 0xd8 + && (header[2] & 0xff) == 0xff; + } + return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50 + && header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d + && header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a; + } catch (IOException exception) { + throw new BizException("Unable to read enterprise qualification image"); + } + } + + public Resource load(long tenantId, String fileName) { + if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name"); + Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + Path target = directory.resolve(fileName).normalize(); + if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); + try { return new UrlResource(target.toUri()); } + catch (Exception exception) { throw new BizException("File not found"); } + } + + public void delete(StoredFile file) { + try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { } + } + + public record StoredFile(Path path, String fileName, String documentUrl) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java new file mode 100644 index 000000000..0b07bf0e3 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java @@ -0,0 +1,58 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Service +public class QualificationFileStorage { + private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png"); + private final Path root; + + public QualificationFileStorage(@Value("${baiyue.file.storage-root}") String root) { + this.root = Path.of(root).toAbsolutePath().normalize().resolve("qualifications"); + } + + public StoredFile store(long tenantId, MultipartFile file) { + if (file == null || file.isEmpty()) throw new BizException("One qualification image is required"); + if (file.getSize() > 5L * 1024 * 1024) throw new BizException("Qualification image must not exceed 5MB"); + String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!CONTENT_TYPES.contains(contentType)) throw new BizException("Only JPG and PNG qualification images are supported"); + String filename = UUID.randomUUID() + ("image/png".equals(contentType) ? ".png" : ".jpg"); + Path tenantRoot = root.resolve(Long.toString(tenantId)).normalize(); + Path target = tenantRoot.resolve(filename).normalize(); + if (!target.startsWith(root)) throw new BizException("Invalid qualification file path"); + try { + Files.createDirectories(tenantRoot); + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + return new StoredFile(filename, target); + } catch (IOException exception) { + throw new BizException("Unable to store qualification image"); + } + } + + public Resource load(long tenantId, String filename) { + if (!filename.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name"); + Path path = root.resolve(Long.toString(tenantId)).resolve(filename).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) throw new BizException("Qualification image not found"); + try { return new UrlResource(path.toUri()); } + catch (IOException exception) { throw new BizException("Unable to read qualification image"); } + } + + public void delete(StoredFile stored) { + try { Files.deleteIfExists(stored.path()); } catch (IOException ignored) { } + } + + public record StoredFile(String filename, Path path) { } +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml new file mode 100644 index 000000000..834f1fb1d --- /dev/null +++ b/file/src/main/resources/application.yml @@ -0,0 +1,39 @@ +server: + port: ${FILE_PORT:9206} +spring: + application: + name: file-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: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${FILE_DB_USERNAME:root} + password: ${FILE_DB_PASSWORD:123456} + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB +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:} + file: + storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql new file mode 100644 index 000000000..7e35cf92d --- /dev/null +++ b/file/src/main/resources/db/file.sql @@ -0,0 +1,61 @@ +-- 文件中心独占数据库,其他服务只保存文件逻辑ID。 +CREATE DATABASE IF NOT EXISTS baiyue_file + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_file; + +CREATE TABLE IF NOT EXISTS file_object ( + id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', + object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', + storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', + bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称', + content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数', + sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要', + status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' 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_file_tenant_object (tenant_id, object_key), + KEY idx_file_tenant_business (tenant_id, business_type, business_id), + KEY idx_file_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB COMMENT='文件元数据表'; + +CREATE TABLE IF NOT EXISTS file_download_token ( + id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', + token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', + expires_at DATETIME(3) NOT NULL COMMENT '过期时间', + max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数', + download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数', + revoked_at DATETIME(3) NULL COMMENT '撤销时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_download_token_hash (token_hash), + KEY idx_download_file (tenant_id, file_id), + KEY idx_download_expiry (expires_at) +) ENGINE=InnoDB COMMENT='临时下载授权令牌表'; + +CREATE TABLE IF NOT EXISTS file_download_log ( + id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', + user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', + request_id VARCHAR(64) NULL COMMENT '请求ID', + client_ip VARCHAR(64) NULL COMMENT '客户端IP', + result VARCHAR(16) NOT NULL COMMENT '下载结果', + failure_reason VARCHAR(500) NULL COMMENT '失败原因', + downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间', + PRIMARY KEY (id), + KEY idx_download_log_file (tenant_id, file_id, downloaded_at), + KEY idx_download_log_user (tenant_id, user_id, downloaded_at) +) ENGINE=InnoDB COMMENT='文件下载审计日志表'; diff --git a/firstleg/pom.xml b/firstleg/pom.xml new file mode 100644 index 000000000..344b9aca3 --- /dev/null +++ b/firstleg/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + firstleg-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 + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + com.alibaba.cloudspring-cloud-starter-stream-rocketmq + 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/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java new file mode 100644 index 000000000..8532d169c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 + */ +@MapperScan("com.baiyue.firstleg.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueFirstlegApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/FirstlegHealthController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/FirstlegHealthController.java new file mode 100644 index 000000000..325f2bc0d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/FirstlegHealthController.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +/** + * 头程履约服务状态检查接口,供网关、部署平台或运维探针确认服务可用性。 + */ +@Tag( + name = "First-leg service status / 头程服务状态", + description = "First-leg service availability API. / 头程服务可用性接口。" +) +@RestController +@RequestMapping("/api/platform/v1/first-leg") +public class FirstlegHealthController { + @Operation( + summary = "Get service status / 查询服务状态", + description = "Returns the logical status of firstleg-service. / 返回 firstleg-service 的逻辑运行状态。" + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Service is available / 服务可用" + ) + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "firstleg-service", "status", "UP")); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java new file mode 100644 index 000000000..c70dac8c9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java @@ -0,0 +1,88 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.AssignRequest; +import com.baiyue.firstleg.service.FulfillmentTaskService; +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.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; + +/** + * 平台端头程履约任务指派接口,在目标租户内完成任务指派或改派并记录指派历史。 + */ +@Tag( + name = "Platform first-leg assignment / 平台头程指派", + description = "Platform APIs for assigning tenant-scoped first-leg fulfillment tasks. / 平台端按租户指派头程履约任务的接口。" +) +@RestController +@RequestMapping("/api/platform/v1/first-leg/tasks") +public class PlatformFulfillmentAssignmentController { + @Resource + private FulfillmentTaskService service; + + @BusinessAudit( + event = "FIRSTLEG_ASSIGN", + resourceType = "FIRSTLEG_TASK", + resourceId = "#taskId", + description = "Assign first-leg task / 指派头程任务" + ) + @Operation( + summary = "Assign fulfillment task / 指派履约任务", + description = "Assigns or reassigns an open first-leg task in the target tenant and records assignment history. / 指派或改派目标租户下的未关闭头程任务,并记录指派历史。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Assignment succeeded / 指派成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid tenant or request / 租户或请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Task not found in target tenant / 目标租户下任务不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Task is closed or concurrently updated / 任务已关闭或发生并发更新") + }) + @PostMapping("/{taskId}/assignment") + public ApiResponse 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 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> status() { + return ApiResponse.success(Map.of("service", "iam-service", "status", "UP")); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java new file mode 100644 index 000000000..46a0f711c --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java @@ -0,0 +1,52 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse; +import com.baiyue.iam.service.AuthorizationSnapshotService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * IAM 内部授权接口,仅供认证服务查询账号在指定租户和应用域下的角色、权限及数据范围快照。 + */ +@RestController +@RequestMapping("/internal/iam") +public class InternalAuthorizationController { + @Resource + private AuthorizationSnapshotService service; + @Value("${baiyue.security.internal-service-token}") + private String internalTokenValue; + private byte[] internalToken; + + @PostConstruct + void initializeInternalToken() { + if (internalTokenValue == null || internalTokenValue.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); + } + + @PostMapping("/authorization-snapshot") + public ApiResponse load( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody SnapshotRequest request) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "无效的服务间凭据"); + } + return ApiResponse.success(service.load(request)); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java new file mode 100644 index 000000000..461a3c26a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -0,0 +1,65 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest; +import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse; +import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; +import com.baiyue.iam.service.MerchantOnboardingService; +import com.baiyue.iam.service.ProfileResourceService; +import com.baiyue.iam.service.TenantService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/iam") +public class InternalTenantController { + @Resource private MerchantOnboardingService onboardingService; + @Resource private ProfileResourceService profileResourceService; + @Resource private TenantService tenantService; + @Value("${baiyue.security.internal-service-token}") private String internalTokenValue; + private byte[] internalToken; + + @PostConstruct + void initializeToken() { internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); } + + @PostMapping("/merchant-registrations") + public ApiResponse register( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody MerchantRegistrationRequest request) { + verify(token); + return ApiResponse.success(onboardingService.register(request)); + } + + @PostMapping("/qualifications") + public ApiResponse submitQualification( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QualificationSubmissionRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.submitQualification(request)); + } + + @GetMapping("/tenants/{tenantId}/order-eligibility") + public ApiResponse orderEligibility( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId) { + verify(token); + return ApiResponse.success(tenantService.orderEligibility(tenantId)); + } + + private void verify(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token"); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java new file mode 100644 index 000000000..d54e08b41 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -0,0 +1,122 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.MemberDtos.*; +import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse; +import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; +import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.service.PersonalProfileService; +import com.baiyue.iam.service.TenantGuard; +import com.baiyue.iam.service.TenantService; +import io.swagger.v3.oas.annotations.Operation; +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.*; + +import java.util.List; + +/** + * 商户端租户接口,提供当前租户资料维护、成员列表与成员新增能力,并强制使用登录租户边界。 + */ +@RestController +@RequestMapping("/api/merchant/v1/iam") +@Tag(name = "Merchant tenant and members / 商户企业与成员", description = "Manage the current JWT tenant profile and enterprise members. / 管理当前 JWT 租户的企业资料和成员。") +public class MerchantTenantController { + @Resource + private TenantService tenantService; + @Resource + private MemberService memberService; + @Resource + private PersonalProfileService personalProfileService; + + @GetMapping("/profile") + @Operation(summary = "查看当前登录用户个人资料") + public ApiResponse personalProfile(@AuthenticationPrincipal AuthenticatedUser user) { + long tenantId = TenantGuard.tenant(user, "MERCHANT"); + return ApiResponse.success(personalProfileService.get(tenantId, user.userId(), user.username())); + } + + @PutMapping("/profile") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "编辑当前登录用户个人资料") + public ApiResponse updatePersonalProfile( + @Valid @RequestBody UpdatePersonalProfileRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + long tenantId = TenantGuard.tenant(user, "MERCHANT"); + return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request)); + } + + @GetMapping("/tenant/profile") + @Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")}) + public ApiResponse profile(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(tenantService.profile(TenantGuard.tenant(user, "MERCHANT"))); + } + + @PutMapping("/tenant/profile") + @PreAuthorize("hasAuthority('merchant:profile:edit')") + @Operation(summary = "Update current enterprise profile / 更新当前企业资料", description = "Update basic profile fields within the authenticated tenant. / 在当前认证租户内更新企业基础资料。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")}) + public ApiResponse update(@Valid @RequestBody UpdateProfileRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request)); + } + + @GetMapping("/members") + @Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")}) + @PreAuthorize("hasAuthority('merchant:member:list')") + public ApiResponse> members(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(memberService.list(TenantGuard.tenant(user, "MERCHANT"))); + } + + @GetMapping("/members/{membershipId:\\d+}") + @PreAuthorize("hasAuthority('merchant:member:list')") + @Operation(summary = "查询成员详情及当前角色") + public ApiResponse member(@PathVariable Long membershipId, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(memberService.detail(TenantGuard.tenant(user, "MERCHANT"), membershipId)); + } + + @PostMapping("/members") + @Operation(summary = "Add enterprise member / 新增企业成员", description = "Create a membership under the current JWT tenant. / 在当前 JWT 租户下创建成员关系。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Member already exists / 成员已存在")}) + @PreAuthorize("hasAuthority('merchant:member:create')") + public ApiResponse addMember(@Valid @RequestBody CreateMemberRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request)); + } + + @PutMapping("/members/{membershipId:\\d+}") + @PreAuthorize("hasAuthority('merchant:member:edit')") + @Operation(summary = "编辑企业成员及角色") + public ApiResponse updateMember(@PathVariable Long membershipId, + @Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request)); + } + + @PutMapping("/members/{membershipId:\\d+}/status") + @PreAuthorize("hasAuthority('merchant:member:status')") + @Operation(summary = "启用或停用企业成员") + public ApiResponse changeMemberStatus(@PathVariable Long membershipId, + @Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"), + membershipId, request, user.membershipId())); + } + + @PutMapping("/members/{membershipId:\\d+}/password") + @PreAuthorize("hasAuthority('merchant:member:reset-password')") + @Operation(summary = "管理员重置成员密码") + public ApiResponse resetMemberPassword(@PathVariable Long membershipId, + @Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request); + return ApiResponse.success(null); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java new file mode 100644 index 000000000..f53766baa --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -0,0 +1,90 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.TenantDtos.RegisterRequest; +import com.baiyue.iam.dto.TenantDtos.ReviewRequest; +import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.service.TenantGuard; +import com.baiyue.iam.service.TenantService; +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.*; + +import java.util.List; + +/** + * 后台端租户管理接口,提供商户入驻登记、待审列表和审批处理能力。 + */ +@RestController +@RequestMapping("/api/platform/v1/iam") +@Tag(name = "Platform tenant administration / 平台企业管理", description = "Enterprise registration review and tenant activation for platform operators. / 平台运营人员进行企业注册审核和租户启用。") +public class PlatformTenantController { + @Resource + private TenantService service; + + @PostMapping("/tenants") + @PreAuthorize("hasAuthority('platform:customer:create')") + @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在") + }) + public ApiResponse register(@Valid @RequestBody RegisterRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.register(request)); + } + + @GetMapping("/reviews") + @Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域") + }) + public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.pendingReviews()); + } + + @PostMapping("/reviews/{id}/approve") + @PreAuthorize("hasAuthority('platform:customer:approve')") + @Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成") + }) + public ApiResponse approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, + @RequestBody ReviewRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + service.review(id, user.userId(), true, request.comment()); + return ApiResponse.success(null); + } + + @PostMapping("/reviews/{id}/reject") + @PreAuthorize("hasAuthority('platform:customer:reject')") + @Operation(summary = "Reject enterprise registration / 驳回企业注册", description = "Reject a pending review and retain the rejection comment. The operation is audited. / 驳回待审核企业并保留审核意见,操作写入审计日志。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Rejected / 已驳回"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成") + }) + public ApiResponse reject(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, + @RequestBody ReviewRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + service.review(id, user.userId(), false, request.comment()); + return ApiResponse.success(null); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java new file mode 100644 index 000000000..470649548 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -0,0 +1,90 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.ProfileDtos.AddressRequest; +import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationRequest; +import com.baiyue.iam.entity.IamTenantAddress; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.service.ProfileResourceService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +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.*; + +import java.util.List; + +/** + * IAM 档案资源接口,管理内部用户、租户地址和租户资质等身份附属资料。 + */ +@RestController +@Tag(name = "IAM profiles and qualifications / IAM 用户、地址与资质", description = "Platform internal users and merchant tenant profile resources. / 平台内部用户及商户企业地址、资质资源。") +public class ProfileResourceController { + @Resource + private ProfileResourceService service; + + @GetMapping("/api/platform/v1/iam/internal-users") + @Operation(summary = "List platform internal users / 查询平台内部用户", description = "Platform scope is required. / 需要平台端权限域。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")}) + public ApiResponse> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.internalUsers()); + } + + @PostMapping("/api/platform/v1/iam/internal-users") + @PreAuthorize("hasAuthority('platform:user:create')") + @Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")}) + public ApiResponse addUser(@Valid @RequestBody CreateUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.addUser(request)); + } + + @GetMapping("/api/merchant/v1/iam/tenant/addresses") + @Operation(summary = "List enterprise addresses / 查询企业地址", description = "Tenant ID is taken from the merchant JWT. / 租户 ID 从商户 JWT 获取。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address list returned / 查询成功")}) + public ApiResponse> addresses(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.addresses(TenantGuard.tenant(user, "MERCHANT"))); + } + + @PostMapping("/api/merchant/v1/iam/tenant/addresses") + @PreAuthorize("hasAuthority('merchant:profile:edit')") + @Operation(summary = "Add enterprise address / 新增企业地址", description = "Add an operating, registered or return address to the current tenant. / 为当前租户新增经营、注册地址或退货地址。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid address / 地址不合法")}) + public ApiResponse addAddress(@Valid @RequestBody AddressRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.addAddress(TenantGuard.tenant(user, "MERCHANT"), request)); + } + + @GetMapping("/api/merchant/v1/iam/tenant/qualifications") + @Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")}) + public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT"))); + } + + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") + @PreAuthorize("hasAuthority('platform:customer:review')") + public ApiResponse> platformQualifications( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.qualifications(tenantId)); + } + + @PostMapping("/api/merchant/v1/iam/tenant/qualifications") + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + @Operation(summary = "Submit enterprise qualification / 提交企业资质", description = "Create a pending qualification record; document content is stored externally. / 创建待审核资质记录,文件内容由外部对象存储保存。") + @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification submitted / 提交成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid qualification / 资质信息不合法")}) + public ApiResponse addQualification(@Valid @RequestBody QualificationRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.addQualification(TenantGuard.tenant(user, "MERCHANT"), request)); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java new file mode 100644 index 000000000..29b315105 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java @@ -0,0 +1,146 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.RbacDtos.*; +import com.baiyue.iam.entity.IamPermission; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.service.RbacService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 平台端和商户端的角色权限管理接口。 */ +@RestController +@RequestMapping("/api/{app}/v1/iam") +@Tag(name = "角色与权限管理") +public class RbacController { + @Resource private RbacService service; + + @GetMapping("/roles") + @Operation(summary = "查询角色及当前权限") + @PreAuthorize("hasAuthority(#app + ':role:list')") + public ApiResponse> roles(@PathVariable String app, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.roles(scope.tenantId(), scope.appCode())); + } + + @GetMapping("/roles/{roleId}") + @Operation(summary = "查询角色详情及当前权限") + @PreAuthorize("hasAuthority(#app + ':role:list')") + public ApiResponse role(@PathVariable String app, @PathVariable Long roleId, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.role(scope.tenantId(), scope.appCode(), roleId)); + } + + @PostMapping("/roles") + @Operation(summary = "创建角色") + @PreAuthorize("hasAuthority(#app + ':role:create')") + public ApiResponse createRole(@PathVariable String app, + @Valid @RequestBody CreateRoleRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request)); + } + + @PutMapping("/roles/{roleId}") + @Operation(summary = "编辑自定义角色") + @PreAuthorize("hasAuthority(#app + ':role:edit')") + public ApiResponse updateRole(@PathVariable String app, @PathVariable Long roleId, + @Valid @RequestBody UpdateRoleRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request)); + } + + @PutMapping("/roles/{roleId}/status") + @Operation(summary = "启用或停用自定义角色") + @PreAuthorize("hasAuthority(#app + ':role:status')") + public ApiResponse changeStatus(@PathVariable String app, @PathVariable Long roleId, + @Valid @RequestBody ChangeRoleStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request)); + } + + @DeleteMapping("/roles/{roleId}") + @Operation(summary = "删除未分配成员的自定义角色") + @PreAuthorize("hasAuthority(#app + ':role:delete')") + public ApiResponse deleteRole(@PathVariable String app, @PathVariable Long roleId, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + service.deleteRole(scope.tenantId(), scope.appCode(), roleId); + return ApiResponse.success(null); + } + + @GetMapping("/members/{membershipId}/roles") + @Operation(summary = "查询成员当前角色") + @PreAuthorize("hasAuthority(#app + ':member:list')") + public ApiResponse memberRoles(@PathVariable String app, + @PathVariable Long membershipId, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.memberRoles(scope.tenantId(), scope.appCode(), membershipId)); + } + + @PutMapping("/members/roles") + @Operation(summary = "全量设置成员角色,空集合表示清空") + @PreAuthorize("hasAuthority(#app + ':member:grant-role')") + public ApiResponse assignRoles(@PathVariable String app, + @Valid @RequestBody AssignRolesRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + service.assignRoles(scope.tenantId(), scope.appCode(), request); + return ApiResponse.success(null); + } + + @GetMapping("/permissions") + @Operation(summary = "查询权限资源") + @PreAuthorize("hasAuthority(#app + ':role:list')") + public ApiResponse> permissions(@PathVariable String app, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.permissions(scope.tenantId(), scope.appCode())); + } + + @PostMapping("/permissions") + @Operation(summary = "创建权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:create')") + public ApiResponse createPermission(@PathVariable String app, + @Valid @RequestBody CreatePermissionRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request)); + } + + @PutMapping("/roles/permissions") + @Operation(summary = "全量设置角色权限,空集合表示清空") + @PreAuthorize("hasAuthority(#app + ':role:grant')") + public ApiResponse grant(@PathVariable String app, + @Valid @RequestBody GrantPermissionsRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + service.grantPermissions(scope.tenantId(), scope.appCode(), request); + return ApiResponse.success(null); + } + + private Scope scope(String app, AuthenticatedUser user) { + String appCode = switch (app) { + case "platform" -> "PLATFORM"; + case "merchant" -> "MERCHANT"; + default -> throw new IllegalArgumentException("不支持的应用端"); + }; + return new Scope(TenantGuard.tenant(user, appCode), appCode); + } + + private record Scope(long tenantId, String appCode) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java new file mode 100644 index 000000000..1ea405654 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -0,0 +1,43 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +/** + * 授权快照内部接口的数据契约集合,定义快照查询请求和包含角色、权限、数据范围的响应模型。 + */ +public final class AuthorizationDtos { + private AuthorizationDtos() { } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SnapshotRequest { + @NotNull + private Long userId; + @NotNull + private Long tenantId; + @NotNull + private Long membershipId; + @NotBlank + private String appCode; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SnapshotResponse { + private Long userId; + private Long tenantId; + private Long membershipId; + private String appCode; + private String clientType; + private Set roles; + private Set permissions; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java new file mode 100644 index 000000000..b7e8936e8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java @@ -0,0 +1,51 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 企业成员接口的数据契约。 */ +public final class MemberDtos { + private MemberDtos() { } + + public record CreateMemberRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, + @NotBlank @Size(max = 64) String displayName, + @Size(max = 32) String phone, + @Email @Size(max = 128) String email, + @NotBlank String memberType, + @Size(max = 64) String memberNo, + List roleIds) { + public CreateMemberRequest { + roleIds = roleIds == null ? List.of() : List.copyOf(roleIds); + } + } + + public record UpdateMemberRequest( + @NotBlank @Size(max = 64) String displayName, + @Size(max = 32) String phone, + @Email @Size(max = 128) String email, + @NotBlank String memberType, + @Size(max = 64) String memberNo, + List roleIds) { + public UpdateMemberRequest { + roleIds = roleIds == null ? List.of() : List.copyOf(roleIds); + } + } + + public record ChangeMemberStatusRequest(@NotBlank String status) { } + + public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } + + public record MemberResponse(Long membershipId, Long userId, Long authUserId, + String displayName, String phone, String email, + String memberType, String memberNo, String status, List roleIds) { } + +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java new file mode 100644 index 000000000..e5180c013 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -0,0 +1,32 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public final class OnboardingDtos { + private OnboardingDtos() { } + + public record MerchantRegistrationRequest( + @NotNull Long authUserId, + @NotBlank String displayName, + @NotBlank String companyName, + @NotBlank String legalPerson, + @NotBlank String legalPhone, + String contactEmail, + String countryCode, + String address + ) { } + + public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { } + + public record QualificationSubmissionRequest( + @NotNull Long tenantId, + @NotBlank String qualificationType, + String certificateNo, + @NotBlank String documentUrl + ) { } + + public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + + public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java new file mode 100644 index 000000000..059927156 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java @@ -0,0 +1,30 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +/** 用户档案、地址和资质的数据契约。 */ +public final class ProfileDtos { + private ProfileDtos() { } + + public record CreateUserRequest(@NotNull Long authUserId, @NotBlank String userType, + @NotBlank String displayName, String phone, String email) { } + + public record PersonalProfileResponse(Long userId, Long authUserId, String username, + String displayName, String phone, String email) { } + + public record UpdatePersonalProfileRequest(@NotBlank @Size(max = 64) String displayName, + @Size(max = 32) String phone, + @Email @Size(max = 128) String email) { } + + public record AddressRequest(@NotBlank String addressType, String countryCode, String province, + String city, String district, @NotBlank String detail, + String postalCode, Boolean isDefault) { } + + public record QualificationRequest(@NotBlank String qualificationType, String certificateNo, + @NotBlank String documentUrl, LocalDate validFrom, LocalDate validTo) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java new file mode 100644 index 000000000..a10dafd27 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.util.Set; + +/** RBAC 管理接口的数据契约。 */ +public final class RbacDtos { + private RbacDtos() { } + + public record CreateRoleRequest(@NotBlank String roleCode, + @NotBlank String roleName, + String description) { } + + public record UpdateRoleRequest(@NotBlank String roleName, + String description) { } + + public record ChangeRoleStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED", message = "角色状态只能是 ACTIVE 或 DISABLED") + String status) { } + + /** 角色回显模型,permissionIds 可直接用于全量授权保存。 */ + public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode, + String roleName, String roleType, String status, + String description, Set permissionIds) { } + + public record MemberRolesResponse(Long membershipId, Set roleIds) { } + + public record CreatePermissionRequest( + @Schema(allowableValues = {"MENU", "PAGE", "BUTTON", "API"}) @NotBlank String type, + @NotBlank String code, + @NotBlank String name, + Long parentId, + String routePath, + String httpMethod, + Integer sortNo) { } + + public record AssignRolesRequest(@NotNull Long membershipId, + @NotNull Set roleIds) { } + + public record GrantPermissionsRequest(@NotNull Long roleId, + @NotNull Set permissionIds) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java new file mode 100644 index 000000000..f424ac26a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -0,0 +1,42 @@ +package com.baiyue.iam.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * 租户生命周期数据契约集合,定义入驻、资料更新、审核请求和租户响应。 + */ +public final class TenantDtos { + private TenantDtos() { } + + @Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料") + public record RegisterRequest( + @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, + @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, + @Schema(description = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode, + @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, + @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail + ) { } + + @Schema(name = "TenantProfileUpdateRequest", description = "Enterprise profile update / 企业资料更新") + public record UpdateProfileRequest( + @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, + @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, + @Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName, + @Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail + ) { } + + @Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见") + public record ReviewRequest( + @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment + ) { } + + @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") + public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + String legalName, String unifiedSocialCreditCode, String contactName, + String contactPhone, String contactEmail) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamDataScope.java b/iam/src/main/java/com/baiyue/iam/entity/IamDataScope.java new file mode 100644 index 000000000..d3fe5f49c --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamDataScope.java @@ -0,0 +1,18 @@ +package com.baiyue.iam.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** 角色数据范围规则,仅用于保证角色删除时关联数据一致清理。 */ +@Getter +@Setter +@TableName("iam_data_scope") +public class IamDataScope { + @TableId private Long id; + private Long roleId; + private Long tenantId; + private String scopeType; + private String scopeExpression; +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java new file mode 100644 index 000000000..1b045d0ac --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java @@ -0,0 +1,14 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; +/** + * 租户成员关系实体,对应表 {@code iam_membership};关联用户与租户并保存成员类型和状态,是租户内授权的主体。 + */ +@Getter +@Setter +@TableName("iam_membership") public class IamMembership extends BaseEntity { + @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembershipRole.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembershipRole.java new file mode 100644 index 000000000..9482243d5 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembershipRole.java @@ -0,0 +1,14 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; +/** + * 成员角色关联实体,对应表 {@code iam_membership_role};在租户维度记录成员获授角色的多对多关系。 + */ +@Getter +@Setter +@TableName("iam_membership_role") public class IamMembershipRole { + private Long membershipId; private Long roleId; private Long tenantId; private LocalDateTime createdAt; + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java new file mode 100644 index 000000000..a52de5219 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java @@ -0,0 +1,72 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +/** + * 权限资源实体,对应表 {@code iam_permission};按租户和应用域定义菜单、按钮或接口权限编码及资源属性。 + */ +@Getter +@Setter +@TableName("iam_permission") +public class IamPermission { + @TableId + private Long id; + private Long tenantId; + private String appCode; + private String permissionType; + private Long parentId; + private String permissionCode; + private String permissionName; + private String routePath; + private String httpMethod; + private Integer sortNo; + private String status; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamRole.java b/iam/src/main/java/com/baiyue/iam/entity/IamRole.java new file mode 100644 index 000000000..5893a5e8d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamRole.java @@ -0,0 +1,58 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baiyue.common.core.model.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +/** + * 角色实体,对应表 {@code iam_role};按租户和应用域维护平台端或商户端角色,实现两套权限体系相互隔离。 + */ +@Getter +@Setter +@TableName("iam_role") +public class IamRole extends BaseEntity { + @TableId + private Long id; + private Long tenantId; + private String appCode; + private String roleCode; + private String roleName; + private String roleType; + private String status; + private String description; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamRolePermission.java b/iam/src/main/java/com/baiyue/iam/entity/IamRolePermission.java new file mode 100644 index 000000000..e40945634 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamRolePermission.java @@ -0,0 +1,14 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; +/** + * 角色权限关联实体,对应表 {@code iam_role_permission};在租户维度记录角色获授菜单、按钮或接口权限的关系。 + */ +@Getter +@Setter +@TableName("iam_role_permission") public class IamRolePermission { + private Long roleId; private Long permissionId; private Long tenantId; private LocalDateTime createdAt; + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java new file mode 100644 index 000000000..9a3d06472 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -0,0 +1,42 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baiyue.common.core.model.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 租户主数据实体,对应表 {@code iam_tenant};保存商户租户编码、企业信息和生命周期状态,是 SaaS 租户隔离的核心边界。 + */ +@Getter +@Setter +@TableName("iam_tenant") +public class IamTenant extends BaseEntity { + @TableId private Long id; + private String tenantCode; + private String tenantName; + private String tenantType; + private String status; + private String legalName; + private String unifiedSocialCreditCode; + private String contactName; + private String contactPhone; + private String contactEmail; + private LocalDateTime approvedAt; + private Long approvedBy; + private String rejectReason; + + + + + + + + + + + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantAddress.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantAddress.java new file mode 100644 index 000000000..8cf21600b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantAddress.java @@ -0,0 +1,11 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +/** + * 租户地址实体,对应表 {@code iam_tenant_address};按租户保存经营、提货或联系地址及默认地址标识。 + */ +@Getter +@Setter +@TableName("iam_tenant_address") public class IamTenantAddress { @TableId private Long id; private Long tenantId; private String addressType; private String countryCode; private String province; private String city; private String district; private String detail; private String postalCode; private Boolean isDefault; } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java new file mode 100644 index 000000000..eb5585fdb --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java @@ -0,0 +1,11 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; +/** + * 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。 + */ +@Getter +@Setter +@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantReview.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantReview.java new file mode 100644 index 000000000..60a662a8d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantReview.java @@ -0,0 +1,15 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; +/** + * 租户审核实体,对应表 {@code iam_tenant_review};记录商户入驻或资料变更的审核单、审核人、结论和意见。 + */ +@Getter +@Setter +@TableName("iam_tenant_review") +public class IamTenantReview { + @TableId private Long id; private Long tenantId; private String reviewType; private String status; private Long reviewerId; private String reviewComment; private LocalDateTime reviewedAt; private LocalDateTime createdAt; + +} diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamUser.java b/iam/src/main/java/com/baiyue/iam/entity/IamUser.java new file mode 100644 index 000000000..cb5872081 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/entity/IamUser.java @@ -0,0 +1,11 @@ +package com.baiyue.iam.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +/** + * IAM 用户档案实体,对应表 {@code iam_user};关联认证账号并保存内部或商户人员的展示名、联系方式和启停状态。 + */ +@Getter +@Setter +@TableName("iam_user") public class IamUser { @TableId private Long id; private Long authUserId; private String userType; private String displayName; private String phone; private String email; private String status; } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java new file mode 100644 index 000000000..d39c7807b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.iam.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 授权快照只读查询 Mapper,聚合查询成员角色、权限编码和数据范围,避免在认证流程逐表读取。 + */ +@Mapper +public interface AuthorizationQueryMapper { + @Select(""" + SELECT COUNT(1) + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + WHERE u.auth_user_id = #{userId} + AND u.status = 'ACTIVE' AND u.deleted = 0 + AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} + AND m.status = 'ACTIVE' AND m.deleted = 0 + """) + int countActiveIdentity(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); + + @Select(""" + SELECT DISTINCT r.role_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + WHERE u.auth_user_id = #{userId} + AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} + AND r.status = 'ACTIVE' AND r.deleted = 0 + ORDER BY r.role_code + """) + List findRoles(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("membershipId") long membershipId, + @Param("appCode") String appCode); + + @Select(""" + SELECT DISTINCT p.permission_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + JOIN iam_role_permission rp ON rp.role_id = r.id + JOIN iam_permission p ON p.id = rp.permission_id + WHERE u.auth_user_id = #{userId} + AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 + AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) + AND p.app_code = #{appCode} AND p.status = 'ACTIVE' + ORDER BY p.permission_code + """) + List findPermissions(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("membershipId") long membershipId, + @Param("appCode") String appCode); +} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamDataScopeMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamDataScopeMapper.java new file mode 100644 index 000000000..c5550d513 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamDataScopeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.iam.mapper; + +import com.baiyue.iam.entity.IamDataScope; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** 角色数据范围 Mapper。 */ +@Mapper +public interface IamDataScopeMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipMapper.java new file mode 100644 index 000000000..9a79923e9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipMapper.java @@ -0,0 +1,4 @@ +/** + * 租户成员关系 MyBatis-Plus Mapper,负责 {@code iam_membership} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamMembership; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamMembershipMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java new file mode 100644 index 000000000..79637cd6d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java @@ -0,0 +1,4 @@ +/** + * 成员角色关联 MyBatis-Plus Mapper,负责 {@code iam_membership_role} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamMembershipRole; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamMembershipRoleMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamPermissionMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamPermissionMapper.java new file mode 100644 index 000000000..e978fd839 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamPermissionMapper.java @@ -0,0 +1,4 @@ +/** + * 权限资源 MyBatis-Plus Mapper,负责 {@code iam_permission} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamPermission; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamPermissionMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamRoleMapper.java new file mode 100644 index 000000000..dda1e6724 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamRoleMapper.java @@ -0,0 +1,4 @@ +/** + * 角色 MyBatis-Plus Mapper,负责 {@code iam_role} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamRole; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamRoleMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamRolePermissionMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamRolePermissionMapper.java new file mode 100644 index 000000000..d12c3da2a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamRolePermissionMapper.java @@ -0,0 +1,4 @@ +/** + * 角色权限关联 MyBatis-Plus Mapper,负责 {@code iam_role_permission} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamRolePermission; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamRolePermissionMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamTenantAddressMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantAddressMapper.java new file mode 100644 index 000000000..7089ab07d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantAddressMapper.java @@ -0,0 +1,4 @@ +/** + * 租户地址 MyBatis-Plus Mapper,负责 {@code iam_tenant_address} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantAddress; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantAddressMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamTenantMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantMapper.java new file mode 100644 index 000000000..4c4b3fd3f --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantMapper.java @@ -0,0 +1,4 @@ +/** + * 租户主数据 MyBatis-Plus Mapper,负责 {@code iam_tenant} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenant; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamTenantQualificationMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantQualificationMapper.java new file mode 100644 index 000000000..23d3c1194 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantQualificationMapper.java @@ -0,0 +1,4 @@ +/** + * 租户资质 MyBatis-Plus Mapper,负责 {@code iam_tenant_qualification} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantQualification; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantQualificationMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamTenantReviewMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantReviewMapper.java new file mode 100644 index 000000000..c430241e1 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamTenantReviewMapper.java @@ -0,0 +1,4 @@ +/** + * 租户审核 MyBatis-Plus Mapper,负责 {@code iam_tenant_review} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantReview; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantReviewMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamUserMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamUserMapper.java new file mode 100644 index 000000000..e117731be --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamUserMapper.java @@ -0,0 +1,4 @@ +/** + * IAM 用户档案 MyBatis-Plus Mapper,负责 {@code iam_user} 表的基础持久化操作。 + */ +package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamUser; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamUserMapper extends BaseMapper{} diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java new file mode 100644 index 000000000..79b82b81b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -0,0 +1,79 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamMembershipRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 企业成员仓储,所有成员查询均显式携带租户条件。 */ +@Repository +public class MemberRepository { + private final IamMembershipMapper membershipMapper; + private final IamUserMapper userMapper; + private final IamMembershipRoleMapper roleMapper; + + public MemberRepository(IamMembershipMapper membershipMapper, IamUserMapper userMapper, + IamMembershipRoleMapper roleMapper) { + this.membershipMapper = membershipMapper; + this.userMapper = userMapper; + this.roleMapper = roleMapper; + } + + public List findByTenant(long tenantId) { + return membershipMapper.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); + } + public IamMembership findById(Long id) { return membershipMapper.selectById(id); } + public IamMembership findByTenantAndId(long tenantId, Long id) { + return membershipMapper.selectOne(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1")); + } + public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) { + IamUser user = findUserByAuthId(authUserId); + if (user == null) return null; + return membershipMapper.selectOne(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getUserId, user.getId()).last("LIMIT 1")); + } + public boolean exists(long tenantId, Long userId) { + return membershipMapper.selectCount(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getUserId, userId)) > 0; + } + public boolean existsMemberNo(long tenantId, String memberNo, Long excludedMembershipId) { + if (memberNo == null || memberNo.isBlank()) return false; + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getMemberNo, memberNo); + if (excludedMembershipId != null) query.ne(IamMembership::getId, excludedMembershipId); + return membershipMapper.selectCount(query) > 0; + } + public IamUser findUser(Long id) { return userMapper.selectById(id); } + public IamUser findUserByAuthId(Long authId) { + return userMapper.selectOne(new LambdaQueryWrapper() + .eq(IamUser::getAuthUserId, authId).last("LIMIT 1")); + } + public List findRoleIds(long tenantId, Long membershipId) { + return roleMapper.selectList(new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getMembershipId, membershipId)) + .stream().map(IamMembershipRole::getRoleId).toList(); + } + public void replaceRoles(long tenantId, Long membershipId, List roleIds) { + roleMapper.delete(new QueryWrapper().eq("tenant_id", tenantId) + .eq("membership_id", membershipId)); + for (Long roleId : roleIds) { + IamMembershipRole item = new IamMembershipRole(); + item.setTenantId(tenantId); item.setMembershipId(membershipId); item.setRoleId(roleId); + item.setCreatedAt(java.time.LocalDateTime.now()); roleMapper.insert(item); + } + } + public void insert(IamMembership value) { membershipMapper.insert(value); } + public void save(IamMembership value) { membershipMapper.updateById(value); } + public void insertUser(IamUser value) { userMapper.insert(value); } + public void saveUser(IamUser value) { userMapper.updateById(value); } +} diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java new file mode 100644 index 000000000..e08d1cbb5 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java @@ -0,0 +1,44 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.IamTenantAddress; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamTenantAddressMapper; +import com.baiyue.iam.mapper.IamTenantQualificationMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * IAM 档案资源仓储,封装用户档案、租户地址和租户资质的写入与分租户查询。 + */ +@Repository +public class ProfileResourceRepository { + private final IamUserMapper userMapper; + private final IamTenantAddressMapper addressMapper; + private final IamTenantQualificationMapper qualificationMapper; + + public ProfileResourceRepository(IamUserMapper userMapper, IamTenantAddressMapper addressMapper, + IamTenantQualificationMapper qualificationMapper) { + this.userMapper = userMapper; + this.addressMapper = addressMapper; + this.qualificationMapper = qualificationMapper; + } + + public void insertUser(IamUser user) { userMapper.insert(user); } + public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); } + public void saveUser(IamUser user) { userMapper.updateById(user); } + public void insertAddress(IamTenantAddress address) { addressMapper.insert(address); } + public void insertQualification(IamTenantQualification qualification) { qualificationMapper.insert(qualification); } + public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } + public List findInternalUsers() { return userMapper.selectList(new LambdaQueryWrapper().eq(IamUser::getUserType, "INTERNAL")); } + public List findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper().eq(IamTenantAddress::getTenantId, tenantId)); } + public List findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper().eq(IamTenantQualification::getTenantId, tenantId)); } + public long countApprovedQualifications(long tenantId) { + return qualificationMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .eq(IamTenantQualification::getStatus, "APPROVED")); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java new file mode 100644 index 000000000..155d08a17 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -0,0 +1,150 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.*; +import com.baiyue.iam.mapper.*; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */ +@Repository +public class RbacRepository { + private final IamRoleMapper roleMapper; + private final IamPermissionMapper permissionMapper; + private final IamMembershipRoleMapper membershipRoleMapper; + private final IamRolePermissionMapper rolePermissionMapper; + private final IamDataScopeMapper dataScopeMapper; + + public RbacRepository(IamRoleMapper roleMapper, IamPermissionMapper permissionMapper, + IamMembershipRoleMapper membershipRoleMapper, + IamRolePermissionMapper rolePermissionMapper, + IamDataScopeMapper dataScopeMapper) { + this.roleMapper = roleMapper; + this.permissionMapper = permissionMapper; + this.membershipRoleMapper = membershipRoleMapper; + this.rolePermissionMapper = rolePermissionMapper; + this.dataScopeMapper = dataScopeMapper; + } + + public IamRole findRole(Long id, long tenantId, String appCode) { + return roleMapper.selectOne(new LambdaQueryWrapper() + .eq(IamRole::getId, id).eq(IamRole::getTenantId, tenantId) + .eq(IamRole::getAppCode, appCode)); + } + + public IamPermission findPermission(Long id, long tenantId, String appCode) { + return permissionMapper.selectOne(new LambdaQueryWrapper() + .eq(IamPermission::getId, id) + .in(IamPermission::getTenantId, List.of(0L, tenantId)) + .eq(IamPermission::getAppCode, appCode)); + } + + public void insertRole(IamRole role) { roleMapper.insert(role); } + public void updateRole(IamRole role) { roleMapper.updateById(role); } + public void insertPermission(IamPermission permission) { permissionMapper.insert(permission); } + + public List findRoles(long tenantId, String appCode) { + return roleMapper.selectList(new LambdaQueryWrapper() + .eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode) + .orderByAsc(IamRole::getRoleCode)); + } + + public boolean existsRoleCode(long tenantId, String appCode, String roleCode, Long excludeId) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode) + .eq(IamRole::getRoleCode, roleCode); + if (excludeId != null) query.ne(IamRole::getId, excludeId); + return roleMapper.selectCount(query) > 0; + } + + public List findPermissions(long tenantId, String appCode) { + return permissionMapper.selectList(new LambdaQueryWrapper() + .in(IamPermission::getTenantId, List.of(0L, tenantId)) + .eq(IamPermission::getAppCode, appCode) + .orderByAsc(IamPermission::getPermissionCode)); + } + + public boolean existsPermissionCode(long tenantId, String appCode, String permissionCode) { + return permissionMapper.selectCount(new LambdaQueryWrapper() + .eq(IamPermission::getTenantId, tenantId) + .eq(IamPermission::getAppCode, appCode) + .eq(IamPermission::getPermissionCode, permissionCode)) > 0; + } + + public Set findRolePermissionIds(long tenantId, Long roleId) { + List rows = rolePermissionMapper.selectList( + new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getRoleId, roleId) + .orderByAsc(IamRolePermission::getPermissionId)); + Set result = new LinkedHashSet<>(); + rows.forEach(row -> result.add(row.getPermissionId())); + return result; + } + + public Set findMemberRoleIds(long tenantId, Long membershipId) { + List rows = membershipRoleMapper.selectList( + new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getMembershipId, membershipId) + .orderByAsc(IamMembershipRole::getRoleId)); + Set result = new LinkedHashSet<>(); + rows.forEach(row -> result.add(row.getRoleId())); + return result; + } + + public boolean isRoleAssigned(long tenantId, Long roleId) { + return membershipRoleMapper.selectCount(new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getRoleId, roleId)) > 0; + } + + public void deleteRole(long tenantId, String appCode, Long roleId) { + rolePermissionMapper.delete(new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getRoleId, roleId)); + dataScopeMapper.delete(new LambdaQueryWrapper() + .eq(IamDataScope::getTenantId, tenantId) + .eq(IamDataScope::getRoleId, roleId)); + roleMapper.delete(new LambdaQueryWrapper() + .eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode) + .eq(IamRole::getId, roleId)); + } + + public void replaceMemberRoles(long tenantId, String appCode, Long membershipId, Iterable roleIds) { + // 仅删除当前应用域角色,避免一个应用端保存时误清另一个应用端的角色。 + List scopedRoleIds = findRoles(tenantId, appCode).stream().map(IamRole::getId).toList(); + if (!scopedRoleIds.isEmpty()) { + membershipRoleMapper.delete(new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getMembershipId, membershipId) + .in(IamMembershipRole::getRoleId, scopedRoleIds)); + } + for (Long roleId : roleIds) { + IamMembershipRole relation = new IamMembershipRole(); + relation.setMembershipId(membershipId); + relation.setRoleId(roleId); + relation.setTenantId(tenantId); + relation.setCreatedAt(LocalDateTime.now()); + membershipRoleMapper.insert(relation); + } + } + + public void replaceRolePermissions(long tenantId, Long roleId, Iterable permissionIds) { + rolePermissionMapper.delete(new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getRoleId, roleId)); + for (Long permissionId : permissionIds) { + IamRolePermission relation = new IamRolePermission(); + relation.setRoleId(roleId); + relation.setPermissionId(permissionId); + relation.setTenantId(tenantId); + relation.setCreatedAt(LocalDateTime.now()); + rolePermissionMapper.insert(relation); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java new file mode 100644 index 000000000..aa93883e2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -0,0 +1,48 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.mapper.IamTenantMapper; +import com.baiyue.iam.mapper.IamTenantReviewMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。 + */ +@Repository +public class TenantRepository { + private final IamTenantMapper tenantMapper; + private final IamTenantReviewMapper reviewMapper; + + public TenantRepository(IamTenantMapper tenantMapper, IamTenantReviewMapper reviewMapper) { + this.tenantMapper = tenantMapper; + this.reviewMapper = reviewMapper; + } + + public boolean existsByCreditCode(String creditCode) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0; + } + + public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); } + public IamTenantReview findPendingReviewByTenant(Long tenantId) { + return reviewMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantReview::getTenantId, tenantId) + .eq(IamTenantReview::getStatus, "PENDING") + .last("LIMIT 1")); + } + public void insertTenant(IamTenant tenant) { tenantMapper.insert(tenant); } + public void saveTenant(IamTenant tenant) { tenantMapper.updateById(tenant); } + public void insertReview(IamTenantReview review) { reviewMapper.insert(review); } + public void saveReview(IamTenantReview review) { reviewMapper.updateById(review); } + + public List findPendingReviews() { + return reviewMapper.selectList(new LambdaQueryWrapper() + .eq(IamTenantReview::getStatus, "PENDING") + .orderByAsc(IamTenantReview::getId)); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java new file mode 100644 index 000000000..e8931d5d0 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -0,0 +1,48 @@ +package com.baiyue.iam.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 授权快照服务,校验账号与成员关系,并聚合指定租户、应用域下的角色、权限和数据范围。 + */ +@Service +public class AuthorizationSnapshotService { + private static final Set APPS = Set.of("MERCHANT", "PLATFORM"); + @Resource + private AuthorizationQueryMapper mapper; + + + @Transactional(readOnly = true) + public SnapshotResponse load(SnapshotRequest request) { + String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); + if (!APPS.contains(appCode)) { + throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域"); + } + if ("MERCHANT".equals(appCode) && request.getTenantId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "商户账号缺少有效租户"); + } + if ("PLATFORM".equals(appCode) && request.getTenantId() != 0) { + throw new BizException(ResultCode.FORBIDDEN, "平台账号必须属于平台租户"); + } + if (mapper.countActiveIdentity(request.getUserId(), request.getTenantId(), request.getMembershipId()) == 0) { + throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); + } + + Set roles = new LinkedHashSet<>(mapper.findRoles( + request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); + Set permissions = new LinkedHashSet<>(mapper.findPermissions( + request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); + return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), + appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions)); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java new file mode 100644 index 000000000..83e27c0bf --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -0,0 +1,200 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.MemberDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */ +@Service +public class MemberService { + private static final Logger log = LoggerFactory.getLogger(MemberService.class); + private final MemberRepository repository; + private final IamRoleMapper roleMapper; + private final AuthAccountClient authClient; + private final String internalToken; + + public MemberService(MemberRepository repository, IamRoleMapper roleMapper, AuthAccountClient authClient, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.repository = repository; + this.roleMapper = roleMapper; + this.authClient = authClient; + this.internalToken = internalToken; + } + + public List list(long tenantId) { + return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList(); + } + + public MemberResponse detail(long tenantId, Long membershipId) { + return response(tenantId, required(tenantId, membershipId)); + } + + @Transactional + @BusinessAudit(event = "MEMBER_CREATE", resourceType = "MEMBERSHIP", resourceId = "#request.username", description = "新增企业成员") + public MemberResponse create(long tenantId, CreateMemberRequest request) { + rejectOwnerMutation(request.memberType()); + validateRoles(tenantId, request.roleIds()); + validateMemberNo(tenantId, request.memberNo(), null); + long authId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( + authId, request.username(), request.password(), request.displayName(), tenantId, membershipId))); + try { + IamUser user = new IamUser(); + user.setId(userId); user.setAuthUserId(authId); user.setUserType("ENTERPRISE"); + user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email()); + user.setStatus("ACTIVE"); repository.insertUser(user); + + IamMembership membership = new IamMembership(); + membership.setId(membershipId); membership.setTenantId(tenantId); membership.setUserId(userId); + membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); + membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); + repository.replaceRoles(tenantId, membershipId, request.roleIds()); + return response(tenantId, membership, user); + } catch (RuntimeException exception) { + try { + authClient.compensate(internalToken, authId); + } catch (RuntimeException compensationFailure) { + log.error("成员创建失败且认证账号补偿失败,authUserId={}", authId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "成员创建未完成,认证账号补偿失败,请按 authUserId=" + authId + " 进行一致性修复"); + } + throw exception; + } + } + + @Transactional + @BusinessAudit(event = "MEMBER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "编辑企业成员") + public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request) { + rejectOwnerMutation(request.memberType()); + validateRoles(tenantId, request.roleIds()); + validateMemberNo(tenantId, request.memberNo(), membershipId); + IamMembership membership = required(tenantId, membershipId); + if ("OWNER".equals(membership.getMemberType())) { + throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑"); + } + IamUser user = requiredUser(membership.getUserId()); + user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email()); + membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); + repository.saveUser(user); repository.save(membership); + repository.replaceRoles(tenantId, membershipId, request.roleIds()); + return response(tenantId, membership); + } + + @Transactional + @BusinessAudit(event = "MEMBER_STATUS_CHANGE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "启停企业成员") + public MemberResponse changeStatus(long tenantId, Long membershipId, ChangeMemberStatusRequest request, + Long operatorMembershipId) { + if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) + throw new BizException(ResultCode.BAD_REQUEST, "成员状态只允许 ACTIVE 或 DISABLED"); + IamMembership membership = required(tenantId, membershipId); + if ("OWNER".equals(membership.getMemberType()) && "DISABLED".equals(request.status())) + throw new BizException(ResultCode.FORBIDDEN, "企业所有者不能被停用"); + if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) + throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录成员"); + IamUser user = requiredUser(membership.getUserId()); + String oldStatus = membership.getStatus(); + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(request.status()))); + try { + membership.setStatus(request.status()); + membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); + user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); + } catch (RuntimeException localFailure) { + try { + authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); + } catch (RuntimeException compensationFailure) { + log.error("成员状态写入失败且认证账号状态补偿失败,authUserId={}", user.getAuthUserId(), compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "成员状态未完成,认证账号补偿失败,请按 authUserId=" + user.getAuthUserId() + " 进行一致性修复"); + } + throw localFailure; + } + return response(tenantId, membership); + } + + @BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "管理员重置成员密码", captureAfter = false) + public void resetPassword(long tenantId, Long membershipId, ResetMemberPasswordRequest request) { + IamMembership membership = required(tenantId, membershipId); + if ("OWNER".equals(membership.getMemberType())) { + throw new BizException(ResultCode.FORBIDDEN, "企业所有者只能自行修改密码"); + } + IamUser user = requiredUser(membership.getUserId()); + call(() -> authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword()))); + } + + private MemberResponse response(long tenantId, IamMembership membership) { + IamUser user = requiredUser(membership.getUserId()); + return response(tenantId, membership, user); + } + private MemberResponse response(long tenantId, IamMembership m, IamUser u) { + return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(), + u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), + repository.findRoleIds(tenantId, m.getId())); + } + private IamMembership required(long tenantId, Long membershipId) { + IamMembership value = repository.findByTenantAndId(tenantId, membershipId); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "成员不存在"); + return value; + } + private IamUser requiredUser(Long id) { + IamUser user = repository.findUser(id); + if (user == null) throw new BizException(ResultCode.NOT_FOUND, "成员档案不存在"); + return user; + } + private void validateRoles(long tenantId, List ids) { + for (Long id : ids) { + IamRole role = roleMapper.selectById(id); + if (role == null || role.getTenantId() == null || role.getTenantId() != tenantId + || !"MERCHANT".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus()) + || "MERCHANT_OWNER".equals(role.getRoleCode())) + throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); + } + } + private void rejectOwnerMutation(String memberType) { + if ("OWNER".equals(memberType)) { + throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者"); + } + if (!"EMPLOYEE".equals(memberType)) { + throw new BizException(ResultCode.BAD_REQUEST, "成员类型只能是 EMPLOYEE"); + } + } + private void validateMemberNo(long tenantId, String memberNo, Long excludedId) { + if (repository.existsMemberNo(tenantId, memberNo, excludedId)) + throw new BizException(ResultCode.CONFLICT, "企业成员编号已存在"); + } + private ApiResponse call(RemoteCall action) { + try { + ApiResponse result = action.call(); + if (result == null || !result.successful()) throw unavailable(); + return result; + } catch (FeignException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在"); + throw unavailable(); + } + } + private T data(ApiResponse value) { return value.data(); } + private BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); } + @FunctionalInterface private interface RemoteCall { ApiResponse call(); } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java new file mode 100644 index 000000000..7cf003a1a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -0,0 +1,115 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest; +import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamMembershipRole; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantAddress; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.mapper.IamTenantAddressMapper; +import com.baiyue.iam.mapper.IamTenantMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +@Service +public class MerchantOnboardingService { + @Resource private IamTenantMapper tenantMapper; + @Resource private IamUserMapper userMapper; + @Resource private IamMembershipMapper membershipMapper; + @Resource private IamRoleMapper roleMapper; + @Resource private IamMembershipRoleMapper membershipRoleMapper; + @Resource private IamTenantAddressMapper addressMapper; + + @Transactional + public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { + if (userMapper.selectCount(new LambdaQueryWrapper() + .eq(IamUser::getAuthUserId, request.authUserId())) > 0) { + throw new BizException(ResultCode.CONFLICT, "Registration already exists"); + } + IamRole ownerRole = roleMapper.selectOne(new LambdaQueryWrapper() + .eq(IamRole::getTenantId, 0L) + .eq(IamRole::getAppCode, "MERCHANT") + .eq(IamRole::getRoleCode, "MERCHANT_OWNER") + .eq(IamRole::getStatus, "ACTIVE") + .last("LIMIT 1")); + if (ownerRole == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Merchant role template is not initialized"); + } + + long tenantId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + + IamTenant tenant = new IamTenant(); + tenant.setId(tenantId); + tenant.setTenantCode("TEN" + tenantId); + tenant.setTenantName(request.companyName()); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + tenant.setLegalName(request.companyName()); + tenant.setContactName(request.legalPerson()); + tenant.setContactPhone(request.legalPhone()); + tenant.setContactEmail(request.contactEmail()); + tenant.setDeleted(false); + tenant.setVersion(0); + tenantMapper.insert(tenant); + + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(request.authUserId()); + user.setUserType("ENTERPRISE"); + user.setDisplayName(request.displayName()); + user.setPhone(request.legalPhone()); + user.setEmail(request.contactEmail()); + user.setStatus("ACTIVE"); + userMapper.insert(user); + + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(tenantId); + membership.setUserId(userId); + membership.setMemberType("OWNER"); + membership.setMemberNo("OWNER-" + membershipId); + membership.setStatus("ACTIVE"); + membership.setJoinedAt(LocalDateTime.now()); + membership.setCreatedBy(request.authUserId()); + membership.setUpdatedBy(request.authUserId()); + membership.setDeleted(false); + membership.setVersion(0); + membershipMapper.insert(membership); + + IamMembershipRole membershipRole = new IamMembershipRole(); + membershipRole.setMembershipId(membershipId); + membershipRole.setRoleId(ownerRole.getId()); + membershipRole.setTenantId(tenantId); + membershipRole.setCreatedAt(LocalDateTime.now()); + membershipRoleMapper.insert(membershipRole); + + if (StringUtils.hasText(request.address())) { + IamTenantAddress address = new IamTenantAddress(); + address.setId(IdWorker.getId()); + address.setTenantId(tenantId); + address.setAddressType("REGISTERED"); + address.setCountryCode(StringUtils.hasText(request.countryCode()) + ? request.countryCode().toUpperCase() : "CN"); + address.setDetail(request.address()); + address.setIsDefault(true); + addressMapper.insert(address); + } + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java new file mode 100644 index 000000000..0dc55e438 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java @@ -0,0 +1,47 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse; +import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.repository.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baiyue.common.audit.BusinessAudit; + +/** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */ +@Service +public class PersonalProfileService { + private final MemberRepository repository; + public PersonalProfileService(MemberRepository repository) { this.repository = repository; } + + public PersonalProfileResponse get(long tenantId, Long authUserId, String username) { + return response(tenantId, authUserId, username); + } + + @Transactional + @BusinessAudit(event = "PERSONAL_PROFILE_UPDATE", resourceType = "IAM_USER", resourceId = "#authUserId", description = "编辑个人资料") + public PersonalProfileResponse update(long tenantId, Long authUserId, String username, + UpdatePersonalProfileRequest request) { + IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId); + if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden(); + IamUser user = repository.findUser(membership.getUserId()); + if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden(); + user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email()); + repository.saveUser(user); + return new PersonalProfileResponse(user.getId(), authUserId, username, + user.getDisplayName(), user.getPhone(), user.getEmail()); + } + + private PersonalProfileResponse response(long tenantId, Long authUserId, String username) { + IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId); + if (membership == null) throw forbidden(); + IamUser user = repository.findUser(membership.getUserId()); + if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden(); + return new PersonalProfileResponse(user.getId(), authUserId, username, + user.getDisplayName(), user.getPhone(), user.getEmail()); + } + private BizException forbidden() { return new BizException(ResultCode.FORBIDDEN, "当前身份不属于该租户"); } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java new file mode 100644 index 000000000..716f31950 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -0,0 +1,105 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; +import com.baiyue.iam.dto.ProfileDtos.AddressRequest; +import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationRequest; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantAddress; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.TenantRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +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.util.List; + +@Service +public class ProfileResourceService { + @Resource private ProfileResourceRepository repository; + @Resource private TenantRepository tenantRepository; + + @BusinessAudit(event = "INTERNAL_USER_CREATE", resourceType = "IAM_USER", + resourceId = "#q.authUserId", description = "新增平台内部用户") + public IamUser addUser(CreateUserRequest q) { + IamUser u = new IamUser(); + u.setId(IdWorker.getId()); u.setAuthUserId(q.authUserId()); u.setUserType(q.userType()); + u.setDisplayName(q.displayName()); u.setPhone(q.phone()); u.setEmail(q.email()); u.setStatus("ACTIVE"); + repository.insertUser(u); return u; + } + + public List internalUsers() { return repository.findInternalUsers(); } + public List addresses(long tenant) { return repository.findAddresses(tenant); } + + @BusinessAudit(event = "TENANT_ADDRESS_CREATE", resourceType = "TENANT_ADDRESS", + resourceId = "#tenant", description = "新增企业地址") + public IamTenantAddress addAddress(long tenant, AddressRequest q) { + IamTenantAddress a = new IamTenantAddress(); + a.setId(IdWorker.getId()); a.setTenantId(tenant); a.setAddressType(q.addressType()); + a.setCountryCode(q.countryCode() == null ? "CN" : q.countryCode()); a.setProvince(q.province()); + a.setCity(q.city()); a.setDistrict(q.district()); a.setDetail(q.detail()); + a.setPostalCode(q.postalCode()); a.setIsDefault(Boolean.TRUE.equals(q.isDefault())); + repository.insertAddress(a); return a; + } + + public List qualifications(long tenant) { return repository.findQualifications(tenant); } + + public IamTenantQualification addQualification(long tenant, QualificationRequest q) { + throw new BizException(ResultCode.CONFLICT, + "Upload the business license through the qualification file API"); + } + + @Transactional + @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false) + public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) { + IamTenant tenant = tenantRepository.findTenant(request.tenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found"); + } + if ("ACTIVE".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved"); + } + if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) { + throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review"); + } + if (repository.findQualifications(tenant.getId()).stream() + .anyMatch(item -> !"REJECTED".equals(item.getStatus()))) { + throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image"); + } + + IamTenantQualification qualification = createQualification(tenant.getId(), + request.qualificationType(), request.certificateNo(), request.documentUrl()); + if (StringUtils.hasText(request.certificateNo()) + && request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) { + tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase()); + } + tenant.setStatus("PENDING"); + tenant.setRejectReason(null); + tenantRepository.saveTenant(tenant); + + IamTenantReview review = new IamTenantReview(); + review.setId(IdWorker.getId()); review.setTenantId(tenant.getId()); + review.setReviewType("REGISTER"); review.setStatus("PENDING"); + review.setCreatedAt(LocalDateTime.now()); + tenantRepository.insertReview(review); + return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus()); + } + + private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) { + IamTenantQualification x = new IamTenantQualification(); + x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type); + x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING"); + repository.insertQualification(x); return x; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java new file mode 100644 index 000000000..e991e1aeb --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -0,0 +1,218 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.RbacDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamPermission; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.RbacRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Comparator; + +/** 双端 RBAC 应用服务,严格按租户和应用域隔离。 */ +@Service +public class RbacService { + @Resource private RbacRepository repository; + @Resource private MemberRepository memberRepository; + + public List roles(long tenantId, String appCode) { + return repository.findRoles(tenantId, appCode).stream() + .map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()))) + .toList(); + } + + public RoleResponse role(long tenantId, String appCode, Long roleId) { + IamRole role = requireRole(tenantId, appCode, roleId); + return response(role, repository.findRolePermissionIds(tenantId, roleId)); + } + + @BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode", + description = "创建角色", captureBefore = false, captureAfter = true) + public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) { + if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) { + throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); + } + IamRole role = new IamRole(); + role.setId(IdWorker.getId()); + role.setTenantId(tenantId); + role.setAppCode(appCode); + role.setRoleCode(request.roleCode()); + role.setRoleName(request.roleName()); + role.setDescription(request.description()); + role.setRoleType("CUSTOM"); + role.setStatus("ACTIVE"); + try { + repository.insertRole(role); + } catch (DataIntegrityViolationException exception) { + // 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。 + throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); + } + return role; + } + + @BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId", + description = "编辑角色", captureBefore = true, captureAfter = true) + public RoleResponse updateRole(long tenantId, String appCode, Long roleId, UpdateRoleRequest request) { + IamRole role = requireCustomRole(tenantId, appCode, roleId); + role.setRoleName(request.roleName()); + role.setDescription(request.description()); + repository.updateRole(role); + return response(role, repository.findRolePermissionIds(tenantId, roleId)); + } + + @BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId", + description = "启停角色", captureBefore = true, captureAfter = true) + public RoleResponse changeStatus(long tenantId, String appCode, Long roleId, + ChangeRoleStatusRequest request) { + IamRole role = requireCustomRole(tenantId, appCode, roleId); + role.setStatus(request.status()); + repository.updateRole(role); + return response(role, repository.findRolePermissionIds(tenantId, roleId)); + } + + /** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */ + @BusinessAudit(event = "ROLE_DELETE", resourceType = "ROLE", resourceId = "#roleId", + description = "删除角色", captureBefore = true, captureAfter = false) + @Transactional + public void deleteRole(long tenantId, String appCode, Long roleId) { + IamRole role = requireCustomRole(tenantId, appCode, roleId); + if (repository.isRoleAssigned(tenantId, role.getId())) { + throw new BizException(ResultCode.CONFLICT, "角色已分配给成员,不能删除"); + } + repository.deleteRole(tenantId, appCode, roleId); + } + + public List permissions(long tenantId, String appCode) { + Map effective = new LinkedHashMap<>(); + repository.findPermissions(tenantId, appCode).stream() + .sorted(Comparator + .comparingInt((IamPermission permission) -> + permission.getTenantId() != null && permission.getTenantId() == tenantId ? 0 : 1) + .thenComparing(IamPermission::getPermissionCode)) + .forEach(permission -> effective.putIfAbsent(permission.getPermissionCode(), permission)); + return List.copyOf(effective.values()); + } + + @BusinessAudit(event = "PERMISSION_CREATE", resourceType = "PERMISSION", resourceId = "#request.code", + description = "创建权限资源", captureBefore = false, captureAfter = true) + public IamPermission createPermission(long tenantId, String appCode, CreatePermissionRequest request) { + if (!Set.of("MENU", "PAGE", "BUTTON", "API").contains(request.type())) { + throw new BizException(ResultCode.BAD_REQUEST, "权限类型只能是 MENU、PAGE、BUTTON 或 API"); + } + if (repository.existsPermissionCode(tenantId, appCode, request.code())) { + throw new BizException(ResultCode.CONFLICT, "权限编码已存在"); + } + if (request.parentId() != null && request.parentId() > 0 + && repository.findPermission(request.parentId(), tenantId, appCode) == null) { + throw forbidden(); + } + IamPermission permission = new IamPermission(); + permission.setId(IdWorker.getId()); + permission.setTenantId(tenantId); + permission.setAppCode(appCode); + permission.setPermissionType(request.type()); + permission.setPermissionCode(request.code()); + permission.setPermissionName(request.name()); + permission.setParentId(request.parentId() == null ? 0L : request.parentId()); + permission.setRoutePath(request.routePath()); + permission.setHttpMethod(request.httpMethod()); + permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo()); + permission.setStatus("ACTIVE"); + try { + repository.insertPermission(permission); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "权限编码已存在"); + } + return permission; + } + + public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) { + requireMembership(tenantId, membershipId); + Set scopedIds = new LinkedHashSet<>(); + for (Long roleId : repository.findMemberRoleIds(tenantId, membershipId)) { + if (repository.findRole(roleId, tenantId, appCode) != null) scopedIds.add(roleId); + } + return new MemberRolesResponse(membershipId, Set.copyOf(scopedIds)); + } + + @BusinessAudit(event = "MEMBER_ROLE_ASSIGN", resourceType = "MEMBERSHIP", + resourceId = "#request.membershipId", description = "分配成员角色", + captureBefore = true, captureAfter = true) + @Transactional + public void assignRoles(long tenantId, String appCode, AssignRolesRequest request) { + IamMembership membership = requireMembership(tenantId, request.membershipId()); + if ("OWNER".equals(membership.getMemberType())) { + throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不允许通过普通授权接口修改"); + } + for (Long roleId : request.roleIds()) { + IamRole role = requireRole(tenantId, appCode, roleId); + if ("MERCHANT_OWNER".equals(role.getRoleCode())) { + throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不能通过普通授权接口分配"); + } + if (!"ACTIVE".equals(role.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员"); + } + } + repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds()); + } + + @BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE", + resourceId = "#request.roleId", description = "设置角色权限", + captureBefore = true, captureAfter = true) + @Transactional + public void grantPermissions(long tenantId, String appCode, GrantPermissionsRequest request) { + requireCustomRole(tenantId, appCode, request.roleId()); + for (Long permissionId : request.permissionIds()) { + IamPermission permission = repository.findPermission(permissionId, tenantId, appCode); + if (permission == null) throw forbidden(); + if (!"ACTIVE".equals(permission.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "停用权限不能授予角色"); + } + } + repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); + } + + private IamRole requireRole(long tenantId, String appCode, Long roleId) { + IamRole role = repository.findRole(roleId, tenantId, appCode); + if (role == null) throw forbidden(); + return role; + } + + private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) { + IamRole role = requireRole(tenantId, appCode, roleId); + if ("SYSTEM".equals(role.getRoleType())) { + throw new BizException(ResultCode.CONFLICT, "系统角色不允许修改、停用或删除"); + } + return role; + } + + private IamMembership requireMembership(long tenantId, Long membershipId) { + IamMembership membership = memberRepository.findById(membershipId); + if (membership == null || membership.getTenantId() == null + || membership.getTenantId().longValue() != tenantId) throw forbidden(); + return membership; + } + + private RoleResponse response(IamRole role, Set permissionIds) { + return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(), + role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(), + Set.copyOf(permissionIds)); + } + + private BizException forbidden() { + return new BizException(ResultCode.FORBIDDEN, "禁止跨租户或跨应用访问"); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java new file mode 100644 index 000000000..8ff2a9e30 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java @@ -0,0 +1,15 @@ +package com.baiyue.iam.service; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; +/** + * 租户访问守卫,从登录身份提取租户并校验目标应用域,防止平台端、商户端或不同租户间越权。 + */ +public final class TenantGuard { + private TenantGuard(){} + public static long tenant(AuthenticatedUser user,String expectedApp){ + if(user==null||user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"Authentication required / 请先登录"); + if(!expectedApp.equals(user.appCode())) throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope / 应用权限域不匹配"); + if("PLATFORM".equals(expectedApp)) return 0L; + if(user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); + return user.tenantId(); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java new file mode 100644 index 000000000..70704bc15 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -0,0 +1,169 @@ +package com.baiyue.iam.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; + +import com.baiyue.iam.dto.TenantDtos.RegisterRequest; +import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。 + */ +@Service +public class TenantService { + @Resource + private TenantRepository repository; + @Resource + private ProfileResourceRepository profileRepository; + + + @BusinessAudit( + event = "TENANT_REGISTER", + resourceType = "TENANT", + description = "Register tenant / 企业注册", + captureBefore = false, + captureAfter = false + ) + @Transactional + public TenantResponse register(RegisterRequest request) { + if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) { + throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在"); + } + + IamTenant tenant = new IamTenant(); + tenant.setId(IdWorker.getId()); + tenant.setTenantCode("TEN" + tenant.getId()); + tenant.setTenantName(request.tenantName()); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PENDING"); + tenant.setLegalName(request.legalName()); + tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode()); + tenant.setContactName(request.contactName()); + tenant.setContactPhone(request.contactPhone()); + tenant.setContactEmail(request.contactEmail()); + repository.insertTenant(tenant); + + IamTenantReview review = new IamTenantReview(); + review.setId(IdWorker.getId()); + review.setTenantId(tenant.getId()); + review.setReviewType("REGISTER"); + review.setStatus("PENDING"); + review.setCreatedAt(LocalDateTime.now()); + repository.insertReview(review); + return response(tenant); + } + + public List pendingReviews() { + return repository.findPendingReviews(); + } + + @BusinessAudit( + event = "TENANT_REVIEW", + resourceType = "TENANT_REVIEW", + resourceId = "#reviewId", + description = "Review tenant registration / 企业注册审核", + captureBefore = false, + captureAfter = false + ) + @Transactional + public void review(long reviewId, long reviewerId, boolean approve, String comment) { + IamTenantReview review = repository.findReview(reviewId); + if (review == null) { + throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在"); + } + if (!"PENDING".equals(review.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成"); + } + + review.setStatus(approve ? "APPROVED" : "REJECTED"); + review.setReviewerId(reviewerId); + review.setReviewComment(comment); + review.setReviewedAt(LocalDateTime.now()); + repository.saveReview(review); + + IamTenant tenant = repository.findTenant(review.getTenantId()); + if (tenant == null) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); + } + tenant.setStatus(approve ? "ACTIVE" : "REJECTED"); + tenant.setRejectReason(approve ? null : comment); + tenant.setApprovedAt(approve ? LocalDateTime.now() : null); + tenant.setApprovedBy(approve ? reviewerId : null); + repository.saveTenant(tenant); + for (var qualification : profileRepository.findQualifications(tenant.getId())) { + if ("PENDING".equals(qualification.getStatus())) { + qualification.setStatus(approve ? "APPROVED" : "REJECTED"); + qualification.setReviewComment(comment); + profileRepository.saveQualification(qualification); + } + } + } + + public OrderEligibilityResponse orderEligibility(long tenantId) { + IamTenant tenant = repository.findTenant(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + return new OrderEligibilityResponse(false, "NOT_FOUND", "Enterprise does not exist"); + } + if (!"ACTIVE".equals(tenant.getStatus())) { + return new OrderEligibilityResponse(false, tenant.getStatus(), + "Enterprise information must be approved before ordering"); + } + if (profileRepository.countApprovedQualifications(tenantId) < 1) { + return new OrderEligibilityResponse(false, tenant.getStatus(), + "At least one approved enterprise qualification is required"); + } + return new OrderEligibilityResponse(true, tenant.getStatus(), null); + } + + public TenantResponse profile(long tenantId) { + IamTenant tenant = repository.findTenant(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId)) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); + } + return response(tenant); + } + + @Transactional + @BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT", + resourceId = "#tenantId", description = "编辑企业资料") + public TenantResponse update(long tenantId, UpdateProfileRequest request) { + IamTenant tenant = repository.findTenant(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId)) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); + } + tenant.setTenantName(request.tenantName()); + tenant.setLegalName(request.legalName()); + tenant.setContactName(request.contactName()); + tenant.setContactPhone(request.contactPhone()); + tenant.setContactEmail(request.contactEmail()); + repository.saveTenant(tenant); + return response(tenant); + } + + private boolean tenantIdEquals(IamTenant tenant, long id) { + return tenant.getId() != null && tenant.getId().longValue() == id; + } + + private TenantResponse response(IamTenant tenant) { + return new TenantResponse( + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), + tenant.getContactPhone(), tenant.getContactEmail() + ); + } +} diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml new file mode 100644 index 000000000..03228f214 --- /dev/null +++ b/iam/src/main/resources/application.yml @@ -0,0 +1,34 @@ +server: + port: ${IAM_PORT:9201} +spring: + application: + name: iam-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: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${IAM_DB_USERNAME:root} + password: ${IAM_DB_PASSWORD:123456} +baiyue: + clients: + auth: + url: ${AUTH_SERVICE_URL:} + 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} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql new file mode 100644 index 000000000..7ffb12b24 --- /dev/null +++ b/iam/src/main/resources/db/iam.sql @@ -0,0 +1,199 @@ +SET NAMES utf8mb4; +-- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。 +CREATE DATABASE IF NOT EXISTS baiyue_iam + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_iam; + +CREATE TABLE IF NOT EXISTS iam_tenant ( + id BIGINT NOT NULL COMMENT '租户ID(雪花ID)', + tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一租户编码', + tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称', + tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型', + status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED', + legal_name VARCHAR(128) NULL COMMENT '企业法定名称', + unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', + contact_name VARCHAR(64) NULL COMMENT '企业联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', + contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + approved_at DATETIME(3) NULL COMMENT '审核通过时间', + approved_by BIGINT NULL COMMENT '审核人ID', + reject_reason VARCHAR(500) NULL COMMENT '驳回原因', + created_by BIGINT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_tenant_code (tenant_code), + UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code), + KEY idx_iam_tenant_status (status, created_at) +) ENGINE=InnoDB COMMENT='企业租户主表:作为SaaS数据隔离边界,保存商户企业资料、审核结果和启停状态'; + +CREATE TABLE IF NOT EXISTS iam_tenant_review ( + id BIGINT NOT NULL COMMENT '审核记录ID', + tenant_id BIGINT NOT NULL COMMENT '被审核租户ID', + review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态', + reviewer_id BIGINT NULL COMMENT '审核人ID', + review_comment VARCHAR(500) NULL COMMENT '审核意见', + reviewed_at DATETIME(3) NULL COMMENT '审核完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '进入审核队列时间', + PRIMARY KEY (id), + KEY idx_review_queue (status, created_at), + KEY idx_review_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='租户审核记录表:保存企业注册、资料变更等审核过程和审核意见'; + +CREATE TABLE IF NOT EXISTS iam_tenant_address ( + id BIGINT NOT NULL COMMENT '租户地址ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + address_type VARCHAR(20) NOT NULL COMMENT '地址类型', + country_code CHAR(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码', + province VARCHAR(64) NULL COMMENT '省/州', + city VARCHAR(64) NULL COMMENT '城市', + district VARCHAR(64) NULL COMMENT '区县', + detail VARCHAR(255) NOT NULL COMMENT '详细地址', + postal_code VARCHAR(16) NULL COMMENT '邮政编码', + is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_address_tenant (tenant_id, address_type) +) ENGINE=InnoDB COMMENT='租户地址表:保存企业注册地址、办公地址等租户级资料'; + +CREATE TABLE IF NOT EXISTS iam_tenant_qualification ( + id BIGINT NOT NULL COMMENT '资质记录ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + qualification_type VARCHAR(32) NOT NULL COMMENT '资质类型', + certificate_no VARCHAR(128) NULL COMMENT '证书编号', + document_url VARCHAR(500) NULL COMMENT '资质文件存储地址', + valid_from DATE NULL COMMENT '有效期开始日期', + valid_to DATE NULL COMMENT '有效期结束日期', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态', + review_comment VARCHAR(500) NULL COMMENT '审核意见', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_qualification_tenant (tenant_id, status) +) ENGINE=InnoDB COMMENT='租户资质表:保存企业证照、有效期、文件地址和审核状态'; + +CREATE TABLE IF NOT EXISTS iam_user ( + id BIGINT NOT NULL COMMENT 'IAM用户资料ID', + auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID', + user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型', + display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称', + phone VARCHAR(32) NULL COMMENT '手机号', + email VARCHAR(128) NULL COMMENT '邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_user_auth (auth_user_id), + KEY idx_iam_user_status (status) +) ENGINE=InnoDB COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码'; + +CREATE TABLE IF NOT EXISTS iam_membership ( + id BIGINT NOT NULL COMMENT '租户成员关系ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', + member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型', + member_no VARCHAR(64) NULL COMMENT '租户内部成员编号', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态', + joined_at DATETIME(3) NULL COMMENT '加入租户时间', + left_at DATETIME(3) NULL COMMENT '离开租户时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_membership_tenant_user (tenant_id, user_id), + UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), + KEY idx_membership_user (user_id, status), + KEY idx_membership_tenant (tenant_id, status) +) ENGINE=InnoDB COMMENT='租户成员关系表:表示一个用户在某个企业租户中的成员身份,是分配商户端角色的主体'; + +CREATE TABLE IF NOT EXISTS iam_role ( + id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色所属租户;0表示平台预置角色', + app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT或PLATFORM', + role_code VARCHAR(64) NOT NULL COMMENT '作用域内唯一角色编码', + role_name VARCHAR(128) NOT NULL COMMENT '角色名称', + role_type VARCHAR(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态', + description VARCHAR(255) NULL COMMENT '角色用途说明', + created_by BIGINT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT NULL COMMENT '更新人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_role_scope_code (tenant_id, app_code, role_code), + KEY idx_role_tenant (tenant_id, app_code, status) +) ENGINE=InnoDB COMMENT='角色表:按租户和应用端隔离商户端、平台后台两套角色体系'; + +CREATE TABLE IF NOT EXISTS iam_membership_role ( + membership_id BIGINT NOT NULL COMMENT '租户成员关系ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于租户隔离校验和查询', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (membership_id, role_id), + KEY idx_mr_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='成员角色关联表:将租户成员分配到对应应用端的一个或多个角色'; + +CREATE TABLE IF NOT EXISTS iam_permission ( + id BIGINT NOT NULL COMMENT '权限资源ID', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限所属租户;0表示平台预置权限', + app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT或PLATFORM', + permission_type VARCHAR(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API', + parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID,用于构造菜单和页面树', + permission_code VARCHAR(128) NOT NULL COMMENT '鉴权使用的唯一权限码', + permission_name VARCHAR(128) NOT NULL COMMENT '权限名称', + route_path VARCHAR(255) NULL COMMENT '前端路由或API路径', + http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法', + sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code), + KEY idx_permission_parent (parent_id), + KEY idx_permission_api (http_method, route_path) +) ENGINE=InnoDB COMMENT='统一权限资源表:支持商户端和平台后台两套菜单、页面、按钮及API权限,权限最细可控制到按钮'; + +CREATE TABLE IF NOT EXISTS iam_role_permission ( + role_id BIGINT NOT NULL COMMENT '角色ID', + permission_id BIGINT NOT NULL COMMENT '权限资源ID', + tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于隔离校验和查询', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (role_id, permission_id), + KEY idx_rp_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; + +CREATE TABLE IF NOT EXISTS iam_data_scope ( + id BIGINT NOT NULL COMMENT '数据范围规则ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL COMMENT '规则所属租户ID', + scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '范围类型:SELF、DEPARTMENT、TENANT或CUSTOM', + scope_expression JSON NULL COMMENT '自定义数据范围表达式', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_scope_role (role_id) +) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的租户内数据集合'; + +-- 存量数据库重复执行初始化脚本时,不重复创建审核状态索引。 +SET @review_status_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'iam_tenant_review' + AND index_name = 'idx_tenant_review_status'), + 'SELECT 1', + 'CREATE INDEX idx_tenant_review_status ON iam_tenant_review (tenant_id, status)' +); +PREPARE review_status_index_stmt FROM @review_status_index_sql; +EXECUTE review_status_index_stmt; +DEALLOCATE PREPARE review_status_index_stmt; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql new file mode 100644 index 000000000..53dad1952 --- /dev/null +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -0,0 +1,185 @@ +-- Phase-one administrator identities and RBAC seed data. +-- Scope is intentionally limited to first-level menus, second-level menus and buttons. +-- PAGE and API permission resources are not part of phase one. +USE baiyue_iam; +SET NAMES utf8mb4; + +-- Keep the role table aligned with the shared BaseEntity audit fields. +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'created_by'), + 'SELECT 1', + 'ALTER TABLE iam_role ADD COLUMN created_by BIGINT NULL AFTER description' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'updated_by'), + 'SELECT 1', + 'ALTER TABLE iam_role ADD COLUMN updated_by BIGINT NULL AFTER created_at' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO iam_tenant ( + id, tenant_code, tenant_name, tenant_type, status, legal_name, + unified_social_credit_code, contact_name, contact_phone, contact_email, + approved_at, approved_by, created_by, deleted, version +) VALUES + (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', + NULL, '平台管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), + (10001, 'PHASE1_DEMO', '一期演示企业', 'MERCHANT', 'ACTIVE', '一期演示企业有限公司', + '91310000PHASE10001', '企业管理员', '13800000000', 'merchant@example.com', + NOW(3), 1000001, 1000001, 0, 0) +ON DUPLICATE KEY UPDATE + tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), + status = 'ACTIVE', legal_name = VALUES(legal_name), + contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone), + contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), + approved_by = VALUES(approved_by), deleted = 0; + +INSERT INTO iam_user ( + id, auth_user_id, user_type, display_name, phone, email, status, deleted, version +) VALUES + (2000001, 1000001, 'INTERNAL', '平台管理员', NULL, NULL, 'ACTIVE', 0, 0), + (2000002, 1000002, 'ENTERPRISE', '一期演示企业管理员', '13800000000', + 'merchant@example.com', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), + status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership ( + id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version +) VALUES + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), + (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) +ON DUPLICATE KEY UPDATE + member_type = VALUES(member_type), member_no = VALUES(member_no), + status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, description, deleted, version +) VALUES + (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), + (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), + (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), + (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +-- Platform: level-one menus. +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5100000, 0, 'PLATFORM', 'MENU', 0, 'platform:customer', '客户管理', '/customers', NULL, 10, 'ACTIVE', 0), + (5101000, 0, 'PLATFORM', 'MENU', 0, 'platform:system', '系统管理', '/system', NULL, 20, 'ACTIVE', 0), + (5102000, 0, 'PLATFORM', 'MENU', 0, 'platform:order', '头程订单', '/first-leg-orders', NULL, 30, 'ACTIVE', 0), + (5103000, 0, 'PLATFORM', 'MENU', 0, 'platform:master-data', '基础资料', '/master-data', NULL, 40, 'ACTIVE', 0), + (5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0), +-- Platform: level-two menus. + (5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0), + (5100020, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:review', '企业审核', '/customers/reviews', NULL, 20, 'ACTIVE', 0), + (5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0), + (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0), + (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0), + (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), + (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), + (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), +-- Platform: buttons. + (5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0), + (5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0), + (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0), + (5102011, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:approve', '订单审核通过', NULL, NULL, 10, 'ACTIVE', 0), + (5102012, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:reject', '订单驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5102013, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:accept', '订单受理', NULL, NULL, 30, 'ACTIVE', 0), + (5102014, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:process', '开始处理', NULL, NULL, 40, 'ACTIVE', 0), + (5102015, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:complete', '完成订单', NULL, NULL, 50, 'ACTIVE', 0), + (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), + (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), + (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), + (5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0), + (5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0), + (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +-- Merchant: level-one menus, level-two menus and buttons. +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), + (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES + (3000001, 4000001, 0), + (3000002, 4000101, 10001) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- Full administrator roles receive all phase-one permissions in their application scope. +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- Limited platform and merchant operational roles. +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000002, id, 0 FROM iam_permission +WHERE permission_code IN ( + 'platform:customer', 'platform:customer:list', 'platform:customer:review', + 'platform:customer:approve', 'platform:customer:reject' +) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE permission_code LIKE 'platform:order%' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000102, id, 10001 FROM iam_permission +WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES + (6000001, 4000001, 0, 'TENANT', NULL), + (6000002, 4000002, 0, 'TENANT', NULL), + (6000003, 4000003, 0, 'TENANT', NULL), + (6000101, 4000101, 10001, 'TENANT', NULL), + (6000102, 4000102, 10001, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql new file mode 100644 index 000000000..8a88a7896 --- /dev/null +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -0,0 +1,38 @@ +SET NAMES utf8mb4; +-- 企业成员管理按钮权限增量;部署后应将权限按需授予企业管理员角色。 +USE baiyue_iam; + +-- 为存量数据库补齐企业内成员编号唯一约束,防止并发重复创建。 +SET @member_no_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND index_name = 'uk_membership_tenant_no'), + 'SELECT 1', + 'ALTER TABLE iam_membership ADD UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no)' +); +PREPARE member_no_index_stmt FROM @member_no_index_sql; +EXECUTE member_no_index_stmt; +DEALLOCATE PREPARE member_no_index_stmt; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) +VALUES + (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), + (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), + (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), + (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), + (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), + (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE tenant_id = 10001 AND permission_code IN + ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000100, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND permission_code IN + ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql new file mode 100644 index 000000000..5d2c493eb --- /dev/null +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -0,0 +1,71 @@ +-- Phase-one merchant onboarding state migration. +USE baiyue_iam; +SET NAMES utf8mb4; + +ALTER TABLE iam_tenant MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' + COMMENT 'PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED or DISABLED'; +DROP PROCEDURE IF EXISTS phase1_iam_add_column; +DELIMITER $$ +CREATE PROCEDURE phase1_iam_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_iam_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase1_iam_stmt FROM @phase1_iam_sql; + EXECUTE phase1_iam_stmt; + DEALLOCATE PREPARE phase1_iam_stmt; + END IF; +END$$ +DELIMITER ; +CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'"); +CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'"); +CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'"); +CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'"); +CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'"); +CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'"); +DROP PROCEDURE phase1_iam_add_column; + +-- A global merchant owner template is assigned to newly registered enterprises. +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, created_by, updated_by, deleted, version +) VALUES ( + 4000100, 0, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', + '一期企业全部菜单和操作按钮模板', 1000001, 1000001, 0, 0 +) +ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; + +-- Clone the一期 merchant menu/button template into the global scope. +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, deleted +) +SELECT id + 100000, 0, app_code, permission_type, + CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, + permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 +FROM iam_permission +WHERE tenant_id = 10001 AND app_code = 'MERCHANT' + AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), route_path=VALUES(route_path), + parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000100, id, 0 +FROM iam_permission +WHERE app_code='MERCHANT' AND tenant_id=0 + AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0; + +-- Keep the phase-one demo merchant eligible for existing order acceptance tests. +INSERT INTO iam_tenant_qualification ( + id, tenant_id, qualification_type, certificate_no, document_url, status, deleted +) VALUES ( + 7000101, 10001, 'BUSINESS_LICENSE', '91310000PHASE10001', + '/phase1-seed/demo-business-license.jpg', 'APPROVED', 0 +) +ON DUPLICATE KEY UPDATE status='APPROVED', deleted=0; diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql new file mode 100644 index 000000000..fe97b6c9a --- /dev/null +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -0,0 +1,44 @@ +SET NAMES utf8mb4; +-- 角色管理闭环权限迁移,可重复执行。 +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, sort_no, status, deleted) +VALUES + (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0), + (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0), + (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0), + (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0), + (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, 'ACTIVE', 0), + (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), + (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), + (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), + (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), + status = 'ACTIVE', deleted = 0; + +-- 示例管理员获得新增权限;其他角色仍由管理员按需授权。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete', + 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE tenant_id = 10001 AND app_code = 'MERCHANT' + AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000100, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'MERCHANT' + AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml new file mode 100644 index 000000000..c4928ea49 --- /dev/null +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -0,0 +1,391 @@ +openapi: 3.0.3 +info: + title: Baiyue IAM API / 百悦身份与权限 API + description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。 + version: 1.0.1 +servers: + - url: / +security: + - bearerAuth: [] +tags: + - name: Platform Tenant / 平台租户 + description: Enterprise registration and review / 企业注册与审核 + - name: Merchant Tenant / 商户企业 + description: Current tenant profile and members / 当前企业资料与成员 + - name: IAM Resources / IAM 资源 + description: Internal users, addresses and qualifications / 内部用户、地址与资质 + - name: RBAC + description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 +paths: + /api/platform/v1/iam/tenants: + post: + tags: [Platform Tenant / 平台租户] + summary: Register enterprise tenant / 创建企业租户 + description: Create a pending enterprise tenant and review task. Platform identity is required. / 创建待审核企业租户及审核任务,要求平台端身份。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RegisterRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/reviews: + get: + tags: [Platform Tenant / 平台租户] + summary: List pending tenant reviews / 查询待审核企业 + description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/reviews/{id}/approve: + post: + tags: [Platform Tenant / 平台租户] + summary: Approve enterprise registration / 审核通过企业注册 + description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。 + parameters: + - {$ref: '#/components/parameters/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReviewRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/reviews/{id}/reject: + post: + tags: [Platform Tenant / 平台租户] + summary: Reject enterprise registration / 驳回企业注册 + description: Reject a pending registration and save the review comment. / 驳回待审核注册申请并保存审核意见。 + parameters: + - {$ref: '#/components/parameters/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReviewRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/internal-users: + get: + tags: [IAM Resources / IAM 资源] + summary: List platform internal users / 查询平台内部用户 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [IAM Resources / IAM 资源] + summary: Create platform internal user / 新增平台内部用户 + description: Create an IAM profile linked to an existing authentication account. / 创建关联已有认证账号的 IAM 用户资料。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateIamUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/merchant/v1/iam/tenant/profile: + get: + tags: [Merchant Tenant / 商户企业] + summary: Get current tenant profile / 查询当前企业资料 + description: Tenant ID comes only from the verified JWT. / 租户 ID 仅取自已验证 JWT。 + responses: + '200': {$ref: '#/components/responses/Success'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [Merchant Tenant / 商户企业] + summary: Update current tenant profile / 更新当前企业资料 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateProfileRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/merchant/v1/iam/members: + get: + tags: [Merchant Tenant / 商户企业] + summary: List enterprise members / 查询企业成员 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [Merchant Tenant / 商户企业] + summary: Add enterprise member / 新增企业成员 + description: Attach an existing IAM user to the current tenant. / 将已有 IAM 用户加入当前企业。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateMemberRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/merchant/v1/iam/tenant/addresses: + get: + tags: [IAM Resources / IAM 资源] + summary: List tenant addresses / 查询企业地址 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [IAM Resources / IAM 资源] + summary: Add tenant address / 新增企业地址 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AddressRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/tenant/qualifications: + get: + tags: [IAM Resources / IAM 资源] + summary: List tenant qualifications / 查询企业资质 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [IAM Resources / IAM 资源] + summary: Add tenant qualification / 新增企业资质 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/{app}/v1/iam/roles: + parameters: + - {$ref: '#/components/parameters/App'} + get: + tags: [RBAC] + summary: List roles / 查询角色 + description: List roles in the selected application and tenant scope. / 查询指定应用及租户范围内的角色。 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [RBAC] + summary: Create role / 新增角色 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateRoleRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/{app}/v1/iam/members/roles: + put: + tags: [RBAC] + summary: Assign roles to member / 设置成员角色 + description: Replace member role assignments in the same tenant and application. / 在同一租户和应用域内替换成员角色。 + parameters: + - {$ref: '#/components/parameters/App'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AssignRolesRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/{app}/v1/iam/permissions: + parameters: + - {$ref: '#/components/parameters/App'} + get: + tags: [RBAC] + summary: List menu, page, button and API permissions / 查询菜单、页面、按钮和 API 权限 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [RBAC] + summary: Create permission / 新增权限 + description: Create a MENU, PAGE, BUTTON or API permission node. / 创建 MENU、PAGE、BUTTON 或 API 权限节点。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreatePermissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/{app}/v1/iam/roles/permissions: + put: + tags: [RBAC] + summary: Grant permissions to role / 设置角色权限 + description: Replace role permissions; cross-tenant and cross-application grants are rejected. / 替换角色权限,拒绝跨租户和跨应用授权。 + parameters: + - {$ref: '#/components/parameters/App'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/GrantPermissionsRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Id: + name: id + in: path + required: true + description: Resource ID / 资源 ID + schema: {type: integer, format: int64} + example: 190000000000000001 + App: + name: app + in: path + required: true + description: Application domain / 应用域 + schema: {type: string, enum: [platform, merchant]} + example: platform + responses: + Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + schemas: + ApiResponse: + type: object + required: [code, message, timestamp] + properties: + code: {type: string, example: '00000'} + message: {type: string, example: Success} + data: {} + requestId: {type: string} + traceId: {type: string} + timestamp: {type: string, format: date-time} + RegisterRequest: + type: object + required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] + properties: + tenantName: {type: string, example: 深圳虫虫出海科技有限公司} + legalName: {type: string, example: 张三} + unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12} + contactName: {type: string, example: 李四} + contactPhone: {type: string, example: '13800138000'} + contactEmail: {type: string, format: email, example: contact@example.com} + UpdateProfileRequest: + type: object + required: [tenantName, legalName, contactName, contactPhone] + properties: + tenantName: {type: string} + legalName: {type: string} + contactName: {type: string} + contactPhone: {type: string} + contactEmail: {type: string, format: email} + ReviewRequest: + type: object + properties: + comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因} + CreateIamUserRequest: + type: object + required: [authUserId, userType, displayName] + properties: + authUserId: {type: integer, format: int64} + userType: {type: string, enum: [PLATFORM, ENTERPRISE]} + displayName: {type: string} + phone: {type: string} + email: {type: string, format: email} + CreateMemberRequest: + type: object + required: [username, password, displayName, memberType] + properties: + username: {type: string, minLength: 4, maxLength: 64} + password: {type: string, format: password, minLength: 8, maxLength: 64} + displayName: {type: string, maxLength: 64} + phone: {type: string, maxLength: 32} + email: {type: string, format: email, maxLength: 128} + memberType: {type: string, enum: [OWNER, ADMIN, EMPLOYEE]} + memberNo: {type: string} + roleIds: {type: array, items: {type: integer, format: int64}} + AddressRequest: + type: object + required: [addressType, detail] + properties: + addressType: {type: string, enum: [OPERATING, BILLING, RETURN]} + countryCode: {type: string, example: CN} + province: {type: string} + city: {type: string} + district: {type: string} + detail: {type: string} + postalCode: {type: string} + isDefault: {type: boolean} + QualificationRequest: + type: object + required: [qualificationType, documentUrl] + properties: + qualificationType: {type: string, example: BUSINESS_LICENSE} + certificateNo: {type: string} + documentUrl: {type: string, format: uri} + validFrom: {type: string, format: date} + validTo: {type: string, format: date} + CreateRoleRequest: + type: object + required: [roleCode, roleName] + properties: + roleCode: {type: string, example: ORDER_OPERATOR} + roleName: {type: string, example: 订单操作员} + description: {type: string} + CreatePermissionRequest: + type: object + required: [type, code, name] + properties: + type: {type: string, enum: [MENU, PAGE, BUTTON, API], example: BUTTON} + code: {type: string, example: 'order:firstleg:submit'} + name: {type: string, example: 提交头程订单} + parentId: {type: integer, format: int64, example: 0} + routePath: {type: string} + httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} + sortNo: {type: integer, example: 10} + AssignRolesRequest: + type: object + required: [membershipId, roleIds] + properties: + membershipId: {type: integer, format: int64} + roleIds: {type: array, items: {type: integer, format: int64}} + GrantPermissionsRequest: + type: object + required: [roleId, permissionIds] + properties: + roleId: {type: integer, format: int64} + permissionIds: {type: array, items: {type: integer, format: int64}} diff --git a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java new file mode 100644 index 000000000..f55bb491c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java @@ -0,0 +1,20 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.util.pattern.PathPatternParser; +import org.springframework.http.server.PathContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 防止成员数字主键路由再次拦截固定的角色分配路径。 */ +class MerchantMemberRouteTest { + private final PathPatternParser parser = new PathPatternParser(); + + @Test + void 成员编辑路由只接受数字主键() { + var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}"); + + assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue(); + assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/roles"))).isFalse(); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java new file mode 100644 index 000000000..67fea8c50 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthorizationSnapshotServiceTest { + @Test + void loadsRolesAndPermissionsForActiveMerchantIdentity() { + AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) + .thenReturn(List.of("ADMIN", "OPERATOR")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) + .thenReturn(List.of("order:read", "order:create")); + + var result = service(mapper) + .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); + + assertThat(result.getAppCode()).isEqualTo("MERCHANT"); + assertThat(result.getClientType()).isEqualTo("MERCHANT"); + assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); + assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + } + + @Test + void rejectsPlatformIdentityOutsidePlatformTenant() { + AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + + assertThatThrownBy(() -> service(mapper) + .load(new SnapshotRequest(42L, 7L, 9L, "PLATFORM"))) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + } + + @Test + void rejectsInactiveMembership() { + AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0); + + assertThatThrownBy(() -> service(mapper) + .load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + verify(mapper).countActiveIdentity(42L, 7L, 9L); + } + + private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) { + AuthorizationSnapshotService service = new AuthorizationSnapshotService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + return service; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java new file mode 100644 index 000000000..a9219af34 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -0,0 +1,94 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.MemberDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MemberServiceTest { + private final MemberRepository repository = mock(MemberRepository.class); + private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); + private final AuthAccountClient auth = mock(AuthAccountClient.class); + private final MemberService service = new MemberService(repository, roleMapper, auth, "internal-token"); + private IamMembership member; + private IamUser user; + + @BeforeEach void setUp() { + member = new IamMembership(); member.setId(20L); member.setTenantId(100L); member.setUserId(30L); + member.setMemberType("EMPLOYEE"); member.setStatus("ACTIVE"); + user = new IamUser(); user.setId(30L); user.setAuthUserId(40L); user.setDisplayName("测试成员"); + when(repository.findByTenantAndId(100L, 20L)).thenReturn(member); + when(repository.findUser(30L)).thenReturn(user); + when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); + } + + @Test void 成员详情返回联系方式和已有角色() { + user.setPhone("13800138000"); user.setEmail("member@example.com"); + MemberResponse result = service.detail(100L, 20L); + assertEquals("测试成员", result.displayName()); + assertEquals("13800138000", result.phone()); + assertEquals(List.of(50L), result.roleIds()); + verifyNoInteractions(auth); + } + + @Test void 跨租户查询被拒绝() { + when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); + assertThrows(BizException.class, () -> service.detail(200L, 20L)); + } + + @Test void 企业所有者不可停用() { + member.setMemberType("OWNER"); + BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, + new ChangeMemberStatusRequest("DISABLED"), 20L)); + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test void 停用成员同步认证账号并保存本地状态() { + when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null)); + MemberResponse result = service.changeStatus(100L, 20L, + new ChangeMemberStatusRequest("DISABLED"), 99L); + assertEquals("DISABLED", result.status()); + verify(repository).save(member); + verify(repository).saveUser(user); + } + + @Test void 认证服务失败时不得写入本地状态() { + when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败")); + assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L, + new ChangeMemberStatusRequest("DISABLED"), 99L)); + verify(repository, never()).save(member); + } + + @Test void 企业所有者不能通过成员编辑接口降级或变更角色() { + member.setMemberType("OWNER"); + UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, + "EMPLOYEE", null, List.of()); + + assertThrows(BizException.class, () -> service.update(100L, 20L, request)); + + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } + + @Test void 企业所有者密码不能被成员管理员重置() { + member.setMemberType("OWNER"); + + assertThrows(BizException.class, () -> service.resetPassword(100L, 20L, + new ResetMemberPasswordRequest("OwnerNew123"))); + + verify(auth, never()).reset(anyString(), anyLong(), any()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java new file mode 100644 index 000000000..ce88c4f79 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java @@ -0,0 +1,31 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PersonalProfileServiceTest { + private final MemberRepository repository = mock(MemberRepository.class); + private final PersonalProfileService service = new PersonalProfileService(repository); + + @Test void 只能修改当前租户中的本人资料() { + IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE"); + IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); + when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership); + when(repository.findUser(3L)).thenReturn(user); + var result = service.update(1L, 2L, "tester", + new UpdatePersonalProfileRequest("新姓名", "13800138000", "a@example.com")); + assertEquals("新姓名", result.displayName()); + verify(repository).saveUser(user); + } + + @Test void 跨租户身份被拒绝() { + assertThrows(BizException.class, () -> service.get(9L, 2L, "tester")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java new file mode 100644 index 000000000..3e8800d72 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -0,0 +1,282 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest; +import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest; +import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest; +import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest; +import com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest; +import com.baiyue.iam.dto.RbacDtos.CreatePermissionRequest; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamPermission; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.RbacRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RbacServiceTest { + @Mock private RbacRepository repository; + @Mock private MemberRepository memberRepository; + private RbacService service; + + @BeforeEach + void setUp() { + service = new RbacService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "memberRepository", memberRepository); + } + + @Test + void 角色列表返回当前权限用于安全回显() { + IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); + when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); + when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); + + var result = service.roles(100L, "MERCHANT"); + + assertEquals(Set.of(21L, 22L), result.get(0).permissionIds()); + assertEquals(100L, result.get(0).tenantId()); + } + + @Test + void 权限列表同码时优先当前租户并去除全局模板重复项() { + IamPermission global = permission(21L, 0L, "merchant:member:list"); + IamPermission tenant = permission(22L, 100L, "merchant:member:list"); + IamPermission fallback = permission(23L, 0L, "merchant:member:status"); + when(repository.findPermissions(100L, "MERCHANT")) + .thenReturn(List.of(global, fallback, tenant)); + + var result = service.permissions(100L, "MERCHANT"); + + assertEquals(2, result.size()); + assertEquals(22L, result.get(0).getId()); + assertEquals(23L, result.get(1).getId()); + } + + @Test + void 跨租户角色访问被拒绝() { + when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null); + + BizException exception = assertThrows(BizException.class, + () -> service.role(200L, "MERCHANT", 11L)); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + @Test + void 系统角色禁止删除() { + when(repository.findRole(11L, 0L, "PLATFORM")) + .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); + + BizException exception = assertThrows(BizException.class, + () -> service.deleteRole(0L, "PLATFORM", 11L)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).deleteRole(anyLong(), any(), anyLong()); + } + + @Test + void 已分配成员的自定义角色禁止删除() { + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + when(repository.isRoleAssigned(100L, 11L)).thenReturn(true); + + BizException exception = assertThrows(BizException.class, + () -> service.deleteRole(100L, "MERCHANT", 11L)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).deleteRole(anyLong(), any(), anyLong()); + } + + @Test + void 空权限集合可以明确清空授权() { + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + + service.grantPermissions(100L, "MERCHANT", new GrantPermissionsRequest(11L, Set.of())); + + verify(repository).replaceRolePermissions(100L, 11L, Set.of()); + } + + @Test + void 空角色集合可以明确清空成员授权() { + IamMembership membership = new IamMembership(); + membership.setId(31L); + membership.setTenantId(100L); + when(memberRepository.findById(31L)).thenReturn(membership); + + service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of())); + + verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of()); + } + + @Test + void 成员角色回显只返回当前应用域角色() { + IamMembership membership = new IamMembership(); + membership.setId(31L); + membership.setTenantId(100L); + when(memberRepository.findById(31L)).thenReturn(membership); + when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L, 12L)); + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + when(repository.findRole(12L, 100L, "MERCHANT")).thenReturn(null); + + var result = service.memberRoles(100L, "MERCHANT", 31L); + + assertEquals(Set.of(11L), result.roleIds()); + } + + @Test + void 同一租户同一应用角色编码重复时返回冲突() { + when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true); + + BizException exception = assertThrows(BizException.class, () -> service.createRole( + 100L, "MERCHANT", new CreateRoleRequest("ORDER_ADMIN", "订单管理员", null))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).insertRole(any()); + } + + @Test + void 停用角色不得继续分配给成员() { + IamMembership membership = new IamMembership(); + membership.setId(31L); + membership.setTenantId(100L); + when(memberRepository.findById(31L)).thenReturn(membership); + IamRole disabled = role(11L, 100L, "MERCHANT", "CUSTOM"); + disabled.setStatus("DISABLED"); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(disabled); + + BizException exception = assertThrows(BizException.class, () -> service.assignRoles( + 100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L)))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any()); + } + + @Test + void 自定义角色可以编辑和停用并保持权限回显() { + IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); + when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); + + var updated = service.updateRole(100L, "MERCHANT", 11L, + new UpdateRoleRequest("新名称", "新说明")); + var disabled = service.changeStatus(100L, "MERCHANT", 11L, + new ChangeRoleStatusRequest("DISABLED")); + + assertEquals("新名称", updated.roleName()); + assertEquals(Set.of(21L), updated.permissionIds()); + assertEquals("DISABLED", disabled.status()); + verify(repository, times(2)).updateRole(role); + } + + @Test + void 未使用的自定义角色可以安全删除() { + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + when(repository.isRoleAssigned(100L, 11L)).thenReturn(false); + + service.deleteRole(100L, "MERCHANT", 11L); + + verify(repository).deleteRole(100L, "MERCHANT", 11L); + } + + @Test + void 企业所有者角色不能通过普通成员授权接口修改() { + IamMembership owner = new IamMembership(); + owner.setId(31L); + owner.setTenantId(100L); + owner.setMemberType("OWNER"); + when(memberRepository.findById(31L)).thenReturn(owner); + + BizException exception = assertThrows(BizException.class, () -> service.assignRoles( + 100L, "MERCHANT", new AssignRolesRequest(31L, Set.of()))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any()); + } + + @Test + void 系统角色权限不能通过普通授权接口修改() { + when(repository.findRole(11L, 0L, "PLATFORM")) + .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); + + BizException exception = assertThrows(BizException.class, () -> service.grantPermissions( + 0L, "PLATFORM", new GrantPermissionsRequest(11L, Set.of()))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).replaceRolePermissions(anyLong(), anyLong(), any()); + } + + @Test + void 企业所有者系统角色不能分配给普通成员() { + IamMembership member = new IamMembership(); + member.setId(31L); + member.setTenantId(100L); + member.setMemberType("EMPLOYEE"); + when(memberRepository.findById(31L)).thenReturn(member); + IamRole ownerRole = role(11L, 100L, "MERCHANT", "SYSTEM"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(ownerRole); + + BizException exception = assertThrows(BizException.class, () -> service.assignRoles( + 100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L)))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any()); + } + + @Test + void 权限编码重复时返回稳定冲突且不写数据库() { + when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test")) + .thenReturn(true); + CreatePermissionRequest request = new CreatePermissionRequest( + "BUTTON", "merchant:role:test", "测试按钮", 0L, null, null, 1); + + BizException exception = assertThrows(BizException.class, + () -> service.createPermission(100L, "MERCHANT", request)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).insertPermission(any()); + } + + private IamRole role(Long id, Long tenantId, String appCode, String roleType) { + IamRole role = new IamRole(); + role.setId(id); + role.setTenantId(tenantId); + role.setAppCode(appCode); + role.setRoleCode("ROLE_" + id); + role.setRoleName("测试角色"); + role.setRoleType(roleType); + role.setStatus("ACTIVE"); + return role; + } + + private IamPermission permission(Long id, Long tenantId, String code) { + IamPermission permission = new IamPermission(); + permission.setId(id); + permission.setTenantId(tenantId); + permission.setAppCode("MERCHANT"); + permission.setPermissionCode(code); + permission.setPermissionName(code); + permission.setPermissionType("BUTTON"); + permission.setStatus("ACTIVE"); + return permission; + } +} diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql new file mode 100644 index 000000000..b9a2df7b1 --- /dev/null +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -0,0 +1,39 @@ +-- Any returned row represents a failed phase-one seed assertion. +SELECT 'missing_auth_accounts' AS failure +WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account + WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 +UNION ALL +SELECT 'missing_active_identities' +WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u + JOIN baiyue_iam.iam_membership m ON m.user_id = u.id + WHERE u.auth_user_id IN (1000001, 1000002) + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 +UNION ALL +SELECT 'unexpected_permission_type' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission + WHERE id BETWEEN 5100000 AND 5299999 AND permission_type NOT IN ('MENU', 'BUTTON')) +UNION ALL +SELECT 'invalid_menu_depth' +WHERE EXISTS ( + SELECT 1 FROM baiyue_iam.iam_permission child + JOIN baiyue_iam.iam_permission parent ON parent.id = child.parent_id + WHERE child.id BETWEEN 5100000 AND 5299999 + AND child.permission_type = 'MENU' AND parent.parent_id <> 0 +) +UNION ALL +SELECT 'invalid_button_parent' +WHERE EXISTS ( + SELECT 1 FROM baiyue_iam.iam_permission button_permission + LEFT JOIN baiyue_iam.iam_permission menu_permission ON menu_permission.id = button_permission.parent_id + WHERE button_permission.id BETWEEN 5100000 AND 5299999 + AND button_permission.permission_type = 'BUTTON' + AND (menu_permission.id IS NULL OR menu_permission.permission_type <> 'MENU' OR menu_permission.parent_id = 0) +) +UNION ALL +SELECT 'platform_admin_not_fully_granted' +WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) + <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) +UNION ALL +SELECT 'merchant_owner_not_fully_granted' +WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) + <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')); diff --git a/integration-service/pom.xml b/integration-service/pom.xml new file mode 100644 index 000000000..b032012af --- /dev/null +++ b/integration-service/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + integration-service + integration-service + + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java new file mode 100644 index 000000000..670ad361c --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java @@ -0,0 +1,13 @@ +package com.baiyue.integration; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class IntegrationServiceApplication { + public static void main(String[] args) { + SpringApplication.run(IntegrationServiceApplication.class, args); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java new file mode 100644 index 000000000..09c4eec94 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.integration.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "baiyue.integration") +public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) { + public record BaiduOcr(boolean enabled, String apiKey, String secretKey) { + public boolean configured() { + return enabled && hasText(apiKey) && hasText(secretKey); + } + } + + public record AmazonSpApi(boolean enabled, + String clientId, + String clientSecret, + String refreshToken, + String awsAccessKey, + String awsSecretKey, + String region) { + public boolean configured() { + return enabled + && hasText(clientId) + && hasText(clientSecret) + && hasText(refreshToken) + && hasText(awsAccessKey) + && hasText(awsSecretKey) + && hasText(region); + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java new file mode 100644 index 000000000..6205eac7b --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java @@ -0,0 +1,29 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.baiyue.integration.service.ProviderStatusService; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/internal/integrations") +public class IntegrationProviderController { + @Resource + private ProviderStatusService providerStatusService; + @Resource + private InternalServiceTokenGuard internalServiceTokenGuard; + + @GetMapping("/providers") + public ApiResponse> providers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(providerStatusService.providers()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java b/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java new file mode 100644 index 000000000..c062fc17a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java @@ -0,0 +1,17 @@ +package com.baiyue.integration.dto; + +public record ProviderStatus(String provider, + boolean enabled, + boolean configured, + String status, + String message) { + public static ProviderStatus of(String provider, boolean enabled, boolean configured) { + if (!enabled) { + return new ProviderStatus(provider, false, false, "DISABLED", "第三方能力未启用"); + } + if (!configured) { + return new ProviderStatus(provider, true, false, "NOT_CONFIGURED", "第三方密钥配置不完整"); + } + return new ProviderStatus(provider, true, true, "READY", "配置已就绪"); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java new file mode 100644 index 000000000..759db0b21 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +@Component +public class AmazonLogisticsAdapter implements IntegrationProviderAdapter { + private final IntegrationProperties properties; + + public AmazonLogisticsAdapter(IntegrationProperties properties) { + this.properties = properties; + } + + @Override + public ProviderStatus status() { + IntegrationProperties.AmazonSpApi config = properties.amazonSpApi(); + return ProviderStatus.of("AMAZON_SP_API", config.enabled(), config.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java new file mode 100644 index 000000000..651d5d0e6 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +@Component +public class BaiduOcrAdapter implements IntegrationProviderAdapter { + private final IntegrationProperties properties; + + public BaiduOcrAdapter(IntegrationProperties properties) { + this.properties = properties; + } + + @Override + public ProviderStatus status() { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + return ProviderStatus.of("BAIDU_OCR", config.enabled(), config.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java new file mode 100644 index 000000000..70fef0f1a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java @@ -0,0 +1,7 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.ProviderStatus; + +public interface IntegrationProviderAdapter { + ProviderStatus status(); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..dd0519417 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java @@ -0,0 +1,28 @@ +package com.baiyue.integration.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final byte[] expectedToken; + + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) { + if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + } + + public void verify(String actualToken) { + byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效"); + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java new file mode 100644 index 000000000..63b23feaf --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.IntegrationProviderAdapter; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ProviderStatusService { + private final List adapters; + + public ProviderStatusService(List adapters) { + this.adapters = adapters; + } + + public List providers() { + return adapters.stream().map(IntegrationProviderAdapter::status).toList(); + } +} diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml new file mode 100644 index 000000000..6416a8b68 --- /dev/null +++ b/integration-service/src/main/resources/application.yml @@ -0,0 +1,53 @@ +server: + port: ${INTEGRATION_PORT:9210} + +spring: + application: + name: integration-service + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration + 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} + +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + integration: + baidu-ocr: + enabled: ${BAIDU_OCR_ENABLED:false} + api-key: ${BAIDU_OCR_API_KEY:} + secret-key: ${BAIDU_OCR_SECRET_KEY:} + amazon-sp-api: + enabled: ${AMAZON_SP_API_ENABLED:false} + client-id: ${AMAZON_SP_API_CLIENT_ID:} + client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} + refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} + aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} + aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} + region: ${AMAZON_SP_API_REGION:} + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true diff --git a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java new file mode 100644 index 000000000..2d23af5ee --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class IntegrationPropertiesTest { + @Test + void providerIsConfiguredOnlyWhenEnabledAndAllSecretsExist() { + var baidu = new IntegrationProperties.BaiduOcr(true, "key", "secret"); + var amazon = new IntegrationProperties.AmazonSpApi( + true, "client", "secret", "refresh", "access", "aws-secret", "us-east-1"); + + assertThat(baidu.configured()).isTrue(); + assertThat(amazon.configured()).isTrue(); + assertThat(new IntegrationProperties.BaiduOcr(false, "key", "secret").configured()).isFalse(); + assertThat(new IntegrationProperties.AmazonSpApi( + true, "client", "secret", "", "access", "aws-secret", "us-east-1").configured()).isFalse(); + } +} diff --git a/order/README-ORDER.md b/order/README-ORDER.md new file mode 100644 index 000000000..7697f320d --- /dev/null +++ b/order/README-ORDER.md @@ -0,0 +1,31 @@ +# 头程订单一期 / First-leg Order Phase 1 + +## 数据库归属 / Database ownership + +- `baiyue_order`:订单主数据、货物、收发货快照、目的仓快照、内部备注、状态历史和事务发件箱。 / Order master data, cargo, sender/receiver and destination snapshots, internal notes, status history, and transactional outbox. +- `baiyue_firstleg`:头程履约任务、指派历史与事件消费幂等。 / First-leg fulfillment tasks, assignment history, and event-consumer idempotency. +- 两个服务仅通过 `tenant_id + order_id + order_no` 逻辑关联,不建立跨库外键。 / Services use logical `tenant_id + order_id + order_no` references and never create cross-database foreign keys. + +## 接口隔离 / API separation + +- 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs. +- 平台端 `/api/platform/v1/orders/first-leg`:租户订单查询、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform tenant query, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes. +- 平台指派 `/api/platform/v1/first-leg/tasks/{taskId}/assignment`。 / Platform fulfillment assignment. + +商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台接口要求 `appCode=PLATFORM`,并显式传入目标 `tenantId`;所有服务层 SQL 都带租户条件。 + +Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. Platform APIs require `appCode=PLATFORM` and an explicit target `tenantId`; every service query includes the tenant predicate. + +## 状态与审计 / State and audit + +主流程为 `DRAFT → SUBMITTED → APPROVED → ACCEPTED → PROCESSING → COMPLETED`,驳回路径为 `SUBMITTED → REJECTED → DRAFT`,处理前可取消。状态变化追加写入 `ord_order_status_history`;创建、提交、审核、驳回、受理、取消、状态推进、备注和指派使用 `@BusinessAudit` 记录。 + +The main flow is `DRAFT → SUBMITTED → APPROVED → ACCEPTED → PROCESSING → COMPLETED`; rejection follows `SUBMITTED → REJECTED → DRAFT`, and cancellation is permitted before processing. Status changes append to `ord_order_status_history`; key operations use `@BusinessAudit`. + +## 验证 / Verification + +```bash +mvn -pl order,firstleg -am -DskipTests clean compile +``` + +已通过 Java 17 干净编译。 / Verified with a clean Java 17 compilation. diff --git a/order/pom.xml b/order/pom.xml new file mode 100644 index 000000000..76491ae8e --- /dev/null +++ b/order/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + order-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 + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + 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/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java b/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java new file mode 100644 index 000000000..467388cbb --- /dev/null +++ b/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java @@ -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); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/PartnerClient.java b/order/src/main/java/com/baiyue/order/client/PartnerClient.java new file mode 100644 index 000000000..158e90f5b --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/PartnerClient.java @@ -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 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) { } +} diff --git a/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java new file mode 100644 index 000000000..48061a6dd --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java @@ -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 check( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable("tenantId") long tenantId); + + record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { } +} diff --git a/order/src/main/java/com/baiyue/order/client/WarehouseClient.java b/order/src/main/java/com/baiyue/order/client/WarehouseClient.java new file mode 100644 index 000000000..a98ba07e7 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/WarehouseClient.java @@ -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 active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("tenantId") long tenantId, @PathVariable("id") long id); + record Detail(Warehouse warehouse, List
addresses, List 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) { } +} diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java new file mode 100644 index 000000000..4e1c72388 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -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 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 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> 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 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 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 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 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); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/OrderHealthController.java b/order/src/main/java/com/baiyue/order/controller/OrderHealthController.java new file mode 100644 index 000000000..08169e0b1 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/OrderHealthController.java @@ -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> status() { + return ApiResponse.success(Map.of("service", "order-service", "status", "UP")); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java new file mode 100644 index 000000000..418ebfcf8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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> 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 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 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 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 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 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 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 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 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 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 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); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderDetailResponse.java b/order/src/main/java/com/baiyue/order/dto/OrderDetailResponse.java new file mode 100644 index 000000000..79ec8bc0f --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/OrderDetailResponse.java @@ -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 cargoes, List + parties, List internalNotes, List statusHistory) { +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java new file mode 100644 index 000000000..9dc21d26d --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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 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) { } +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java new file mode 100644 index 000000000..e09ed5295 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java @@ -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 +/** + * 订单货物明细持久化实体。 + * + *

对应表 {@code ord_order_cargo},按租户和订单保存品名、包装、重量、体积、 + * 申报价值及危险品标识等货物信息。

+ */ +@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; + + +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java new file mode 100644 index 000000000..e172cdcf3 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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.*; + +/** + * 头程订单主表持久化实体。 + * + *

对应表 {@code ord_order},保存租户内订单编号、客户、运输方案、目的地及 + * 生命周期状态等订单核心信息。

+ */ +@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; + + +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderInternalNoteEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderInternalNoteEntity.java new file mode 100644 index 000000000..489457e34 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderInternalNoteEntity.java @@ -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.*; + +/** + * 订单内部备注持久化实体。 + * + *

对应表 {@code ord_order_internal_note},保存平台内部针对租户订单记录的 + * 非客户可见备注。

+ */ +@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; + +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderPartyEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderPartyEntity.java new file mode 100644 index 000000000..706ce3c0c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderPartyEntity.java @@ -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 +/** + * 订单参与方持久化实体。 + * + *

对应表 {@code ord_order_party},按租户和订单保存发货方、收货方等主体的 + * 联系人及地址信息。

+ */ +@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; + + +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderStatus.java b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java new file mode 100644 index 000000000..693aaab40 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java @@ -0,0 +1,5 @@ +package com.baiyue.order.entity; +/** + * 头程订单生命周期状态,约束草稿、提交、审核、受理、处理、完成及取消等状态流转。 + */ +public enum OrderStatus { DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED } diff --git a/order/src/main/java/com/baiyue/order/entity/OrderStatusHistoryEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderStatusHistoryEntity.java new file mode 100644 index 000000000..c82df0536 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderStatusHistoryEntity.java @@ -0,0 +1,32 @@ +package com.baiyue.order.entity; + +import lombok.Getter; +import lombok.Setter; +import com.baomidou.mybatisplus.annotation.*; + +import java.time.LocalDateTime; + +/** + * 订单状态流转历史持久化实体。 + * + *

对应表 {@code ord_order_status_history},记录每次状态变更的前后状态、动作、 + * 原因、操作人和发生时间,用于追溯订单生命周期。

+ */ +@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; + + +} diff --git a/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java b/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java new file mode 100644 index 000000000..f6e8b7c41 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java @@ -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 持久化实体。 + * + *

对应表 {@code ord_outbox_event}。该表与订单业务变更在同一本地事务中写入, + * 由后台发布器异步投递至 RocketMQ,并通过失败重试实现数据库状态与领域事件的 + * 最终一致性;它不是普通操作日志。

+ */ +@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; + +} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderCargoMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderCargoMapper.java new file mode 100644 index 000000000..0a210fe51 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderCargoMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderChildPurgeMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderChildPurgeMapper.java new file mode 100644 index 000000000..d8e3c58d4 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderChildPurgeMapper.java @@ -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); +} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderInternalNoteMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderInternalNoteMapper.java new file mode 100644 index 000000000..c90f9ec68 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderInternalNoteMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java new file mode 100644 index 000000000..391360af6 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderPartyMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderPartyMapper.java new file mode 100644 index 000000000..8e9f67906 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderPartyMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java new file mode 100644 index 000000000..0cfd83782 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderStatusHistoryMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/mapper/OutboxEventMapper.java b/order/src/main/java/com/baiyue/order/mapper/OutboxEventMapper.java new file mode 100644 index 000000000..57260b9f9 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OutboxEventMapper.java @@ -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{} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java new file mode 100644 index 000000000..db7f2731a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -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() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getId, orderId)); + } + + public PageResult page(long tenantId, OrderRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 findCargo(long tenantId, long orderId) { + return cargoMapper.selectList(new LambdaQueryWrapper() + .eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId) + .orderByAsc(OrderCargoEntity::getItemNo)); + } + + public List findParties(long tenantId, long orderId) { + return partyMapper.selectList(new LambdaQueryWrapper() + .eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId)); + } + + public List findNotes(long tenantId, long orderId) { + return noteMapper.selectList(new LambdaQueryWrapper() + .eq(OrderInternalNoteEntity::getTenantId, tenantId).eq(OrderInternalNoteEntity::getOrderId, orderId) + .orderByDesc(OrderInternalNoteEntity::getCreatedAt)); + } + + public List findHistory(long tenantId, long orderId) { + return historyMapper.selectList(new LambdaQueryWrapper() + .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); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java new file mode 100644 index 000000000..7866225d4 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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 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); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java new file mode 100644 index 000000000..06b6b7a2f --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java @@ -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"); + } + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java new file mode 100644 index 000000000..c8dae67f6 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -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) { } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java new file mode 100644 index 000000000..5ce43f5b4 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java @@ -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");} +} diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml new file mode 100644 index 000000000..a7bf7069a --- /dev/null +++ b/order/src/main/resources/application.yml @@ -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:} diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql new file mode 100644 index 000000000..77a537c8c --- /dev/null +++ b/order/src/main/resources/db/order.sql @@ -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,保证数据库状态与领域事件最终一致;不是普通操作日志'; diff --git a/order/src/main/resources/db/phase1-masterdata-extension.sql b/order/src/main/resources/db/phase1-masterdata-extension.sql new file mode 100644 index 000000000..a3f643ff0 --- /dev/null +++ b/order/src/main/resources/db/phase1-masterdata-extension.sql @@ -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; diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql new file mode 100644 index 000000000..8d5e57d88 --- /dev/null +++ b/order/src/main/resources/db/phase1-order-extension.sql @@ -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; diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java new file mode 100644 index 000000000..ef24b75cd --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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()); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java new file mode 100644 index 000000000..a10311684 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -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); + } +} diff --git a/platform-bom/pom.xml b/platform-bom/pom.xml new file mode 100644 index 000000000..a60379d84 --- /dev/null +++ b/platform-bom/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + com.baiyue.shared + baiyue-platform-bom + pom + 百悦公共组件版本清单 + + + com.baiyue.sharedbaiyue-common-core${project.version} + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedbaiyue-common-audit${project.version} + com.baiyue.sharedcommon-event-contract${project.version} + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..8c2e4fb38 --- /dev/null +++ b/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.9 + + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + pom + 百悦头程平台 + 百悦物流国内头程业务微服务平台 + + + platform-bom + common-core + common-web + common-security + common-openapi + common-audit + common-event-contract + gateway + auth + iam + order + firstleg + warehouse + file + print + audit-service + integration-service + event-bridge-service + + + + 17 + ${java.version} + UTF-8 + UTF-8 + 2023.0.3 + 2023.0.3.2 + 3.5.7 + 2.5.0 + 3.13.0 + 3.4.1 + + + + + org.projectlombok + lombok + true + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring-cloud-alibaba.version} + pom + import + + + com.baomidou + mybatis-plus-bom + ${mybatis-plus.version} + pom + import + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-starter-webflux-ui + ${springdoc.version} + + + + + + + + org.springframework.bootspring-boot-maven-plugin + + org.apache.maven.pluginsmaven-compiler-plugin + ${maven.compiler.plugin.version} + ${maven.compiler.release}true + + + + + + org.apache.maven.pluginsmaven-enforcer-plugin + ${maven.enforcer.plugin.version} + 校验构建环境enforce + [17,) + [3.8.8,) + + + + + diff --git a/print/pom.xml b/print/pom.xml new file mode 100644 index 000000000..cd73698b8 --- /dev/null +++ b/print/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + print-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 + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + 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/print/src/main/java/com/baiyue/print/PrintServiceApplication.java b/print/src/main/java/com/baiyue/print/PrintServiceApplication.java new file mode 100644 index 000000000..6fa3f7c42 --- /dev/null +++ b/print/src/main/java/com/baiyue/print/PrintServiceApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.print; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 打印服务启动入口。 + */ +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class PrintServiceApplication { + public static void main(String[] args) { + SpringApplication.run(PrintServiceApplication.class, args); + } +} diff --git a/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java b/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java new file mode 100644 index 000000000..62ffc9a9c --- /dev/null +++ b/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java @@ -0,0 +1,28 @@ +package com.baiyue.print.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +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/print") +@Tag(name = "打印服务状态", description = "打印服务可用性接口") +public class PrintHealthController { + @GetMapping("/status") + @Operation(summary = "查询服务状态", description = "返回打印服务的逻辑运行状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功") + }) + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "print-service", "status", "UP")); + } +} diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml new file mode 100644 index 000000000..ff1997f9d --- /dev/null +++ b/print/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: ${PRINT_PORT:9207} +spring: + application: + name: print-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: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${PRINT_DB_USERNAME:root} + password: ${PRINT_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/print/src/main/resources/db/print.sql b/print/src/main/resources/db/print.sql new file mode 100644 index 000000000..959656f69 --- /dev/null +++ b/print/src/main/resources/db/print.sql @@ -0,0 +1,62 @@ +-- 打印服务独占数据库,业务服务只保存打印任务逻辑ID。 +CREATE DATABASE IF NOT EXISTS baiyue_print + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_print; + +CREATE TABLE IF NOT EXISTS print_template ( + id BIGINT UNSIGNED NOT NULL COMMENT '模板ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID,平台模板使用平台租户ID', + template_code VARCHAR(64) NOT NULL COMMENT '模板编码', + template_name VARCHAR(128) NOT NULL COMMENT '模板名称', + template_type VARCHAR(32) NOT NULL COMMENT '模板类型', + content TEXT NOT NULL COMMENT '模板内容', + version_no INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '模板版本号', + status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' 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_print_template_code (tenant_id, template_code, version_no), + KEY idx_print_template_status (tenant_id, status) +) ENGINE=InnoDB COMMENT='打印模板表'; + +CREATE TABLE IF NOT EXISTS print_job ( + id BIGINT UNSIGNED NOT NULL COMMENT '打印任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + job_no VARCHAR(40) NOT NULL COMMENT '打印任务编号', + template_id BIGINT UNSIGNED NOT NULL COMMENT '打印模板逻辑ID', + business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + printer_code VARCHAR(64) NULL COMMENT '目标打印机编码', + copies INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '打印份数', + payload_json JSON NOT NULL COMMENT '模板数据快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', + output_file_id BIGINT UNSIGNED NULL COMMENT '生成文件的逻辑ID', + error_message VARCHAR(1000) NULL COMMENT '失败原因', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请人ID', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间', + started_at DATETIME(3) NULL COMMENT '开始时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_print_job_no (tenant_id, job_no), + KEY idx_print_job_status (tenant_id, status, requested_at), + KEY idx_print_job_business (tenant_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='打印任务表'; + +CREATE TABLE IF NOT EXISTS print_job_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '任务历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + print_job_id BIGINT UNSIGNED NOT NULL COMMENT '打印任务逻辑ID', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', + message VARCHAR(500) NULL COMMENT '状态说明', + operator_id BIGINT UNSIGNED NULL COMMENT '操作人ID', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (id), + KEY idx_print_history_job (tenant_id, print_job_id, occurred_at) +) ENGINE=InnoDB COMMENT='打印任务状态历史表'; diff --git a/reports/phase1-business-maturity-regression-20260730155654978.json b/reports/phase1-business-maturity-regression-20260730155654978.json new file mode 100644 index 000000000..2f8edded6 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730155654978.json @@ -0,0 +1,48 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T15:56:54.9765391+08:00", + "finishedAt": "2026-07-30T15:56:55.5527633+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19000", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 4, + "passed": 2, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730155654978" + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T15:56:55.4254974+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T15:56:55.5055578+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": false, + "evidence": "HTTP=401, code=无", + "checkedAt": "2026-07-30T15:56:55.5481933+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:旧刷新令牌不可重复使用;HTTP=401, code=无", + "checkedAt": "2026-07-30T15:56:55.5517780+08:00" + } + ], + "fatalMessage": "验收失败:旧刷新令牌不可重复使用;HTTP=401, code=无", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730155800189.json b/reports/phase1-business-maturity-regression-20260730155800189.json new file mode 100644 index 000000000..57acaacae --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730155800189.json @@ -0,0 +1,62 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T15:58:00.1874584+08:00", + "finishedAt": "2026-07-30T15:58:00.9810254+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19000", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 6, + "passed": 4, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730155800189" + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T15:58:00.6511762+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T15:58:00.7211762+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T15:58:00.7605641+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T15:58:00.8628925+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": false, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T15:58:00.9765062+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:查询本人个人资料;HTTP=401, code=A0401", + "checkedAt": "2026-07-30T15:58:00.9790176+08:00" + } + ], + "fatalMessage": "验收失败:查询本人个人资料;HTTP=401, code=A0401", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730160041399.json b/reports/phase1-business-maturity-regression-20260730160041399.json new file mode 100644 index 000000000..4a027414d --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730160041399.json @@ -0,0 +1,76 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:00:41.3973078+08:00", + "finishedAt": "2026-07-30T16:00:45.4857548+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19001", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 8, + "passed": 6, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730160041399" + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:00:44.5129253+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:00:44.6035924+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:00:44.6532085+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:00:45.1225549+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:00:45.3047363+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:00:45.4742131+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": false, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:00:45.4772172+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:个人资料修改后正确回显;displayName 已更新", + "checkedAt": "2026-07-30T16:00:45.4827570+08:00" + } + ], + "fatalMessage": "验收失败:个人资料修改后正确回显;displayName 已更新", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730160253985.json b/reports/phase1-business-maturity-regression-20260730160253985.json new file mode 100644 index 000000000..6ce8d8930 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730160253985.json @@ -0,0 +1,90 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:02:53.9844566+08:00", + "finishedAt": "2026-07-30T16:02:54.6694199+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19001", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 10, + "passed": 8, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730160253985" + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:02:54.4056237+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:02:54.4645520+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:02:54.5013384+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:02:54.5109315+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:02:54.5407183+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:02:54.5758724+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:02:54.5786199+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:02:54.6168020+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": false, + "evidence": "HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:02:54.6647332+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:错误旧密码禁止修改密码;HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:02:54.6673667+08:00" + } + ], + "fatalMessage": "验收失败:错误旧密码禁止修改密码;HTTP=500, code=S0500", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730160821202.json b/reports/phase1-business-maturity-regression-20260730160821202.json new file mode 100644 index 000000000..f06cd1568 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730160821202.json @@ -0,0 +1,168 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:08:21.2014235+08:00", + "finishedAt": "2026-07-30T16:08:25.2419447+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 21, + "passed": 19, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730160821202", + "RoleId": 2082740079662407681 + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:22.0509398+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:22.1226201+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:08:22.1637038+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:08:22.2374192+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:22.2714935+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:22.3106777+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:08:22.3136787+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:22.3533787+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:08:22.6339515+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:23.3906033+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:08:23.4004530+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:08:23.4166781+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:23.7065086+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T16:08:24.4711674+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:24.7539594+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:25.0484029+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:08:25.0614739+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:25.1253052+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:08:25.2023939+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": false, + "evidence": "权限数量=4", + "checkedAt": "2026-07-30T16:08:25.2369386+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:自助保护所需权限已初始化;权限数量=4", + "checkedAt": "2026-07-30T16:08:25.2399441+08:00" + } + ], + "fatalMessage": "验收失败:自助保护所需权限已初始化;权限数量=4", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730161327226.json b/reports/phase1-business-maturity-regression-20260730161327226.json new file mode 100644 index 000000000..ecae4e87a --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730161327226.json @@ -0,0 +1,182 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:13:27.2246883+08:00", + "finishedAt": "2026-07-30T16:13:33.7180384+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 23, + "passed": 21, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730161327226", + "RoleId": 2082741370379780098 + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:28.6663445+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:28.7327958+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:13:28.7748720+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:13:28.7951796+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:29.5653317+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:29.7026259+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:13:29.7046272+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:29.7609794+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:13:30.0535752+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:30.8840127+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:13:30.8940871+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:13:30.9186296+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:31.2595575+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T16:13:32.0840366+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:32.4218430+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:32.7772327+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:13:32.7869939+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:32.8643575+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:32.9383893+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": true, + "evidence": "权限数量=2", + "checkedAt": "2026-07-30T16:13:32.9549139+08:00" + }, + { + "name": "授予验收角色最小权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:13:33.0227792+08:00" + }, + { + "name": "新增企业成员及登录账号", + "category": "成员", + "passed": false, + "evidence": "HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:13:33.7114212+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:新增企业成员及登录账号;HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:13:33.7160237+08:00" + } + ], + "fatalMessage": "验收失败:新增企业成员及登录账号;HTTP=500, code=S0500", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730161839242.json b/reports/phase1-business-maturity-regression-20260730161839242.json new file mode 100644 index 000000000..c7d9aa24a --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730161839242.json @@ -0,0 +1,289 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:18:39.2416037+08:00", + "finishedAt": "2026-07-30T16:18:45.9972190+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 38, + "passed": 36, + "failed": 2 + }, + "testData": { + "Prefix": "p1m_20260730161839242", + "RoleId": 2082742676951584769, + "MemberUsername": "p1m_20260730161839242_member", + "MembershipId": 2082742677664616452 + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:41.2123221+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:41.2783995+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:41.3148819+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:41.3243941+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:41.7787197+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:41.8823546+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:18:41.8843557+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:41.9219269+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:18:42.1719581+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:42.8306091+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:42.8381526+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:42.8516674+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:43.1108893+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T16:18:43.7828478+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.0342685+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.3009158+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:18:44.3074229+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.3576837+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.4112071+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": true, + "evidence": "权限数量=2", + "checkedAt": "2026-07-30T16:18:44.4207252+08:00" + }, + { + "name": "授予验收角色最小权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.4704710+08:00" + }, + { + "name": "新增企业成员及登录账号", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.7800455+08:00" + }, + { + "name": "新增响应包含当前角色", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T16:18:44.7840497+08:00" + }, + { + "name": "查询企业成员列表", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.8145603+08:00" + }, + { + "name": "列表返回姓名联系方式及角色", + "category": "成员", + "passed": true, + "evidence": "成员列表字段完整", + "checkedAt": "2026-07-30T16:18:44.8339401+08:00" + }, + { + "name": "查询成员详情", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.8627215+08:00" + }, + { + "name": "成员详情角色正确回显", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T16:18:44.8643183+08:00" + }, + { + "name": "编辑企业成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:44.9159647+08:00" + }, + { + "name": "成员编辑结果正确", + "category": "成员", + "passed": true, + "evidence": "姓名和手机已更新", + "checkedAt": "2026-07-30T16:18:44.9164688+08:00" + }, + { + "name": "新增成员可登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:45.1540966+08:00" + }, + { + "name": "管理员重置成员密码", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:45.4187994+08:00" + }, + { + "name": "重置密码后成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:45.4263084+08:00" + }, + { + "name": "成员原密码登录失败", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:18:45.6435442+08:00" + }, + { + "name": "成员使用重置密码登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:45.8898627+08:00" + }, + { + "name": "普通成员不能停用本人", + "category": "权限", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:18:45.9215561+08:00" + }, + { + "name": "重新读取管理员个人资料", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:18:45.9640966+08:00" + }, + { + "name": "企业所有者不可被停用", + "category": "权限", + "passed": false, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:18:45.9920719+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:企业所有者不可被停用;HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:18:45.9960730+08:00" + } + ], + "fatalMessage": "验收失败:企业所有者不可被停用;HTTP=400, code=A0400", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730162800192.json b/reports/phase1-business-maturity-regression-20260730162800192.json new file mode 100644 index 000000000..ab1d1cdd2 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730162800192.json @@ -0,0 +1,451 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:28:00.1907439+08:00", + "finishedAt": "2026-07-30T16:28:07.1832736+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 59, + "passed": 53, + "failed": 6 + }, + "testData": { + "Prefix": "p1m_20260730162800192", + "RoleId": 2082745023782154242, + "MemberUsername": "p1m_20260730162800192_member", + "MembershipId": 2082745024528740356, + "CrossTenantUsername": "p1m_20260730162800192_tenant", + "CrossTenantId": 2082745032623747073 + }, + "residualData": { + "retainedTestMemberIds": [ + 2082745024528740356, + 2082742677664616452 + ], + "undeletedRoleIds": [ + 2082742676951584769, + 2082745023782154242 + ], + "crossTenantIds": [ + 2082745032623747073 + ] + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:00.6778720+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:00.7451420+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:00.7830173+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:00.7940553+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:01.0956500+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:01.2423627+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T16:28:01.2453622+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:01.2937134+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:28:01.5767542+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:02.3569992+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:02.3714125+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:02.3834473+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:02.6339219+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T16:28:03.3096270+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:03.5671817+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:03.8320450+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:28:03.8402630+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:03.8900448+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:03.9480115+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": true, + "evidence": "权限数量=2", + "checkedAt": "2026-07-30T16:28:03.9796438+08:00" + }, + { + "name": "授予验收角色最小权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.0215641+08:00" + }, + { + "name": "新增企业成员及登录账号", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.3122125+08:00" + }, + { + "name": "新增响应包含当前角色", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T16:28:04.3166818+08:00" + }, + { + "name": "查询企业成员列表", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.3408697+08:00" + }, + { + "name": "列表返回姓名联系方式及角色", + "category": "成员", + "passed": true, + "evidence": "成员列表字段完整", + "checkedAt": "2026-07-30T16:28:04.3635151+08:00" + }, + { + "name": "查询成员详情", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.3956446+08:00" + }, + { + "name": "成员详情角色正确回显", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T16:28:04.3956446+08:00" + }, + { + "name": "编辑企业成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.4481374+08:00" + }, + { + "name": "成员编辑结果正确", + "category": "成员", + "passed": true, + "evidence": "姓名和手机已更新", + "checkedAt": "2026-07-30T16:28:04.4491422+08:00" + }, + { + "name": "新增成员可登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.7042514+08:00" + }, + { + "name": "管理员重置成员密码", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:04.9553672+08:00" + }, + { + "name": "重置密码后成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:04.9598784+08:00" + }, + { + "name": "成员原密码登录失败", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:05.1760932+08:00" + }, + { + "name": "成员使用重置密码登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:05.4470354+08:00" + }, + { + "name": "普通成员不能停用本人", + "category": "权限", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T16:28:05.4767378+08:00" + }, + { + "name": "重新读取管理员个人资料", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:05.5051743+08:00" + }, + { + "name": "企业所有者不可被停用", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:28:05.5317553+08:00" + }, + { + "name": "停用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:05.6072929+08:00" + }, + { + "name": "停用成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:05.6132431+08:00" + }, + { + "name": "停用成员禁止登录", + "category": "成员", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:28:05.6277574+08:00" + }, + { + "name": "重新启用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:05.6870155+08:00" + }, + { + "name": "启用后成员恢复登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:05.9290217+08:00" + }, + { + "name": "注册跨租户验收企业", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:06.2134782+08:00" + }, + { + "name": "跨租户账号登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:06.4773391+08:00" + }, + { + "name": "跨租户读取成员被拒绝", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=404, code=A0404", + "checkedAt": "2026-07-30T16:28:06.4898976+08:00" + }, + { + "name": "成员退出登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:06.5163223+08:00" + }, + { + "name": "退出后访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:06.5213204+08:00" + }, + { + "name": "退出后刷新令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:28:06.5352294+08:00" + }, + { + "name": "清理时停用验收成员", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:28:06.5933124+08:00" + }, + { + "name": "清理成员角色关系", + "category": "清理", + "passed": false, + "evidence": "HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:28:06.6084108+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:清理成员角色关系;HTTP=500, code=S0500", + "checkedAt": "2026-07-30T16:28:06.6104082+08:00" + }, + { + "name": "清理阶段登录商户管理员", + "category": "清理", + "passed": true, + "evidence": "登录成功", + "checkedAt": "2026-07-30T16:28:06.8775105+08:00" + }, + { + "name": "清理历史测试成员角色 2082745024528740356", + "category": "清理", + "passed": false, + "evidence": "HTTP=500", + "checkedAt": "2026-07-30T16:28:06.9334926+08:00" + }, + { + "name": "清理历史测试成员角色 2082742677664616452", + "category": "清理", + "passed": false, + "evidence": "HTTP=500", + "checkedAt": "2026-07-30T16:28:07.0139935+08:00" + }, + { + "name": "删除历史测试角色 2082740079662407681", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T16:28:07.0949450+08:00" + }, + { + "name": "删除历史测试角色 2082741370379780098", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T16:28:07.1217746+08:00" + }, + { + "name": "删除历史测试角色 2082742676951584769", + "category": "清理", + "passed": false, + "evidence": "HTTP=409", + "checkedAt": "2026-07-30T16:28:07.1391888+08:00" + }, + { + "name": "删除历史测试角色 2082745023782154242", + "category": "清理", + "passed": false, + "evidence": "HTTP=409", + "checkedAt": "2026-07-30T16:28:07.1572339+08:00" + }, + { + "name": "退出清理会话", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T16:28:07.1822738+08:00" + } + ], + "fatalMessage": "验收失败:清理成员角色关系;HTTP=500, code=S0500", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730163944806.json b/reports/phase1-business-maturity-regression-20260730163944806.json new file mode 100644 index 000000000..705b232d0 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730163944806.json @@ -0,0 +1,52 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:39:44.8040766+08:00", + "finishedAt": "2026-07-30T16:39:45.7530518+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 3, + "passed": 0, + "failed": 3 + }, + "testData": { + "Prefix": "p1m_20260730163944806" + }, + "residualData": { + "retainedTestMemberIds": [ + + ], + "undeletedRoleIds": [ + + ], + "crossTenantIds": [ + + ] + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": false, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:39:45.2775730+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:商户管理员登录;HTTP=403, code=A0403", + "checkedAt": "2026-07-30T16:39:45.2850820+08:00" + }, + { + "name": "清理阶段登录商户管理员", + "category": "清理", + "passed": false, + "evidence": "原密码和临时密码均无法登录", + "checkedAt": "2026-07-30T16:39:45.7520528+08:00" + } + ], + "fatalMessage": "验收失败:商户管理员登录;HTTP=403, code=A0403", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730165247153.json b/reports/phase1-business-maturity-regression-20260730165247153.json new file mode 100644 index 000000000..b13a913fa --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730165247153.json @@ -0,0 +1,87 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T16:52:47.1519490+08:00", + "finishedAt": "2026-07-30T16:52:48.0021266+08:00", + "gatewayBaseUrl": "http://127.0.0.1:19002", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": false, + "summary": { + "total": 8, + "passed": 5, + "failed": 3 + }, + "testData": { + "Prefix": "p1m_20260730165247153" + }, + "residualData": { + "retainedTestMemberIds": [ + + ], + "undeletedRoleIds": [ + + ], + "crossTenantIds": [ + + ] + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:52:47.5903256+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T16:52:47.6551606+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:52:47.6933188+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:52:47.7105404+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": false, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:52:47.7135359+08:00" + }, + { + "name": "脚本主流程", + "category": "执行", + "passed": false, + "evidence": "验收失败:查询本人个人资料;HTTP=401, code=A0401", + "checkedAt": "2026-07-30T16:52:47.7178172+08:00" + }, + { + "name": "清理阶段登录商户管理员", + "category": "清理", + "passed": true, + "evidence": "登录成功", + "checkedAt": "2026-07-30T16:52:47.9971300+08:00" + }, + { + "name": "扫描历史一期测试成员", + "category": "清理", + "passed": false, + "evidence": "HTTP=401", + "checkedAt": "2026-07-30T16:52:48.0021266+08:00" + } + ], + "fatalMessage": "验收失败:查询本人个人资料;HTTP=401, code=A0401", + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730171035324.json b/reports/phase1-business-maturity-regression-20260730171035324.json new file mode 100644 index 000000000..0d94d5142 --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730171035324.json @@ -0,0 +1,458 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T17:10:35.3216252+08:00", + "finishedAt": "2026-07-30T17:10:46.2755700+08:00", + "gatewayBaseUrl": "http://127.0.0.1:9000", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": true, + "summary": { + "total": 60, + "passed": 60, + "failed": 0 + }, + "testData": { + "Prefix": "p1m_20260730171035324", + "RoleId": 2082755748344864769, + "MemberUsername": "p1m_20260730171035324_member", + "MembershipId": 2082755749875785732, + "CrossTenantUsername": "p1m_20260730171035324_tenant", + "CrossTenantId": 2082755762911682561 + }, + "residualData": { + "retainedTestMemberIds": [ + 2082755749875785732, + 2082745024528740356, + 2082742677664616452 + ], + "undeletedRoleIds": [ + + ], + "crossTenantIds": [ + 2082755762911682561 + ] + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:36.6977453+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:36.8228508+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:36.8902067+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:37.0433659+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:37.3544924+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:37.5014320+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T17:10:37.5039680+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:37.5697537+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T17:10:37.9459032+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:38.8279656+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:38.8415327+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:38.8615902+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:39.2077686+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T17:10:40.0469765+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:40.3828138+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:40.6985882+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:10:40.7247842+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:40.8267721+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:40.9435927+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": true, + "evidence": "权限数量=2", + "checkedAt": "2026-07-30T17:10:40.9606519+08:00" + }, + { + "name": "授予验收角色最小权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:41.0794905+08:00" + }, + { + "name": "新增企业成员及登录账号", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:41.6857023+08:00" + }, + { + "name": "新增响应包含当前角色", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T17:10:41.6982497+08:00" + }, + { + "name": "查询企业成员列表", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:41.7758382+08:00" + }, + { + "name": "列表返回姓名联系方式及角色", + "category": "成员", + "passed": true, + "evidence": "成员列表字段完整", + "checkedAt": "2026-07-30T17:10:41.7885952+08:00" + }, + { + "name": "查询成员详情", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:41.8897005+08:00" + }, + { + "name": "成员详情角色正确回显", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T17:10:41.8916975+08:00" + }, + { + "name": "编辑企业成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:41.9850689+08:00" + }, + { + "name": "成员编辑结果正确", + "category": "成员", + "passed": true, + "evidence": "姓名和手机已更新", + "checkedAt": "2026-07-30T17:10:41.9870686+08:00" + }, + { + "name": "新增成员可登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:42.3090898+08:00" + }, + { + "name": "管理员重置成员密码", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:42.7256706+08:00" + }, + { + "name": "重置密码后成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:42.7371905+08:00" + }, + { + "name": "成员原密码登录失败", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:43.0180002+08:00" + }, + { + "name": "成员使用重置密码登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:43.3565456+08:00" + }, + { + "name": "普通成员不能停用本人", + "category": "权限", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T17:10:43.4425236+08:00" + }, + { + "name": "重新读取管理员个人资料", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:43.4861471+08:00" + }, + { + "name": "企业所有者不可被停用", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:10:43.5651662+08:00" + }, + { + "name": "停用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:43.6748364+08:00" + }, + { + "name": "停用成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:43.6999250+08:00" + }, + { + "name": "停用成员禁止登录", + "category": "成员", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:10:43.7225294+08:00" + }, + { + "name": "重新启用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:43.8187234+08:00" + }, + { + "name": "启用后成员恢复登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:44.1595061+08:00" + }, + { + "name": "注册跨租户验收企业", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:44.5936840+08:00" + }, + { + "name": "跨租户账号登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:44.9277517+08:00" + }, + { + "name": "跨租户读取成员被拒绝", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=404, code=A0404", + "checkedAt": "2026-07-30T17:10:44.9516553+08:00" + }, + { + "name": "成员退出登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.0330582+08:00" + }, + { + "name": "退出后访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:45.0415765+08:00" + }, + { + "name": "退出后刷新令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:10:45.0606366+08:00" + }, + { + "name": "清理时停用验收成员", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.1567115+08:00" + }, + { + "name": "清理成员角色关系", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.2486306+08:00" + }, + { + "name": "删除临时验收角色", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.3500678+08:00" + }, + { + "name": "退出跨租户验收账号", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.3907762+08:00" + }, + { + "name": "退出商户管理员", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:10:45.4346665+08:00" + }, + { + "name": "清理阶段登录商户管理员", + "category": "清理", + "passed": true, + "evidence": "登录成功", + "checkedAt": "2026-07-30T17:10:45.7904583+08:00" + }, + { + "name": "清理历史测试成员角色 2082755749875785732", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:45.9016226+08:00" + }, + { + "name": "清理历史测试成员角色 2082745024528740356", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:45.9527838+08:00" + }, + { + "name": "清理历史测试成员角色 2082742677664616452", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:46.0014212+08:00" + }, + { + "name": "删除历史测试角色 2082742676951584769", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:46.1420020+08:00" + }, + { + "name": "删除历史测试角色 2082745023782154242", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:46.2323550+08:00" + }, + { + "name": "退出清理会话", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:10:46.2745708+08:00" + } + ], + "fatalMessage": null, + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/phase1-business-maturity-regression-20260730171959885.json b/reports/phase1-business-maturity-regression-20260730171959885.json new file mode 100644 index 000000000..95435284d --- /dev/null +++ b/reports/phase1-business-maturity-regression-20260730171959885.json @@ -0,0 +1,515 @@ +{ + "reportName": "一期业务成熟度真实 API 回归报告", + "startedAt": "2026-07-30T17:19:59.8824488+08:00", + "finishedAt": "2026-07-30T17:20:06.0407829+08:00", + "gatewayBaseUrl": "http://127.0.0.1:9000", + "repositoryRoot": "C:\\Users\\25137\\Desktop\\工作\\baiyue-phase1-regression", + "success": true, + "summary": { + "total": 68, + "passed": 68, + "failed": 0 + }, + "testData": { + "Prefix": "p1m_20260730171959885", + "RoleId": 2082758105191391234, + "MemberUsername": "p1m_20260730171959885_member", + "MembershipId": 2082758106495819780, + "CrossTenantUsername": "p1m_20260730171959885_tenant", + "CrossTenantId": 2082758114301419521 + }, + "residualData": { + "retainedTestMemberIds": [ + 2082758106495819780, + 2082755749875785732, + 2082745024528740356, + 2082742677664616452 + ], + "undeletedRoleIds": [ + + ], + "crossTenantIds": [ + 2082758114301419521 + ] + }, + "results": [ + { + "name": "商户管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:00.2809923+08:00" + }, + { + "name": "刷新令牌并轮换会话", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:00.3263843+08:00" + }, + { + "name": "旧刷新令牌不可重复使用", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:00.3579897+08:00" + }, + { + "name": "刷新后旧访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:00.3665034+08:00" + }, + { + "name": "查询本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:00.3920646+08:00" + }, + { + "name": "编辑本人个人资料", + "category": "个人资料", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:00.4217833+08:00" + }, + { + "name": "个人资料修改后正确回显", + "category": "个人资料", + "passed": true, + "evidence": "displayName 已更新", + "checkedAt": "2026-07-30T17:20:00.4237809+08:00" + }, + { + "name": "恢复本人个人资料", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:00.4588082+08:00" + }, + { + "name": "错误旧密码禁止修改密码", + "category": "密码", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T17:20:00.6717172+08:00" + }, + { + "name": "正确旧密码修改成功", + "category": "密码", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:01.3106777+08:00" + }, + { + "name": "修改密码后旧访问令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:01.3167932+08:00" + }, + { + "name": "修改密码后旧刷新令牌失效", + "category": "密码", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:01.3286425+08:00" + }, + { + "name": "恢复前重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:01.5757016+08:00" + }, + { + "name": "恢复管理员原密码", + "category": "清理", + "passed": true, + "evidence": "恢复成功", + "checkedAt": "2026-07-30T17:20:02.2101666+08:00" + }, + { + "name": "恢复密码后重新登录管理员", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.4569931+08:00" + }, + { + "name": "平台管理员登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.7021016+08:00" + }, + { + "name": "平台令牌访问商户接口返回403", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:20:02.7081016+08:00" + }, + { + "name": "创建验收角色", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.7351668+08:00" + }, + { + "name": "编辑验收角色", + "category": "角色", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.7961288+08:00" + }, + { + "name": "角色编辑结果正确回显", + "category": "角色", + "passed": true, + "evidence": "roleName 已更新", + "checkedAt": "2026-07-30T17:20:02.7981339+08:00" + }, + { + "name": "停用自定义角色", + "category": "角色", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.8568977+08:00" + }, + { + "name": "角色停用状态正确回显", + "category": "角色", + "passed": true, + "evidence": "status=DISABLED", + "checkedAt": "2026-07-30T17:20:02.8589004+08:00" + }, + { + "name": "重新启用自定义角色", + "category": "角色", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.8875952+08:00" + }, + { + "name": "角色启用状态正确回显", + "category": "角色", + "passed": true, + "evidence": "status=ACTIVE", + "checkedAt": "2026-07-30T17:20:02.8885985+08:00" + }, + { + "name": "查询成员按钮权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.9152016+08:00" + }, + { + "name": "自助保护所需权限已初始化", + "category": "权限", + "passed": true, + "evidence": "权限数量=2", + "checkedAt": "2026-07-30T17:20:02.9442060+08:00" + }, + { + "name": "授予验收角色最小权限", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.9748641+08:00" + }, + { + "name": "查询角色当前权限回显", + "category": "权限", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:02.9978979+08:00" + }, + { + "name": "角色 permissionIds 完整回显", + "category": "权限", + "passed": true, + "evidence": "permissionIds 数量=2", + "checkedAt": "2026-07-30T17:20:03.0154655+08:00" + }, + { + "name": "新增企业成员及登录账号", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.2634238+08:00" + }, + { + "name": "新增响应包含当前角色", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T17:20:03.2674261+08:00" + }, + { + "name": "已分配成员的角色禁止删除", + "category": "角色", + "passed": true, + "evidence": "HTTP=409, code=A0409", + "checkedAt": "2026-07-30T17:20:03.2866510+08:00" + }, + { + "name": "查询企业成员列表", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.3247039+08:00" + }, + { + "name": "列表返回姓名联系方式及角色", + "category": "成员", + "passed": true, + "evidence": "成员列表字段完整", + "checkedAt": "2026-07-30T17:20:03.3322120+08:00" + }, + { + "name": "查询成员详情", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.3896928+08:00" + }, + { + "name": "成员详情角色正确回显", + "category": "成员", + "passed": true, + "evidence": "roleIds 已回显", + "checkedAt": "2026-07-30T17:20:03.3906926+08:00" + }, + { + "name": "编辑企业成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.4257961+08:00" + }, + { + "name": "成员编辑结果正确", + "category": "成员", + "passed": true, + "evidence": "姓名和手机已更新", + "checkedAt": "2026-07-30T17:20:03.4267976+08:00" + }, + { + "name": "新增成员可登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.6655616+08:00" + }, + { + "name": "管理员重置成员密码", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:03.9311372+08:00" + }, + { + "name": "重置密码后成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:03.9355784+08:00" + }, + { + "name": "成员原密码登录失败", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:04.1507726+08:00" + }, + { + "name": "成员使用重置密码登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:04.3869894+08:00" + }, + { + "name": "普通成员不能停用本人", + "category": "权限", + "passed": true, + "evidence": "HTTP=400, code=A0400", + "checkedAt": "2026-07-30T17:20:04.4252694+08:00" + }, + { + "name": "重新读取管理员个人资料", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:04.4495822+08:00" + }, + { + "name": "企业所有者不可被停用", + "category": "权限", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:20:04.4727393+08:00" + }, + { + "name": "停用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:04.5295337+08:00" + }, + { + "name": "停用成员旧访问令牌失效", + "category": "成员", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:04.5350523+08:00" + }, + { + "name": "停用成员禁止登录", + "category": "成员", + "passed": true, + "evidence": "HTTP=403, code=A0403", + "checkedAt": "2026-07-30T17:20:04.5482975+08:00" + }, + { + "name": "重新启用普通成员", + "category": "成员", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:04.6052832+08:00" + }, + { + "name": "启用后成员恢复登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:04.8673956+08:00" + }, + { + "name": "注册跨租户验收企业", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.1045815+08:00" + }, + { + "name": "跨租户账号登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.3648953+08:00" + }, + { + "name": "跨租户读取成员被拒绝", + "category": "租户隔离", + "passed": true, + "evidence": "HTTP=404, code=A0404", + "checkedAt": "2026-07-30T17:20:05.3780033+08:00" + }, + { + "name": "成员退出登录", + "category": "认证", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.4026195+08:00" + }, + { + "name": "退出后访问令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:05.4076538+08:00" + }, + { + "name": "退出后刷新令牌失效", + "category": "认证", + "passed": true, + "evidence": "HTTP=401, code=A0401", + "checkedAt": "2026-07-30T17:20:05.4181443+08:00" + }, + { + "name": "清理时停用验收成员", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.4716936+08:00" + }, + { + "name": "清理成员角色关系", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.4980420+08:00" + }, + { + "name": "删除临时验收角色", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.5287125+08:00" + }, + { + "name": "退出跨租户验收账号", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.5511117+08:00" + }, + { + "name": "退出商户管理员", + "category": "清理", + "passed": true, + "evidence": "HTTP=200, code=0", + "checkedAt": "2026-07-30T17:20:05.5795606+08:00" + }, + { + "name": "清理阶段登录商户管理员", + "category": "清理", + "passed": true, + "evidence": "登录成功", + "checkedAt": "2026-07-30T17:20:05.8306799+08:00" + }, + { + "name": "清理历史测试成员角色 2082758106495819780", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:20:05.8820172+08:00" + }, + { + "name": "清理历史测试成员角色 2082755749875785732", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:20:05.9261460+08:00" + }, + { + "name": "清理历史测试成员角色 2082745024528740356", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:20:05.9567577+08:00" + }, + { + "name": "清理历史测试成员角色 2082742677664616452", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:20:05.9819794+08:00" + }, + { + "name": "退出清理会话", + "category": "清理", + "passed": true, + "evidence": "HTTP=200", + "checkedAt": "2026-07-30T17:20:06.0392780+08:00" + } + ], + "fatalMessage": null, + "securityNote": "报告不包含密码、访问令牌或刷新令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171119.json b/reports/订单仓库往来单位真实API回归-20260730-171119.json new file mode 100644 index 000000000..92667b65f --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171119.json @@ -0,0 +1,43 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:11:19.8446019+08:00", + "finishedAt": "2026-07-30T17:11:20.1106205+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 4, + "passed": 3, + "failed": 1 + }, + "testData": { + + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "mysql: [Warning] Using a password on the command line interface can be insecure." + } + ], + "fatalMessage": "mysql: [Warning] Using a password on the command line interface can be insecure.", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171226.json b/reports/订单仓库往来单位真实API回归-20260730-171226.json new file mode 100644 index 000000000..6e3a879bd --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171226.json @@ -0,0 +1,43 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:12:26.7777162+08:00", + "finishedAt": "2026-07-30T17:12:27.0256327+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 4, + "passed": 3, + "failed": 1 + }, + "testData": { + + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "mysql: [Warning] Using a password on the command line interface can be insecure." + } + ], + "fatalMessage": "mysql: [Warning] Using a password on the command line interface can be insecure.", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171336.json b/reports/订单仓库往来单位真实API回归-20260730-171336.json new file mode 100644 index 000000000..68c1e1e1a --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171336.json @@ -0,0 +1,43 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:13:36.0554509+08:00", + "finishedAt": "2026-07-30T17:13:36.3216130+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 4, + "passed": 3, + "failed": 1 + }, + "testData": { + + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "SQL标量查询失败" + } + ], + "fatalMessage": "SQL标量查询失败", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171426.json b/reports/订单仓库往来单位真实API回归-20260730-171426.json new file mode 100644 index 000000000..048635d39 --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171426.json @@ -0,0 +1,43 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:14:26.3220006+08:00", + "finishedAt": "2026-07-30T17:14:26.6219316+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 4, + "passed": 3, + "failed": 1 + }, + "testData": { + + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "SQL标量查询失败" + } + ], + "fatalMessage": "SQL标量查询失败", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171615.json b/reports/订单仓库往来单位真实API回归-20260730-171615.json new file mode 100644 index 000000000..7a252ebad --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171615.json @@ -0,0 +1,83 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:16:15.5185825+08:00", + "finishedAt": "2026-07-30T17:16:18.5881919+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 12, + "passed": 10, + "failed": 2 + }, + "testData": { + + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "merchant_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "platform_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "商户查询启用仓库", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库列表强制启用过滤", + "passed": true, + "evidence": "返回ID=190000000000000001" + }, + { + "name": "仓库详情回显地址和收货能力", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库地址完整", + "passed": true, + "evidence": "地址=航城街道示例物流园1号仓" + }, + { + "name": "收货截单时间回显", + "passed": true, + "evidence": "截单时间=16:00:00" + }, + { + "name": "停用仓库详情不可见", + "passed": false, + "evidence": "HTTP=404,code=" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "停用仓库详情不可见:HTTP=404,code=" + } + ], + "fatalMessage": "停用仓库详情不可见:HTTP=404,code=", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171707.json b/reports/订单仓库往来单位真实API回归-20260730-171707.json new file mode 100644 index 000000000..9a2765508 --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171707.json @@ -0,0 +1,143 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:17:07.2931401+08:00", + "finishedAt": "2026-07-30T17:17:11.1331363+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": false, + "summary": { + "total": 24, + "passed": 22, + "failed": 2 + }, + "testData": { + "orderId": 2082757383309684738 + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "merchant_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "platform_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "商户查询启用仓库", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库列表强制启用过滤", + "passed": true, + "evidence": "返回ID=190000000000000001" + }, + { + "name": "仓库详情回显地址和收货能力", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库地址完整", + "passed": true, + "evidence": "地址=航城街道示例物流园1号仓" + }, + { + "name": "收货截单时间回显", + "passed": true, + "evidence": "截单时间=16:00:00" + }, + { + "name": "停用仓库详情不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "跨租户仓库详情不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "商户查询启用往来单位", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "往来单位强制启用过滤", + "passed": true, + "evidence": "返回ID=191000000000000001" + }, + { + "name": "停用往来单位不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "跨租户往来单位不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "创建订单草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "查询新建草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "订单使用服务端可信名称快照", + "passed": true, + "evidence": "客户=深圳示例贸易有限公司,仓库=深圳头程收货仓" + }, + { + "name": "订单保存仓库地址和收货时间快照", + "passed": true, + "evidence": "地址=CN 广东省 深圳市 宝安区 航城街道示例物流园1号仓 518000,收货=每日 16:00 前收货" + }, + { + "name": "编辑并重新保存草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "读取编辑后的草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "订单参与方地址保存为历史快照", + "passed": false, + "evidence": "地址=???????2?" + }, + { + "name": "脚本主流程", + "passed": false, + "evidence": "订单参与方地址保存为历史快照:地址=???????2?" + } + ], + "fatalMessage": "订单参与方地址保存为历史快照:地址=???????2?", + "securityNote": "报告不保存密码或令牌" +} diff --git a/reports/订单仓库往来单位真实API回归-20260730-171805.json b/reports/订单仓库往来单位真实API回归-20260730-171805.json new file mode 100644 index 000000000..8c9747c76 --- /dev/null +++ b/reports/订单仓库往来单位真实API回归-20260730-171805.json @@ -0,0 +1,208 @@ +{ + "reportName": "订单仓库往来单位真实API回归报告", + "startedAt": "2026-07-30T17:18:05.0746571+08:00", + "finishedAt": "2026-07-30T17:18:08.4647309+08:00", + "stack": { + "gateway": "http://127.0.0.1:9000", + "order": "http://127.0.0.1:9204", + "warehouse": "http://127.0.0.1:9205" + }, + "success": true, + "summary": { + "total": 37, + "passed": 37, + "failed": 0 + }, + "testData": { + "orderId": 2082757616051613698 + }, + "results": [ + { + "name": "服务健康:http://127.0.0.1:9000/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9204/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "服务健康:http://127.0.0.1:9205/actuator/health", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "merchant_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "platform_admin 登录", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "商户查询启用仓库", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库列表强制启用过滤", + "passed": true, + "evidence": "返回ID=190000000000000001" + }, + { + "name": "仓库详情回显地址和收货能力", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "仓库地址完整", + "passed": true, + "evidence": "地址=航城街道示例物流园1号仓" + }, + { + "name": "收货截单时间回显", + "passed": true, + "evidence": "截单时间=16:00:00" + }, + { + "name": "停用仓库详情不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "跨租户仓库详情不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "商户查询启用往来单位", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "往来单位强制启用过滤", + "passed": true, + "evidence": "返回ID=191000000000000001" + }, + { + "name": "停用往来单位不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "跨租户往来单位不可见", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "创建订单草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "查询新建草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "订单使用服务端可信名称快照", + "passed": true, + "evidence": "客户=深圳示例贸易有限公司,仓库=深圳头程收货仓" + }, + { + "name": "订单保存仓库地址和收货时间快照", + "passed": true, + "evidence": "地址=CN 广东省 深圳市 宝安区 航城街道示例物流园1号仓 518000,收货=每日 16:00 前收货" + }, + { + "name": "编辑并重新保存草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "读取编辑后的草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "订单参与方地址保存为历史快照", + "passed": true, + "evidence": "地址=订单编辑后地址2号" + }, + { + "name": "编辑仍不信任客户端主数据名称", + "passed": true, + "evidence": "可信名称未被伪造值覆盖" + }, + { + "name": "仓库停用后禁止保存草稿", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "往来单位停用后禁止保存草稿", + "passed": true, + "evidence": "HTTP=404,code=A0404" + }, + { + "name": "企业审核未通过禁止创建订单", + "passed": true, + "evidence": "HTTP=403,code=A0403" + }, + { + "name": "提交草稿", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "重复提交返回409", + "passed": true, + "evidence": "HTTP=409,code=A0409" + }, + { + "name": "平台履约写入快递信息", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "商户身份不能写平台快递接口", + "passed": true, + "evidence": "HTTP=403,code=A0403" + }, + { + "name": "商户订单详情只读展示快递信息", + "passed": true, + "evidence": "HTTP=200,code=0" + }, + { + "name": "快递公司和单号真实回显", + "passed": true, + "evidence": "快递=顺丰速运,单号=SF0730171806" + }, + { + "name": "Order merchant api-docs", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "Warehouse merchant api-docs", + "passed": true, + "evidence": "HTTP=200" + }, + { + "name": "Gateway Order api-docs 路由源配置", + "passed": true, + "evidence": "已静态审查源配置" + }, + { + "name": "Gateway Warehouse api-docs 路由源配置", + "passed": true, + "evidence": "已静态审查源配置" + } + ], + "fatalMessage": null, + "securityNote": "报告不保存密码或令牌" +} diff --git a/scripts/local-env.ps1 b/scripts/local-env.ps1 new file mode 100644 index 000000000..d14fb33f5 --- /dev/null +++ b/scripts/local-env.ps1 @@ -0,0 +1,21 @@ +$env:NACOS_SERVER_ADDR = '127.0.0.1:8848' +$env:NACOS_NAMESPACE = 'public' +$env:NACOS_GROUP = 'BAIYUE' +$env:REDIS_HOST = '127.0.0.1' +$env:REDIS_PORT = '6379' +$env:SPRING_DATA_REDIS_HOST = '127.0.0.1' +$env:SPRING_DATA_REDIS_PORT = '6379' +$env:AUTH_DB_PASSWORD = '123456' +$env:IAM_DB_PASSWORD = '123456' +$env:ORDER_DB_PASSWORD = '123456' +$env:FIRSTLEG_DB_PASSWORD = '123456' +$env:WAREHOUSE_DB_PASSWORD = '123456' +$env:FILE_DB_PASSWORD = '123456' +$env:PRINT_DB_PASSWORD = '123456' +$env:IAM_SERVICE_URL = 'http://127.0.0.1:9201' +$env:JWT_SECRET = 'phase1-local-test-jwt-secret-at-least-32-bytes' +$env:INTERNAL_SERVICE_TOKEN = 'phase1-local-internal-token-at-least-32-bytes' +$env:SWAGGER_ENABLED = 'true' +$env:CORS_ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174,http://192.168.110.149:5173,http://192.168.110.149:5174' +$env:FILE_STORAGE_ROOT = 'D:/baiyue-firstleg-runtime/uploads' +Write-Host '头程本地环境已加载:Nacos 8848、Redis 6379、MySQL 密码、JWT、Swagger 和本地跨域白名单' diff --git a/scripts/order-warehouse-real-api-regression.ps1 b/scripts/order-warehouse-real-api-regression.ps1 new file mode 100644 index 000000000..919d52bfa --- /dev/null +++ b/scripts/order-warehouse-real-api-regression.ps1 @@ -0,0 +1,230 @@ +param( + [string]$GatewayBaseUrl = 'http://127.0.0.1:19002', + [string]$OrderBaseUrl = 'http://127.0.0.1:19204', + [string]$WarehouseBaseUrl = 'http://127.0.0.1:19205', + [string]$MerchantUsername = 'merchant_admin', + [string]$PlatformUsername = 'platform_admin', + [string]$AdminPassword = 'ChangeMe123!', + [string]$MysqlPassword = '123456', + [string]$OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $root ("reports\订单仓库往来单位真实API回归-{0}.json" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) +} +$mysql = 'D:\baiyue-runtime\mysql\server\bin\mysql.exe' +$results = [System.Collections.Generic.List[object]]::new() +$startedAt = Get-Date +$testData = [ordered]@{} +$previousMysqlPassword = $env:MYSQL_PWD +$env:MYSQL_PWD = $MysqlPassword + +function Add-Result([string]$Name,[bool]$Passed,[string]$Evidence) { + $results.Add([pscustomobject]@{ name=$Name; passed=$Passed; evidence=$Evidence }) + Write-Host "[$(if($Passed){'通过'}else{'失败'})] $Name - $Evidence" + if (-not $Passed) { throw "$Name:$Evidence" } +} +function Invoke-Api([string]$Method,[string]$Url,[object]$Body=$null,[string]$Token=$null) { + $headers=@{}; if($Token){$headers.Authorization="Bearer $Token"} + $args=@{Method=$Method;Uri=$Url;Headers=$headers;TimeoutSec=15;UseBasicParsing=$true} + if($null -ne $Body){ + $args.ContentType='application/json; charset=utf-8' + $jsonText=$Body|ConvertTo-Json -Depth 15 -Compress + $args.Body=[Text.Encoding]::UTF8.GetBytes($jsonText) + } + try { + $response=Invoke-WebRequest @args + $content=$response.Content + if($null -ne $response.RawContentStream){try{$content=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())}catch{}} + $json=$null; if($content){try{$json=$content|ConvertFrom-Json}catch{}} + return [pscustomobject]@{status=[int]$response.StatusCode;json=$json;body=$content} + } catch { + $status=0; if($_.Exception.Response){$status=[int]$_.Exception.Response.StatusCode} + $content=$_.ErrorDetails.Message; $json=$null; if($content){try{$json=$content|ConvertFrom-Json}catch{}} + if($null -eq $json -and $null -ne $_.Exception.Response){ + try { + $reader=New-Object IO.StreamReader($_.Exception.Response.GetResponseStream(),[Text.Encoding]::UTF8) + $content=$reader.ReadToEnd();$reader.Dispose() + if($content){$json=$content|ConvertFrom-Json} + } catch {$json=$null} + } + return [pscustomobject]@{status=$status;json=$json;body=$content} + } +} +function Require-Success([string]$Name,$Response) { + $ok=$Response.status -eq 200 -and $Response.json.code -eq '0' + Add-Result $Name $ok "HTTP=$($Response.status),code=$($Response.json.code)" + return $Response.json.data +} +function Require-Http([string]$Name,$Response,[int]$Status,[string]$Code='') { + $ok=$Response.status -eq $Status -and ([string]::IsNullOrWhiteSpace($Code) -or $Response.json.code -eq $Code) + Add-Result $Name $ok "HTTP=$($Response.status),code=$($Response.json.code)" +} +function Require-Check([string]$Name,[bool]$Condition,[string]$Evidence) { Add-Result $Name $Condition $Evidence } +function Invoke-Sql([string]$Database,[string]$Sql) { + # MySQL 会在标准错误流输出命令行密码提示,但成功退出码仍为 0;避免将提示误判为 SQL 失败。 + $previousErrorAction=$ErrorActionPreference + $ErrorActionPreference='Continue' + try { + $output=& $mysql '--default-character-set=utf8mb4' '--host=127.0.0.1' '--port=3306' '--user=root' "--database=$Database" "--execute=$Sql" 2>$null + $exitCode=$LASTEXITCODE + } finally { + $ErrorActionPreference=$previousErrorAction + } + if($exitCode -ne 0){throw "SQL执行失败:$output"} +} +function Invoke-SqlScalar([string]$Database,[string]$Sql) { + $previousErrorAction=$ErrorActionPreference + $ErrorActionPreference='Continue' + try { + $output=& $mysql '--batch' '--skip-column-names' '--default-character-set=utf8mb4' '--host=127.0.0.1' '--port=3306' '--user=root' "--database=$Database" "--execute=$Sql" 2>$null + $exitCode=$LASTEXITCODE + } finally { + $ErrorActionPreference=$previousErrorAction + } + if($exitCode -ne 0){throw 'SQL标量查询失败'} + return [string](@($output)[0]) +} +function Login([string]$Username) { + return Require-Success "$Username 登录" (Invoke-Api POST "$GatewayBaseUrl/api/auth/login" @{username=$Username;password=$AdminPassword}) +} + +$activeWarehouse=190000000000000001L +$inactiveWarehouse=190000000000000002L +$crossWarehouse=190000000000000003L +$activePartner=191000000000000001L +$inactivePartner=191000000000000002L +$crossPartner=191000000000000003L +$orderId=$null +$fatal=$null +$permissionPreexisting=$false + +try { + if(-not(Test-Path -LiteralPath $mysql)){throw "找不到 MySQL 客户端:$mysql"} + foreach($url in @("$GatewayBaseUrl/actuator/health","$OrderBaseUrl/actuator/health","$WarehouseBaseUrl/actuator/health")) { + $health=Invoke-Api GET $url + Require-Check "服务健康:$url" ($health.status -eq 200) "HTTP=$($health.status)" + } + + # 回归数据只使用固定测试 ID,重复执行会恢复到启用/停用基线。 + $permissionPreexisting=(Invoke-SqlScalar baiyue_iam "SELECT COUNT(*) FROM iam_permission WHERE id=5102018;") -eq '1' + Invoke-Sql baiyue_iam @" +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,sort_no,status,deleted) +VALUES(5102018,0,'PLATFORM','BUTTON',5102010,'platform:order:fulfillment','登记履约快递',80,'ACTIVE',0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT INTO iam_role_permission(role_id,permission_id,tenant_id) VALUES(4000001,5102018,0) +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); +"@ + Invoke-Sql baiyue_warehouse @" +INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,status,remark) +VALUES +($inactiveWarehouse,10001,'REG-INACTIVE','回归停用仓','DOMESTIC','CN','Asia/Shanghai','INACTIVE','自动化回归'), +($crossWarehouse,10002,'REG-CROSS','其他企业仓库','DOMESTIC','CN','Asia/Shanghai','ACTIVE','自动化回归') +ON DUPLICATE KEY UPDATE status=VALUES(status),warehouse_name=VALUES(warehouse_name); +INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1) +VALUES +(190000000000000012,10001,$inactiveWarehouse,'OPERATING','CN','广东省','深圳市','龙岗区','518000','停用仓测试地址'), +(190000000000000013,10002,$crossWarehouse,'OPERATING','CN','广东省','广州市','白云区','510000','跨租户仓测试地址') +ON DUPLICATE KEY UPDATE address_line1=VALUES(address_line1); +INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capability_name,enabled,cutoff_time,description) +VALUES +(190000000000000022,10001,$inactiveWarehouse,'RECEIVING','头程收货',1,'15:00:00','自动化回归'), +(190000000000000023,10002,$crossWarehouse,'RECEIVING','头程收货',1,'15:30:00','自动化回归') +ON DUPLICATE KEY UPDATE enabled=1,cutoff_time=VALUES(cutoff_time); +INSERT INTO wh_partner(id,tenant_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) +VALUES +($inactivePartner,10001,'REG-INACTIVE','回归停用往来单位','CUSTOMER','停用联系人','13800000011','inactive@example.com','CN','广东省','深圳市','龙岗区','停用往来单位地址','518000','INACTIVE'), +($crossPartner,10002,'REG-CROSS','其他企业往来单位','CUSTOMER','跨租户联系人','13800000012','cross@example.com','CN','广东省','广州市','白云区','跨租户往来单位地址','510000','ACTIVE') +ON DUPLICATE KEY UPDATE status=VALUES(status),partner_name=VALUES(partner_name); +UPDATE wh_warehouse SET status='ACTIVE' WHERE id=$activeWarehouse AND tenant_id=10001; +UPDATE wh_partner SET status='ACTIVE' WHERE id=$activePartner AND tenant_id=10001; +"@ + + $merchant=Login $MerchantUsername; $merchantToken=$merchant.accessToken + $platform=Login $PlatformUsername; $platformToken=$platform.accessToken + + $warehouses=Require-Success '商户查询启用仓库' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/warehouses?page=1&size=50" $null $merchantToken) + $warehouseIds=@($warehouses.records|ForEach-Object{[long]$_.id}) + Require-Check '仓库列表强制启用过滤' ($activeWarehouse -in $warehouseIds -and $inactiveWarehouse -notin $warehouseIds) "返回ID=$($warehouseIds -join ',')" + $warehouseDetail=Require-Success '仓库详情回显地址和收货能力' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/warehouses/$activeWarehouse" $null $merchantToken) + Require-Check '仓库地址完整' (@($warehouseDetail.addresses).Count -gt 0 -and $warehouseDetail.addresses[0].addressLine1) "地址=$($warehouseDetail.addresses[0].addressLine1)" + $receiving=@($warehouseDetail.capabilities|Where-Object{$_.capabilityCode -eq 'RECEIVING' -and $_.enabled})|Select-Object -First 1 + Require-Check '收货截单时间回显' ($null -ne $receiving -and $receiving.cutoffTime) "截单时间=$($receiving.cutoffTime)" + Require-Http '停用仓库详情不可见' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/warehouses/$inactiveWarehouse" $null $merchantToken) 404 'A0404' + Require-Http '跨租户仓库详情不可见' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/warehouses/$crossWarehouse" $null $merchantToken) 404 'A0404' + + $partners=Require-Success '商户查询启用往来单位' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/partners/candidates" $null $merchantToken) + $partnerIds=@($partners|ForEach-Object{[long]$_.id}) + Require-Check '往来单位强制启用过滤' ($activePartner -in $partnerIds -and $inactivePartner -notin $partnerIds) "返回ID=$($partnerIds -join ',')" + Require-Http '停用往来单位不可见' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/partners/$inactivePartner" $null $merchantToken) 404 'A0404' + Require-Http '跨租户往来单位不可见' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/partners/$crossPartner" $null $merchantToken) 404 'A0404' + + $stamp=Get-Date -Format 'MMddHHmmss' + $party=@{companyName='客户端可调整公司';contactName='联系人';phone='13800138000';email='order@example.com';countryCode='CN';province='广东省';city='深圳市';district='南山区';addressLine='订单地址快照1号';postalCode='518000'} + $cargo=@{cargoName='回归货物';packageType='CARTON';packageCount=1;grossWeightKg=10;volumeCbm=1;dangerousGoods=$false} + $payload=@{customerId=$activePartner;customerName='伪造客户名';transportMode='SEA';tradeTerm='FOB';destinationCountryCode='US';destinationWarehouseName='海外目的仓';expectedShipDate=(Get-Date).AddDays(10).ToString('yyyy-MM-dd');customerRemark='真实API回归';sender=$party;receiver=$party;cargoes=@($cargo);pickupMethod='PICKUP';receivingWarehouseId=$activeWarehouse;receivingWarehouseName='伪造收货仓名';channelCode='REG';channelName='回归渠道';taxMode='BUY_DECLARATION';containerMode='LCL';customsInspection=$false;destinationType='OVERSEAS'} + + $created=Require-Success '创建订单草稿' (Invoke-Api POST "$GatewayBaseUrl/api/merchant/v1/orders/first-leg" $payload $merchantToken) + $orderId=[long]$created; $testData.orderId=$orderId + $detail=Require-Success '查询新建草稿' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $null $merchantToken) + Require-Check '订单使用服务端可信名称快照' ($detail.order.customerName -eq '深圳示例贸易有限公司' -and $detail.order.receivingWarehouseName -eq '深圳头程收货仓') "客户=$($detail.order.customerName),仓库=$($detail.order.receivingWarehouseName)" + Require-Check '订单保存仓库地址和收货时间快照' ($detail.order.receivingWarehouseAddress -match '示例物流园' -and $detail.order.receivingTime -match '16:00') "地址=$($detail.order.receivingWarehouseAddress),收货=$($detail.order.receivingTime)" + + $payload.sender.addressLine='订单编辑后地址2号'; $payload.customerName='再次伪造名称'; $payload.receivingWarehouseName='再次伪造仓名' + $null=Require-Success '编辑并重新保存草稿' (Invoke-Api PUT "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $payload $merchantToken) + $detail=Require-Success '读取编辑后的草稿' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $null $merchantToken) + $sender=@($detail.parties|Where-Object{$_.partyType -eq 'SENDER'})|Select-Object -First 1 + Require-Check '订单参与方地址保存为历史快照' ($sender.addressLine -eq '订单编辑后地址2号') "地址=$($sender.addressLine)" + Require-Check '编辑仍不信任客户端主数据名称' ($detail.order.customerName -eq '深圳示例贸易有限公司' -and $detail.order.receivingWarehouseName -eq '深圳头程收货仓') '可信名称未被伪造值覆盖' + + Invoke-Sql baiyue_warehouse "UPDATE wh_warehouse SET status='INACTIVE' WHERE id=$activeWarehouse AND tenant_id=10001;" + Require-Http '仓库停用后禁止保存草稿' (Invoke-Api PUT "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $payload $merchantToken) 404 'A0404' + Invoke-Sql baiyue_warehouse "UPDATE wh_warehouse SET status='ACTIVE' WHERE id=$activeWarehouse AND tenant_id=10001;" + Invoke-Sql baiyue_warehouse "UPDATE wh_partner SET status='INACTIVE' WHERE id=$activePartner AND tenant_id=10001;" + Require-Http '往来单位停用后禁止保存草稿' (Invoke-Api PUT "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $payload $merchantToken) 404 'A0404' + Invoke-Sql baiyue_warehouse "UPDATE wh_partner SET status='ACTIVE' WHERE id=$activePartner AND tenant_id=10001;" + + Invoke-Sql baiyue_iam "UPDATE iam_tenant SET status='PENDING' WHERE id=10001;" + Require-Http '企业审核未通过禁止创建订单' (Invoke-Api POST "$GatewayBaseUrl/api/merchant/v1/orders/first-leg" $payload $merchantToken) 403 'A0403' + Invoke-Sql baiyue_iam "UPDATE iam_tenant SET status='ACTIVE' WHERE id=10001;" + + $null=Require-Success '提交草稿' (Invoke-Api POST "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken) + Require-Http '重复提交返回409' (Invoke-Api POST "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken) 409 'A0409' + + $null=Require-Success '平台履约写入快递信息' (Invoke-Api PUT "$GatewayBaseUrl/api/platform/v1/orders/first-leg/$orderId/express?tenantId=10001" @{expressCompany='顺丰速运';expressTrackingNo="SF$stamp"} $platformToken) + Require-Http '商户身份不能写平台快递接口' (Invoke-Api PUT "$GatewayBaseUrl/api/platform/v1/orders/first-leg/$orderId/express?tenantId=10001" @{expressCompany='顺丰速运';expressTrackingNo="SF$stamp-X"} $merchantToken) 403 'A0403' + $detail=Require-Success '商户订单详情只读展示快递信息' (Invoke-Api GET "$GatewayBaseUrl/api/merchant/v1/orders/first-leg/$orderId" $null $merchantToken) + Require-Check '快递公司和单号真实回显' ($detail.order.expressCompany -eq '顺丰速运' -and $detail.order.expressTrackingNo -eq "SF$stamp") "快递=$($detail.order.expressCompany),单号=$($detail.order.expressTrackingNo)" + + foreach($item in @( + @{name='Order merchant api-docs';url="$OrderBaseUrl/v3/api-docs/merchant"}, + @{name='Warehouse merchant api-docs';url="$WarehouseBaseUrl/v3/api-docs/merchant"} + )) { + $doc=Invoke-Api GET $item.url + Require-Check $item.name ($doc.status -eq 200 -and $doc.body -match '"openapi"') "HTTP=$($doc.status)" + } + $gatewayConfig=Get-Content -LiteralPath (Join-Path $root 'gateway\src\main\resources\application-cn.yml') -Raw -Encoding UTF8 + Require-Check 'Gateway Order api-docs 路由源配置' ($gatewayConfig -match 'order-service/v3/api-docs') '已静态审查源配置' + Require-Check 'Gateway Warehouse api-docs 路由源配置' ($gatewayConfig -match 'warehouse-service/v3/api-docs') '已静态审查源配置' +} catch { + $fatal=$_.Exception.Message + $results.Add([pscustomobject]@{name='脚本主流程';passed=$false;evidence=$fatal}) +} finally { + try { Invoke-Sql baiyue_iam "UPDATE iam_tenant SET status='ACTIVE' WHERE id=10001;" } catch {} + try { + if($orderId) { + Invoke-Sql baiyue_order "DELETE FROM ord_order_internal_note WHERE tenant_id=10001 AND order_id=$orderId; DELETE FROM ord_order_status_history WHERE tenant_id=10001 AND order_id=$orderId; DELETE FROM ord_order_cargo WHERE tenant_id=10001 AND order_id=$orderId; DELETE FROM ord_order_party WHERE tenant_id=10001 AND order_id=$orderId; DELETE FROM ord_outbox_event WHERE tenant_id=10001 AND aggregate_id=$orderId; DELETE FROM ord_order WHERE tenant_id=10001 AND id=$orderId;" + } + } catch {} + try { Invoke-Sql baiyue_warehouse "UPDATE wh_warehouse SET status='ACTIVE' WHERE id=$activeWarehouse AND tenant_id=10001; UPDATE wh_partner SET status='ACTIVE' WHERE id=$activePartner AND tenant_id=10001; DELETE FROM wh_service_capability WHERE id IN (190000000000000022,190000000000000023); DELETE FROM wh_warehouse_address WHERE id IN (190000000000000012,190000000000000013); DELETE FROM wh_partner WHERE id IN ($inactivePartner,$crossPartner); DELETE FROM wh_warehouse WHERE id IN ($inactiveWarehouse,$crossWarehouse);" } catch {} + try { if(-not $permissionPreexisting){Invoke-Sql baiyue_iam "DELETE FROM iam_role_permission WHERE permission_id=5102018; DELETE FROM iam_permission WHERE id=5102018;"} } catch {} + $passed=@($results|Where-Object{$_.passed}).Count; $failed=@($results|Where-Object{-not $_.passed}).Count + $report=[ordered]@{reportName='订单仓库往来单位真实API回归报告';startedAt=$startedAt.ToString('o');finishedAt=(Get-Date).ToString('o');stack=[ordered]@{gateway=$GatewayBaseUrl;order=$OrderBaseUrl;warehouse=$WarehouseBaseUrl};success=($failed -eq 0);summary=[ordered]@{total=$results.Count;passed=$passed;failed=$failed};testData=$testData;results=$results;fatalMessage=$fatal;securityNote='报告不保存密码或令牌'} + $dir=Split-Path -Parent $OutputPath; if(-not(Test-Path $dir)){New-Item -ItemType Directory -Path $dir -Force|Out-Null} + $report|ConvertTo-Json -Depth 12|Set-Content -LiteralPath $OutputPath -Encoding UTF8 + if ($null -eq $previousMysqlPassword) { Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue } else { $env:MYSQL_PWD=$previousMysqlPassword } + Write-Host "报告已生成:$OutputPath" + if(-not $report.success){exit 1} +} diff --git a/scripts/phase1-acceptance.ps1 b/scripts/phase1-acceptance.ps1 new file mode 100644 index 000000000..87dd45e09 --- /dev/null +++ b/scripts/phase1-acceptance.ps1 @@ -0,0 +1,100 @@ +param( + [string]$MysqlPassword = "123456", + [string]$AdminPassword = "ChangeMe123!", + [string]$GatewayBaseUrl = "http://127.0.0.1:9000", + [string]$IamBaseUrl = "" +) + +$ErrorActionPreference = "Stop" +$results = [System.Collections.Generic.List[object]]::new() +if ([string]::IsNullOrWhiteSpace($IamBaseUrl)) { $IamBaseUrl = $GatewayBaseUrl } + +function Add-Result([string]$Name, [bool]$Passed, [string]$Evidence) { + $results.Add([pscustomobject]@{ Test = $Name; Passed = $Passed; Evidence = $Evidence }) + Write-Host "[$(if ($Passed) { 'PASS' } else { 'FAIL' })] $Name - $Evidence" + if (-not $Passed) { throw "FAILED: $Name - $Evidence" } +} + +function Invoke-Json([string]$Method, [string]$Uri, [hashtable]$Headers = @{}, [object]$Body = $null) { + $args = @{ Method = $Method; Uri = $Uri; Headers = $Headers; TimeoutSec = 10 } + if ($null -ne $Body) { + $args.ContentType = "application/json" + $args.Body = $Body | ConvertTo-Json -Depth 10 + } + Invoke-RestMethod @args +} + +function Decode-JwtPayload([string]$Token) { + $part = $Token.Split('.')[1].Replace('-', '+').Replace('_', '/') + switch ($part.Length % 4) { 2 { $part += '==' } 3 { $part += '=' } } + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($part)) | ConvertFrom-Json +} + +function Assert-ApiSuccess([string]$Name, [string]$Uri, [hashtable]$Headers) { + $response = Invoke-Json GET $Uri $Headers + Add-Result $Name ($response.code -eq '0') "code=$($response.code)" +} + +$mysql = "D:\baiyue-runtime\mysql\server\bin\mysql.exe" +$redisCli = "D:\baiyue-runtime\redis\redis-cli.exe" +$verifySql = "D:/baiyue/iam/src/test/resources/db/verify-phase1-admin-rbac.sql" + +$env:MYSQL_PWD = $MysqlPassword +$dbFailures = & $mysql '--host=127.0.0.1' '--port=3306' '--user=root' '--batch' '--raw' '--skip-column-names' "--execute=source $verifySql" +Add-Result "database seed assertions" ([string]::IsNullOrWhiteSpace(($dbFailures -join ''))) "no assertion rows" + +$pong = & $redisCli -h 127.0.0.1 -p 6379 PING +Add-Result "redis connectivity" ($pong -eq 'PONG') "PING=$pong" + +$nacosReady = (Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:8848/nacos/v1/console/health/readiness' -TimeoutSec 5).Content +Add-Result "nacos readiness" ($nacosReady -eq 'OK') "readiness=$nacosReady" + +$nacosServices = Invoke-Json GET 'http://127.0.0.1:8848/nacos/v1/ns/service/list?pageNo=1&pageSize=50&groupName=BAIYUE&namespaceId=public' +$expectedServices = @('gateway', 'auth-service', 'iam-service', 'order-service', 'warehouse-service', 'firstleg-service', 'lastleg-service', 'audit-service') +$missingServices = @($expectedServices | Where-Object { $_ -notin $nacosServices.doms }) +Add-Result "phase-one service registration" ($missingServices.Count -eq 0) "registered=$($nacosServices.count), missing=$($missingServices -join ',')" + +$platformLogin = Invoke-Json POST "$GatewayBaseUrl/api/auth/login" @{} @{ username = 'platform_admin'; password = $AdminPassword } +$merchantLogin = Invoke-Json POST "$GatewayBaseUrl/api/auth/login" @{} @{ username = 'merchant_admin'; password = $AdminPassword } +Add-Result "platform administrator login" ($platformLogin.code -eq '0') "login success" +Add-Result "merchant administrator login" ($merchantLogin.code -eq '0') "login success" + +$platformClaims = Decode-JwtPayload $platformLogin.data.accessToken +$merchantClaims = Decode-JwtPayload $merchantLogin.data.accessToken +Add-Result "platform JWT identity" ( + $platformClaims.app_code -eq 'PLATFORM' -and $platformClaims.tenant_id -eq 0 -and + $platformClaims.membership_id -eq 3000001 -and 'PLATFORM_ADMIN' -in $platformClaims.roles -and + @($platformClaims.permissions).Count -eq 31 +) "tenant=$($platformClaims.tenant_id), roles=$($platformClaims.roles -join ','), permissions=$(@($platformClaims.permissions).Count)" +Add-Result "merchant JWT identity" ( + $merchantClaims.app_code -eq 'MERCHANT' -and $merchantClaims.tenant_id -eq 10001 -and + $merchantClaims.membership_id -eq 3000002 -and 'MERCHANT_OWNER' -in $merchantClaims.roles -and + @($merchantClaims.permissions).Count -eq 21 +) "tenant=$($merchantClaims.tenant_id), roles=$($merchantClaims.roles -join ','), permissions=$(@($merchantClaims.permissions).Count)" + +$platformHeaders = @{ Authorization = "Bearer $($platformLogin.data.accessToken)" } +$merchantHeaders = @{ Authorization = "Bearer $($merchantLogin.data.accessToken)" } + +Assert-ApiSuccess "platform role list" "$IamBaseUrl/api/platform/v1/iam/roles" $platformHeaders +Assert-ApiSuccess "platform menu/button list" "$IamBaseUrl/api/platform/v1/iam/permissions" $platformHeaders +Assert-ApiSuccess "platform enterprise review list" "$IamBaseUrl/api/platform/v1/iam/reviews" $platformHeaders +Assert-ApiSuccess "platform first-leg order list" "$GatewayBaseUrl/api/platform/v1/orders/first-leg?tenantId=10001&page=1&size=10" $platformHeaders +Assert-ApiSuccess "platform warehouse list" "$GatewayBaseUrl/api/platform/v1/warehouse-admin?tenantId=10001&page=1&size=10" $platformHeaders + +Assert-ApiSuccess "merchant enterprise profile" "$IamBaseUrl/api/merchant/v1/iam/tenant/profile" $merchantHeaders +Assert-ApiSuccess "merchant role list" "$IamBaseUrl/api/merchant/v1/iam/roles" $merchantHeaders +Assert-ApiSuccess "merchant menu/button list" "$IamBaseUrl/api/merchant/v1/iam/permissions" $merchantHeaders +Assert-ApiSuccess "merchant warehouse list" "$GatewayBaseUrl/api/merchant/v1/warehouses?page=1&size=10" $merchantHeaders +Assert-ApiSuccess "merchant first-leg order list" "$GatewayBaseUrl/api/merchant/v1/orders/first-leg?page=1&size=10" $merchantHeaders + +$crossScopeStatus = 0 +try { + Invoke-WebRequest -UseBasicParsing -Uri "$IamBaseUrl/api/merchant/v1/iam/tenant/profile" -Headers $platformHeaders -TimeoutSec 5 | Out-Null + $crossScopeStatus = 200 +} catch { + $crossScopeStatus = [int]$_.Exception.Response.StatusCode +} +Add-Result "platform token denied from merchant APIs" ($crossScopeStatus -eq 403) "HTTP $crossScopeStatus" + +$results | Format-Table -AutoSize +"PHASE1_ACCEPTANCE_PASSED=$($results.Count)" diff --git a/scripts/phase1-business-maturity-regression.ps1 b/scripts/phase1-business-maturity-regression.ps1 new file mode 100644 index 000000000..01774a271 --- /dev/null +++ b/scripts/phase1-business-maturity-regression.ps1 @@ -0,0 +1,410 @@ +param( + [string]$RepositoryRoot = (Split-Path -Parent $PSScriptRoot), + [string]$GatewayBaseUrl = "http://127.0.0.1:9000", + [string]$MerchantAdminUsername = "merchant_admin", + [Parameter(Mandatory = $true)][string]$MerchantAdminPassword, + [string]$PlatformAdminUsername = "platform_admin", + [string]$PlatformAdminPassword = "", + [string]$TemporaryAdminPassword = "Phase1Temp456!", + [string]$MemberInitialPassword = "Phase1Member123!", + [string]$MemberResetPassword = "Phase1Member456!", + [string]$OutputPath = "" +) + +$ErrorActionPreference = "Stop" +$startedAt = Get-Date +$stamp = Get-Date -Format "yyyyMMddHHmmssfff" +$prefix = "p1m_$stamp" +$results = [System.Collections.Generic.List[object]]::new() +$testData = [ordered]@{ Prefix = $prefix } +$adminPasswordChanged = $false +$memberId = $null +$memberUsername = "${prefix}_member" +$tenantUsername = "${prefix}_tenant" +$roleId = $null +$latestAdminAccess = $null +$latestAdminRefresh = $null +$cleanupResidual = [ordered]@{ + retainedTestMemberIds = [System.Collections.Generic.List[long]]::new() + undeletedRoleIds = [System.Collections.Generic.List[long]]::new() + crossTenantIds = [System.Collections.Generic.List[long]]::new() +} + +if ([string]::IsNullOrWhiteSpace($PlatformAdminPassword)) { + $PlatformAdminPassword = $MerchantAdminPassword +} +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $RepositoryRoot "reports\phase1-business-maturity-regression-$stamp.json" +} +if (-not (Test-Path -LiteralPath $RepositoryRoot -PathType Container)) { + throw "仓库根目录不存在:$RepositoryRoot" +} + +function Add-Check([string]$Name, [bool]$Passed, [string]$Evidence, [string]$Category = "业务") { + $script:results.Add([pscustomobject]@{ + name = $Name + category = $Category + passed = $Passed + evidence = $Evidence + checkedAt = (Get-Date).ToString("o") + }) + Write-Host "[$(if ($Passed) { '通过' } else { '失败' })] $Name - $Evidence" +} + +function Require-Check([string]$Name, [bool]$Passed, [string]$Evidence, [string]$Category = "业务") { + Add-Check $Name $Passed $Evidence $Category + if (-not $Passed) { throw "验收失败:$Name;$Evidence" } +} + +function Invoke-Api([string]$Method, [string]$Path, [object]$Body = $null, [string]$AccessToken = $null) { + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($AccessToken)) { + $headers.Authorization = "Bearer $AccessToken" + } + $arguments = @{ + Method = $Method + Uri = "$($GatewayBaseUrl.TrimEnd('/'))$Path" + Headers = $headers + TimeoutSec = 20 + UseBasicParsing = $true + } + if ($null -ne $Body) { + $arguments.ContentType = "application/json; charset=utf-8" + $jsonText = $Body | ConvertTo-Json -Depth 20 -Compress + $arguments.Body = [Text.Encoding]::UTF8.GetBytes($jsonText) + } + try { + $response = Invoke-WebRequest @arguments + $contentText = $response.Content + if ($null -ne $response.RawContentStream) { + try { $contentText = [Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) } catch { } + } + $json = if ([string]::IsNullOrWhiteSpace($contentText)) { $null } else { $contentText | ConvertFrom-Json } + return [pscustomobject]@{ HttpStatus = [int]$response.StatusCode; Body = $json } + } catch { + $status = 0 + if ($null -ne $_.Exception.Response) { + try { $status = [int]$_.Exception.Response.StatusCode } catch { $status = 0 } + } + $json = $null + if (-not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { + try { $json = $_.ErrorDetails.Message | ConvertFrom-Json } catch { $json = $null } + } + if ($null -eq $json -and $null -ne $_.Exception.Response) { + try { + $stream = $_.Exception.Response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream, [Text.Encoding]::UTF8) + $content = $reader.ReadToEnd() + $reader.Dispose() + if (-not [string]::IsNullOrWhiteSpace($content)) { + $json = $content | ConvertFrom-Json + } + } catch { + $json = $null + } + } + return [pscustomobject]@{ HttpStatus = $status; Body = $json } + } +} + +function Require-Success([string]$Name, [object]$Response, [string]$Category = "业务") { + $ok = $Response.HttpStatus -eq 200 -and $null -ne $Response.Body -and $Response.Body.code -eq "0" + $code = if ($null -eq $Response.Body) { "无响应码" } else { $Response.Body.code } + Require-Check $Name $ok "HTTP=$($Response.HttpStatus), code=$code" $Category + return $Response.Body.data +} + +function Require-Failure([string]$Name, [object]$Response, [int]$HttpStatus, [string[]]$Codes, [string]$Category = "异常") { + $code = if ($null -eq $Response.Body) { $null } else { [string]$Response.Body.code } + $ok = $Response.HttpStatus -eq $HttpStatus -and ($Codes.Count -eq 0 -or $code -in $Codes) + Require-Check $Name $ok "HTTP=$($Response.HttpStatus), code=$(if ($null -eq $code) { '无' } else { $code })" $Category +} + +function Login([string]$Username, [string]$Password, [string]$Name) { + $response = Invoke-Api "POST" "/api/auth/login" @{ username = $Username; password = $Password } + return Require-Success $Name $response "认证" +} + +function Restore-AdminPassword { + if (-not $script:adminPasswordChanged) { return } + try { + $login = Login $MerchantAdminUsername $TemporaryAdminPassword "恢复前重新登录管理员" + $restore = Invoke-Api "PUT" "/api/auth/password" @{ + oldPassword = $TemporaryAdminPassword + newPassword = $MerchantAdminPassword + } $login.accessToken + if ($restore.HttpStatus -eq 200 -and $restore.Body.code -eq "0") { + $script:adminPasswordChanged = $false + Add-Check "恢复管理员原密码" $true "恢复成功" "清理" + } else { + Add-Check "恢复管理员原密码" $false "HTTP=$($restore.HttpStatus)" "清理" + } + } catch { + Add-Check "恢复管理员原密码" $false "恢复过程异常,需人工检查测试账号" "清理" + } +} + +function Invoke-BestEffortCleanup { + # 清理范围必须同时满足一期脚本的成员编号和显示名特征,禁止按单一模糊条件处理正常成员。 + $cleanupLogin = $null + foreach ($candidatePassword in @($MerchantAdminPassword, $TemporaryAdminPassword)) { + if ($null -ne $cleanupLogin) { break } + $attempt = Invoke-Api "POST" "/api/auth/login" @{ + username = $MerchantAdminUsername + password = $candidatePassword + } + if ($attempt.HttpStatus -eq 200 -and $null -ne $attempt.Body -and $attempt.Body.code -eq "0") { + $cleanupLogin = $attempt.Body.data + } + } + if ($null -eq $cleanupLogin) { + Add-Check "清理阶段登录商户管理员" $false "原密码和临时密码均无法登录" "清理" + return + } + Add-Check "清理阶段登录商户管理员" $true "登录成功" "清理" + + $memberResponse = Invoke-Api "GET" "/api/merchant/v1/iam/members" $null $cleanupLogin.accessToken + if ($memberResponse.HttpStatus -ne 200 -or $null -eq $memberResponse.Body -or $memberResponse.Body.code -ne "0") { + Add-Check "扫描历史一期测试成员" $false "HTTP=$($memberResponse.HttpStatus)" "清理" + return + } + $testMembers = @($memberResponse.Body.data | Where-Object { + $_.memberNo -like "P1-*" -and $_.displayName -like "一期验收成员*" + }) + foreach ($testMember in $testMembers) { + $id = [long]$testMember.membershipId + if ($testMember.status -ne "DISABLED") { + $disable = Invoke-Api "PUT" "/api/merchant/v1/iam/members/$id/status" @{ status = "DISABLED" } $cleanupLogin.accessToken + if ($disable.HttpStatus -ne 200 -or $disable.Body.code -ne "0") { + Add-Check "停用历史测试成员 $id" $false "HTTP=$($disable.HttpStatus)" "清理" + if ($id -notin $cleanupResidual.retainedTestMemberIds) { $cleanupResidual.retainedTestMemberIds.Add($id) } + continue + } + } + $clearRoles = Invoke-Api "PUT" "/api/merchant/v1/iam/members/roles" @{ + membershipId = $id + roleIds = @() + } $cleanupLogin.accessToken + $cleared = $clearRoles.HttpStatus -eq 200 -and $null -ne $clearRoles.Body -and $clearRoles.Body.code -eq "0" + Add-Check "清理历史测试成员角色 $id" $cleared "HTTP=$($clearRoles.HttpStatus)" "清理" + if ($id -notin $cleanupResidual.retainedTestMemberIds) { $cleanupResidual.retainedTestMemberIds.Add($id) } + } + + $roleResponse = Invoke-Api "GET" "/api/merchant/v1/iam/roles" $null $cleanupLogin.accessToken + if ($roleResponse.HttpStatus -ne 200 -or $null -eq $roleResponse.Body -or $roleResponse.Body.code -ne "0") { + Add-Check "扫描历史一期测试角色" $false "HTTP=$($roleResponse.HttpStatus)" "清理" + } else { + $testRoles = @($roleResponse.Body.data | Where-Object { + $_.roleCode -like "P1_SELF_*" -and $_.roleType -eq "CUSTOM" + }) + foreach ($testRole in $testRoles) { + $id = [long]$testRole.id + $delete = Invoke-Api "DELETE" "/api/merchant/v1/iam/roles/$id" $null $cleanupLogin.accessToken + $deleted = $delete.HttpStatus -eq 200 -and $null -ne $delete.Body -and $delete.Body.code -eq "0" + Add-Check "删除历史测试角色 $id" $deleted "HTTP=$($delete.HttpStatus)" "清理" + if (-not $deleted) { $cleanupResidual.undeletedRoleIds.Add($id) } + } + } + + $logout = Invoke-Api "POST" "/api/auth/logout" @{ refreshToken = $cleanupLogin.refreshToken } $cleanupLogin.accessToken + Add-Check "退出清理会话" ($logout.HttpStatus -eq 200 -and $logout.Body.code -eq "0") "HTTP=$($logout.HttpStatus)" "清理" +} + +$fatalMessage = $null +try { + # 登录、刷新及刷新令牌轮换。 + $initialLogin = Login $MerchantAdminUsername $MerchantAdminPassword "商户管理员登录" + $initialAccess = $initialLogin.accessToken + $initialRefresh = $initialLogin.refreshToken + $refreshResponse = Invoke-Api "POST" "/api/auth/refresh" @{ refreshToken = $initialRefresh } + $rotated = Require-Success "刷新令牌并轮换会话" $refreshResponse "认证" + $latestAdminAccess = $rotated.accessToken + $latestAdminRefresh = $rotated.refreshToken + Require-Failure "旧刷新令牌不可重复使用" (Invoke-Api "POST" "/api/auth/refresh" @{ refreshToken = $initialRefresh }) 401 @("A0401") "认证" + Require-Failure "刷新后旧访问令牌失效" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $initialAccess) 401 @("A0401") "认证" + + # 个人资料查询、修改、回显和恢复。 + $profile = Require-Success "查询本人个人资料" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $latestAdminAccess) "个人资料" + $updatedName = "一期验收-$stamp" + $updated = Require-Success "编辑本人个人资料" (Invoke-Api "PUT" "/api/merchant/v1/iam/profile" @{ + displayName = $updatedName + phone = $profile.phone + email = $profile.email + } $latestAdminAccess) "个人资料" + Require-Check "个人资料修改后正确回显" ($updated.displayName -eq $updatedName) "displayName 已更新" "个人资料" + $null = Require-Success "恢复本人个人资料" (Invoke-Api "PUT" "/api/merchant/v1/iam/profile" @{ + displayName = $profile.displayName + phone = $profile.phone + email = $profile.email + } $latestAdminAccess) "清理" + + # 修改密码失败和成功场景,随后恢复测试管理员密码。 + Require-Failure "错误旧密码禁止修改密码" (Invoke-Api "PUT" "/api/auth/password" @{ + oldPassword = "WrongOldPassword999!" + newPassword = $TemporaryAdminPassword + } $latestAdminAccess) 400 @("A0400") "密码" + $null = Require-Success "正确旧密码修改成功" (Invoke-Api "PUT" "/api/auth/password" @{ + oldPassword = $MerchantAdminPassword + newPassword = $TemporaryAdminPassword + } $latestAdminAccess) "密码" + $adminPasswordChanged = $true + Require-Failure "修改密码后旧访问令牌失效" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $latestAdminAccess) 401 @("A0401") "密码" + Require-Failure "修改密码后旧刷新令牌失效" (Invoke-Api "POST" "/api/auth/refresh" @{ refreshToken = $latestAdminRefresh }) 401 @("A0401") "密码" + Restore-AdminPassword + $adminLogin = Login $MerchantAdminUsername $MerchantAdminPassword "恢复密码后重新登录管理员" + $latestAdminAccess = $adminLogin.accessToken + $latestAdminRefresh = $adminLogin.refreshToken + + # 平台身份不能访问商户端接口。 + $platformLogin = Login $PlatformAdminUsername $PlatformAdminPassword "平台管理员登录" + Require-Failure "平台令牌访问商户接口返回403" (Invoke-Api "GET" "/api/merchant/v1/iam/members" $null $platformLogin.accessToken) 403 @("A0403") "权限" + + # 创建仅用于本次验收的自助保护角色,并授予最小成员权限。 + $role = Require-Success "创建验收角色" (Invoke-Api "POST" "/api/merchant/v1/iam/roles" @{ + roleCode = "P1_SELF_$stamp" + roleName = "一期自助保护验收角色" + description = "一期自动化验收数据,禁止用于生产授权" + } $latestAdminAccess) "成员" + $roleId = [long]$role.id + $testData.RoleId = $roleId + $editedRole = Require-Success "编辑验收角色" (Invoke-Api "PUT" "/api/merchant/v1/iam/roles/$roleId" @{ + roleName = "一期自助保护验收角色-已编辑" + description = "一期自动化验收编辑回显" + } $latestAdminAccess) "角色" + Require-Check "角色编辑结果正确回显" ($editedRole.roleName -eq "一期自助保护验收角色-已编辑") "roleName 已更新" "角色" + $disabledRole = Require-Success "停用自定义角色" (Invoke-Api "PUT" "/api/merchant/v1/iam/roles/$roleId/status" @{ status = "DISABLED" } $latestAdminAccess) "角色" + Require-Check "角色停用状态正确回显" ($disabledRole.status -eq "DISABLED") "status=DISABLED" "角色" + $enabledRole = Require-Success "重新启用自定义角色" (Invoke-Api "PUT" "/api/merchant/v1/iam/roles/$roleId/status" @{ status = "ACTIVE" } $latestAdminAccess) "角色" + Require-Check "角色启用状态正确回显" ($enabledRole.status -eq "ACTIVE") "status=ACTIVE" "角色" + $permissions = Require-Success "查询成员按钮权限" (Invoke-Api "GET" "/api/merchant/v1/iam/permissions" $null $latestAdminAccess) "权限" + $neededCodes = @("merchant:member:list", "merchant:member:status") + $permissionIds = @($permissions | Where-Object { $_.permissionCode -in $neededCodes } | ForEach-Object { [long]$_.id }) + Require-Check "自助保护所需权限已初始化" ($permissionIds.Count -eq $neededCodes.Count) "权限数量=$($permissionIds.Count)" "权限" + $null = Require-Success "授予验收角色最小权限" (Invoke-Api "PUT" "/api/merchant/v1/iam/roles/permissions" @{ + roleId = $roleId + permissionIds = $permissionIds + } $latestAdminAccess) "权限" + $roleDetail = Require-Success "查询角色当前权限回显" (Invoke-Api "GET" "/api/merchant/v1/iam/roles/$roleId" $null $latestAdminAccess) "权限" + Require-Check "角色 permissionIds 完整回显" (@($roleDetail.permissionIds).Count -eq $permissionIds.Count -and @($permissionIds | Where-Object { $_ -notin @($roleDetail.permissionIds) }).Count -eq 0) "permissionIds 数量=$(@($roleDetail.permissionIds).Count)" "权限" + + # 新增成员、列表/详情角色回显及编辑。 + $member = Require-Success "新增企业成员及登录账号" (Invoke-Api "POST" "/api/merchant/v1/iam/members" @{ + username = $memberUsername + password = $MemberInitialPassword + displayName = "一期验收成员" + phone = "13800000001" + email = "$memberUsername@example.com" + memberType = "EMPLOYEE" + memberNo = "P1-$stamp" + roleIds = @($roleId) + } $latestAdminAccess) "成员" + $memberId = [long]$member.membershipId + $testData.MemberUsername = $memberUsername + $testData.MembershipId = $memberId + Require-Check "新增响应包含当前角色" ($roleId -in @($member.roleIds)) "roleIds 已回显" "成员" + Require-Failure "已分配成员的角色禁止删除" (Invoke-Api "DELETE" "/api/merchant/v1/iam/roles/$roleId" $null $latestAdminAccess) 409 @("A0409") "角色" + $members = Require-Success "查询企业成员列表" (Invoke-Api "GET" "/api/merchant/v1/iam/members" $null $latestAdminAccess) "成员" + $listed = @($members | Where-Object { [long]$_.membershipId -eq $memberId } | Select-Object -First 1) + Require-Check "列表返回姓名联系方式及角色" ($listed.Count -eq 1 -and $listed[0].displayName -eq "一期验收成员" -and $listed[0].phone -eq "13800000001" -and $roleId -in @($listed[0].roleIds)) "成员列表字段完整" "成员" + $detail = Require-Success "查询成员详情" (Invoke-Api "GET" "/api/merchant/v1/iam/members/$memberId" $null $latestAdminAccess) "成员" + Require-Check "成员详情角色正确回显" ($roleId -in @($detail.roleIds)) "roleIds 已回显" "成员" + $edited = Require-Success "编辑企业成员" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId" @{ + displayName = "一期验收成员-已编辑" + phone = "13800000002" + email = "${memberUsername}_edited@example.com" + memberType = "EMPLOYEE" + memberNo = "P1-$stamp" + roleIds = @($roleId) + } $latestAdminAccess) "成员" + Require-Check "成员编辑结果正确" ($edited.displayName -eq "一期验收成员-已编辑" -and $edited.phone -eq "13800000002") "姓名和手机已更新" "成员" + + # 重置密码、启停、本人保护和会话撤销。 + $memberLogin = Login $memberUsername $MemberInitialPassword "新增成员可登录" + $null = Require-Success "管理员重置成员密码" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId/password" @{ + newPassword = $MemberResetPassword + } $latestAdminAccess) "成员" + Require-Failure "重置密码后成员旧访问令牌失效" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $memberLogin.accessToken) 401 @("A0401") "成员" + Require-Failure "成员原密码登录失败" (Invoke-Api "POST" "/api/auth/login" @{ username = $memberUsername; password = $MemberInitialPassword }) 401 @("A0401") "成员" + $memberLogin = Login $memberUsername $MemberResetPassword "成员使用重置密码登录" + Require-Failure "普通成员不能停用本人" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId/status" @{ status = "DISABLED" } $memberLogin.accessToken) 400 @("A0400") "权限" + + $ownerMembershipId = [long](Require-Success "重新读取管理员个人资料" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $latestAdminAccess) "成员").userId + # 个人资料返回的是 IAM 用户 ID,不是成员 ID;从成员列表按 authUserId 定位所有者成员。 + $adminClaimsPayload = $latestAdminAccess.Split('.')[1].Replace('-', '+').Replace('_', '/') + switch ($adminClaimsPayload.Length % 4) { 2 { $adminClaimsPayload += '==' } 3 { $adminClaimsPayload += '=' } } + $adminClaims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($adminClaimsPayload)) | ConvertFrom-Json + $ownerMembershipId = [long]$adminClaims.membership_id + Require-Failure "企业所有者不可被停用" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$ownerMembershipId/status" @{ status = "DISABLED" } $latestAdminAccess) 403 @("A0403") "权限" + + $null = Require-Success "停用普通成员" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId/status" @{ status = "DISABLED" } $latestAdminAccess) "成员" + Require-Failure "停用成员旧访问令牌失效" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $memberLogin.accessToken) 401 @("A0401") "成员" + Require-Failure "停用成员禁止登录" (Invoke-Api "POST" "/api/auth/login" @{ username = $memberUsername; password = $MemberResetPassword }) 403 @("A0403") "成员" + $null = Require-Success "重新启用普通成员" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId/status" @{ status = "ACTIVE" } $latestAdminAccess) "成员" + $memberLogin = Login $memberUsername $MemberResetPassword "启用后成员恢复登录" + + # 第二租户只能看到自己的数据,不能读取第一租户成员。 + $tenantPassword = $MemberInitialPassword + $registration = Require-Success "注册跨租户验收企业" (Invoke-Api "POST" "/api/auth/register" @{ + username = $tenantUsername + password = $tenantPassword + displayName = "跨租户验收所有者" + companyName = "跨租户验收企业-$stamp" + legalPerson = "验收人员" + legalPhone = "13800000003" + contactEmail = "$tenantUsername@example.com" + countryCode = "CN" + address = "一期自动化验收地址" + }) "租户隔离" + $testData.CrossTenantUsername = $tenantUsername + $testData.CrossTenantId = [long]$registration.tenantId + $crossTenantLogin = Login $tenantUsername $tenantPassword "跨租户账号登录" + Require-Failure "跨租户读取成员被拒绝" (Invoke-Api "GET" "/api/merchant/v1/iam/members/$memberId" $null $crossTenantLogin.accessToken) 404 @("A0404") "租户隔离" + + # 退出必须同时使访问和刷新会话失效。 + $null = Require-Success "成员退出登录" (Invoke-Api "POST" "/api/auth/logout" @{ refreshToken = $memberLogin.refreshToken } $memberLogin.accessToken) "认证" + Require-Failure "退出后访问令牌失效" (Invoke-Api "GET" "/api/merchant/v1/iam/profile" $null $memberLogin.accessToken) 401 @("A0401") "认证" + Require-Failure "退出后刷新令牌失效" (Invoke-Api "POST" "/api/auth/refresh" @{ refreshToken = $memberLogin.refreshToken }) 401 @("A0401") "认证" + + # 无删除成员接口,最终停用并保留带前缀的可审计测试数据。 + $null = Require-Success "清理时停用验收成员" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/$memberId/status" @{ status = "DISABLED" } $latestAdminAccess) "清理" + $null = Require-Success "清理成员角色关系" (Invoke-Api "PUT" "/api/merchant/v1/iam/members/roles" @{ + membershipId = $memberId + roleIds = @() + } $latestAdminAccess) "清理" + $null = Require-Success "删除临时验收角色" (Invoke-Api "DELETE" "/api/merchant/v1/iam/roles/$roleId" $null $latestAdminAccess) "清理" + $null = Require-Success "退出跨租户验收账号" (Invoke-Api "POST" "/api/auth/logout" @{ refreshToken = $crossTenantLogin.refreshToken } $crossTenantLogin.accessToken) "清理" + $null = Require-Success "退出商户管理员" (Invoke-Api "POST" "/api/auth/logout" @{ refreshToken = $latestAdminRefresh } $latestAdminAccess) "清理" +} catch { + $fatalMessage = $_.Exception.Message + Add-Check "脚本主流程" $false $fatalMessage "执行" +} finally { + Restore-AdminPassword + if ($testData.Contains("CrossTenantId")) { $cleanupResidual.crossTenantIds.Add([long]$testData.CrossTenantId) } + Invoke-BestEffortCleanup + $finishedAt = Get-Date + $passed = @($results | Where-Object { $_.passed }).Count + $failed = @($results | Where-Object { -not $_.passed }).Count + $report = [ordered]@{ + reportName = "一期业务成熟度真实 API 回归报告" + startedAt = $startedAt.ToString("o") + finishedAt = $finishedAt.ToString("o") + gatewayBaseUrl = $GatewayBaseUrl + repositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path + success = ($failed -eq 0 -and [string]::IsNullOrWhiteSpace($fatalMessage)) + summary = [ordered]@{ total = $results.Count; passed = $passed; failed = $failed } + testData = $testData + residualData = $cleanupResidual + results = $results + fatalMessage = $fatalMessage + securityNote = "报告不包含密码、访问令牌或刷新令牌" + } + $directory = Split-Path -Parent $OutputPath + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + Write-Host "报告已生成:$OutputPath" + Write-Output ($report | ConvertTo-Json -Depth 12) + if (-not $report.success) { exit 1 } +} + + + diff --git a/scripts/phase1-onboarding-order-e2e.ps1 b/scripts/phase1-onboarding-order-e2e.ps1 new file mode 100644 index 000000000..bd5d7a50d --- /dev/null +++ b/scripts/phase1-onboarding-order-e2e.ps1 @@ -0,0 +1,77 @@ +param( + [string]$AuthUrl = 'http://127.0.0.1:9502', + [string]$IamUrl = 'http://127.0.0.1:9501', + [string]$FileUrl = 'http://127.0.0.1:9506', + [string]$OrderUrl = 'http://127.0.0.1:9504', + [string]$ImagePath, + [string]$PlatformPassword = 'ChangeMe123!' +) +$ErrorActionPreference = 'Stop' +function Invoke-Json($method, $url, $body = $null, $token = $null) { + $headers = @{}; if ($token) { $headers.Authorization = "Bearer $token" } + $args = @{ Method=$method; Uri=$url; Headers=$headers; TimeoutSec=20 } + if ($null -ne $body) { $args.ContentType='application/json'; $args.Body=$body | ConvertTo-Json -Depth 12 } + Invoke-RestMethod @args +} +function Invoke-Status($method, $url, $body, $token) { + try { Invoke-Json $method $url $body $token | Out-Null; return 200 } + catch { return [int]$_.Exception.Response.StatusCode } +} +function Assert-Equal($name, $actual, $expected) { + if ($actual -ne $expected) { throw "$name expected=$expected actual=$actual" } +} + +$stamp = Get-Date -Format 'MMddHHmmss' +$username = "phase1_$stamp"; $password = 'Phase1Test123!' +$registration = Invoke-Json POST "$AuthUrl/api/auth/register" @{ + username=$username; password=$password; displayName='Phase One Customer'; companyName="Phase One Enterprise $stamp" + legalPerson='Test Legal Person'; legalPhone='13800138000'; contactEmail="phase1_$stamp@example.com" + countryCode='CN'; address='No. 1 Test Road, Nanshan, Shenzhen' +} +Assert-Equal 'registration status' $registration.data.tenantStatus 'PROFILE_REQUIRED' +$tenantId = [long]$registration.data.tenantId +$merchantLogin = Invoke-Json POST "$AuthUrl/api/auth/login" @{username=$username;password=$password} +$merchantToken = $merchantLogin.data.accessToken + +$order = @{ + customerId=$tenantId; customerName="Phase One Enterprise $stamp"; transportMode='SEA'; tradeTerm='FOB' + destinationCountryCode='US'; destinationWarehouseId=1001; destinationWarehouseName='ONT8' + expectedShipDate='2026-08-15'; customerRemark='Phase one E2E'; pickupMethod='PICKUP' + receivingWarehouseId=2001; receivingWarehouseName='Shenzhen Receiving Warehouse'; channelCode='SEA-US-FBA' + channelName='US Sea Channel'; taxMode='BUY_DECLARATION'; containerMode='LCL'; customsInspection=$false + destinationType='FBA'; amazonWarehouseCode='ONT8'; shipmentId="FBA$stamp"; referenceId="REF$stamp" + sender=@{companyName="Phase One Enterprise $stamp";contactName='Test Sender';phone='13800138000';email="p$stamp@example.com";countryCode='CN';province='Guangdong';city='Shenzhen';district='Nanshan';addressLine='No. 1 Test Road';postalCode='518000'} + receiver=@{companyName='Amazon';contactName='ONT8';phone='1234567890';email='ont8@example.com';countryCode='US';province='CA';city='Moreno Valley';district='Riverside';addressLine='24300 Nandina Ave';postalCode='92551'} + cargoes=@(@{cargoName='Bluetooth Speaker';hsCode='8518210000';packageType='CARTON';packageCount=2;grossWeightKg=20.5;volumeCbm=0.2;lengthCm=50;widthCm=40;heightCm=30;declaredValue=200;currency='USD';dangerousGoods=$false;cargoRemark='Phase one test';skuNo="SKU$stamp";upcNo='123456789012';cargoNameEn='Bluetooth Speaker';customsHsCode='8518210000';unitsPerPackage=10;totalQuantity=20;netWeightKg=18;unitDeclaredValue=10;material='Plastic';usage='Audio';model='P1';brand='Demo'}) +} +$preApprovalStatus = Invoke-Status POST "$OrderUrl/api/merchant/v1/orders/first-leg" $order $merchantToken +Assert-Equal 'pre-approval order status' $preApprovalStatus 403 + +$uploadJson = & curl.exe -sS -X POST "$FileUrl/api/merchant/v1/files/qualifications" ` + -H "Authorization: Bearer $merchantToken" -F "file=@$ImagePath;type=image/png" ` + -F "certificateNo=91310101$stamp" +if ($LASTEXITCODE -ne 0) { throw 'qualification upload transport failed' } +$upload = $uploadJson | ConvertFrom-Json +Assert-Equal 'upload API code' $upload.code '0' +Assert-Equal 'upload tenant status' $upload.data.tenantStatus 'PENDING' + +$platformLogin = Invoke-Json POST "$AuthUrl/api/auth/login" @{username='platform_admin';password=$PlatformPassword} +$platformToken = $platformLogin.data.accessToken +$reviews = Invoke-Json GET "$IamUrl/api/platform/v1/iam/reviews" $null $platformToken +$review = @($reviews.data) | Where-Object { [long]$_.tenantId -eq $tenantId } | Select-Object -First 1 +if ($null -eq $review) { throw 'pending review not found' } +$qualifications = Invoke-Json GET "$IamUrl/api/platform/v1/iam/tenants/$tenantId/qualifications" $null $platformToken +Assert-Equal 'qualification count' @($qualifications.data).Count 1 +$fileName = Split-Path $upload.data.documentUrl -Leaf +$preview = Invoke-WebRequest -UseBasicParsing -Uri "$FileUrl/api/platform/v1/files/qualifications/$tenantId/$fileName" ` + -Headers @{Authorization="Bearer $platformToken"} -TimeoutSec 20 +Assert-Equal 'platform preview status' $preview.StatusCode 200 +Invoke-Json POST "$IamUrl/api/platform/v1/iam/reviews/$($review.id)/approve" @{comment='Phase one E2E approved'} $platformToken | Out-Null + +$created = Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg" $order $merchantToken +$orderId = [long]$created.data +Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken | Out-Null +$detail = Invoke-Json GET "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId" $null $merchantToken +Assert-Equal 'submitted order status' $detail.data.order.status 'SUBMITTED' + +[pscustomobject]@{username=$username;tenantId=$tenantId;registerStatus='PROFILE_REQUIRED';preApprovalOrderHttp=$preApprovalStatus;uploadStatus='PENDING';reviewId=$review.id;previewHttp=200;orderId=$orderId;finalOrderStatus='SUBMITTED'} | ConvertTo-Json diff --git a/scripts/publish-local-nacos-config.ps1 b/scripts/publish-local-nacos-config.ps1 new file mode 100644 index 000000000..e982df016 --- /dev/null +++ b/scripts/publish-local-nacos-config.ps1 @@ -0,0 +1,45 @@ +param( + [string]$ServerAddr = '127.0.0.1:8848', + [string]$Group = 'BAIYUE', + [string]$Namespace = 'public' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$configRoot = Join-Path $projectRoot 'config\nacos\local' +$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" +$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" + +$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 +if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { + throw "Local Nacos is not ready: $healthUrl" +} + +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name +if ($files.Count -eq 0) { + throw "No local Nacos configuration found: $configRoot" +} + +$published = 0 +foreach ($file in $files) { + $body = @{ + dataId = $file.Name + group = $Group + content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 + type = 'yaml' + } + if ($Namespace -and $Namespace -ne 'public') { + $body.tenant = $Namespace + } + + $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` + -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` + -Body $body -TimeoutSec 10 + if ([string]$result -ne 'true') { + throw "Publish failed: $($file.Name), Nacos response: $result" + } + $published++ + Write-Host "Published $($file.Name) -> $Namespace / $Group" +} + +Write-Host "Local first-leg Nacos configuration published: $published items." diff --git a/warehouse/pom.xml b/warehouse/pom.xml new file mode 100644 index 000000000..58592ae71 --- /dev/null +++ b/warehouse/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + warehouse-service + + + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + + + com.baiyue.shared + baiyue-common-audit + ${project.version} + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.baiyue.shared + baiyue-common-security + ${project.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java new file mode 100644 index 000000000..93002b5ca --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.warehouse; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。 + */ +@MapperScan("com.baiyue.warehouse.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueWarehouseApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseApplication.class, args); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java new file mode 100644 index 000000000..b0bcb0493 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java @@ -0,0 +1,28 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.service.PartnerQueryService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/warehouse-partners") +public class InternalPartnerController { + private final PartnerQueryService service; private final byte[] expected; + public InternalPartnerController(PartnerQueryService service, + @Value("${baiyue.security.internal-service-token}") String token) { + this.service=service; this.expected=token.getBytes(StandardCharsets.UTF_8); + } + @GetMapping("/{tenantId}/{id}/active") + public ApiResponse active(@RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @PathVariable long tenantId,@PathVariable long id) { + if (token==null || !MessageDigest.isEqual(expected,token.getBytes(StandardCharsets.UTF_8))) + throw new BizException(ResultCode.FORBIDDEN,"内部服务凭证无效"); + return ApiResponse.success(service.active(tenantId,id)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java new file mode 100644 index 000000000..fd5fadca9 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -0,0 +1,30 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/warehouses") +public class InternalWarehouseController { + private final WarehouseApplicationService service; + private final byte[] expected; + public InternalWarehouseController(WarehouseApplicationService service, + @Value("${baiyue.security.internal-service-token}") String token) { + this.service = service; this.expected = token.getBytes(StandardCharsets.UTF_8); + } + @GetMapping("/{tenantId}/{id}/active") + public ApiResponse active( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable long tenantId, @PathVariable long id) { + if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + return ApiResponse.success(service.activeDetail(tenantId, id)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java new file mode 100644 index 000000000..f6881a830 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantPartnerController.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.service.PartnerQueryService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/merchant/v1/partners") +public class MerchantPartnerController { + private final PartnerQueryService service; + public MerchantPartnerController(PartnerQueryService service) { this.service=service; } + @GetMapping("/candidates") + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false) String keyword,@RequestParam(required=false) String type) { + return ApiResponse.success(service.candidates(user.tenantId(),keyword,type)); + } + @GetMapping("/{id}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { + return ApiResponse.success(service.active(user.tenantId(),id)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java new file mode 100644 index 000000000..598fa665c --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商户端仓库查询接口,仅允许在认证令牌所属租户范围内读取仓库列表和详情。 + */ +@Tag( + name = "Merchant warehouses / 商户仓库", + description = "Merchant read APIs restricted to the tenant in the authenticated JWT. / 商户端只读接口,租户范围取自认证 JWT。" +) +@RestController +@RequestMapping("/api/merchant/v1/warehouses") +public class MerchantWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "List available warehouses / 查询可用仓库", + description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + ) + @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 = "401", description = "Authentication required / 需要登录") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid WarehouseRequests.QueryRequest query) { + return ApiResponse.success(service.merchantPage(user.tenantId(), query)); + } + + @Operation( + summary = "Get warehouse details / 查询仓库详情", + description = "Returns warehouse details only within 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 = "401", description = "Authentication required / 需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + return ApiResponse.success(service.activeDetail(user.tenantId(), id)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java new file mode 100644 index 000000000..5f98848b3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -0,0 +1,166 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +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 jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 + */ +@Validated +@Tag( + name = "Platform warehouse administration / 平台仓库管理", + description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" +) +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin") +public class PlatformWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "Create warehouse / 新建仓库", + description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(request.tenantId()); + return ApiResponse.success(service.create(request)); + } + + @Operation( + summary = "Update warehouse / 更新仓库", + description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PutMapping("/{id}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(tenantId); + service.update(tenantId, id, request); + return ApiResponse.success(null); + } + + @Operation( + summary = "List tenant warehouses / 查询租户仓库", + description = "Returns a tenant-scoped warehouse page with optional country, capability and status 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 = "Platform access required / 需要平台端权限") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Valid WarehouseRequests.QueryRequest request) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.page(tenantId, request)); + } + + @Operation( + summary = "Get tenant warehouse details / 查询租户仓库详情", + description = "Returns the warehouse, addresses and service capabilities in the 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 access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.detail(tenantId, id)); + } + + @Operation( + summary = "Enable or disable warehouse / 启用或停用仓库", + description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @PatchMapping("/{id}/status") + public ApiResponse status( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Parameter(description = "Target status: ACTIVE or INACTIVE / 目标状态:ACTIVE 或 INACTIVE", example = "ACTIVE", required = true) + @RequestParam String status) { + requirePlatform(user); + requireTenant(tenantId); + service.changeStatus(tenantId, id, status); + 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 == null || tenantId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + } + } +} + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java new file mode 100644 index 000000000..e519c0841 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +/** + * 仓库微服务可用性接口,向平台端暴露服务逻辑状态。 + */ +@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") +@RestController +@RequestMapping("/api/platform/v1/warehouses") +public class WarehouseHealthController { + @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "warehouse-service", "status", "UP")); + } +} + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java new file mode 100644 index 000000000..f9ec1c882 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 仓库聚合详情响应,组合仓库主数据、地址列表和服务能力列表。 + */ +@Schema(name = "WarehouseDetailResponse", description = "Warehouse aggregate details / 仓库聚合详情") +public record WarehouseDetailResponse( + @Schema(description = "Warehouse master data / 仓库主数据") WarehouseEntity warehouse, + @Schema(description = "Warehouse addresses / 仓库地址列表") List addresses, + @Schema(description = "Warehouse service capabilities / 仓库服务能力列表") List capabilities +) { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java new file mode 100644 index 000000000..079625e5b --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -0,0 +1,117 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +/** + * 仓库接口请求模型容器,集中定义仓库保存、地址、服务能力、查询和状态变更请求契约。 + */ +public final class WarehouseRequests { + private WarehouseRequests() { + } + + @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") + public record SaveRequest( + @Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull Long tenantId, + @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 120) String warehouseName, + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String warehouseType, + @Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String timezone, + @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) + @Size(max = 64) String contactName, + @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) + @Size(max = 32) String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") + @Email String contactEmail, + @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String status, + @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) + @Size(max = 500) String remark, + @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid @NotNull AddressRequest address, + @Schema(description = "Supported service capabilities / 支持的服务能力") + @Valid List capabilities + ) { + } + + @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") + public record AddressRequest( + @Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String addressType, + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "State or province / 州或省", example = "California", maxLength = 100) + @Size(max = 100) String stateProvince, + @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String city, + @Schema(description = "District / 区县", example = "Commerce", maxLength = 100) + @Size(max = 100) String district, + @Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20) + @Size(max = 20) String postalCode, + @Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 255) String addressLine1, + @Schema(description = "Address line 2 / 地址第二行", example = "Building A", maxLength = 255) + @Size(max = 255) String addressLine2, + @Schema(description = "Latitude / 纬度", example = "34.052235") + BigDecimal latitude, + @Schema(description = "Longitude / 经度", example = "-118.243683") + BigDecimal longitude + ) { + } + + @Schema(name = "WarehouseCapabilityRequest", description = "Warehouse service capability / 仓库服务能力") + public record CapabilityRequest( + @Schema(description = "Capability code / 能力编码", example = "STORAGE", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String capabilityCode, + @Schema(description = "Capability name / 能力名称", example = "Storage / 仓储", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String capabilityName, + @Schema(description = "Whether enabled / 是否启用", example = "true", defaultValue = "true") + Boolean enabled, + @Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time") + LocalTime cutoffTime, + @Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储", maxLength = 500) + @Size(max = 500) String description + ) { + } + + @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") + public record QueryRequest( + @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") + @Min(1) long page, + @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") + @Min(1) @Max(200) long size, + @Schema(description = "Country filter / 国家筛选", example = "US") + String countryCode, + @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") + String capabilityCode, + @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + String status + ) { + public QueryRequest { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java new file mode 100644 index 000000000..11afddba8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +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 lombok.Getter; +import lombok.Setter; + +/** 仓储域内的租户往来单位配置。 */ +@Getter @Setter +@TableName("wh_partner") +public class PartnerEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long tenantId; + private String partnerCode; + private String partnerName; + private String partnerType; + private String contactName; + private String contactPhone; + private String contactEmail; + private String countryCode; + private String province; + private String city; + private String district; + private String addressLine; + private String postalCode; + private String status; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java new file mode 100644 index 000000000..9210cca20 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java @@ -0,0 +1,57 @@ +package com.baiyue.warehouse.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.LocalTime; + +/** + * 仓库服务能力持久化实体,对应 {@code wh_service_capability} 表。 + * 用于记录租户仓库可提供的能力、启用状态和每日截单时间。 + */ +@Schema(name = "WarehouseServiceCapability", description = "Warehouse service capability / 仓库服务能力") +@Getter +@Setter +@TableName("wh_service_capability") +public class ServiceCapabilityEntity extends BaseEntity { + @Schema(description = "Capability record ID / 能力记录 ID", example = "190000000000000021") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "Tenant ID / 租户 ID", example = "10001") + private Long tenantId; + @Schema(description = "Warehouse ID / 仓库 ID", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "Capability code / 能力编码", example = "STORAGE") + private String capabilityCode; + @Schema(description = "Capability name / 能力名称", example = "Storage / 仓储") + private String capabilityName; + @Schema(description = "Whether enabled / 是否启用", example = "true") + private Boolean enabled; + @Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time") + private LocalTime cutoffTime; + @Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储") + private String description; + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java new file mode 100644 index 000000000..eaa2ff424 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java @@ -0,0 +1,77 @@ +package com.baiyue.warehouse.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.math.BigDecimal; + +/** + * 仓库地址持久化实体,对应 {@code wh_warehouse_address} 表。 + * 用于保存租户仓库的结构化地址与经纬度信息。 + */ +@Schema(name = "WarehouseAddress", description = "Warehouse address data / 仓库地址数据") +@Getter +@Setter +@TableName("wh_warehouse_address") +public class WarehouseAddressEntity extends BaseEntity { + @Schema(description = "Address ID / 地址 ID", example = "190000000000000011") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "Tenant ID / 租户 ID", example = "10001") + private Long tenantId; + @Schema(description = "Warehouse ID / 仓库 ID", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "Address type / 地址类型", example = "WAREHOUSE") + private String addressType; + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "State or province / 州或省", example = "California") + private String stateProvince; + @Schema(description = "City / 城市", example = "Los Angeles") + private String city; + @Schema(description = "District / 区县", example = "Commerce") + private String district; + @Schema(description = "Postal code / 邮政编码", example = "90040") + private String postalCode; + @Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave") + private String addressLine1; + @Schema(description = "Address line 2 / 地址第二行", example = "Building A") + private String addressLine2; + @Schema(description = "Latitude / 纬度", example = "34.052235") + private BigDecimal latitude; + @Schema(description = "Longitude / 经度", example = "-118.243683") + private BigDecimal longitude; + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java new file mode 100644 index 000000000..48e32e6c7 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -0,0 +1,71 @@ +package com.baiyue.warehouse.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; + +/** + * 仓库主数据持久化实体,对应 {@code wh_warehouse} 表。 + * 用于记录租户下仓库的编码、类型、国家、联系方式和启停状态。 + */ +@Schema(name = "Warehouse", description = "Warehouse master data / 仓库主数据") +@Getter +@Setter +@TableName("wh_warehouse") +public class WarehouseEntity extends BaseEntity { + @Schema(description = "Warehouse ID / 仓库 ID", example = "190000000000000001") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "Tenant ID / 租户 ID", example = "10001") + private Long tenantId; + @Schema(description = "Warehouse code / 仓库编码", example = "US-LAX-01") + private String warehouseCode; + @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓") + private String warehouseName; + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS") + private String warehouseType; + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles") + private String timezone; + @Schema(description = "Contact name / 联系人", example = "Alex Chen") + private String contactName; + @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100") + private String contactPhone; + @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") + private String contactEmail; + @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + private String status; + @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库") + private String remark; + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java new file mode 100644 index 000000000..01b8d9e20 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.PartnerEntity; +public interface PartnerMapper extends BaseMapper { } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java new file mode 100644 index 000000000..8f861d1db --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库服务能力 MyBatis-Plus Mapper,负责 {@code wh_service_capability} 表访问及仓库子记录的物理清理。 + */ +public interface ServiceCapabilityMapper extends BaseMapper { + + @Delete("DELETE FROM wh_service_capability WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java new file mode 100644 index 000000000..90716b582 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库地址 MyBatis-Plus Mapper,负责 {@code wh_warehouse_address} 表访问及仓库子记录的物理清理。 + */ +public interface WarehouseAddressMapper extends BaseMapper { + + @Delete("DELETE FROM wh_warehouse_address WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java new file mode 100644 index 000000000..5c9ef0172 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseEntity; + +/** + * 仓库主数据 MyBatis-Plus Mapper,提供 {@code wh_warehouse} 表的单表持久化访问。 + */ +public interface WarehouseMapper extends BaseMapper {} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java new file mode 100644 index 000000000..c23154a26 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.mapper.PartnerMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +@Repository +public class PartnerRepository { + private final PartnerMapper mapper; + public PartnerRepository(PartnerMapper mapper) { this.mapper = mapper; } + public List activeCandidates(long tenantId, String keyword, String type) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getStatus, "ACTIVE") + .eq(StringUtils.hasText(type), PartnerEntity::getPartnerType, type) + .and(StringUtils.hasText(keyword), q -> q.like(PartnerEntity::getPartnerCode, keyword) + .or().like(PartnerEntity::getPartnerName, keyword).or().like(PartnerEntity::getContactName, keyword)) + .orderByAsc(PartnerEntity::getPartnerName)); + } + public PartnerEntity findActive(long tenantId, long id) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id) + .eq(PartnerEntity::getStatus, "ACTIVE")); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java new file mode 100644 index 000000000..cbda164f4 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -0,0 +1,117 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseAddressMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** + * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 + */ +@Repository +public class WarehouseRepository { + private final WarehouseMapper warehouses; + private final WarehouseAddressMapper addresses; + private final ServiceCapabilityMapper capabilities; + + public WarehouseRepository(WarehouseMapper warehouses, + WarehouseAddressMapper addresses, + ServiceCapabilityMapper capabilities) { + this.warehouses = warehouses; + this.addresses = addresses; + this.capabilities = capabilities; + } + + public void insertWarehouse(WarehouseEntity warehouse) { + warehouses.insert(warehouse); + } + + public void saveWarehouse(WarehouseEntity warehouse) { + warehouses.updateById(warehouse); + } + + public WarehouseEntity findOwned(long tenantId, long id) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, id)); + } + + public WarehouseEntity findActiveOwned(long tenantId, long id) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, id) + .eq(WarehouseEntity::getStatus, "ACTIVE")); + } + + public boolean existsCode(long tenantId, String code, Long excludedId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseCode, code) + .ne(excludedId != null, WarehouseEntity::getId, excludedId)) > 0; + } + + public PageResult page(long tenantId, WarehouseRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) + .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .orderByDesc(WarehouseEntity::getCreatedAt); + + if (StringUtils.hasText(query.capabilityCode())) { + List ids = capabilities.selectList(new LambdaQueryWrapper() + .select(ServiceCapabilityEntity::getWarehouseId) + .eq(ServiceCapabilityEntity::getTenantId, tenantId) + .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) + .eq(ServiceCapabilityEntity::getEnabled, true)) + .stream() + .map(ServiceCapabilityEntity::getWarehouseId) + .distinct() + .toList(); + if (ids.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + wrapper.in(WarehouseEntity::getId, ids); + } + return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public PageResult activePage(long tenantId, WarehouseRequests.QueryRequest query) { + return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), + query.countryCode(), query.capabilityCode(), "ACTIVE")); + } + + public List findAddresses(long tenantId, long warehouseId) { + return addresses.selectList(new LambdaQueryWrapper() + .eq(WarehouseAddressEntity::getTenantId, tenantId) + .eq(WarehouseAddressEntity::getWarehouseId, warehouseId)); + } + + public List findCapabilities(long tenantId, long warehouseId) { + return capabilities.selectList(new LambdaQueryWrapper() + .eq(ServiceCapabilityEntity::getTenantId, tenantId) + .eq(ServiceCapabilityEntity::getWarehouseId, warehouseId)); + } + + public void physicallyDeleteChildren(long tenantId, long warehouseId) { + addresses.physicallyDeleteByWarehouse(tenantId, warehouseId); + capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId); + } + + public void insertAddress(WarehouseAddressEntity address) { + addresses.insert(address); + } + + public void insertCapability(ServiceCapabilityEntity capability) { + capabilities.insert(capability); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java new file mode 100644 index 000000000..d5dac3d2c --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java @@ -0,0 +1,22 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.springframework.stereotype.Service; +import java.util.List; + +@Service +public class PartnerQueryService { + private final PartnerRepository repository; + public PartnerQueryService(PartnerRepository repository) { this.repository = repository; } + public List candidates(long tenantId, String keyword, String type) { + return repository.activeCandidates(tenantId, keyword, type); + } + public PartnerEntity active(long tenantId, long id) { + PartnerEntity value=repository.findActive(tenantId,id); + if (value==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位不存在、已停用或不属于当前企业"); + return value; + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java new file mode 100644 index 000000000..f2ca67760 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -0,0 +1,169 @@ +package com.baiyue.warehouse.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。 + */ +@Service +public class WarehouseApplicationService { + @Resource + private WarehouseRepository repository; + + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_CREATE", + resourceType = "WAREHOUSE", + resourceId = "#r.warehouseCode", + description = "Create warehouse / 新建仓库", + captureBefore = false + ) + public Long create(WarehouseRequests.SaveRequest request) { + ensureCodeUnique(request.tenantId(), request.warehouseCode(), null); + WarehouseEntity warehouse = toEntity(request); + repository.insertWarehouse(warehouse); + insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities()); + return warehouse.getId(); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_UPDATE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "Update warehouse / 更新仓库" + ) + public void update(long tenantId, long id, WarehouseRequests.SaveRequest request) { + if (request.tenantId() == null || tenantId != request.tenantId()) { + throw new BizException(ResultCode.FORBIDDEN, "Tenant mismatch / 租户不匹配"); + } + WarehouseEntity old = owned(tenantId, id); + ensureCodeUnique(tenantId, request.warehouseCode(), id); + + WarehouseEntity warehouse = toEntity(request); + warehouse.setId(old.getId()); + repository.saveWarehouse(warehouse); + + repository.physicallyDeleteChildren(tenantId, id); + insertChildren(tenantId, id, request.address(), request.capabilities()); + } + + public PageResult page(long tenantId, WarehouseRequests.QueryRequest query) { + return repository.page(tenantId, query); + } + + public WarehouseDetailResponse detail(long tenantId, long id) { + WarehouseEntity warehouse = owned(tenantId, id); + List addresses = repository.findAddresses(tenantId, id); + List capabilities = repository.findCapabilities(tenantId, id); + return new WarehouseDetailResponse(warehouse, addresses, capabilities); + } + + public PageResult merchantPage(long tenantId, WarehouseRequests.QueryRequest query) { + return repository.activePage(tenantId, query); + } + + public WarehouseDetailResponse activeDetail(long tenantId, long id) { + WarehouseEntity warehouse = repository.findActiveOwned(tenantId, id); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "仓库不存在、已停用或不属于当前企业"); + } + return new WarehouseDetailResponse(warehouse, repository.findAddresses(tenantId, id), + repository.findCapabilities(tenantId, id).stream().filter(item -> Boolean.TRUE.equals(item.getEnabled())).toList()); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_STATUS_CHANGE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "Enable or disable warehouse / 启用或停用仓库" + ) + public void changeStatus(long tenantId, long id, String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException("Invalid status / 无效状态"); + } + WarehouseEntity warehouse = owned(tenantId, id); + warehouse.setStatus(status); + repository.saveWarehouse(warehouse); + } + + private WarehouseEntity owned(long tenantId, long id) { + WarehouseEntity warehouse = repository.findOwned(tenantId, id); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "Warehouse not found / 仓库不存在"); + } + return warehouse; + } + + private void ensureCodeUnique(long tenantId, String code, Long excludedId) { + if (repository.existsCode(tenantId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "Warehouse code already exists / 仓库编码已存在"); + } + } + + private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setTenantId(request.tenantId()); + warehouse.setWarehouseCode(request.warehouseCode()); + warehouse.setWarehouseName(request.warehouseName()); + warehouse.setWarehouseType(request.warehouseType()); + warehouse.setCountryCode(request.countryCode()); + warehouse.setTimezone(request.timezone()); + warehouse.setContactName(request.contactName()); + warehouse.setContactPhone(request.contactPhone()); + warehouse.setContactEmail(request.contactEmail()); + warehouse.setStatus(request.status()); + warehouse.setRemark(request.remark()); + return warehouse; + } + + private void insertChildren(long tenantId, long warehouseId, + WarehouseRequests.AddressRequest address, + List requestedCapabilities) { + WarehouseAddressEntity addressEntity = new WarehouseAddressEntity(); + addressEntity.setTenantId(tenantId); + addressEntity.setWarehouseId(warehouseId); + addressEntity.setAddressType(address.addressType()); + addressEntity.setCountryCode(address.countryCode()); + addressEntity.setStateProvince(address.stateProvince()); + addressEntity.setCity(address.city()); + addressEntity.setDistrict(address.district()); + addressEntity.setPostalCode(address.postalCode()); + addressEntity.setAddressLine1(address.addressLine1()); + addressEntity.setAddressLine2(address.addressLine2()); + addressEntity.setLatitude(address.latitude()); + addressEntity.setLongitude(address.longitude()); + repository.insertAddress(addressEntity); + + if (requestedCapabilities == null) { + return; + } + for (WarehouseRequests.CapabilityRequest capability : requestedCapabilities) { + ServiceCapabilityEntity entity = new ServiceCapabilityEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(warehouseId); + entity.setCapabilityCode(capability.capabilityCode()); + entity.setCapabilityName(capability.capabilityName()); + entity.setEnabled(capability.enabled() == null || capability.enabled()); + entity.setCutoffTime(capability.cutoffTime()); + entity.setDescription(capability.description()); + repository.insertCapability(entity); + } + } +} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml new file mode 100644 index 000000000..8bffff78a --- /dev/null +++ b/warehouse/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: ${WAREHOUSE_PORT:9205} +spring: + application: + name: warehouse-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: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:123456} +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} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql new file mode 100644 index 000000000..552709bbb --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -0,0 +1,20 @@ +SET NAMES utf8mb4; +USE baiyue_warehouse; +CREATE TABLE IF NOT EXISTS wh_partner ( + id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, + partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL, + partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', + contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL, + contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL, + province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL, + address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), + KEY idx_wh_partner_tenant_status(tenant_id,status,deleted) +) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置'; +INSERT INTO wh_partner(id,tenant_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) +VALUES(191000000000000001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') +ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE'; diff --git a/warehouse/src/main/resources/db/warehouse-seed.sql b/warehouse/src/main/resources/db/warehouse-seed.sql new file mode 100644 index 000000000..bba655a92 --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-seed.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4; +USE baiyue_warehouse; +-- 一期联调初始化仓库,重复执行不会产生重复数据。 +INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,contact_name,contact_phone,status,remark) +VALUES(190000000000000001,10001,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','Asia/Shanghai','仓库收货组','0755-88886666','ACTIVE','工作日预约收货') +ON DUPLICATE KEY UPDATE warehouse_name=VALUES(warehouse_name),status='ACTIVE'; +INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1) +VALUES(190000000000000011,10001,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓') +ON DUPLICATE KEY UPDATE address_line1=VALUES(address_line1); +INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capability_name,enabled,cutoff_time,description) +VALUES(190000000000000021,10001,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货') +ON DUPLICATE KEY UPDATE enabled=1,cutoff_time=VALUES(cutoff_time); diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql new file mode 100644 index 000000000..b157f4f75 --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -0,0 +1,76 @@ +SET NAMES utf8mb4; +CREATE DATABASE IF NOT EXISTS baiyue_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', + contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_wh_tenant_code (tenant_id, warehouse_code, deleted), + KEY idx_wh_tenant_status (tenant_id, status, deleted), + KEY idx_wh_country (tenant_id, country_code, deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', + district VARCHAR(100) DEFAULT NULL COMMENT '区县', + postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', + longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_address_type (tenant_id, warehouse_id, address_type, deleted), + KEY idx_address_warehouse (tenant_id, warehouse_id, deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间', + description VARCHAR(500) DEFAULT NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_capability (tenant_id, warehouse_id, capability_code, deleted), + KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。 diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java new file mode 100644 index 000000000..0656c1779 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.mapper.PartnerMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import org.mockito.ArgumentCaptor; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class PartnerRepositoryTest { + @BeforeAll static void 初始化实体字段缓存() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PartnerEntity.class); + } + @Test void 候选查询强制租户和启用状态() { + PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); + new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER"); + @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment(); + assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER"); + } + @Test void 详情查询强制租户和启用状态() { + PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectOne(any())).thenReturn(null); + new PartnerRepository(mapper).findActive(10002,9); + @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectOne(c.capture()); c.getValue().getSqlSegment(); + assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java new file mode 100644 index 000000000..7ca3c87fe --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -0,0 +1,30 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +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 WarehouseApplicationServiceTest { + @Test + void 商户详情只返回启用且属于租户的仓库() { + WarehouseRepository repository=mock(WarehouseRepository.class); + WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); + when(repository.findActiveOwned(10001,1)).thenReturn(warehouse); + when(repository.findAddresses(10001,1)).thenReturn(java.util.List.of()); + when(repository.findCapabilities(10001,1)).thenReturn(java.util.List.of()); + WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); + assertThat(service.activeDetail(10001,1).warehouse()).isSameAs(warehouse); + verify(repository).findActiveOwned(10001,1); + } + @Test + void 停用或跨租户仓库按不存在处理() { + WarehouseRepository repository=mock(WarehouseRepository.class); + WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); + assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) + .hasMessageContaining("停用"); + } +}