diff --git a/AGENTS.md b/AGENTS.md index c3d67900d..2cc520267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ | `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` | | `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` | | `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` | -| `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`src/main/resources/application*.yml` | +| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` | | `auth/` | 登录、令牌、注册、账号与密码 | `InternalAccountController.java`、`AccountManagementService.java`、`src/main/resources/db/` | | `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` | | `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` | @@ -32,12 +32,14 @@ ## 配置、数据库与验证入口 - 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。 +- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。 - 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue//` 起算;资源路径从模块根目录起算。 - 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。 - `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。 - 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。 - 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。 - 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。 +- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。 - 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl -am test`。 - `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。 @@ -49,3 +51,8 @@ - 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 - 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。 + +## 本地内部服务令牌 + +- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。 +- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。 diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index 9cc97711a..4930224a2 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -24,3 +24,14 @@ spring: baiyue.audit.enabled: false management.endpoints.web.exposure.include: health,info,prometheus + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index d213306c2..865307a73 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -42,3 +42,14 @@ baiyue.clients: url: ${IAM_SERVICE_URL:} management.endpoints.web.exposure.include: health,info,prometheus + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index e7d9a989e..429ab4a59 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -4,10 +4,6 @@ spring: 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:} + +# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。 +baiyue.file.storage-root: ${FILE_STORAGE_ROOT} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/cn/order-service.yml index 390e43a77..ffcc1335b 100644 --- a/config/nacos/cn/order-service.yml +++ b/config/nacos/cn/order-service.yml @@ -4,3 +4,7 @@ spring: username: ${ORDER_DB_USERNAME} password: ${ORDER_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + +baiyue.clients: + # 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。 + warehouse.url: ${WAREHOUSE_SERVICE_URL:} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml index 767874fca..293f4136d 100644 --- a/config/nacos/local/baiyue-common.yml +++ b/config/nacos/local/baiyue-common.yml @@ -7,7 +7,7 @@ baiyue: 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} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} openapi.enabled: ${SWAGGER_ENABLED:true} audit.enabled: true diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml index ca0bf449e..13bb3220a 100644 --- a/config/nacos/local/order-service.yml +++ b/config/nacos/local/order-service.yml @@ -4,4 +4,5 @@ spring.datasource: username: ${ORDER_DB_USERNAME:root} password: ${ORDER_DB_PASSWORD:123456} baiyue.clients: - warehouse.url: ${WAREHOUSE_SERVICE_URL:http://127.0.0.1:9205} + # 默认留空并使用 Nacos 的 warehouse-service 实例;仅独立端口回归时显式设置直连 URL。 + warehouse.url: ${WAREHOUSE_SERVICE_URL:} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index e8fa1ad67..ecd1131f5 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -6,8 +6,7 @@ RUN addgroup -S app \ WORKDIR /app -# 预建运行时数据和日志目录并归属 app 用户:bind mount 首次挂载时会继承此属主, -# 否则 Docker 以 root:root 0755 创建挂载点,非 root 容器无法写入。 +# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。 RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs ARG JAR_FILE diff --git a/docs/企业资质文件迁移说明.md b/docs/企业资质文件迁移说明.md index f481b01aa..774598462 100644 --- a/docs/企业资质文件迁移说明.md +++ b/docs/企业资质文件迁移说明.md @@ -43,6 +43,14 @@ WHERE deleted = 0 5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。 6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。 +容器部署前必须确认 File 实例注册名为 `file-service`,Gateway 路由目标为 `lb://file-service`,并确认 `/data/files` 是持久卷且容器用户 UID 10001 可以实际创建和删除文件。镜像内的目录权限不会覆盖宿主机 bind mount 的属主;使用宿主机目录时,须先创建目录并把所有权授予 UID 10001。旧版本已创建的卷或目录可在维护窗口使用当前 File 镜像一次性修复后重启: + +```sh +docker compose --env-file <环境文件> -f deploy/docker-compose.yml run --rm --user 0 --entrypoint sh file -c 'chown -R 10001:10001 /data/files' +``` + +`/actuator/health` 通过后,还应执行一次脱敏图片上传和平台预览,验证 Gateway、Nacos、挂载和 IAM 协作的完整链路。 + 当前 File 服务使用本地或共享文件目录时,可先运行仓库内脚本生成真实文件、前向 SQL 和回滚 SQL: ```powershell @@ -54,7 +62,7 @@ WHERE deleted = 0 -OldDocumentUrl /phase1-seed/demo-business-license.jpg ``` -脚本会校验文件大小、JPG/PNG 文件签名和复制前后 SHA-256,并生成带 `id + tenant_id + 旧 URL` 乐观条件的前向 SQL及回滚 SQL。脚本不会连接数据库;执行人员确认文件已落到 File 服务实际挂载目录后,才可人工执行生成的前向 SQL。若受影响行数不是 1,必须回滚并删除本次新增文件。对象存储环境不使用该脚本,应通过 File 上传接口迁移。 +脚本会校验文件大小、JPG/PNG 文件签名和复制前后 SHA-256,并生成带 `id + tenant_id + 旧 URL` 乐观条件的前向 SQL及回滚 SQL。脚本不会连接数据库,也不会在生成的 SQL 中自动提交事务;执行人员确认文件已落到 File 服务实际挂载目录后,才可人工执行前向 SQL,并在 `affected_rows = 1` 且平台预览校验成功后显式 `COMMIT`。否则必须 `ROLLBACK` 并删除本次新增文件。对象存储环境不使用该脚本,应通过 File 上传接口迁移。 数据库更新模板如下,执行时必须替换为经过文件校验的真实值: @@ -67,7 +75,7 @@ WHERE id = :qualification_id AND document_url = :expected_old_url AND deleted = 0; -- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。 -COMMIT; +-- 执行人员检查后显式 COMMIT 或 ROLLBACK,模板不自动提交。 ``` ## 新环境初始化 diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 426ff4409..ec24e0714 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -35,3 +35,14 @@ management: springdoc: api-docs: path: /v3/api-docs + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 91f37a3e6..72185c614 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -35,12 +35,23 @@ public class LocalQualificationStorage { /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ @PostConstruct public void initialize() { + Path probe = null; try { Files.createDirectories(root); if (!Files.isDirectory(root) || !Files.isWritable(root)) { throw new IOException("存储根目录不可写"); } + probe = root.resolve(".qualification-storage-probe-" + UUID.randomUUID()); + Files.write(probe, new byte[] {0x42}, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + Files.delete(probe); } catch (IOException exception) { + if (probe != null) { + try { + Files.deleteIfExists(probe); + } catch (IOException cleanupException) { + exception.addSuppressed(cleanupException); + } + } LOGGER.error("企业资质存储根目录不可用:{}", root, exception); throw new IllegalStateException("企业资质存储根目录不可用", exception); } diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index a024e7c2c..8d08ce680 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -34,3 +34,14 @@ baiyue: file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} management.endpoints.web.exposure.include: health,info,prometheus + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 552c7793e..5a3eb2c4e 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -34,6 +34,7 @@ class LocalQualificationStorageTest { assertTrue(Files.isRegularFile(stored.path())); assertEquals(stored.path().toAbsolutePath(), storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + assertThrows(BizException.class, () -> storage.load(10002L, stored.fileName())); } @Test diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index ab66d9035..8711b2e51 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -24,3 +24,14 @@ baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} management.endpoints.web.exposure.include: health,info,prometheus + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 8a6d0162d..e488aa234 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -15,6 +15,7 @@ | JWT 与安全配置项 | `config/GatewaySecurityProperties.java` | | Redis 会话过滤 | `config/RedisTokenSessionWebFilter.java` | | CORS | `config/GatewayCorsConfiguration.java`、`GatewayCorsProperties.java` | +| 请求标识 | `filter/CorrelationIdWebFilter.java`;包括预检和安全失败响应的 `X-Request-Id` | | 可信身份头清理/注入 | `filter/TrustedIdentityHeaderFilter.java` | | 本地路由 | `src/main/resources/application.yml` | | 国内环境与 Swagger 聚合 | `src/main/resources/application-cn.yml`、`../config/nacos/{local,cn}/gateway.yml` | diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 4b24942a9..9b99e0159 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -205,3 +205,14 @@ springdoc: url: /integration-service/v3/api-docs - name: event-bridge url: /event-bridge-service/v3/api-docs + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index 142e22997..8b13163fd 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -9,13 +9,17 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.test.web.reactive.server.WebTestClient; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + @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" + "baiyue.cors.allowed-origins=http://localhost:5173,http://localhost:5174" }) @AutoConfigureWebTestClient @DisplayName("网关跨域配置功能") @@ -26,6 +30,30 @@ class GatewayCorsConfigurationTest { @Autowired private WebTestClient webTestClient; + @Test + void wildcardOriginIsRejected() { + assertThrows(IllegalArgumentException.class, () -> new GatewayCorsProperties( + List.of("*"), List.of("GET"), List.of("Content-Type"), List.of(), false, 3600)); + } + + @Test + void localhost5174CanPreflightLoginWithoutTokenAndKeepsRequestId() { + webTestClient.options() + .uri("http://localhost/api/auth/login") + .header(HttpHeaders.ORIGIN, "http://localhost:5174") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.POST.name()) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5174") + .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, + value -> org.assertj.core.api.Assertions.assertThat(value).contains("POST")) + .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, + value -> org.assertj.core.api.Assertions.assertThat(value.toLowerCase()).contains("content-type")) + .expectHeader().exists("X-Request-Id") + .expectHeader().doesNotExist(HttpHeaders.WWW_AUTHENTICATE); + } + @Test void allowedOriginCanCompletePutPreflightWithoutToken() { webTestClient.options() @@ -52,6 +80,7 @@ class GatewayCorsConfigurationTest { .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name()) .exchange() .expectStatus().isForbidden() + .expectHeader().exists("X-Request-Id") .expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 2e9495867..1b77a634c 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -32,6 +32,7 @@ - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 +- 成员新增携带角色、编辑时变更或清空角色,必须额外具有 `merchant:member:grant-role`;仅资料编辑且角色集合不变时不要求该权限。 - `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 - 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index 77569392c..84491bb47 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -92,7 +92,8 @@ public class MerchantTenantController { @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)); + return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request, + user.permissions())); } @PutMapping("/members/{membershipId:\\d+}") @@ -100,7 +101,8 @@ public class MerchantTenantController { @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)); + return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request, + user.permissions())); } @PutMapping("/members/{membershipId:\\d+}/status") diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 83e27c0bf..80d582e35 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -18,13 +18,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */ @Service public class MemberService { + private static final String GRANT_ROLE_PERMISSION = "merchant:member:grant-role"; private static final Logger log = LoggerFactory.getLogger(MemberService.class); private final MemberRepository repository; private final IamRoleMapper roleMapper; @@ -49,8 +52,9 @@ public class MemberService { @Transactional @BusinessAudit(event = "MEMBER_CREATE", resourceType = "MEMBERSHIP", resourceId = "#request.username", description = "新增企业成员") - public MemberResponse create(long tenantId, CreateMemberRequest request) { + public MemberResponse create(long tenantId, CreateMemberRequest request, Set operatorPermissions) { rejectOwnerMutation(request.memberType()); + requireGrantRoleIfChanged(List.of(), request.roleIds(), operatorPermissions); validateRoles(tenantId, request.roleIds()); validateMemberNo(tenantId, request.memberNo(), null); long authId = IdWorker.getId(); @@ -84,11 +88,14 @@ public class MemberService { @Transactional @BusinessAudit(event = "MEMBER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "编辑企业成员") - public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request) { + public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request, + Set operatorPermissions) { rejectOwnerMutation(request.memberType()); + IamMembership membership = required(tenantId, membershipId); + List currentRoleIds = repository.findRoleIds(tenantId, membershipId); + requireGrantRoleIfChanged(currentRoleIds, request.roleIds(), operatorPermissions); validateRoles(tenantId, request.roleIds()); validateMemberNo(tenantId, request.memberNo(), membershipId); - IamMembership membership = required(tenantId, membershipId); if ("OWNER".equals(membership.getMemberType())) { throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑"); } @@ -171,6 +178,13 @@ public class MemberService { throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); } } + private void requireGrantRoleIfChanged(List currentRoleIds, List requestedRoleIds, + Set operatorPermissions) { + if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(requestedRoleIds)) + && (operatorPermissions == null || !operatorPermissions.contains(GRANT_ROLE_PERMISSION))) { + throw new BizException(ResultCode.FORBIDDEN, "变更成员角色需要 merchant:member:grant-role 权限"); + } + } private void rejectOwnerMutation(String memberType) { if ("OWNER".equals(memberType)) { throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者"); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 7006edfe7..dad2da71f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -29,3 +29,14 @@ baiyue: 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 + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 8034e5a10..74bc6ae9e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -6,6 +6,7 @@ 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.IamRole; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; @@ -14,6 +15,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -80,11 +82,88 @@ class MemberServiceTest { UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, "EMPLOYEE", null, List.of()); - assertThrows(BizException.class, () -> service.update(100L, 20L, request)); + assertThrows(BizException.class, () -> service.update(100L, 20L, request, Set.of())); verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } + @Test void createWithRolesRequiresGrantRolePermission() { + CreateMemberRequest request = new CreateMemberRequest("member01", "Password1", "成员", + null, null, "EMPLOYEE", null, List.of(50L)); + + BizException exception = assertThrows(BizException.class, + () -> service.create(100L, request, Set.of("merchant:member:create"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verifyNoInteractions(auth); + verify(repository, never()).insert(any()); + } + + @Test void profileOnlyUpdateAllowsUnchangedRolesWithoutGrantPermission() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null, + "EMPLOYEE", "M-001", List.of(50L)); + + MemberResponse result = service.update(100L, 20L, request, Set.of("merchant:member:edit")); + + assertEquals("新姓名", result.displayName()); + verify(repository).saveUser(user); + verify(repository).replaceRoles(100L, 20L, List.of(50L)); + } + + @Test void roleChangeRequiresGrantRolePermission() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of(60L)); + + BizException exception = assertThrows(BizException.class, + () -> service.update(100L, 20L, request, Set.of("merchant:member:edit"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).save(any()); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } + + @Test void clearingRolesAlsoRequiresGrantRolePermission() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of()); + + BizException exception = assertThrows(BizException.class, + () -> service.update(100L, 20L, request, Set.of("merchant:member:edit"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } + + @Test void grantRolePermissionAllowsClearingRoles() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of()); + + service.update(100L, 20L, request, + Set.of("merchant:member:edit", "merchant:member:grant-role")); + + verify(repository).replaceRoles(100L, 20L, List.of()); + } + + @Test void crossTenantUpdateReturnsNotFoundBeforeReadingRoles() { + when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of(50L)); + + BizException exception = assertThrows(BizException.class, + () -> service.update(200L, 20L, request, Set.of("merchant:member:grant-role"))); + + assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); + verify(repository, never()).findRoleIds(200L, 20L); + } + + private IamRole activeMerchantRole(long id, long tenantId) { + IamRole role = new IamRole(); + role.setId(id); role.setTenantId(tenantId); role.setAppCode("MERCHANT"); + role.setRoleCode("MERCHANT_EMPLOYEE"); role.setStatus("ACTIVE"); + return role; + } + @Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() { member.setMemberType("OWNER"); diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index d095bb084..9976b9c16 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -64,3 +64,14 @@ management: health: probes: enabled: true + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/order/AGENTS.md b/order/AGENTS.md index 89bc39abe..041a5c3d2 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -28,6 +28,7 @@ - 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。 - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 +- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 - 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 - 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。 diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index d6f6be372..834e57b27 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -11,9 +11,12 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Service public class OrderMasterDataGuard { + private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class); private final WarehouseClient warehouses; private final PartnerClient partners; private final String token; @@ -37,13 +40,15 @@ public class OrderMasterDataGuard { } return new Snapshots(partner,receiving,destination); } catch (FeignException.NotFound exception) { - throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业"); + throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业"); + } catch (FeignException.Conflict exception) { + throw new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用"); } catch (FeignException exception) { - throw unavailable("主数据"); + throw downstreamFailure(exception); } catch (BizException exception) { throw exception; } catch (RuntimeException exception) { - throw unavailable("主数据"); + throw downstreamFailure(exception); } } public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) { @@ -54,13 +59,15 @@ public class OrderMasterDataGuard { loadWarehouse(tenantId,receivingWarehouseId,"收货仓库"); if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库"); } catch (FeignException.NotFound exception) { - throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在"); + throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业"); + } catch (FeignException.Conflict exception) { + throw new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用"); } catch (FeignException exception) { - throw unavailable("主数据"); + throw downstreamFailure(exception); } catch (BizException exception) { throw exception; } catch (RuntimeException exception) { - throw unavailable("主数据"); + throw downstreamFailure(exception); } } private PartnerClient.Partner loadPartner(long tenantId,long id) { @@ -68,8 +75,10 @@ public class OrderMasterDataGuard { 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,"往来单位不属于当前企业或已停用"); + if (!Long.valueOf(tenantId).equals(value.tenantId())) + throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业"); + if (!"ACTIVE".equals(value.status())) + throw new BizException(ResultCode.CONFLICT,"往来单位已停用"); if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位"); return value; } @@ -78,8 +87,10 @@ public class OrderMasterDataGuard { 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+"不属于当前企业或已停用"); + if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId())) + throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业"); + if (!"ACTIVE".equals(value.warehouse().status())) + throw new BizException(ResultCode.CONFLICT,name+"已停用"); if (!Long.valueOf(id).equals(value.warehouse().id()) || !StringUtils.hasText(value.warehouse().warehouseName())) { throw unavailable(name); } @@ -88,6 +99,27 @@ public class OrderMasterDataGuard { private BizException unavailable(String name) { return new BizException(ResultCode.SERVICE_UNAVAILABLE,name+"服务暂时不可用"); } + private BizException downstreamFailure(RuntimeException exception) { + FeignException feignException = findFeignException(exception); + if (feignException != null) { + if (feignException.status() == 404) + return new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业"); + if (feignException.status() == 409) + return new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用"); + if (feignException.status() == 403) + return new BizException(ResultCode.SERVICE_UNAVAILABLE, + "主数据内部服务凭证不一致,请检查 INTERNAL_SERVICE_TOKEN"); + } + log.warn("订单主数据内部调用失败,已转换为 S0503", exception); + return unavailable("主数据"); + } + private FeignException findFeignException(Throwable exception) { + Throwable current = exception; + for (int depth = 0; current != null && depth < 10; depth++, current = current.getCause()) { + if (current instanceof FeignException feignException) return feignException; + } + return null; + } public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving, WarehouseClient.Detail destination) { } } diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index 850794351..f80a6c630 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -31,3 +31,14 @@ baiyue: url: ${IAM_SERVICE_URL:} warehouse: url: ${WAREHOUSE_SERVICE_URL:} + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 417ac2277..aa51d2c82 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -6,11 +6,16 @@ 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 feign.Request; +import feign.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -71,6 +76,67 @@ class OrderMasterDataGuardTest { .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } + @Test + void downstreamForbiddenExplainsInternalCredentialMismatch() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenThrow(feign(403)); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .satisfies(exception -> { + BizException businessException = (BizException) exception; + assertThat(businessException.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + assertThat(businessException.getMessage()).contains("内部服务凭证"); + }); + } + @Test + void downstreamNotFoundMapsToBusinessNotFound() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenThrow(feign(404)); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + @Test + void wrappedDownstreamConflictMapsToBusinessConflict() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("wrapped", feign(409))); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + @Test + void downstreamServerErrorMapsToServiceUnavailable() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenThrow(feign(500)); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + @Test + void inactiveMasterDataInSuccessfulResponseMapsToConflict() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)) + .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE"))); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + private FeignException feign(int status) { + Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test", + Map.of(), null, StandardCharsets.UTF_8, null); + Response response = Response.builder().status(status).reason("test").request(request) + .headers(Map.of()).build(); + return FeignException.errorStatus("masterData", response); + } 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, diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml index f97b78029..ac3acea13 100644 --- a/print/src/main/resources/application.yml +++ b/print/src/main/resources/application.yml @@ -24,3 +24,14 @@ baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} management.endpoints.web.exposure.include: health,info,prometheus + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/scripts/local-env.ps1 b/scripts/local-env.ps1 index d14fb33f5..8d4a40c25 100644 --- a/scripts/local-env.ps1 +++ b/scripts/local-env.ps1 @@ -13,6 +13,7 @@ $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:WAREHOUSE_SERVICE_URL = 'http://127.0.0.1:9205' $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' diff --git a/scripts/migrate-local-qualification-file.ps1 b/scripts/migrate-local-qualification-file.ps1 index 52a74dcfe..6f14dd4c1 100644 --- a/scripts/migrate-local-qualification-file.ps1 +++ b/scripts/migrate-local-qualification-file.ps1 @@ -53,8 +53,8 @@ WHERE id = $QualificationId AND tenant_id = $TenantId AND document_url = '$escapedOldUrl' AND deleted = 0; --- 仅当 ROW_COUNT() = 1 时提交,否则回滚并删除:$target -COMMIT; +SELECT ROW_COUNT() AS affected_rows; +-- 仅当 affected_rows = 1 且平台预览校验成功时,由执行人员显式 COMMIT;否则 ROLLBACK 并删除:$target "@ $rollbackSql = @" START TRANSACTION; @@ -64,8 +64,8 @@ WHERE id = $QualificationId AND tenant_id = $TenantId AND document_url = '$escapedNewUrl' AND deleted = 0; --- 仅当 ROW_COUNT() = 1 时提交;确认旧文件可用后再删除:$target -COMMIT; +SELECT ROW_COUNT() AS affected_rows; +-- 仅当 affected_rows = 1 且旧文件可用时,由执行人员显式 COMMIT;否则 ROLLBACK。 "@ $forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql" diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 3e3b106cb..cf4004b43 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -29,6 +29,7 @@ - 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 +- 内部有效性查询必须区分“不存在或跨租户=404”和“已停用=409”,Order 依赖该状态码生成稳定业务错误。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 - 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。 - `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java index d5dac3d2c..d88c30e47 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java @@ -15,8 +15,10 @@ public class PartnerQueryService { 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,"往来单位不存在、已停用或不属于当前企业"); + PartnerEntity value=repository.findOwned(tenantId,id); + if (value==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位不存在或不属于当前企业"); + if (!"ACTIVE".equals(value.getStatus())) + throw new BizException(ResultCode.CONFLICT,"往来单位已停用"); 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 index f2ca67760..8c16805a4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -79,9 +79,12 @@ public class WarehouseApplicationService { } public WarehouseDetailResponse activeDetail(long tenantId, long id) { - WarehouseEntity warehouse = repository.findActiveOwned(tenantId, id); + WarehouseEntity warehouse = repository.findOwned(tenantId, id); if (warehouse == null) { - throw new BizException(ResultCode.NOT_FOUND, "仓库不存在、已停用或不属于当前企业"); + throw new BizException(ResultCode.NOT_FOUND, "仓库不存在或不属于当前企业"); + } + if (!"ACTIVE".equals(warehouse.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "仓库已停用"); } return new WarehouseDetailResponse(warehouse, repository.findAddresses(tenantId, id), repository.findCapabilities(tenantId, id).stream().filter(item -> Boolean.TRUE.equals(item.getEnabled())).toList()); diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index ec3531b85..21beccf0e 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -25,3 +25,14 @@ baiyue: 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 + +logging: + file: + name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" + logback: + rollingpolicy: + file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" + max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" + clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 9824f18fc..d8b80c22e 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.DisplayName; @@ -16,19 +17,29 @@ class WarehouseApplicationServiceTest { void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() { 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.findOwned(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); + verify(repository).findOwned(10001,1); } @Test - @DisplayName("停用或跨租户仓库按不存在处理") - void shouldTreatInactiveOrCrossTenantWarehouseAsNotFound() { + @DisplayName("跨租户或不存在仓库返回 404") + void shouldTreatMissingOrCrossTenantWarehouseAsNotFound() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) - .hasMessageContaining("停用"); + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + } + @Test + @DisplayName("停用仓库返回 409") + void shouldTreatInactiveWarehouseAsConflict() { + WarehouseRepository repository=mock(WarehouseRepository.class); + WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("INACTIVE"); + when(repository.findOwned(10001,1)).thenReturn(warehouse); + WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); + assertThatThrownBy(() -> service.activeDetail(10001,1)).isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.CONFLICT); } }