暂存提交

This commit is contained in:
2026-07-31 18:10:32 +08:00
parent e834482717
commit 9a37657148
35 changed files with 435 additions and 44 deletions
+8 -1
View File
@@ -17,7 +17,7 @@
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` | | `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` | | `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.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/` | | `auth/` | 登录、令牌、注册、账号与密码 | `InternalAccountController.java`、`AccountManagementService.java`、`src/main/resources/db/` |
| `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` | | `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` |
| `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` | | `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/`。 - 服务本地配置在各模块 `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/<domain>/` 起算;资源路径从模块根目录起算。 - 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。 - 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。 - `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。 - 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。 - 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。 - 平台管理与头程履约补齐的本地数据库只读核验、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`。 - 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。 - `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
@@ -49,3 +51,8 @@
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 - 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 对外 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` 发布并重启相关服务。
@@ -24,3 +24,14 @@ spring:
baiyue.audit.enabled: false baiyue.audit.enabled: false
management.endpoints.web.exposure.include: health,info,prometheus 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}"
+11
View File
@@ -42,3 +42,14 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:} url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus 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}"
+3 -7
View File
@@ -4,10 +4,6 @@ spring:
username: ${FILE_DB_USERNAME} username: ${FILE_DB_USERNAME}
password: ${FILE_DB_PASSWORD} password: ${FILE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
file:
storage: # 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。
provider: ${FILE_STORAGE_PROVIDER:oss} baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
endpoint: ${FILE_STORAGE_ENDPOINT:}
bucket: ${FILE_STORAGE_BUCKET:}
access-key: ${FILE_STORAGE_ACCESS_KEY:}
secret-key: ${FILE_STORAGE_SECRET_KEY:}
+4
View File
@@ -4,3 +4,7 @@ spring:
username: ${ORDER_DB_USERNAME} username: ${ORDER_DB_USERNAME}
password: ${ORDER_DB_PASSWORD} password: ${ORDER_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+1 -1
View File
@@ -7,7 +7,7 @@ baiyue:
security: security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
issuer: ${JWT_ISSUER:auth-service} 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} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
openapi.enabled: ${SWAGGER_ENABLED:true} openapi.enabled: ${SWAGGER_ENABLED:true}
audit.enabled: true audit.enabled: true
+2 -1
View File
@@ -4,4 +4,5 @@ spring.datasource:
username: ${ORDER_DB_USERNAME:root} username: ${ORDER_DB_USERNAME:root}
password: ${ORDER_DB_PASSWORD:123456} password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients: baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:http://127.0.0.1:9205} # 默认留空并使用 Nacos 的 warehouse-service 实例;仅独立端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+1 -2
View File
@@ -6,8 +6,7 @@ RUN addgroup -S app \
WORKDIR /app WORKDIR /app
# 预建运行时数据和日志目录并归属 app 用户:bind mount 首次挂载时会继承此属主, # 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
# 否则 Docker 以 root:root 0755 创建挂载点,非 root 容器无法写入。
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs
ARG JAR_FILE ARG JAR_FILE
+10 -2
View File
@@ -43,6 +43,14 @@ WHERE deleted = 0
5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。 5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。
6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。 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: 当前 File 服务使用本地或共享文件目录时,可先运行仓库内脚本生成真实文件、前向 SQL 和回滚 SQL:
```powershell ```powershell
@@ -54,7 +62,7 @@ WHERE deleted = 0
-OldDocumentUrl /phase1-seed/demo-business-license.jpg -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 document_url = :expected_old_url
AND deleted = 0; AND deleted = 0;
-- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。 -- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。
COMMIT; -- 执行人员检查后显式 COMMIT 或 ROLLBACK,模板不自动提交。
``` ```
## 新环境初始化 ## 新环境初始化
@@ -35,3 +35,14 @@ management:
springdoc: springdoc:
api-docs: api-docs:
path: /v3/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}"
@@ -35,12 +35,23 @@ public class LocalQualificationStorage {
/** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */
@PostConstruct @PostConstruct
public void initialize() { public void initialize() {
Path probe = null;
try { try {
Files.createDirectories(root); Files.createDirectories(root);
if (!Files.isDirectory(root) || !Files.isWritable(root)) { if (!Files.isDirectory(root) || !Files.isWritable(root)) {
throw new IOException("存储根目录不可写"); 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) { } catch (IOException exception) {
if (probe != null) {
try {
Files.deleteIfExists(probe);
} catch (IOException cleanupException) {
exception.addSuppressed(cleanupException);
}
}
LOGGER.error("企业资质存储根目录不可用:{}", root, exception); LOGGER.error("企业资质存储根目录不可用:{}", root, exception);
throw new IllegalStateException("企业资质存储根目录不可用", exception); throw new IllegalStateException("企业资质存储根目录不可用", exception);
} }
+11
View File
@@ -34,3 +34,14 @@ baiyue:
file: file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
management.endpoints.web.exposure.include: health,info,prometheus 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}"
@@ -34,6 +34,7 @@ class LocalQualificationStorageTest {
assertTrue(Files.isRegularFile(stored.path())); assertTrue(Files.isRegularFile(stored.path()));
assertEquals(stored.path().toAbsolutePath(), assertEquals(stored.path().toAbsolutePath(),
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
assertThrows(BizException.class, () -> storage.load(10002L, stored.fileName()));
} }
@Test @Test
@@ -24,3 +24,14 @@ baiyue:
security: security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
management.endpoints.web.exposure.include: health,info,prometheus 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}"
+1
View File
@@ -15,6 +15,7 @@
| JWT 与安全配置项 | `config/GatewaySecurityProperties.java` | | JWT 与安全配置项 | `config/GatewaySecurityProperties.java` |
| Redis 会话过滤 | `config/RedisTokenSessionWebFilter.java` | | Redis 会话过滤 | `config/RedisTokenSessionWebFilter.java` |
| CORS | `config/GatewayCorsConfiguration.java`、`GatewayCorsProperties.java` | | CORS | `config/GatewayCorsConfiguration.java`、`GatewayCorsProperties.java` |
| 请求标识 | `filter/CorrelationIdWebFilter.java`;包括预检和安全失败响应的 `X-Request-Id` |
| 可信身份头清理/注入 | `filter/TrustedIdentityHeaderFilter.java` | | 可信身份头清理/注入 | `filter/TrustedIdentityHeaderFilter.java` |
| 本地路由 | `src/main/resources/application.yml` | | 本地路由 | `src/main/resources/application.yml` |
| 国内环境与 Swagger 聚合 | `src/main/resources/application-cn.yml`、`../config/nacos/{local,cn}/gateway.yml` | | 国内环境与 Swagger 聚合 | `src/main/resources/application-cn.yml`、`../config/nacos/{local,cn}/gateway.yml` |
@@ -205,3 +205,14 @@ springdoc:
url: /integration-service/v3/api-docs url: /integration-service/v3/api-docs
- name: event-bridge - name: event-bridge
url: /event-bridge-service/v3/api-docs 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}"
@@ -9,13 +9,17 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.test.web.reactive.server.WebTestClient; 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 = { @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.cloud.nacos.discovery.enabled=false", "spring.cloud.nacos.discovery.enabled=false",
"spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.discovery.register-enabled=false",
"spring.cloud.nacos.config.enabled=false", "spring.cloud.nacos.config.enabled=false",
"spring.cloud.gateway.discovery.locator.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false",
"baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long", "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 @AutoConfigureWebTestClient
@DisplayName("网关跨域配置功能") @DisplayName("网关跨域配置功能")
@@ -26,6 +30,30 @@ class GatewayCorsConfigurationTest {
@Autowired @Autowired
private WebTestClient webTestClient; 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 @Test
void allowedOriginCanCompletePutPreflightWithoutToken() { void allowedOriginCanCompletePutPreflightWithoutToken() {
webTestClient.options() webTestClient.options()
@@ -52,6 +80,7 @@ class GatewayCorsConfigurationTest {
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name()) .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name())
.exchange() .exchange()
.expectStatus().isForbidden() .expectStatus().isForbidden()
.expectHeader().exists("X-Request-Id")
.expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); .expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
} }
+1
View File
@@ -32,6 +32,7 @@
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
- 成员新增携带角色、编辑时变更或清空角色,必须额外具有 `merchant:member:grant-role`;仅资料编辑且角色集合不变时不要求该权限。
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 - `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
@@ -92,7 +92,8 @@ public class MerchantTenantController {
@PreAuthorize("hasAuthority('merchant:member:create')") @PreAuthorize("hasAuthority('merchant:member:create')")
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request, public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @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+}") @PutMapping("/members/{membershipId:\\d+}")
@@ -100,7 +101,8 @@ public class MerchantTenantController {
@Operation(summary = "编辑企业成员及角色") @Operation(summary = "编辑企业成员及角色")
public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId, public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId,
@Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { @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") @PutMapping("/members/{membershipId:\\d+}/status")
@@ -18,13 +18,16 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */ /** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */
@Service @Service
public class MemberService { public class MemberService {
private static final String GRANT_ROLE_PERMISSION = "merchant:member:grant-role";
private static final Logger log = LoggerFactory.getLogger(MemberService.class); private static final Logger log = LoggerFactory.getLogger(MemberService.class);
private final MemberRepository repository; private final MemberRepository repository;
private final IamRoleMapper roleMapper; private final IamRoleMapper roleMapper;
@@ -49,8 +52,9 @@ public class MemberService {
@Transactional @Transactional
@BusinessAudit(event = "MEMBER_CREATE", resourceType = "MEMBERSHIP", resourceId = "#request.username", description = "新增企业成员") @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<String> operatorPermissions) {
rejectOwnerMutation(request.memberType()); rejectOwnerMutation(request.memberType());
requireGrantRoleIfChanged(List.of(), request.roleIds(), operatorPermissions);
validateRoles(tenantId, request.roleIds()); validateRoles(tenantId, request.roleIds());
validateMemberNo(tenantId, request.memberNo(), null); validateMemberNo(tenantId, request.memberNo(), null);
long authId = IdWorker.getId(); long authId = IdWorker.getId();
@@ -84,11 +88,14 @@ public class MemberService {
@Transactional @Transactional
@BusinessAudit(event = "MEMBER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "编辑企业成员") @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<String> operatorPermissions) {
rejectOwnerMutation(request.memberType()); rejectOwnerMutation(request.memberType());
IamMembership membership = required(tenantId, membershipId);
List<Long> currentRoleIds = repository.findRoleIds(tenantId, membershipId);
requireGrantRoleIfChanged(currentRoleIds, request.roleIds(), operatorPermissions);
validateRoles(tenantId, request.roleIds()); validateRoles(tenantId, request.roleIds());
validateMemberNo(tenantId, request.memberNo(), membershipId); validateMemberNo(tenantId, request.memberNo(), membershipId);
IamMembership membership = required(tenantId, membershipId);
if ("OWNER".equals(membership.getMemberType())) { if ("OWNER".equals(membership.getMemberType())) {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑"); throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑");
} }
@@ -171,6 +178,13 @@ public class MemberService {
throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色");
} }
} }
private void requireGrantRoleIfChanged(List<Long> currentRoleIds, List<Long> requestedRoleIds,
Set<String> 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) { private void rejectOwnerMutation(String memberType) {
if ("OWNER".equals(memberType)) { if ("OWNER".equals(memberType)) {
throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者"); throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者");
+11
View File
@@ -29,3 +29,14 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} 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} 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 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}"
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.MemberDtos.*; import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
@@ -14,6 +15,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
@@ -80,11 +82,88 @@ class MemberServiceTest {
UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null,
"EMPLOYEE", null, List.of()); "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()); 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() { @Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() {
member.setMemberType("OWNER"); member.setMemberType("OWNER");
@@ -64,3 +64,14 @@ management:
health: health:
probes: probes:
enabled: true 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}"
+1
View File
@@ -28,6 +28,7 @@
- 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。 - 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。
- 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 - 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。 - 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。
@@ -11,9 +11,12 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.Objects; import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service @Service
public class OrderMasterDataGuard { public class OrderMasterDataGuard {
private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class);
private final WarehouseClient warehouses; private final WarehouseClient warehouses;
private final PartnerClient partners; private final PartnerClient partners;
private final String token; private final String token;
@@ -37,13 +40,15 @@ public class OrderMasterDataGuard {
} }
return new Snapshots(partner,receiving,destination); return new Snapshots(partner,receiving,destination);
} catch (FeignException.NotFound exception) { } 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) { } catch (FeignException exception) {
throw unavailable("主数据"); throw downstreamFailure(exception);
} catch (BizException exception) { } catch (BizException exception) {
throw exception; throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw unavailable("主数据"); throw downstreamFailure(exception);
} }
} }
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) { public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) {
@@ -54,13 +59,15 @@ public class OrderMasterDataGuard {
loadWarehouse(tenantId,receivingWarehouseId,"收货仓库"); loadWarehouse(tenantId,receivingWarehouseId,"收货仓库");
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库"); if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
} catch (FeignException.NotFound exception) { } 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) { } catch (FeignException exception) {
throw unavailable("主数据"); throw downstreamFailure(exception);
} catch (BizException exception) { } catch (BizException exception) {
throw exception; throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw unavailable("主数据"); throw downstreamFailure(exception);
} }
} }
private PartnerClient.Partner loadPartner(long tenantId,long id) { private PartnerClient.Partner loadPartner(long tenantId,long id) {
@@ -68,8 +75,10 @@ public class OrderMasterDataGuard {
if (response==null || !response.successful() || response.data()==null) if (response==null || !response.successful() || response.data()==null)
throw unavailable("往来单位"); throw unavailable("往来单位");
var value=response.data(); var value=response.data();
if (!Long.valueOf(tenantId).equals(value.tenantId()) || !"ACTIVE".equals(value.status())) if (!Long.valueOf(tenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业或已停用"); throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位"); if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位");
return value; return value;
} }
@@ -78,8 +87,10 @@ public class OrderMasterDataGuard {
if (response==null || !response.successful() || response.data()==null || response.data().warehouse()==null) if (response==null || !response.successful() || response.data()==null || response.data().warehouse()==null)
throw unavailable(name); throw unavailable(name);
var value=response.data(); var value=response.data();
if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()) || !"ACTIVE".equals(value.warehouse().status())) if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()))
throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业或已停用"); 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())) { if (!Long.valueOf(id).equals(value.warehouse().id()) || !StringUtils.hasText(value.warehouse().warehouseName())) {
throw unavailable(name); throw unavailable(name);
} }
@@ -88,6 +99,27 @@ public class OrderMasterDataGuard {
private BizException unavailable(String name) { private BizException unavailable(String name) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE,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, public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
WarehouseClient.Detail destination) { } WarehouseClient.Detail destination) { }
} }
+11
View File
@@ -31,3 +31,14 @@ baiyue:
url: ${IAM_SERVICE_URL:} url: ${IAM_SERVICE_URL:}
warehouse: warehouse:
url: ${WAREHOUSE_SERVICE_URL:} 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}"
@@ -6,11 +6,16 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests; 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.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -71,6 +76,67 @@ class OrderMasterDataGuardTest {
.extracting(exception -> ((BizException) exception).getResultCode()) .extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE); .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) { private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
+11
View File
@@ -24,3 +24,14 @@ baiyue:
security: security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
management.endpoints.web.exposure.include: health,info,prometheus 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}"
+1
View File
@@ -13,6 +13,7 @@ $env:WAREHOUSE_DB_PASSWORD = '123456'
$env:FILE_DB_PASSWORD = '123456' $env:FILE_DB_PASSWORD = '123456'
$env:PRINT_DB_PASSWORD = '123456' $env:PRINT_DB_PASSWORD = '123456'
$env:IAM_SERVICE_URL = 'http://127.0.0.1:9201' $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:JWT_SECRET = 'phase1-local-test-jwt-secret-at-least-32-bytes'
$env:INTERNAL_SERVICE_TOKEN = 'phase1-local-internal-token-at-least-32-bytes' $env:INTERNAL_SERVICE_TOKEN = 'phase1-local-internal-token-at-least-32-bytes'
$env:SWAGGER_ENABLED = 'true' $env:SWAGGER_ENABLED = 'true'
+4 -4
View File
@@ -53,8 +53,8 @@ WHERE id = $QualificationId
AND tenant_id = $TenantId AND tenant_id = $TenantId
AND document_url = '$escapedOldUrl' AND document_url = '$escapedOldUrl'
AND deleted = 0; AND deleted = 0;
-- 仅当 ROW_COUNT() = 1 时提交,否则回滚并删除:$target SELECT ROW_COUNT() AS affected_rows;
COMMIT; -- 仅当 affected_rows = 1 且平台预览校验成功时,由执行人员显式 COMMIT;否则 ROLLBACK 并删除:$target
"@ "@
$rollbackSql = @" $rollbackSql = @"
START TRANSACTION; START TRANSACTION;
@@ -64,8 +64,8 @@ WHERE id = $QualificationId
AND tenant_id = $TenantId AND tenant_id = $TenantId
AND document_url = '$escapedNewUrl' AND document_url = '$escapedNewUrl'
AND deleted = 0; AND deleted = 0;
-- 仅当 ROW_COUNT() = 1 时提交;确认旧文件可用后再删除:$target SELECT ROW_COUNT() AS affected_rows;
COMMIT; -- 仅当 affected_rows = 1 且旧文件可用时,由执行人员显式 COMMIT;否则 ROLLBACK。
"@ "@
$forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql" $forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql"
+1
View File
@@ -29,6 +29,7 @@
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 - 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- 内部有效性查询必须区分“不存在或跨租户=404”和“已停用=409”,Order 依赖该状态码生成稳定业务错误。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。 - 既有数据库发布必须按 `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`。 - `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。
@@ -15,8 +15,10 @@ public class PartnerQueryService {
return repository.activeCandidates(tenantId, keyword, type); return repository.activeCandidates(tenantId, keyword, type);
} }
public PartnerEntity active(long tenantId, long id) { public PartnerEntity active(long tenantId, long id) {
PartnerEntity value=repository.findActive(tenantId,id); PartnerEntity value=repository.findOwned(tenantId,id);
if (value==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位不存在、已停用或不属于当前企业"); if (value==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位不存在或不属于当前企业");
if (!"ACTIVE".equals(value.getStatus()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
return value; return value;
} }
} }
@@ -79,9 +79,12 @@ public class WarehouseApplicationService {
} }
public WarehouseDetailResponse activeDetail(long tenantId, long id) { public WarehouseDetailResponse activeDetail(long tenantId, long id) {
WarehouseEntity warehouse = repository.findActiveOwned(tenantId, id); WarehouseEntity warehouse = repository.findOwned(tenantId, id);
if (warehouse == null) { 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), return new WarehouseDetailResponse(warehouse, repository.findAddresses(tenantId, id),
repository.findCapabilities(tenantId, id).stream().filter(item -> Boolean.TRUE.equals(item.getEnabled())).toList()); repository.findCapabilities(tenantId, id).stream().filter(item -> Boolean.TRUE.equals(item.getEnabled())).toList());
@@ -25,3 +25,14 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} 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} 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 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}"
@@ -1,6 +1,7 @@
package com.baiyue.warehouse.service; package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository; import com.baiyue.warehouse.repository.WarehouseRepository;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -16,19 +17,29 @@ class WarehouseApplicationServiceTest {
void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() { void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() {
WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseRepository repository=mock(WarehouseRepository.class);
WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); 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.findAddresses(10001,1)).thenReturn(java.util.List.of());
when(repository.findCapabilities(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); WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository);
assertThat(service.activeDetail(10001,1).warehouse()).isSameAs(warehouse); assertThat(service.activeDetail(10001,1).warehouse()).isSameAs(warehouse);
verify(repository).findActiveOwned(10001,1); verify(repository).findOwned(10001,1);
} }
@Test @Test
@DisplayName("停用或跨租户仓库按不存在处理") @DisplayName("跨租户或不存在仓库返回 404")
void shouldTreatInactiveOrCrossTenantWarehouseAsNotFound() { void shouldTreatMissingOrCrossTenantWarehouseAsNotFound() {
WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseRepository repository=mock(WarehouseRepository.class);
WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository);
assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) 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);
} }
} }