暂存提交
This commit is contained in:
@@ -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}"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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,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:}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "不能通过成员管理接口创建或转让企业所有者");
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -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}"
|
||||||
|
|||||||
+16
-5
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user