From f8804fbc52eff0e26f750011e39606a990c0570f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 31 Jul 2026 14:57:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/scripts/initialize-test-databases.sh | 36 ++ docs/2026-07-31后端缺口修复回归报告.md | 109 ++++++ docs/企业资质文件迁移说明.md | 62 ++++ docs/重量OCR接入说明.md | 91 +++++ .../QualificationFileContractTest.java | 28 ++ .../LocalQualificationStorageTest.java | 42 +++ .../firstleg/dto/FulfillmentTaskQuery.java | 27 ++ .../db/phase1-firstleg-task-list-index.sql | 14 + .../main/resources/openapi/firstleg-v1.yaml | 106 ++++++ ...FulfillmentTaskControllerContractTest.java | 55 +++ .../service/FulfillmentTaskServiceTest.java | 65 ++++ .../controller/AuthorizationController.java | 55 +++ .../iam/dto/PlatformInternalUserDtos.java | 59 ++++ .../service/PlatformInternalUserService.java | 331 ++++++++++++++++++ .../AuthorizationControllerContractTest.java | 23 ++ .../controller/IamOpenApiContractTest.java | 34 ++ .../PlatformInternalUserContractTest.java | 48 +++ .../PlatformTenantControllerContractTest.java | 45 +++ .../QualificationUploadContractTest.java | 40 +++ .../PlatformInternalUserServiceTest.java | 146 ++++++++ .../baiyue/iam/service/TenantServiceTest.java | 44 +++ .../controller/WeightOcrController.java | 41 +++ .../integration/dto/ocr/OcrCandidate.java | 20 ++ .../dto/ocr/WeightOcrBatchRequest.java | 32 ++ .../dto/ocr/WeightOcrBatchResult.java | 21 ++ .../dto/ocr/WeightOcrImageResult.java | 24 ++ .../provider/ocr/BaiduMeterClient.java | 220 ++++++++++++ .../integration/service/ocr/ImageCropper.java | 81 +++++ .../service/ocr/WeightCandidateSelector.java | 60 ++++ .../service/ocr/WeightOcrBatchService.java | 207 +++++++++++ .../service/ocr/WeightOcrStoragePolicy.java | 89 +++++ .../service/ocr/ImageCropperTest.java | 52 +++ .../ocr/WeightCandidateSelectorTest.java | 33 ++ .../ocr/WeightOcrBatchServiceTest.java | 73 ++++ .../ocr/WeightOcrStoragePolicyTest.java | 57 +++ .../entity/OrderIdempotencyRecordEntity.java | 32 ++ .../mapper/OrderIdempotencyRecordMapper.java | 8 + .../src/main/resources/openapi/order-v1.yaml | 67 ++++ .../OrderControllerPermissionTest.java | 36 ++ .../service/OrderIdempotencyServiceTest.java | 129 +++++++ .../PlatformExpressCompanyController.java | 114 ++++++ .../controller/PlatformPartnerController.java | 114 ++++++ .../warehouse/dto/MasterDataQueries.java | 116 ++++++ .../entity/ExpressCompanyEntity.java | 36 ++ .../mapper/ExpressCompanyMapper.java | 8 + .../repository/ExpressCompanyRepository.java | 54 +++ .../service/ExpressCompanyQueryService.java | 98 ++++++ .../service/PlatformPartnerQueryService.java | 104 ++++++ .../db/warehouse-express-company.sql | 23 ++ ...tformMasterDataControllerContractTest.java | 61 ++++ .../ExpressCompanyRepositoryTest.java | 43 +++ .../WarehouseSchemaContractTest.java | 62 ++++ ...atformMasterDataManagementServiceTest.java | 196 +++++++++++ 53 files changed, 3771 insertions(+) create mode 100644 deploy/scripts/initialize-test-databases.sh create mode 100644 docs/2026-07-31后端缺口修复回归报告.md create mode 100644 docs/企业资质文件迁移说明.md create mode 100644 docs/重量OCR接入说明.md create mode 100644 file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/FulfillmentTaskQuery.java create mode 100644 firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql create mode 100644 firstleg/src/main/resources/openapi/firstleg-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java create mode 100644 iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java create mode 100644 order/src/main/java/com/baiyue/order/entity/OrderIdempotencyRecordEntity.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/OrderIdempotencyRecordMapper.java create mode 100644 order/src/main/resources/openapi/order-v1.yaml create mode 100644 order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/ExpressCompanyMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java create mode 100644 warehouse/src/main/resources/db/warehouse-express-company.sql create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh new file mode 100644 index 000000000..b3840db78 --- /dev/null +++ b/deploy/scripts/initialize-test-databases.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +set -eu + +# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句, +# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。 +MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}" + +if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then + echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2 + echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2 + exit 1 +fi + +if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then + echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2 + exit 1 +fi + +for sql_file in \ + iam/src/main/resources/db/iam.sql \ + order/src/main/resources/db/order.sql \ + order/src/main/resources/db/phase1-order-extension.sql \ + order/src/main/resources/db/phase1-masterdata-extension.sql \ + warehouse/src/main/resources/db/warehouse.sql \ + warehouse/src/main/resources/db/warehouse-partner.sql \ + warehouse/src/main/resources/db/warehouse-seed.sql +do + if [ ! -f "$sql_file" ]; then + echo "找不到初始化 SQL:$sql_file" >&2 + exit 1 + fi + echo "初始化测试数据库:$sql_file" + docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" +done + +echo "测试数据库初始化完成。" diff --git a/docs/2026-07-31后端缺口修复回归报告.md b/docs/2026-07-31后端缺口修复回归报告.md new file mode 100644 index 000000000..fad6996fb --- /dev/null +++ b/docs/2026-07-31后端缺口修复回归报告.md @@ -0,0 +1,109 @@ +# 后端缺口修复回归报告 + +日期:2026-07-31 +范围:Auth、IAM、Order、Firstleg、Warehouse、File 及其公共依赖 +测试边界:未连接或写入测试数据库,未启动业务服务,未占用现有服务端口。 + +## 1. 修复结论 + +### 1.1 订单权限与草稿稳定性 + +- 商户订单列表增加 `merchant:order:list`,详情增加 `merchant:order:view`。 +- 平台订单列表和详情统一增加已初始化的 `platform:order:list`。 +- 订单号改为基于雪花 ID 生成,消除原秒级时间加三位随机数在并发下重复的风险。 +- 仓库、往来单位返回空名称、错误仓库 ID、空地址元素或空能力元素时,转换为受控业务错误,不再进入未捕获空指针。 +- 地址和收货能力快照格式化增加空元素防护。 + +### 1.2 平台内部用户与权限 + +- 内部用户列表改为明确 DTO,返回 `membershipId`、`userId`、`authUserId`、登录名、姓名、电话、邮箱、成员编号、状态和 `roleIds`。 +- 列表增加 `platform:user:list` 服务端权限校验。 +- 保留原新增路径,兼容关联已有 Auth 账号,同时支持完整创建平台账号。 +- 新增编辑、启停和重置密码接口,分别校验 `platform:user:edit`、`platform:user:status`、`platform:user:reset-password`。 +- Auth 支持平台应用域账号、账号创建幂等、显示名同步、状态变更和密码重置。 +- IAM/Auth 创建、资料和状态写入增加补偿处理;成功写入后不再执行可能破坏一致性的远程查询。 +- 企业资料查询增加 `merchant:profile:view` 权限校验。 + +### 1.3 企业资质文件契约 + +- File multipart 接口成为唯一公开资质上传入口。 +- 删除 IAM 旧 JSON POST 路由、DTO、Service 固定冲突方法以及 OpenAPI 成功声明。 +- 初始化 SQL 不再生成 `/phase1-seed/...` 虚假已审核资质。 +- 新增 `企业资质文件迁移说明.md`,要求取得真实原文件、写入 File 存储并校验后,才能事务更新旧记录 URL。 + +### 1.4 订单持久化幂等与错误语义 + +- 创建草稿和提交订单强制接收 `Idempotency-Key`,唯一范围为租户、操作者、操作和幂等键。 +- 请求摘要包含资源与请求内容;同键同请求回放首次结果,同键不同请求或处理中请求返回 409。 +- 幂等记录、业务写入和完成状态位于同一本地事务,数据库唯一键负责并发互斥。 +- 不存在订单优先返回 404;Warehouse 下游异常、空响应和无效主数据转换为受控 404、403、422 或 503。 + +### 1.5 Firstleg 与 Warehouse 管理闭环 + +- Firstleg 增加任务分页、详情、按订单查询、分配历史和分配/改派权限,所有仓储查询均带目标租户。 +- `idx_task_tenant_created` 同时写入新库建表脚本和可重复执行的既有库增量迁移。 +- Warehouse 增加租户级往来单位、快递公司分页、详情、新增、编辑和启停;停用保留历史主数据,不物理删除。 +- IAM 同步新增六个 Warehouse 按钮权限及四个 Firstleg 权限,并保持平台管理员、订单运营角色授权一致。 + +## 2. 自动化回归 + +独立总审执行命令: + +```powershell +mvn -pl iam,warehouse,order,firstleg -am clean test +``` + +结果:在完整纯 ASCII 临时构建副本中执行,10 个 Reactor 模块全部构建成功。副本文件数与实施工作树一致;未连接数据库、未启动服务。正式仓同步后还会再次执行同一 `clean test`。 + +| 模块 | 测试数 | 结果 | +|---|---:|---| +| common-core | 2 | 通过 | +| common-web | 4 | 通过 | +| common-security | 6 | 通过 | +| auth-service | 10 | 通过 | +| iam-service | 48 | 通过 | +| order-service | 14 | 通过 | +| firstleg-service | 6 | 通过 | +| warehouse-service | 23 | 通过 | + +重点新增覆盖:订单持久化幂等、404/409/503 语义、平台用户完整生命周期、权限初始化、企业分页、Firstleg 租户过滤与权限、Warehouse CRUD/唯一性/Schema 契约,以及 OpenAPI YAML 解析。 + +静态检查:`git diff --check` 通过;仅存在工作树既有的 CRLF/LF 转换提示,不属于补丁空白错误。 + +## 3. 仍需环境联调确认 + +- 历史两次订单 `S0500` 的完整异常栈仍未取得,因此不能声称已还原历史唯一根因;本次已消除代码中能够确定的编号碰撞和空数据未受控风险。 +- 测试库旧 `/phase1-seed/...` 记录没有原文件,不能安全自动改成随机 UUID。本次没有写测试库,需取得真实营业执照文件后按迁移说明处理。 +- 本地 Warehouse 五张表与 Entity/SQL 的只读核验一致;历史查询 500 没有异常栈,不能把其唯一根因归结为 Schema 漂移。 +- `PENDING`、`REJECTED` 资质样本仍需由测试负责人准备脱敏数据后,完成浏览器五状态验收。 +- Dashboard 数量统计、并发请求隔离、示例 Store 和前端强制授予 `dashboard:view` 属于前端问题,不在后端增加虚假兼容逻辑。 + +## 4. 索引同步 + +- 已更新 `order/AGENTS.md`、`iam/AGENTS.md`、`auth/AGENTS.md`、`file/AGENTS.md`。 +- 新增平台内部用户 DTO、Service 和测试入口后,已同步更新根目录 `AGENTS.md`。 +- 企业资质迁移说明和本报告均为中文文档。 +- 已更新 `warehouse/AGENTS.md`、`firstleg/AGENTS.md` 和根 `AGENTS.md`,并在中文 API 文档补充 IAM、订单幂等、Firstleg 与 Warehouse 管理契约。 +- 正式仓同步与最终测试结果在同步完成后写入本报告末尾。 + +## 5. 正式仓同步与最终复核 + +- 审核通过的文件已按显式文件清单安全同步至 `D:\baiyue-firstleg`;未使用目录覆盖、`reset` 或清理操作,正式仓原有未提交修改已保留。 +- 正式仓已于 2026-07-31 14:52:33 执行并通过: + +```powershell +mvn "-DargLine=-Djdk.attach.allowAttachSelf=true" -pl iam,warehouse,order,firstleg -am clean test +``` + + 共 10 个 Reactor 模块构建成功;该 JVM 参数仅规避本机 Mockito 动态代理加载异常缓慢,不改变业务配置。 +- 正式仓 `git diff --check` 通过。本轮未写数据库、未启动业务服务、未占用业务端口。 + +### 5.1 未提交 SQL 执行清单(尚未执行) + +1. `order/src/main/resources/db/order.sql`:创建 `ord_idempotency_record`。 +2. `iam/src/main/resources/db/phase1-admin-rbac.sql`:补齐 Firstleg、Warehouse 权限和角色授权。 +3. `warehouse/src/main/resources/db/warehouse-express-company.sql`:创建快递公司表。 +4. `firstleg/src/main/resources/db/firstleg.sql`:新库基础结构。 +5. `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`:对既有 Firstleg 库可重复执行,补齐 `fl_fulfillment_task.idx_task_tenant_created`;新库已在基础建表脚本中包含该索引。 + +如后续获得明确数据库写入授权,应先开发库、校验后测试库,并逐项保留执行结果;本报告不构成实际写库记录。 diff --git a/docs/企业资质文件迁移说明.md b/docs/企业资质文件迁移说明.md new file mode 100644 index 000000000..bd4e6e3bc --- /dev/null +++ b/docs/企业资质文件迁移说明.md @@ -0,0 +1,62 @@ +# 企业资质文件迁移说明 + +## 统一接口边界 + +企业资质的唯一真实上传入口是: + +```http +POST /api/merchant/v1/files/qualifications +Content-Type: multipart/form-data +``` + +表单字段为 `file`,可选字段为 `certificateNo`。File 服务先校验并保存真实图片,再通过内部接口通知 IAM 创建资质和审核记录。IAM 的商户资质接口只负责查询,不再提供 JSON 上传入口。 + +有效的商户预览地址格式为: + +```text +/api/merchant/v1/files/qualifications/{uuid}.jpg +/api/merchant/v1/files/qualifications/{uuid}.png +``` + +数据库中的 URL 不是文件本身。禁止只生成 UUID 并修改 `document_url`,也禁止用空文件或任意图片冒充营业执照。 + +## 历史数据盘点 + +发布前先以只读方式盘点历史路径: + +```sql +SELECT id, tenant_id, qualification_type, document_url, status +FROM baiyue_iam.iam_tenant_qualification +WHERE deleted = 0 + AND (document_url IS NULL + OR document_url NOT REGEXP '^/api/merchant/v1/files/qualifications/[0-9a-fA-F-]{36}\\.(jpg|png)$'); +``` + +盘点结果中的每条记录都必须找到原始图片。找不到原始图片时,将记录标记为“待业务补传”,不得伪造文件,也不得直接改 URL。 + +## 可落地迁移步骤 + +1. 暂停对应资质的替换操作,记录资质 ID、租户 ID、旧 URL 和原文件校验和。 +2. 从可信旧存储取得原始图片,校验文件签名、扩展名和不超过 5 MB 的大小限制。 +3. 使用迁移工具按 File 服务同一规则生成 UUID 文件名,将真实文件写入共享存储的 `qualifications/{tenantId}/` 目录。 +4. 写入完成后,从 File 服务平台预览接口读取新文件并核对 SHA-256;读取失败时不得修改数据库。 +5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。 +6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。 + +数据库更新模板如下,执行时必须替换为经过文件校验的真实值: + +```sql +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = :verified_new_url +WHERE id = :qualification_id + AND tenant_id = :tenant_id + AND document_url = :expected_old_url + AND deleted = 0; +-- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。 +COMMIT; +``` + +## 新环境初始化 + +初始化脚本不再创建 `/phase1-seed/...` 虚假资质。测试环境需要可下单的商户时,应使用脱敏但真实存在的 JPG/PNG 文件走 File 上传、平台审核完整流程,使租户达到 `ACTIVE` 且资质达到 `APPROVED`。初始化账号、文件和审核结果应作为一套环境数据验收,不得仅靠 SQL 绕过文件保存。 diff --git a/docs/重量OCR接入说明.md b/docs/重量OCR接入说明.md new file mode 100644 index 000000000..ba1e23dbd --- /dev/null +++ b/docs/重量OCR接入说明.md @@ -0,0 +1,91 @@ +# 重量 OCR 接入说明 + +## 功能位置 + +重量 OCR 已作为功能模块并入 `integration-service`,不再作为独立微服务部署,不新增端口、数据库或注册服务名。 + +主要代码: + +- `controller/WeightOcrController.java`:内部批量识别接口。 +- `service/ocr/WeightOcrBatchService.java`:批次编排、结果汇总和文件落盘。 +- `service/ocr/ImageCropper.java`:图片下方中央区域裁剪。 +- `service/ocr/WeightCandidateSelector.java`:重量候选筛选。 +- `service/ocr/WeightOcrStoragePolicy.java`:输入、输出目录边界保护。 +- `provider/ocr/BaiduMeterClient.java`:百度令牌缓存、识别、限速和重试。 + +## 接口 + +```text +POST /internal/integrations/weight-ocr/batches +X-Internal-Service-Token: 内部服务令牌 +Content-Type: application/json +``` + +请求示例: + +```json +{ + "inputDirectory": "batch-001", + "outputDirectory": null, + "cropStartRatio": 0.5, + "horizontalTrimRatio": 0.25, + "recursive": false +} +``` + +`inputDirectory` 默认相对于 `WEIGHT_OCR_INPUT_ROOT`,`outputDirectory` 默认在 `WEIGHT_OCR_OUTPUT_ROOT` 下按批次 ID 自动创建。接口拒绝访问两个受控根目录之外的路径。 + +## 必需配置 + +```text +BAIDU_OCR_ENABLED=true +BAIDU_OCR_API_KEY=百度应用的API Key +BAIDU_OCR_SECRET_KEY=百度应用的Secret Key +INTERNAL_SERVICE_TOKEN=至少32字节的内部令牌 +``` + +本地默认目录: + +```text +${java.io.tmpdir}/baiyue-weight-ocr/input +${java.io.tmpdir}/baiyue-weight-ocr/output +``` + +国内服务器默认目录: + +```text +/data/baiyue/weight-ocr/input +/data/baiyue/weight-ocr/output +``` + +可选配置: + +| 环境变量 | 默认值 | 说明 | +| --- | ---: | --- | +| `WEIGHT_OCR_MAX_IMAGE_SIZE` | `3MB` | 单张原图及裁剪图上限 | +| `WEIGHT_OCR_MAX_IMAGE_PIXELS` | `40000000` | 解码后最大像素数 | +| `WEIGHT_OCR_MAX_BATCH_SIZE` | `50` | 单批图片上限 | +| `BAIDU_OCR_CONNECT_TIMEOUT` | `10s` | 建连超时 | +| `BAIDU_OCR_REQUEST_TIMEOUT` | `60s` | 单次请求超时 | +| `BAIDU_OCR_MIN_REQUEST_INTERVAL` | `600ms` | 百度请求最小间隔 | +| `BAIDU_OCR_MAX_QPS_RETRIES` | `3` | 百度 QPS 限制重试次数 | + +## 输出文件 + +```text +批次输出目录/ +├─ batch-results.json +├─ cropped/ +└─ raw/ +``` + +接口和结果文件仅返回相对于受控根目录的路径,不返回服务器绝对路径。真实百度响应不写入日志,只保存在批次的 `raw` 目录。 + +## 安全边界 + +- 接口仅供内部服务调用,必须校验 `X-Internal-Service-Token`。 +- 百度能力默认关闭,密钥只从环境变量或配置中心密文注入。 +- 同一实例一次只执行一个 OCR 批次,避免并发压垮第三方接口。 +- 单批顺序调用百度接口,并对 QPS 错误执行有上限的退避重试。 +- 支持 `jpg`、`jpeg`、`png`、`bmp`,同时限制文件大小和解码像素数。 +- 当前不访问业务数据库,识别结果由调用方按自己的业务边界处理。 diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java new file mode 100644 index 000000000..0ce2d6a75 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.file.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class QualificationFileContractTest { + + @Test + void fileServiceExposesTheOnlyPublicMultipartUploadEntry() { + RequestMapping baseMapping = QualificationFileController.class.getAnnotation(RequestMapping.class); + assertTrue(Arrays.asList(baseMapping.value()) + .contains("/api/merchant/v1/files/qualifications")); + + boolean hasMultipartPost = Arrays.stream(QualificationFileController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> Arrays.stream(mapping.consumes())) + .anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals); + + assertTrue(hasMultipartPost, "企业资质真实上传入口必须保持 multipart/form-data 契约"); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java new file mode 100644 index 000000000..292f503c7 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -0,0 +1,42 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalQualificationStorageTest { + + @TempDir + Path storageRoot; + + @Test + void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception { + var storage = new LocalQualificationStorage(storageRoot.toString()); + byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; + var upload = new MockMultipartFile("file", "license.png", "image/png", png); + + var stored = storage.store(10001L, upload); + + assertTrue(stored.documentUrl().matches( + "/api/merchant/v1/files/qualifications/[0-9a-f-]{36}\\.png")); + assertTrue(Files.isRegularFile(stored.path())); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + } + + @Test + void legacySeedPathCannotBePretendedToBeAStoredFile() { + var storage = new LocalQualificationStorage(storageRoot.toString()); + + assertThrows(BizException.class, + () -> storage.load(10001L, "demo-business-license.jpg")); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FulfillmentTaskQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FulfillmentTaskQuery.java new file mode 100644 index 000000000..d4b3446e8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FulfillmentTaskQuery.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; + +/** 头程履约任务分页查询条件。 */ +@Schema(name = "FulfillmentTaskQuery", description = "头程履约任务分页查询条件") +public record FulfillmentTaskQuery( + @Schema(description = "订单号关键字") @Size(max = 64) String keyword, + @Schema(description = "任务状态") + @Size(max = 24) @Pattern(regexp = "PENDING_ASSIGNMENT|ASSIGNED|PROCESSING|COMPLETED|CANCELLED") String status, + @Schema(description = "负责人用户ID") Long assigneeId, + @Schema(description = "页码", defaultValue = "1") @Min(1) long page, + @Schema(description = "每页条数", defaultValue = "20") @Min(1) @Max(200) long size +) { + public FulfillmentTaskQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } +} diff --git a/firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql b/firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql new file mode 100644 index 000000000..24178a644 --- /dev/null +++ b/firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql @@ -0,0 +1,14 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 既有履约任务表补充分页查询索引;重复执行时不重复创建。 +SET @firstleg_task_list_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_fulfillment_task' + AND index_name = 'idx_task_tenant_created'), + 'SELECT 1', + 'CREATE INDEX idx_task_tenant_created ON fl_fulfillment_task (tenant_id, created_at)' +); +PREPARE firstleg_task_list_index_stmt FROM @firstleg_task_list_index_sql; +EXECUTE firstleg_task_list_index_stmt; +DEALLOCATE PREPARE firstleg_task_list_index_stmt; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml new file mode 100644 index 000000000..5bef8f64f --- /dev/null +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -0,0 +1,106 @@ +openapi: 3.0.3 +info: + title: 柏悦头程履约 API + version: 1.1.0 +security: + - bearerAuth: [] +paths: + /api/platform/v1/first-leg/tasks: + get: + summary: 分页查询履约任务 + description: 需要 platform:firstleg:task:list 权限 + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - {name: keyword, in: query, schema: {type: string, maxLength: 64}} + - {name: status, in: query, schema: {type: string, enum: [PENDING_ASSIGNMENT, ASSIGNED, PROCESSING, COMPLETED, CANCELLED]}} + - {name: assigneeId, in: query, schema: {type: integer, format: int64}} + responses: + '200': {description: 查询成功} + '403': {description: 无查询权限} + /api/platform/v1/first-leg/tasks/{taskId}: + get: + summary: 查询履约任务详情 + description: 需要 platform:firstleg:task:view 权限 + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/TaskId' + responses: + '200': {description: 查询成功} + '403': {description: 无查看权限} + '404': {description: 任务不存在} + /api/platform/v1/first-leg/tasks/by-order/{orderId}: + get: + summary: 按订单查询履约任务 + description: 需要 platform:firstleg:task:view 权限 + parameters: + - $ref: '#/components/parameters/TenantId' + - {name: orderId, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': {description: 查询成功} + '403': {description: 无查看权限} + '404': {description: 任务不存在} + /api/platform/v1/first-leg/tasks/{taskId}/assignment-history: + get: + summary: 分页查询任务分配历史 + description: 需要 platform:firstleg:assignment:history 权限 + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + responses: + '200': {description: 查询成功} + '403': {description: 无历史查看权限} + '404': {description: 任务不存在} + /api/platform/v1/first-leg/tasks/{taskId}/assignment: + post: + summary: 分配或改派履约任务 + description: 需要 platform:firstleg:assignment:assign 权限 + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AssignRequest'} + responses: + '200': {description: 分配成功} + '403': {description: 无分配权限} + '404': {description: 任务不存在} + '409': {description: 任务已关闭或发生并发更新} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + TenantId: + name: tenantId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + TaskId: + name: taskId + in: path + required: true + schema: {type: integer, format: int64} + Page: + name: page + in: query + schema: {type: integer, format: int64, minimum: 1, default: 1} + Size: + name: size + in: query + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} + schemas: + AssignRequest: + type: object + required: [assigneeId, assigneeName] + properties: + assigneeId: {type: integer, format: int64} + assigneeName: {type: string, maxLength: 64} + reason: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java new file mode 100644 index 000000000..32cd2f2cb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java @@ -0,0 +1,55 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.AssignRequest; +import com.baiyue.firstleg.dto.FulfillmentTaskQuery; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FulfillmentTaskControllerContractTest { + @Test + void 查询和分配接口声明对应权限码() throws Exception { + assertAuthority("page", "hasAuthority('platform:firstleg:task:list')", + AuthenticatedUser.class, long.class, FulfillmentTaskQuery.class); + assertAuthority("detail", "hasAuthority('platform:firstleg:task:view')", + AuthenticatedUser.class, long.class, long.class); + assertAuthority("byOrder", "hasAuthority('platform:firstleg:task:view')", + AuthenticatedUser.class, long.class, long.class); + assertAuthority("assignmentHistory", "hasAuthority('platform:firstleg:assignment:history')", + AuthenticatedUser.class, long.class, long.class, long.class, long.class); + assertAuthority("assign", "hasAuthority('platform:firstleg:assignment:assign')", + AuthenticatedUser.class, long.class, long.class, AssignRequest.class); + } + + @Test + void 分页查询缺省值为第一页每页二十条() { + FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 0, 0); + assertThat(query.page()).isEqualTo(1); + assertThat(query.size()).isEqualTo(20); + } + + @Test + void OpenAPI覆盖四类只读接口和对应权限() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/api/platform/v1/first-leg/tasks:") + .contains("/api/platform/v1/first-leg/tasks/{taskId}:") + .contains("/api/platform/v1/first-leg/tasks/by-order/{orderId}:") + .contains("/api/platform/v1/first-leg/tasks/{taskId}/assignment-history:") + .contains("platform:firstleg:task:list") + .contains("platform:firstleg:assignment:history"); + } + + private void assertAuthority(String methodName, String permission, Class... parameterTypes) + throws NoSuchMethodException { + Method method = PlatformFulfillmentAssignmentController.class.getDeclaredMethod(methodName, parameterTypes); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(permission); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java new file mode 100644 index 000000000..ce793db13 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FulfillmentTaskQuery; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FulfillmentTaskServiceTest { + @Test + void 列表查询保留租户和分页条件() { + FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); + FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20); + PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.page(10001L, query)).thenReturn(expected); + + assertThat(service(repository).page(10001L, query)).isSameAs(expected); + verify(repository).page(10001L, query); + } + + @Test + void 详情和按订单查询不存在时返回404() { + FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); + FulfillmentTaskService service = service(repository); + + assertThatThrownBy(() -> service.detail(10001L, 1L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + assertThatThrownBy(() -> service.byOrder(10001L, 2L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + void 查询分配历史前先校验任务归属租户() { + FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + when(repository.findOwned(10001L, 1L)).thenReturn(task); + PageResult expected = + new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.pageHistory(10001L, 1L, 1, 20)).thenReturn(expected); + + assertThat(service(repository).assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected); + verify(repository).findOwned(10001L, 1L); + } + + private FulfillmentTaskService service(FulfillmentTaskRepository repository) { + FulfillmentTaskService service = new FulfillmentTaskService(); + ReflectionTestUtils.setField(service, "repository", repository); + return service; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java new file mode 100644 index 000000000..7fe156f7d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java @@ -0,0 +1,55 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.service.AuthorizationSnapshotService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Set; + +/** + * 当前登录成员的有效权限查询接口。 + * + *

此接口只要求调用方已通过对应应用端的 JWT 鉴权;不能以角色管理权限作为 + * 初始化登录会话的前置条件。权限从 IAM 当前有效成员、角色和权限资源实时计算, + * 因此角色被停用或权限被回收后,下一次查询会立即生效。

+ */ +@RestController +@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.") +public class AuthorizationController { + @Resource + private AuthorizationSnapshotService authorizationSnapshotService; + + @GetMapping("/api/{app}/v1/iam/authorization/permissions") + @Operation(summary = "Get current effective permissions / 查询当前用户有效权限", + description = "Does not require role-management permissions. / 不依赖角色管理权限。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity") + }) + public ApiResponse> permissions(@PathVariable String app, + @AuthenticationPrincipal AuthenticatedUser user) { + String appCode = appCode(app); + TenantGuard.tenant(user, appCode); + return ApiResponse.success(authorizationSnapshotService.currentPermissions(user, appCode)); + } + + private String appCode(String app) { + return switch (app) { + case "platform" -> "PLATFORM"; + case "merchant" -> "MERCHANT"; + default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端"); + }; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java new file mode 100644 index 000000000..10becd5e9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java @@ -0,0 +1,59 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 平台内部用户公开接口的数据契约,不向前端暴露密码摘要等认证敏感字段。 */ +public final class PlatformInternalUserDtos { + private PlatformInternalUserDtos() { } + + /** + * 同时兼容历史的 authUserId 关联模式和完整账号创建模式。 + * authUserId 有值时关联已有认证账号;否则 username、password 必填并由 IAM 编排 Auth 创建账号。 + */ + public record CreateInternalUserRequest( + Long authUserId, + String userType, + @NotBlank @Size(max = 64) String displayName, + @Size(max = 32) String phone, + @Email @Size(max = 128) String email, + @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String password, + @Size(max = 64) String memberNo, + List roleIds) { } + + public record UpdateInternalUserRequest( + @NotBlank @Size(max = 64) String displayName, + @Size(max = 32) String phone, + @Email @Size(max = 128) String email, + @NotBlank @Size(max = 64) String memberNo, + @NotNull List roleIds) { } + + public record ChangeInternalUserStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + + public record ResetInternalUserPasswordRequest( + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword) { } + + public record InternalUserResponse( + Long membershipId, + Long userId, + Long authUserId, + String username, + String displayName, + String phone, + String email, + String memberType, + String memberNo, + String status, + List roleIds) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java new file mode 100644 index 000000000..3185e6085 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -0,0 +1,331 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformInternalUserDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** 平台内部用户编排服务:IAM 保存人员和角色,账号凭据只由 Auth 保存。 */ +@Service +public class PlatformInternalUserService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final Logger log = LoggerFactory.getLogger(PlatformInternalUserService.class); + + private final MemberRepository repository; + private final IamRoleMapper roleMapper; + private final AuthAccountClient authClient; + private final String internalToken; + + public PlatformInternalUserService(MemberRepository repository, IamRoleMapper roleMapper, + AuthAccountClient authClient, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.repository = repository; + this.roleMapper = roleMapper; + this.authClient = authClient; + this.internalToken = internalToken; + } + + @Transactional(readOnly = true) + public List list() { + return repository.findByTenant(PLATFORM_TENANT_ID).stream() + .map(this::response).toList(); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_CREATE", resourceType = "MEMBERSHIP", + resourceId = "#request.username", description = "新增平台内部用户", captureAfter = false) + public InternalUserResponse create(CreateInternalUserRequest request) { + validateLegacyType(request.userType()); + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + + if (request.authUserId() != null) return linkExistingAccount(request, roleIds); + if (!StringUtils.hasText(request.username()) || !StringUtils.hasText(request.password())) { + throw new BizException(ResultCode.BAD_REQUEST, "新建平台用户时登录名和密码必填"); + } + + long authUserId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + String memberNo = memberNo(request.memberNo(), membershipId); + validateMemberNo(memberNo, null); + + call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( + authUserId, request.username(), request.password(), request.displayName(), + PLATFORM_TENANT_ID, membershipId, "PLATFORM"))); + try { + IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email()); + IamMembership membership = newMembership(membershipId, userId, memberNo); + repository.insertUser(user); + repository.insert(membership); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + return response(membership, user, request.username(), roleIds); + } catch (RuntimeException localFailure) { + compensateCreate(authUserId, localFailure); + throw localFailure; + } + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_UPDATE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "编辑平台内部用户", captureAfter = false) + public InternalUserResponse update(Long membershipId, UpdateInternalUserRequest request) { + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + validateMemberNo(request.memberNo(), membershipId); + IamMembership membership = required(membershipId); + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName()); + if (displayNameChanged) { + call(() -> authClient.profile(internalToken, user.getAuthUserId(), + new AuthAccountClient.ProfileRequest(request.displayName()))); + } + try { + user.setDisplayName(request.displayName()); + user.setPhone(request.phone()); + user.setEmail(request.email()); + membership.setMemberNo(request.memberNo()); + repository.saveUser(user); + repository.save(membership); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + } catch (RuntimeException localFailure) { + if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure); + throw localFailure; + } + return response(membership, user, request.displayName(), roleIds, oldAccount.username()); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "启停平台内部用户", captureAfter = false) + public InternalUserResponse changeStatus(Long membershipId, ChangeInternalUserStatusRequest request, + Long operatorMembershipId) { + if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户状态只允许 ACTIVE 或 DISABLED"); + } + IamMembership membership = required(membershipId); + if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); + } + IamUser user = requiredUser(membership.getUserId()); + List roleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(request.status()))); + try { + membership.setStatus(request.status()); + membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); + user.setStatus(request.status()); + repository.saveUser(user); + repository.save(membership); + return response(membership, user, authAccount.username(), roleIds); + } catch (RuntimeException localFailure) { + compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); + throw localFailure; + } + } + + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "重置平台内部用户密码", captureAfter = false) + public void resetPassword(Long membershipId, ResetInternalUserPasswordRequest request) { + IamMembership membership = required(membershipId); + IamUser user = requiredUser(membership.getUserId()); + call(() -> authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword()))); + } + + private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List roleIds) { + IamUser existingUser = repository.findUserByAuthId(request.authUserId()); + IamMembership existingMembership = repository.findByTenantAndAuthUser( + PLATFORM_TENANT_ID, request.authUserId()); + if (existingMembership != null) return response(existingMembership); + if (existingUser != null && !"INTERNAL".equals(existingUser.getUserType())) { + throw new BizException(ResultCode.CONFLICT, "认证账号已关联其他类型的 IAM 用户"); + } + AuthAccountClient.AccountSummary authAccount = account(request.authUserId()); + long membershipId = IdWorker.getId(); + String memberNo = memberNo(request.memberNo(), membershipId); + validateMemberNo(memberNo, null); + IamUser user = existingUser == null + ? newUser(IdWorker.getId(), request.authUserId(), request.displayName(), request.phone(), request.email()) + : existingUser; + if (existingUser == null) repository.insertUser(user); + IamMembership membership = newMembership(membershipId, user.getId(), memberNo); + repository.insert(membership); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + return response(membership, user, authAccount.username(), roleIds); + } + + private InternalUserResponse response(IamMembership membership) { + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); + return response(membership, user, authAccount.username(), + repository.findRoleIds(PLATFORM_TENANT_ID, membership.getId())); + } + + private InternalUserResponse response(IamMembership membership, IamUser user, + String username, List roleIds) { + return response(membership, user, user.getDisplayName(), roleIds, username); + } + + private InternalUserResponse response(IamMembership membership, IamUser user, String displayName, + List roleIds, String username) { + return new InternalUserResponse(membership.getId(), user.getId(), user.getAuthUserId(), username, + displayName, user.getPhone(), user.getEmail(), membership.getMemberType(), + membership.getMemberNo(), membership.getStatus(), List.copyOf(roleIds)); + } + + private IamUser newUser(long userId, long authUserId, String displayName, String phone, String email) { + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(authUserId); + user.setUserType("INTERNAL"); + user.setDisplayName(displayName); + user.setPhone(phone); + user.setEmail(email); + user.setStatus("ACTIVE"); + return user; + } + + private IamMembership newMembership(long membershipId, long userId, String memberNo) { + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(PLATFORM_TENANT_ID); + membership.setUserId(userId); + membership.setMemberType("EMPLOYEE"); + membership.setMemberNo(memberNo); + membership.setStatus("ACTIVE"); + membership.setJoinedAt(LocalDateTime.now()); + return membership; + } + + private IamMembership required(Long membershipId) { + IamMembership membership = repository.findByTenantAndId(PLATFORM_TENANT_ID, membershipId); + if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在"); + return membership; + } + + private IamUser requiredUser(Long userId) { + IamUser user = repository.findUser(userId); + if (user == null || !"INTERNAL".equals(user.getUserType())) { + throw new BizException(ResultCode.NOT_FOUND, "平台内部用户档案不存在"); + } + return user; + } + + private List normalizeRoles(List roleIds) { + if (roleIds == null) return List.of(); + if (roleIds.stream().anyMatch(Objects::isNull)) { + throw new BizException(ResultCode.BAD_REQUEST, "角色 ID 不能为空"); + } + return List.copyOf(new LinkedHashSet<>(roleIds)); + } + + private void validateRoles(List roleIds) { + for (Long roleId : roleIds) { + IamRole role = roleMapper.selectById(roleId); + if (role == null || role.getTenantId() == null || role.getTenantId() != PLATFORM_TENANT_ID + || !"PLATFORM".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); + } + } + } + + private void validateMemberNo(String memberNo, Long excludedMembershipId) { + if (repository.existsMemberNo(PLATFORM_TENANT_ID, memberNo, excludedMembershipId)) { + throw new BizException(ResultCode.CONFLICT, "平台成员编号已存在"); + } + } + + private String memberNo(String requested, long membershipId) { + return StringUtils.hasText(requested) ? requested.trim() : "PLATFORM-" + membershipId; + } + + private void validateLegacyType(String userType) { + if (StringUtils.hasText(userType) + && !"INTERNAL".equals(userType) && !"PLATFORM".equals(userType)) { + throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户类型必须是 INTERNAL"); + } + } + + private AuthAccountClient.AccountSummary account(Long authUserId) { + return data(call(() -> authClient.summary(internalToken, authUserId))); + } + + private void compensateCreate(long authUserId, RuntimeException localFailure) { + try { + call(() -> authClient.compensate(internalToken, authUserId)); + } catch (RuntimeException compensationFailure) { + log.error("平台用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateProfile(long authUserId, String oldDisplayName, RuntimeException localFailure) { + try { + call(() -> authClient.profile(internalToken, authUserId, + new AuthAccountClient.ProfileRequest(oldDisplayName))); + } catch (RuntimeException compensationFailure) { + log.error("平台用户编辑失败且认证资料补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户编辑未完成,认证资料补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateStatus(long authUserId, String oldStatus, RuntimeException localFailure) { + try { + call(() -> authClient.status(internalToken, authUserId, + new AuthAccountClient.StatusRequest(oldStatus))); + } catch (RuntimeException compensationFailure) { + log.error("平台用户状态修改失败且认证状态补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户状态未完成,认证状态补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private ApiResponse call(RemoteCall action) { + try { + ApiResponse result = action.call(); + if (result == null || !result.successful()) throw unavailable(); + return result; + } catch (FeignException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在"); + throw unavailable(); + } + } + + private T data(ApiResponse response) { + if (response.data() == null) throw unavailable(); + return response.data(); + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + + @FunctionalInterface + private interface RemoteCall { ApiResponse call(); } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java new file mode 100644 index 000000000..b2b01833f --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class AuthorizationControllerContractTest { + @Test + void 当前权限接口不应依赖角色管理权限() throws Exception { + Method method = AuthorizationController.class.getMethod("permissions", String.class, + AuthenticatedUser.class); + + assertEquals("/api/{app}/v1/iam/authorization/permissions", + method.getAnnotation(GetMapping.class).value()[0]); + assertNull(method.getAnnotation(PreAuthorize.class)); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java new file mode 100644 index 000000000..53e653a26 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -0,0 +1,34 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class IamOpenApiContractTest { + @Test + void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException { + String document; + try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) { + assertThat(stream).isNotNull(); + document = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") + .contains("/api/merchant/v1/iam/authorization/permissions:") + .contains("PermissionCodesApiResponse:") + .contains("TenantPageApiResponse:") + .contains("InternalUserListApiResponse:") + .contains("membershipId: {type: integer, format: int64}") + .contains("roleIds: {type: array, items: {type: integer, format: int64}}") + .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); + + Map openApi = new Yaml().load(document); + assertThat(openApi).containsEntry("openapi", "3.0.3"); + assertThat(openApi.get("paths")).isInstanceOf(Map.class); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java new file mode 100644 index 000000000..34446beb9 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +class PlatformInternalUserContractTest { + + @Test + void 内部用户列表必须校验列表权限() throws Exception { + Method method = ProfileResourceController.class.getMethod("internalUsers", + com.baiyue.common.security.AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:user:list')", + method.getAnnotation(PreAuthorize.class).value()); + assertEquals("/api/platform/v1/iam/internal-users", + method.getAnnotation(GetMapping.class).value()[0]); + } + + @Test + void 平台用户写接口以成员关系主键寻址() { + assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}"); + assertMapping("changeInternalUserStatus", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status"); + assertMapping("resetInternalUserPassword", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password"); + } + + @Test + void 企业资料查看必须校验查看权限() throws Exception { + Method method = MerchantTenantController.class.getMethod("profile", + com.baiyue.common.security.AuthenticatedUser.class); + assertEquals("hasAuthority('merchant:profile:view')", + method.getAnnotation(PreAuthorize.class).value()); + } + + private void assertMapping(String methodName, String path) { + Method method = java.util.Arrays.stream(ProfileResourceController.class.getMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertEquals(path, method.getAnnotation(PutMapping.class).value()[0]); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java new file mode 100644 index 000000000..509ba8559 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class PlatformTenantControllerContractTest { + @Test + void 客户分页接口声明权限分页约束和错误状态() throws Exception { + Method method = PlatformTenantController.class.getMethod("page", long.class, long.class, + String.class, String.class, AuthenticatedUser.class); + + assertEquals("/tenants", method.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:list')", + method.getAnnotation(PreAuthorize.class).value()); + assertNotNull(annotation(method, 0, Min.class)); + assertNotNull(annotation(method, 1, Min.class)); + assertEquals(200, annotation(method, 1, Max.class).value()); + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", + annotation(method, 3, Pattern.class).regexp()); + + Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) + .map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode) + .collect(Collectors.toSet()); + assertEquals(Set.of("200", "400", "401", "403", "422"), statuses); + } + + private T annotation(Method method, int parameter, Class type) { + return method.getParameters()[parameter].getAnnotation(type); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java new file mode 100644 index 000000000..cc6af9acd --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class QualificationUploadContractTest { + + @Test + void iamControllerDoesNotExposeQualificationUpload() { + boolean exposesLegacyPost = java.util.Arrays.stream(ProfileResourceController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> java.util.Arrays.stream(mapping.value())) + .anyMatch("/api/merchant/v1/iam/tenant/qualifications"::equals); + + assertFalse(exposesLegacyPost, "IAM 商户资质接口只读,上传必须由 File 服务处理"); + } + + @Test + void staticOpenApiDoesNotAdvertiseLegacyQualificationPost() throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) { + assertNotNull(input); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + int pathStart = yaml.indexOf(" /api/merchant/v1/iam/tenant/qualifications:"); + int nextPath = yaml.indexOf("\n /api/", pathStart + 1); + assertTrue(pathStart >= 0 && nextPath > pathStart); + String pathContract = yaml.substring(pathStart, nextPath); + + assertFalse(pathContract.contains("\n post:")); + assertFalse(yaml.contains(" QualificationRequest:")); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java new file mode 100644 index 000000000..c56da2c79 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -0,0 +1,146 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformInternalUserDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PlatformInternalUserServiceTest { + private final MemberRepository repository = mock(MemberRepository.class); + private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); + private final AuthAccountClient auth = mock(AuthAccountClient.class); + private final PlatformInternalUserService service = + new PlatformInternalUserService(repository, roleMapper, auth, "internal-token"); + private IamMembership membership; + private IamUser user; + + @BeforeEach + void setUp() { + membership = new IamMembership(); + membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L); + membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); + membership.setStatus("ACTIVE"); + user = new IamUser(); + user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL"); + user.setDisplayName("平台运营"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); + when(repository.findByTenantAndId(0L, 30L)).thenReturn(membership); + when(repository.findUser(20L)).thenReturn(user); + when(repository.findRoleIds(0L, 30L)).thenReturn(List.of(40L)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + } + + @Test + void 列表返回成员主键联系方式和当前角色() { + when(repository.findByTenant(0L)).thenReturn(List.of(membership)); + + InternalUserResponse result = service.list().get(0); + + assertEquals(30L, result.membershipId()); + assertEquals("operator", result.username()); + assertEquals("13800138000", result.phone()); + assertEquals(List.of(40L), result.roleIds()); + } + + @Test + void 新建平台用户在Auth创建平台账号并写入IAM成员() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); + CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营", + "13900139000", "operator@example.com", "new.operator", "Password1", + "PLATFORM-NEW", List.of(40L)); + + InternalUserResponse result = service.create(request); + + assertEquals("new.operator", result.username()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), captor.capture()); + assertEquals("PLATFORM", captor.getValue().appCode()); + assertEquals(0L, captor.getValue().tenantId()); + verify(repository).insertUser(any(IamUser.class)); + verify(repository).insert(any(IamMembership.class)); + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + } + + @Test + void 本地创建失败会补偿认证账号且不记录密码() { + when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); + when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).insertUser(any()); + CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营", + null, null, "new.operator", "Password1", null, List.of()); + + assertThrows(RuntimeException.class, () -> service.create(request)); + + verify(auth).compensate(eq("internal-token"), anyLong()); + } + + @Test + void 不能停用当前登录的平台成员() { + assertThrows(BizException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 30L)); + + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test + void 状态本地写入失败时恢复Auth且成功后不再远程查询() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); + + assertThrows(RuntimeException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L)); + + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "DISABLED".equals(value.status()))); + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "ACTIVE".equals(value.status()))); + verify(auth, times(1)).summary("internal-token", 10L); + } + + @Test + void 状态补偿应恢复Auth原状态而不是IAM旧状态() { + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED"))); + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); + + assertThrows(RuntimeException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("ACTIVE"), 99L)); + + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "ACTIVE".equals(value.status()))); + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "DISABLED".equals(value.status()))); + } + + @Test + void 重置密码只转交Auth不写入IAM() { + when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1")); + + verify(auth).reset(eq("internal-token"), eq(10L), + argThat(value -> "ResetPass1".equals(value.newPassword()))); + verify(repository, never()).saveUser(any()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java new file mode 100644 index 000000000..e034c58d1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TenantServiceTest { + + @Test + void pageMerchantsMapsTenantEntitiesToApiResponses() { + TenantRepository repository = mock(TenantRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setStatus("ACTIVE"); + tenant.setLegalName("Example Merchant Ltd"); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("alice@example.com"); + when(repository.pageMerchants(1, 10, "Example", "ACTIVE")) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageMerchants(1, 10, "Example", "ACTIVE"); + + assertEquals(1, result.total()); + assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java new file mode 100644 index 000000000..eb89dfbdf --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java @@ -0,0 +1,41 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.baiyue.integration.service.ocr.WeightOcrBatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; + +/** + * 供平台内部服务调用的称重图片 OCR 接口。 + */ +@Tag(name = "重量 OCR", description = "受内部服务令牌保护的称重图片批量识别能力") +@RestController +@RequestMapping("/internal/integrations/weight-ocr") +public class WeightOcrController { + @Resource + private WeightOcrBatchService batchService; + + @Resource + private InternalServiceTokenGuard tokenGuard; + + @Operation(summary = "批量识别称重图片") + @PostMapping("/batches") + public ApiResponse recognize( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WeightOcrBatchRequest request) throws IOException { + tokenGuard.verify(token); + return ApiResponse.success(batchService.recognize(request)); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java new file mode 100644 index 000000000..d734a2be1 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.dto.ocr; + +/** + * 百度 OCR 返回的文字候选及其识别框。 + */ +public record OcrCandidate( + String text, + Double confidence, + Integer left, + Integer top, + Integer width, + Integer height +) { + public long area() { + if (width == null || height == null || width <= 0 || height <= 0) { + return 0; + } + return (long) width * height; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java new file mode 100644 index 000000000..bc0142ee2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.dto.ocr; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; + +/** + * 重量 OCR 批次请求。目录必须位于服务配置的允许根目录内。 + */ +public record WeightOcrBatchRequest( + @NotBlank(message = "输入目录不能为空") String inputDirectory, + String outputDirectory, + @DecimalMin(value = "0.0", message = "纵向裁剪起始比例不能小于 0") + @DecimalMax(value = "0.95", message = "纵向裁剪起始比例不能大于 0.95") + Double cropStartRatio, + @DecimalMin(value = "0.0", message = "横向裁剪比例不能小于 0") + @DecimalMax(value = "0.49", message = "横向裁剪比例不能大于 0.49") + Double horizontalTrimRatio, + Boolean recursive +) { + public double effectiveCropStartRatio() { + return cropStartRatio == null ? 0.5 : cropStartRatio; + } + + public double effectiveHorizontalTrimRatio() { + return horizontalTrimRatio == null ? 0.25 : horizontalTrimRatio; + } + + public boolean effectiveRecursive() { + return Boolean.TRUE.equals(recursive); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java new file mode 100644 index 000000000..fc7574cdc --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java @@ -0,0 +1,21 @@ +package com.baiyue.integration.dto.ocr; + +import java.time.Instant; +import java.util.List; + +/** + * 重量 OCR 批次汇总结果。 + */ +public record WeightOcrBatchResult( + String batchId, + Instant startedAt, + Instant finishedAt, + String inputDirectory, + String outputDirectory, + int total, + int recognized, + int noWeightFound, + int failed, + List results +) { +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java new file mode 100644 index 000000000..21b99715a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java @@ -0,0 +1,24 @@ +package com.baiyue.integration.dto.ocr; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 单张称重图片识别结果。路径均为相对于受控根目录的相对路径。 + */ +public record WeightOcrImageResult( + String sourceImage, + String croppedImage, + String rawResponseFile, + Status status, + BigDecimal weight, + Double confidence, + List candidates, + String error +) { + public enum Status { + RECOGNIZED, + NO_WEIGHT_FOUND, + FAILED + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java new file mode 100644 index 000000000..e71774f31 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java @@ -0,0 +1,220 @@ +package com.baiyue.integration.provider.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * 百度仪器仪表盘读数识别客户端,负责令牌缓存、限速和受控重试。 + */ +@Component +public class BaiduMeterClient { + private static final int MAX_FORM_BYTES = 4 * 1024 * 1024; + private static final int QPS_LIMIT_ERROR_CODE = 18; + private static final long INITIAL_RETRY_DELAY_MILLIS = 1_000; + + private final IntegrationProperties.BaiduOcr config; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final Object requestRateLock = new Object(); + + private volatile CachedToken cachedToken; + private long nextRequestAtMillis; + + public BaiduMeterClient(IntegrationProperties properties, ObjectMapper objectMapper) { + this.config = properties.baiduOcr(); + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) + : config.effectiveConnectTimeout()) + .build(); + } + + public void validateReady() { + if (config == null || !config.enabled()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 能力未启用"); + } + if (!config.configured()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "百度 OCR 密钥或接口地址未配置"); + } + } + + public MeterResponse recognize(Path image) throws IOException, InterruptedException { + validateReady(); + long imageBytes = Files.size(image); + if (imageBytes <= 0 || imageBytes > config.effectiveMaxImageSize().toBytes()) { + throw new IOException("裁剪图片超过允许大小: " + image.getFileName()); + } + String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(image)); + String form = "image=" + encode(base64) + "&probability=true&poly_location=true"; + if (form.getBytes(StandardCharsets.UTF_8).length > MAX_FORM_BYTES) { + throw new IOException("裁剪图片编码后超过百度接口 4MB 限制: " + image.getFileName()); + } + + for (int retry = 0; ; retry++) { + waitForRequestSlot(); + try { + return sendRecognitionRequest(form); + } catch (BaiduApiException exception) { + if (exception.errorCode() != QPS_LIMIT_ERROR_CODE + || retry >= config.effectiveMaxQpsRetries()) { + throw exception; + } + Thread.sleep(INITIAL_RETRY_DELAY_MILLIS << retry); + } + } + } + + private MeterResponse sendRecognitionRequest(String form) throws IOException, InterruptedException { + String separator = config.meterUrl().toString().contains("?") ? "&" : "?"; + URI uri = URI.create(config.meterUrl() + separator + "access_token=" + encode(accessToken())); + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(config.effectiveRequestTimeout()) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureSuccessfulHttpStatus(response.statusCode()); + + JsonNode root = objectMapper.readTree(response.body()); + ensureSuccessfulBaiduResponse(root); + return new MeterResponse(response.body(), parseCandidates(root.path("words_result"))); + } + + private void waitForRequestSlot() throws InterruptedException { + synchronized (requestRateLock) { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis > 0) { + Thread.sleep(waitMillis); + } + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + + private String accessToken() throws IOException, InterruptedException { + CachedToken current = cachedToken; + if (usable(current)) { + return current.value(); + } + synchronized (this) { + current = cachedToken; + if (usable(current)) { + return current.value(); + } + String form = "grant_type=client_credentials" + + "&client_id=" + encode(config.apiKey()) + + "&client_secret=" + encode(config.secretKey()); + HttpRequest request = HttpRequest.newBuilder(config.tokenUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureSuccessfulHttpStatus(response.statusCode()); + + JsonNode root = objectMapper.readTree(response.body()); + String token = root.path("access_token").asText(); + if (token.isBlank()) { + throw new IOException("百度 OAuth 获取 access_token 失败: " + safeError(root)); + } + long expiresIn = Math.max(120, root.path("expires_in").asLong(2_592_000)); + cachedToken = new CachedToken(token, Instant.now().plusSeconds(expiresIn)); + return token; + } + } + + private boolean usable(CachedToken token) { + return token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(60)); + } + + private List parseCandidates(JsonNode wordsResult) { + List candidates = new ArrayList<>(); + if (!wordsResult.isArray()) { + return candidates; + } + for (JsonNode item : wordsResult) { + JsonNode probability = item.path("probability"); + JsonNode location = item.path("location"); + candidates.add(new OcrCandidate( + item.path("words").asText(), + nullableDouble(probability.get("average")), + nullableInt(location.get("left")), + nullableInt(location.get("top")), + nullableInt(location.get("width")), + nullableInt(location.get("height")) + )); + } + return List.copyOf(candidates); + } + + private void ensureSuccessfulHttpStatus(int statusCode) throws IOException { + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("百度接口 HTTP 状态异常: " + statusCode); + } + } + + private void ensureSuccessfulBaiduResponse(JsonNode root) throws IOException { + if (root.has("error_code")) { + int errorCode = root.path("error_code").asInt(-1); + throw new BaiduApiException(errorCode, "百度 OCR 调用失败: " + safeError(root)); + } + } + + private String safeError(JsonNode root) { + return root.path("error_code").asText("unknown") + " - " + + root.path("error_msg").asText("unknown error"); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static Double nullableDouble(JsonNode node) { + return node == null || !node.isNumber() ? null : node.doubleValue(); + } + + private static Integer nullableInt(JsonNode node) { + return node == null || !node.isNumber() ? null : node.intValue(); + } + + public record MeterResponse(String rawJson, List candidates) { + } + + private record CachedToken(String value, Instant expiresAt) { + } + + private static final class BaiduApiException extends IOException { + private final int errorCode; + + private BaiduApiException(int errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + private int errorCode() { + return errorCode; + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java new file mode 100644 index 000000000..13cb0f68d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java @@ -0,0 +1,81 @@ +package com.baiyue.integration.service.ocr; + +import org.springframework.stereotype.Component; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Iterator; + +/** + * 裁剪称重图片的下方中央区域,并统一输出为 JPEG。 + */ +@Component +public class ImageCropper { + private static final float JPEG_QUALITY = 0.90f; + + public void cropBottomCenter(Path source, Path target, double startRatio, + double horizontalTrimRatio, long maxImagePixels) throws IOException { + validateRatios(startRatio, horizontalTrimRatio); + BufferedImage sourceImage = ImageIO.read(source.toFile()); + if (sourceImage == null) { + throw new IOException("无法解析图片: " + source.getFileName()); + } + long pixels = (long) sourceImage.getWidth() * sourceImage.getHeight(); + if (pixels <= 0 || pixels > maxImagePixels) { + throw new IOException("图片像素超过允许上限: " + source.getFileName()); + } + + int startY = (int) Math.floor(sourceImage.getHeight() * startRatio); + int startX = (int) Math.floor(sourceImage.getWidth() * horizontalTrimRatio); + int croppedWidth = sourceImage.getWidth() - startX * 2; + int croppedHeight = sourceImage.getHeight() - startY; + if (croppedWidth <= 0 || croppedHeight <= 0) { + throw new IOException("图片裁剪区域无效: " + source.getFileName()); + } + + BufferedImage cropped = new BufferedImage(croppedWidth, croppedHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = cropped.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, croppedWidth, croppedHeight); + graphics.drawImage(sourceImage, 0, 0, croppedWidth, croppedHeight, + startX, startY, startX + croppedWidth, startY + croppedHeight, null); + } finally { + graphics.dispose(); + } + + Files.createDirectories(target.getParent()); + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IOException("当前 Java 环境不支持 JPEG 编码"); + } + ImageWriter writer = writers.next(); + try (ImageOutputStream output = ImageIO.createImageOutputStream(target.toFile())) { + writer.setOutput(output); + ImageWriteParam parameters = writer.getDefaultWriteParam(); + parameters.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + parameters.setCompressionQuality(JPEG_QUALITY); + writer.write(null, new IIOImage(cropped, null, null), parameters); + } finally { + writer.dispose(); + } + } + + private void validateRatios(double startRatio, double horizontalTrimRatio) { + if (startRatio < 0 || startRatio >= 1) { + throw new IllegalArgumentException("cropStartRatio 必须在 [0, 1) 范围内"); + } + if (horizontalTrimRatio < 0 || horizontalTrimRatio >= 0.5) { + throw new IllegalArgumentException("horizontalTrimRatio 必须在 [0, 0.5) 范围内"); + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java new file mode 100644 index 000000000..5c6e30761 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java @@ -0,0 +1,60 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.dto.ocr.OcrCandidate; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * 从 OCR 候选内容中选择最可能的重量数值。 + */ +@Component +public class WeightCandidateSelector { + private static final Pattern WEIGHT_PATTERN = Pattern.compile("^[+-]?\\d{1,8}(?:\\.\\d{1,4})?$"); + + public Optional select(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return Optional.empty(); + } + return candidates.stream() + .map(this::toSelectedWeight) + .flatMap(Optional::stream) + .max(Comparator + .comparingLong((SelectedWeight selected) -> selected.candidate().area()) + .thenComparing(selected -> selected.confidence() == null ? -1 : selected.confidence())); + } + + private Optional toSelectedWeight(OcrCandidate candidate) { + if (candidate == null) { + return Optional.empty(); + } + String normalized = normalize(candidate.text()); + if (!WEIGHT_PATTERN.matcher(normalized).matches()) { + return Optional.empty(); + } + try { + return Optional.of(new SelectedWeight( + new BigDecimal(normalized), candidate.confidence(), candidate)); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + return value.strip() + .replace(" ", "") + .replace(',', '.') + .replace('+', '+') + .replace('-', '-'); + } + + public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) { + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java new file mode 100644 index 000000000..f0f243598 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java @@ -0,0 +1,207 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; +import com.baiyue.integration.provider.ocr.BaiduMeterClient; +import com.baiyue.integration.service.ocr.WeightOcrStoragePolicy.BatchPaths; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import java.util.stream.Stream; + +/** + * 编排称重图片批量裁剪、百度 OCR 调用和结果落盘。 + */ +@Service +public class WeightOcrBatchService { + private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp"); + + private final IntegrationProperties properties; + private final WeightOcrStoragePolicy storagePolicy; + private final ImageCropper imageCropper; + private final BaiduMeterClient baiduMeterClient; + private final WeightCandidateSelector candidateSelector; + private final ObjectMapper objectMapper; + private final Semaphore batchSlot = new Semaphore(1); + + public WeightOcrBatchService(IntegrationProperties properties, + WeightOcrStoragePolicy storagePolicy, + ImageCropper imageCropper, + BaiduMeterClient baiduMeterClient, + WeightCandidateSelector candidateSelector, + ObjectMapper objectMapper) { + this.properties = properties; + this.storagePolicy = storagePolicy; + this.imageCropper = imageCropper; + this.baiduMeterClient = baiduMeterClient; + this.candidateSelector = candidateSelector; + this.objectMapper = objectMapper; + } + + public WeightOcrBatchResult recognize(WeightOcrBatchRequest request) throws IOException { + baiduMeterClient.validateReady(); + if (!batchSlot.tryAcquire()) { + throw new BizException(ResultCode.TOO_MANY_REQUESTS, "已有重量 OCR 批次正在执行"); + } + try { + return doRecognize(request); + } finally { + batchSlot.release(); + } + } + + private WeightOcrBatchResult doRecognize(WeightOcrBatchRequest request) throws IOException { + String batchId = UUID.randomUUID().toString(); + Instant startedAt = Instant.now(); + BatchPaths paths = storagePolicy.resolve( + request.inputDirectory(), request.outputDirectory(), batchId); + List images = findImages(paths.inputDirectory(), paths.outputDirectory(), + request.effectiveRecursive(), config().effectiveMaxBatchSize()); + if (images.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "输入目录中没有支持的图片"); + } + + List results = new ArrayList<>(images.size()); + for (Path image : images) { + results.add(recognizeOne(paths, image, request)); + } + WeightOcrBatchResult result = buildResult(batchId, startedAt, paths, results); + objectMapper.writerWithDefaultPrettyPrinter() + .writeValue(paths.outputDirectory().resolve("batch-results.json").toFile(), result); + return result; + } + + private WeightOcrImageResult recognizeOne(BatchPaths paths, Path source, + WeightOcrBatchRequest request) { + Path relative = paths.inputDirectory().relativize(source); + Path cropped = paths.outputDirectory().resolve("cropped") + .resolve(replaceExtension(relative, ".jpg")); + Path rawResponse = paths.outputDirectory().resolve("raw") + .resolve(replaceExtension(relative, ".json")); + try { + long sourceBytes = Files.size(source); + if (sourceBytes <= 0 || sourceBytes > config().effectiveMaxImageSize().toBytes()) { + throw new IOException("原始图片超过允许大小"); + } + imageCropper.cropBottomCenter(source, cropped, + request.effectiveCropStartRatio(), request.effectiveHorizontalTrimRatio(), + config().effectiveMaxImagePixels()); + BaiduMeterClient.MeterResponse response = baiduMeterClient.recognize(cropped); + Files.createDirectories(rawResponse.getParent()); + Files.writeString(rawResponse, response.rawJson(), StandardCharsets.UTF_8); + + return candidateSelector.select(response.candidates()) + .map(selected -> success(relative, paths, cropped, rawResponse, + response, selected)) + .orElseGet(() -> noWeight(relative, paths, cropped, rawResponse, response)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return failed(relative, paths, cropped, rawResponse, "调用百度 OCR 时线程被中断"); + } catch (Exception exception) { + return failed(relative, paths, cropped, rawResponse, safeMessage(exception)); + } + } + + private WeightOcrImageResult success(Path source, BatchPaths paths, Path cropped, + Path rawResponse, BaiduMeterClient.MeterResponse response, + WeightCandidateSelector.SelectedWeight selected) { + return new WeightOcrImageResult( + source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), + Status.RECOGNIZED, selected.weight(), selected.confidence(), + response.candidates(), null); + } + + private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped, + Path rawResponse, BaiduMeterClient.MeterResponse response) { + return new WeightOcrImageResult( + source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), + Status.NO_WEIGHT_FOUND, null, null, response.candidates(), + "百度响应中没有符合重量格式的候选值"); + } + + private WeightOcrImageResult failed(Path source, BatchPaths paths, Path cropped, + Path rawResponse, String error) { + return new WeightOcrImageResult( + source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null, + Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : null, + Status.FAILED, null, null, List.of(), error); + } + + private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths, + List results) { + return new WeightOcrBatchResult( + batchId, startedAt, Instant.now(), paths.inputDisplayPath(), paths.outputDisplayPath(), + results.size(), count(results, Status.RECOGNIZED), + count(results, Status.NO_WEIGHT_FOUND), count(results, Status.FAILED), + List.copyOf(results)); + } + + private int count(List results, Status status) { + return (int) results.stream().filter(result -> result.status() == status).count(); + } + + private List findImages(Path input, Path output, boolean recursive, int maxBatchSize) + throws IOException { + int maxDepth = recursive ? Integer.MAX_VALUE : 1; + List images; + try (Stream paths = Files.walk(input, maxDepth)) { + images = paths.filter(Files::isRegularFile) + .filter(path -> !path.startsWith(output)) + .filter(this::isSupportedImage) + .sorted(Comparator.comparing(Path::toString)) + .limit((long) maxBatchSize + 1) + .toList(); + } + if (images.size() > maxBatchSize) { + throw new BizException(ResultCode.BAD_REQUEST, + "单批图片数量不能超过 " + maxBatchSize + " 张"); + } + return images; + } + + private boolean isSupportedImage(Path path) { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot >= 0 && dot < name.length() - 1 + && SUPPORTED_EXTENSIONS.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT)); + } + + private Path replaceExtension(Path relative, String extension) { + String name = relative.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String baseName = dot > 0 ? name.substring(0, dot) : name; + return relative.getParent() == null + ? Path.of(baseName + extension) + : relative.getParent().resolve(baseName + extension); + } + + private String outputRelative(BatchPaths paths, Path path) { + return paths.outputDirectory().relativize(path).toString(); + } + + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? "OCR 识别失败" : message; + } + + private IntegrationProperties.BaiduOcr config() { + return properties.baiduOcr(); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java new file mode 100644 index 000000000..a98647747 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java @@ -0,0 +1,89 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 将 OCR 文件访问限制在配置的输入、输出根目录内。 + */ +@Component +public class WeightOcrStoragePolicy { + private final IntegrationProperties properties; + + public WeightOcrStoragePolicy(IntegrationProperties properties) { + this.properties = properties; + } + + public BatchPaths resolve(String inputValue, String outputValue, String batchId) throws IOException { + IntegrationProperties.BaiduOcr config = requireConfig(); + Path inputRoot = prepareRoot(config.inputRoot(), "OCR 输入根目录"); + Path outputRoot = prepareRoot(config.outputRoot(), "OCR 输出根目录"); + + Path input = resolveRequested(inputRoot, inputValue).toRealPath(); + requireInside(inputRoot, input, "输入目录超出允许范围"); + if (!Files.isDirectory(input) || !Files.isReadable(input)) { + throw new BizException(ResultCode.BAD_REQUEST, "输入目录不存在或不可读"); + } + + String outputName = outputValue == null || outputValue.isBlank() ? batchId : outputValue; + Path output = resolveRequested(outputRoot, outputName); + requireInside(outputRoot, output, "输出目录超出允许范围"); + Files.createDirectories(output); + output = output.toRealPath(); + requireInside(outputRoot, output, "输出目录超出允许范围"); + return new BatchPaths(inputRoot, outputRoot, input, output); + } + + private IntegrationProperties.BaiduOcr requireConfig() { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + if (config == null || config.inputRoot() == null || config.outputRoot() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 存储目录未配置"); + } + return config; + } + + private Path prepareRoot(Path configured, String name) throws IOException { + Path root = configured.toAbsolutePath().normalize(); + Files.createDirectories(root); + if (!Files.isDirectory(root) || !Files.isReadable(root) || !Files.isWritable(root)) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, name + "不可用"); + } + return root.toRealPath(); + } + + private Path resolveRequested(Path root, String value) { + try { + Path requested = Path.of(value); + return (requested.isAbsolute() ? requested : root.resolve(requested)).normalize(); + } catch (RuntimeException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "目录格式无效"); + } + } + + private void requireInside(Path root, Path path, String message) { + if (!path.startsWith(root)) { + throw new BizException(ResultCode.FORBIDDEN, message); + } + } + + public record BatchPaths(Path inputRoot, Path outputRoot, Path inputDirectory, Path outputDirectory) { + public String inputDisplayPath() { + return display(inputRoot, inputDirectory); + } + + public String outputDisplayPath() { + return display(outputRoot, outputDirectory); + } + + private String display(Path root, Path path) { + String relative = root.relativize(path).toString(); + return relative.isBlank() ? "." : relative; + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java new file mode 100644 index 000000000..707ebee35 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java @@ -0,0 +1,52 @@ +package com.baiyue.integration.service.ocr; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ImageCropperTest { + @TempDir + Path tempDirectory; + + @Test + void cropsConfiguredBottomCenterRegion() throws Exception { + Path source = createImage(); + Path target = tempDirectory.resolve("cropped/result.jpg"); + + new ImageCropper().cropBottomCenter(source, target, 0.5, 0.25, 10_000); + + BufferedImage cropped = ImageIO.read(target.toFile()); + assertThat(cropped.getWidth()).isEqualTo(50); + assertThat(cropped.getHeight()).isEqualTo(40); + assertThat(new Color(cropped.getRGB(10, 10))).isEqualTo(Color.BLACK); + } + + @Test + void rejectsImageOverPixelLimit() throws Exception { + Path source = createImage(); + assertThatThrownBy(() -> new ImageCropper() + .cropBottomCenter(source, tempDirectory.resolve("result.jpg"), 0.5, 0.25, 100)) + .hasMessageContaining("像素超过"); + } + + private Path createImage() throws Exception { + BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 100, 40); + graphics.setColor(Color.BLACK); + graphics.fillRect(0, 40, 100, 40); + graphics.dispose(); + Path source = tempDirectory.resolve("source.png"); + ImageIO.write(image, "png", source.toFile()); + return source; + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java new file mode 100644 index 000000000..63343beb9 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java @@ -0,0 +1,33 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.dto.ocr.OcrCandidate; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeightCandidateSelectorTest { + private final WeightCandidateSelector selector = new WeightCandidateSelector(); + + @Test + void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() { + List candidates = List.of( + new OcrCandidate("10:38", 0.99, 10, 10, 40, 10), + new OcrCandidate(" 12,35 ", 0.97, 20, 30, 120, 60), + new OcrCandidate("29", 0.99, 160, 10, 20, 10), + new OcrCandidate("kg", 0.99, 150, 70, 30, 15)); + + WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow(); + + assertThat(selected.weight()).isEqualByComparingTo("12.35"); + assertThat(selected.confidence()).isEqualTo(0.97); + } + + @Test + void rejectsNonNumericCandidates() { + assertThat(selector.select(List.of( + new OcrCandidate("kg", 0.99, 0, 0, 20, 10), + new OcrCandidate("10:38", 0.99, 0, 0, 20, 10)))).isEmpty(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java new file mode 100644 index 000000000..8cfa13385 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; +import com.baiyue.integration.provider.ocr.BaiduMeterClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.util.unit.DataSize; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WeightOcrBatchServiceTest { + @TempDir + Path tempDirectory; + + @Test + void completesBatchAndWritesArtifactsWithoutCallingRealProvider() throws Exception { + Path inputRoot = Files.createDirectories(tempDirectory.resolve("input")); + Path outputRoot = tempDirectory.resolve("output"); + Path batchInput = Files.createDirectories(inputRoot.resolve("batch-a")); + BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + image.setRGB(x, y, Color.BLACK.getRGB()); + } + } + ImageIO.write(image, "png", batchInput.resolve("weight.png").toFile()); + + IntegrationProperties properties = properties(inputRoot, outputRoot); + BaiduMeterClient client = mock(BaiduMeterClient.class); + when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse( + "{\"words_result\":[]}", + List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30)))); + ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + WeightOcrBatchService service = new WeightOcrBatchService( + properties, new WeightOcrStoragePolicy(properties), new ImageCropper(), client, + new WeightCandidateSelector(), objectMapper); + + var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false)); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.recognized()).isEqualTo(1); + assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED); + Path batchOutput = outputRoot.resolve(result.outputDirectory()); + assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile(); + assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile(); + assertThat(batchOutput.resolve("batch-results.json")).isRegularFile(); + } + + private IntegrationProperties properties(Path inputRoot, Path outputRoot) { + IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr( + true, "key", "secret", URI.create("https://example.com/token"), + URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), + inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, + 50, Duration.ZERO, 0); + return new IntegrationProperties(baidu, null); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java new file mode 100644 index 000000000..badb48dab --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java @@ -0,0 +1,57 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.util.unit.DataSize; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WeightOcrStoragePolicyTest { + @TempDir + Path tempDirectory; + + @Test + void resolvesDirectoriesInsideConfiguredRoots() throws Exception { + Path input = Files.createDirectories(tempDirectory.resolve("input/batch-a")); + Path outputRoot = tempDirectory.resolve("output"); + WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(input.getParent(), outputRoot)); + + WeightOcrStoragePolicy.BatchPaths paths = policy.resolve("batch-a", null, "batch-id"); + + assertThat(paths.inputDirectory()).isEqualTo(input.toRealPath()); + assertThat(paths.outputDirectory()).isEqualTo(outputRoot.resolve("batch-id").toRealPath()); + } + + @Test + void rejectsInputAndOutputOutsideConfiguredRoots() throws Exception { + Path inputRoot = Files.createDirectories(tempDirectory.resolve("input")); + Path outputRoot = tempDirectory.resolve("output"); + Path outside = Files.createDirectories(tempDirectory.resolve("outside")); + WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(inputRoot, outputRoot)); + + assertThatThrownBy(() -> policy.resolve(outside.toString(), null, "batch-id")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + assertThatThrownBy(() -> policy.resolve(".", "../outside", "batch-id")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + } + + private IntegrationProperties properties(Path inputRoot, Path outputRoot) { + IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr( + true, "key", "secret", URI.create("https://example.com/token"), + URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), + inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, + 50, Duration.ZERO, 0); + return new IntegrationProperties(baidu, null); + } +} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderIdempotencyRecordEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderIdempotencyRecordEntity.java new file mode 100644 index 000000000..364bc9d85 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderIdempotencyRecordEntity.java @@ -0,0 +1,32 @@ +package com.baiyue.order.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 订单写入请求的持久化幂等记录。 + * 同一个租户、操作者、操作和幂等键只能有一条记录,请求摘要负责识别资源与请求内容变化。 + */ +@Getter +@Setter +@TableName("ord_idempotency_record") +public class OrderIdempotencyRecordEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long operatorId; + private String operation; + private Long resourceId; + private String idempotencyKey; + private String requestHash; + private String status; + private Long resultOrderId; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderIdempotencyRecordMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderIdempotencyRecordMapper.java new file mode 100644 index 000000000..0c40e7440 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderIdempotencyRecordMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.order.mapper; + +import com.baiyue.order.entity.OrderIdempotencyRecordEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** 订单写入幂等记录 Mapper。 */ +public interface OrderIdempotencyRecordMapper extends BaseMapper { +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml new file mode 100644 index 000000000..8e5eeac26 --- /dev/null +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -0,0 +1,67 @@ +openapi: 3.0.3 +info: + title: 柏悦头程订单 API + version: 1.1.0 +security: + - bearerAuth: [] +paths: + /api/merchant/v1/orders/first-leg: + post: + summary: 创建订单草稿 + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: 创建成功或同请求重放成功,返回相同订单ID + '400': + description: 缺少或使用了无效幂等键 + '409': + description: 相同幂等键对应不同请求或请求仍在处理中 + '503': + description: 仓库或往来单位下游服务异常 + /api/merchant/v1/orders/first-leg/{id}/submit: + post: + summary: 提交订单 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: 提交成功或同请求重放成功 + '400': + description: 缺少或使用了无效幂等键 + '404': + description: 订单不存在 + '409': + description: 幂等键冲突、请求处理中或订单状态冲突 + '503': + description: 仓库或往来单位下游服务异常 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + OrderId: + name: id + in: path + required: true + schema: + type: integer + format: int64 + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: 同一租户和操作者重试同一请求时必须保持不变 + schema: + type: string + minLength: 1 + maxLength: 128 diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java new file mode 100644 index 000000000..08bb1914a --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -0,0 +1,36 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.OrderRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderControllerPermissionTest { + @Test + void 商户订单列表和详情具有对应查询权限码() throws Exception { + assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')", + AuthenticatedUser.class, OrderRequests.QueryRequest.class); + assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')", + AuthenticatedUser.class, long.class); + } + + @Test + void 平台订单列表和详情使用已配置的订单列表权限码() throws Exception { + assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, long.class, long.class); + } + + private void assertAuthority(Class controller, String methodName, String expected, Class... parameterTypes) + throws NoSuchMethodException { + Method method = controller.getDeclaredMethod(methodName, parameterTypes); + PreAuthorize authorization = method.getAnnotation(PreAuthorize.class); + assertThat(authorization).isNotNull(); + assertThat(authorization.value()).isEqualTo(expected); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java new file mode 100644 index 000000000..fcbe5e8d7 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -0,0 +1,129 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderIdempotencyRecordEntity; +import com.baiyue.order.repository.OrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OrderIdempotencyServiceTest { + @Test + void 同键同请求返回首次创建的订单ID() { + OrderRepository repository = mock(OrderRepository.class); + OrderIdempotencyRecordEntity existing = completed(998L); + AtomicReference attempted = new AtomicReference<>(); + doAnswer(invocation -> { + OrderIdempotencyRecordEntity record = invocation.getArgument(0); + attempted.set(record); + existing.setRequestHash(record.getRequestHash()); + throw new DuplicateKeyException("并发唯一键冲突"); + }).when(repository).insertIdempotency(any()); + when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key")) + .thenReturn(existing); + + Long result = service(repository).createDraft(10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key"); + + assertThat(result).isEqualTo(998L); + assertThat(attempted.get().getTenantId()).isEqualTo(10001L); + assertThat(attempted.get().getOperatorId()).isEqualTo(20001L); + } + + @Test + void 同键不同请求返回冲突() { + OrderRepository repository = mock(OrderRepository.class); + doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); }) + .when(repository).insertIdempotency(any()); + OrderIdempotencyRecordEntity existing = completed(998L); + existing.setRequestHash("different-request"); + when(repository.findIdempotency(anyLong(), anyLong(), anyString(), anyString())) + .thenReturn(existing); + + assertThatThrownBy(() -> service(repository).createDraft( + 10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key")) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + void 并发请求尚未完成时返回冲突() { + OrderRepository repository = mock(OrderRepository.class); + OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity(); + existing.setStatus("PROCESSING"); + doAnswer(invocation -> { + OrderIdempotencyRecordEntity record = invocation.getArgument(0); + existing.setRequestHash(record.getRequestHash()); + throw new DuplicateKeyException("并发唯一键冲突"); + }).when(repository).insertIdempotency(any()); + when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key")) + .thenReturn(existing); + + assertThatThrownBy(() -> service(repository).createDraft( + 10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key")) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + void 不存在订单统一返回404() { + OrderRepository repository = mock(OrderRepository.class); + + assertThatThrownBy(() -> service(repository).detail(10001L, 404L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + void SQL唯一键包含租户操作者操作和幂等键() throws Exception { + String sql = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains( + "UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)"); + } + + @Test + void OpenAPI声明创建和提交均要求幂等键() throws Exception { + String openapi = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/api/merchant/v1/orders/first-leg:") + .contains("/api/merchant/v1/orders/first-leg/{id}/submit:") + .contains("name: Idempotency-Key") + .contains("required: true"); + } + + private OrderApplicationService service(OrderRepository repository) { + OrderApplicationService service = new OrderApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "json", new ObjectMapper()); + ReflectionTestUtils.setField(service, "eligibilityGuard", mock(OrderEligibilityGuard.class)); + ReflectionTestUtils.setField(service, "masterDataGuard", mock(OrderMasterDataGuard.class)); + return service; + } + + private OrderIdempotencyRecordEntity completed(long orderId) { + OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity(); + record.setStatus("COMPLETED"); + record.setResultOrderId(orderId); + return record; + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java new file mode 100644 index 000000000..0b19ff27f --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -0,0 +1,114 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; +import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.service.ExpressCompanyQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 平台快递公司管理接口。 */ +@Validated +@Tag(name = "平台快递公司", description = "按目标租户分页查询快递公司主数据") +@RestController +@RequestMapping("/api/platform/v1/express-companies") +public class PlatformExpressCompanyController { + private final ExpressCompanyQueryService service; + + public PlatformExpressCompanyController(ExpressCompanyQueryService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:express:list')") + @Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台快递公司查询权限") + }) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @ParameterObject ExpressCompanyPageQuery query) { + requirePlatform(user); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:express:list')") + @Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.detail(tenantId, id)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:express:create')") + @Operation(summary = "新增快递公司", description = "在目标租户内新增快递公司") + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @RequestBody ExpressCompanySaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(tenantId, request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:express:edit')") + @Operation(summary = "编辑快递公司", description = "编辑目标租户内的快递公司") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id, + @Valid @RequestBody ExpressCompanySaveRequest request) { + requirePlatform(user); + service.update(tenantId, id, request); + return ApiResponse.success(null); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:express:status')") + @Operation(summary = "启用或停用快递公司", description = "通过状态变更停用主数据,不物理删除历史记录") + public ApiResponse changeStatus( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id, + @Valid @RequestBody StatusRequest request) { + requirePlatform(user); + service.changeStatus(tenantId, id, request.status()); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java new file mode 100644 index 000000000..48dfa3acb --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -0,0 +1,114 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest; +import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.service.PlatformPartnerQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 平台往来单位管理接口。 */ +@Validated +@Tag(name = "平台往来单位", description = "按目标租户分页查询往来单位主数据") +@RestController +@RequestMapping("/api/platform/v1/partners") +public class PlatformPartnerController { + private final PlatformPartnerQueryService service; + + public PlatformPartnerController(PlatformPartnerQueryService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:counterparty:list')") + @Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台往来单位查询权限") + }) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @ParameterObject PartnerPageQuery query) { + requirePlatform(user); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:counterparty:list')") + @Operation(summary = "查询往来单位详情", description = "查询目标租户内的往来单位详情") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.detail(tenantId, id)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:counterparty:create')") + @Operation(summary = "新增往来单位", description = "在目标租户内新增往来单位") + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @RequestBody PartnerSaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(tenantId, request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:counterparty:edit')") + @Operation(summary = "编辑往来单位", description = "编辑目标租户内的往来单位") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id, + @Valid @RequestBody PartnerSaveRequest request) { + requirePlatform(user); + service.update(tenantId, id, request); + return ApiResponse.success(null); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:counterparty:status')") + @Operation(summary = "启用或停用往来单位", description = "通过状态变更停用主数据,不物理删除历史记录") + public ApiResponse changeStatus( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @PathVariable long id, + @Valid @RequestBody StatusRequest request) { + requirePlatform(user); + service.changeStatus(tenantId, id, request.status()); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java new file mode 100644 index 000000000..801ac7afb --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -0,0 +1,116 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** 平台基础资料请求契约。 */ +public final class MasterDataQueries { + private MasterDataQueries() { + } + + @Schema(name = "PartnerPageQuery", description = "平台往来单位分页查询条件") + public record PartnerPageQuery( + @Schema(description = "页码,从 1 开始", example = "1", defaultValue = "1") + @Min(1) long page, + @Schema(description = "每页条数", example = "20", defaultValue = "20") + @Min(1) @Max(200) long size, + @Schema(description = "编码、名称、联系人或联系电话关键字", example = "示例客户") + @Size(max = 128) String keyword, + @Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"}) + @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String type, + @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) + @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status + ) { + public PartnerPageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } + + @Schema(name = "ExpressCompanyPageQuery", description = "平台快递公司分页查询条件") + public record ExpressCompanyPageQuery( + @Schema(description = "页码,从 1 开始", example = "1", defaultValue = "1") + @Min(1) long page, + @Schema(description = "每页条数", example = "20", defaultValue = "20") + @Min(1) @Max(200) long size, + @Schema(description = "快递公司编码或名称关键字", example = "顺丰") + @Size(max = 128) String keyword, + @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) + @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status + ) { + public ExpressCompanyPageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } + + @Schema(name = "PartnerSaveRequest", description = "平台往来单位保存请求") + public record PartnerSaveRequest( + @Schema(description = "租户内唯一往来单位编码", example = "CUS-001") + @NotBlank @Size(max = 50) String partnerCode, + @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") + @NotBlank @Size(max = 128) String partnerName, + @Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"}) + @NotBlank @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String partnerType, + @Schema(description = "联系人", example = "张三") + @NotBlank @Size(max = 64) String contactName, + @Schema(description = "联系电话", example = "13800138000") + @NotBlank @Size(max = 32) String contactPhone, + @Schema(description = "联系邮箱", example = "contact@example.com") + @Email @Size(max = 128) String contactEmail, + @Schema(description = "国家二字码", example = "CN") + @NotBlank @Pattern(regexp = "^[A-Z]{2}$") String countryCode, + @Schema(description = "省或州", example = "广东省") + @Size(max = 64) String province, + @Schema(description = "城市", example = "深圳市") + @NotBlank @Size(max = 64) String city, + @Schema(description = "区县", example = "宝安区") + @Size(max = 64) String district, + @Schema(description = "详细地址", example = "航城街道示例路 1 号") + @NotBlank @Size(max = 255) String addressLine, + @Schema(description = "邮政编码", example = "518000") + @Size(max = 20) String postalCode, + @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) + @NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status + ) { + } + + @Schema(name = "ExpressCompanySaveRequest", description = "平台快递公司保存请求") + public record ExpressCompanySaveRequest( + @Schema(description = "租户内唯一快递公司编码", example = "SF") + @NotBlank @Size(max = 50) String companyCode, + @Schema(description = "快递公司名称", example = "顺丰速运") + @NotBlank @Size(max = 128) String companyName, + @Schema(description = "客服电话", example = "95338") + @Size(max = 32) String servicePhone, + @Schema(description = "官方网站", example = "https://www.sf-express.com") + @Size(max = 255) String website, + @Schema(description = "轨迹查询地址模板,可包含 {trackingNo}") + @Size(max = 500) String trackingUrlTemplate, + @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) + @NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status, + @Schema(description = "备注") + @Size(max = 500) String remark + ) { + } + + @Schema(name = "MasterDataStatusRequest", description = "基础资料状态变更请求") + public record StatusRequest( + @Schema(description = "目标状态", allowableValues = {"ACTIVE", "INACTIVE"}) + @NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java new file mode 100644 index 000000000..414d22351 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/ExpressCompanyEntity.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** 租户维度快递公司主数据。 */ +@Schema(name = "ExpressCompany", description = "租户维度快递公司主数据") +@Getter +@Setter +@TableName("wh_express_company") +public class ExpressCompanyEntity extends BaseEntity { + @Schema(description = "快递公司记录 ID", example = "192000000000000001") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "所属租户 ID", example = "10001") + private Long tenantId; + @Schema(description = "租户内唯一快递公司编码", example = "SF") + private String companyCode; + @Schema(description = "快递公司名称", example = "顺丰速运") + private String companyName; + @Schema(description = "客服电话", example = "95338") + private String servicePhone; + @Schema(description = "官方网站", example = "https://www.sf-express.com") + private String website; + @Schema(description = "轨迹查询地址模板,可包含 {trackingNo}") + private String trackingUrlTemplate; + @Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + private String status; + @Schema(description = "备注") + private String remark; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/ExpressCompanyMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ExpressCompanyMapper.java new file mode 100644 index 000000000..86fa6c384 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ExpressCompanyMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; + +/** 快递公司持久化映射。 */ +public interface ExpressCompanyMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java new file mode 100644 index 000000000..5dbd2f7b8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java @@ -0,0 +1,54 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.mapper.ExpressCompanyMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +/** 快递公司主数据仓储。 */ +@Repository +public class ExpressCompanyRepository { + private final ExpressCompanyMapper mapper; + + public ExpressCompanyRepository(ExpressCompanyMapper mapper) { + this.mapper = mapper; + } + + public PageResult page(long tenantId, ExpressCompanyPageQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ExpressCompanyEntity::getTenantId, tenantId) + .eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status()) + .and(StringUtils.hasText(query.keyword()), condition -> condition + .like(ExpressCompanyEntity::getCompanyCode, query.keyword()) + .or() + .like(ExpressCompanyEntity::getCompanyName, query.keyword())) + .orderByAsc(ExpressCompanyEntity::getCompanyName) + .orderByAsc(ExpressCompanyEntity::getId); + return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public ExpressCompanyEntity findOwned(long tenantId, long id) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(ExpressCompanyEntity::getTenantId, tenantId) + .eq(ExpressCompanyEntity::getId, id)); + } + + public boolean existsCode(long tenantId, String code, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ExpressCompanyEntity::getTenantId, tenantId) + .eq(ExpressCompanyEntity::getCompanyCode, code) + .ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0; + } + + public void insert(ExpressCompanyEntity entity) { + mapper.insert(entity); + } + + public void save(ExpressCompanyEntity entity) { + mapper.updateById(entity); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java new file mode 100644 index 000000000..7e8422051 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java @@ -0,0 +1,98 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import org.springframework.stereotype.Service; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +/** 平台快递公司管理服务。 */ +@Service +public class ExpressCompanyQueryService { + private final ExpressCompanyRepository repository; + + public ExpressCompanyQueryService(ExpressCompanyRepository repository) { + this.repository = repository; + } + + public PageResult page(long tenantId, ExpressCompanyPageQuery query) { + return repository.page(tenantId, query); + } + + public ExpressCompanyEntity detail(long tenantId, long id) { + return owned(tenantId, id); + } + + @Transactional + @BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false) + public Long create(long tenantId, ExpressCompanySaveRequest request) { + ensureCodeUnique(tenantId, request.companyCode(), null); + ExpressCompanyEntity entity = new ExpressCompanyEntity(); + entity.setTenantId(tenantId); + copy(entity, request); + try { + repository.insert(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在"); + } + return entity.getId(); + } + + @Transactional + @BusinessAudit(event = "EXPRESS_COMPANY_UPDATE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "编辑快递公司") + public void update(long tenantId, long id, ExpressCompanySaveRequest request) { + ExpressCompanyEntity entity = owned(tenantId, id); + ensureCodeUnique(tenantId, request.companyCode(), id); + copy(entity, request); + try { + repository.save(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在"); + } + } + + @Transactional + @BusinessAudit(event = "EXPRESS_COMPANY_STATUS_CHANGE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "启停快递公司") + public void changeStatus(long tenantId, long id, String status) { + requireStatus(status); + ExpressCompanyEntity entity = owned(tenantId, id); + entity.setStatus(status); + repository.save(entity); + } + + private ExpressCompanyEntity owned(long tenantId, long id) { + ExpressCompanyEntity entity = repository.findOwned(tenantId, id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "快递公司不存在"); + } + return entity; + } + + private void ensureCodeUnique(long tenantId, String code, Long excludedId) { + if (repository.existsCode(tenantId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在"); + } + } + + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE"); + } + } + + private static void copy(ExpressCompanyEntity entity, ExpressCompanySaveRequest request) { + entity.setCompanyCode(request.companyCode()); + entity.setCompanyName(request.companyName()); + entity.setServicePhone(request.servicePhone()); + entity.setWebsite(request.website()); + entity.setTrackingUrlTemplate(request.trackingUrlTemplate()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java new file mode 100644 index 000000000..c7d15c07e --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -0,0 +1,104 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.springframework.stereotype.Service; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +/** 平台往来单位管理服务。 */ +@Service +public class PlatformPartnerQueryService { + private final PartnerRepository repository; + + public PlatformPartnerQueryService(PartnerRepository repository) { + this.repository = repository; + } + + public PageResult page(long tenantId, PartnerPageQuery query) { + return repository.page(tenantId, query); + } + + public PartnerEntity detail(long tenantId, long id) { + return owned(tenantId, id); + } + + @Transactional + @BusinessAudit(event = "PARTNER_CREATE", resourceType = "PARTNER", description = "新增往来单位", captureBefore = false) + public Long create(long tenantId, PartnerSaveRequest request) { + ensureCodeUnique(tenantId, request.partnerCode(), null); + PartnerEntity entity = new PartnerEntity(); + entity.setTenantId(tenantId); + copy(entity, request); + try { + repository.insert(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在"); + } + return entity.getId(); + } + + @Transactional + @BusinessAudit(event = "PARTNER_UPDATE", resourceType = "PARTNER", resourceId = "#id", description = "编辑往来单位") + public void update(long tenantId, long id, PartnerSaveRequest request) { + PartnerEntity entity = owned(tenantId, id); + ensureCodeUnique(tenantId, request.partnerCode(), id); + copy(entity, request); + try { + repository.save(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在"); + } + } + + @Transactional + @BusinessAudit(event = "PARTNER_STATUS_CHANGE", resourceType = "PARTNER", resourceId = "#id", description = "启停往来单位") + public void changeStatus(long tenantId, long id, String status) { + requireStatus(status); + PartnerEntity entity = owned(tenantId, id); + entity.setStatus(status); + repository.save(entity); + } + + private PartnerEntity owned(long tenantId, long id) { + PartnerEntity entity = repository.findOwned(tenantId, id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "往来单位不存在"); + } + return entity; + } + + private void ensureCodeUnique(long tenantId, String code, Long excludedId) { + if (repository.existsCode(tenantId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在"); + } + } + + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE"); + } + } + + private static void copy(PartnerEntity entity, PartnerSaveRequest request) { + entity.setPartnerCode(request.partnerCode()); + entity.setPartnerName(request.partnerName()); + entity.setPartnerType(request.partnerType()); + entity.setContactName(request.contactName()); + entity.setContactPhone(request.contactPhone()); + entity.setContactEmail(request.contactEmail()); + entity.setCountryCode(request.countryCode()); + entity.setProvince(request.province()); + entity.setCity(request.city()); + entity.setDistrict(request.district()); + entity.setAddressLine(request.addressLine()); + entity.setPostalCode(request.postalCode()); + entity.setStatus(request.status()); + } +} diff --git a/warehouse/src/main/resources/db/warehouse-express-company.sql b/warehouse/src/main/resources/db/warehouse-express-company.sql new file mode 100644 index 000000000..694267a4a --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-express-company.sql @@ -0,0 +1,23 @@ +SET NAMES utf8mb4; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_express_company ( + id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID', + company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码', + company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称', + service_phone VARCHAR(32) DEFAULT NULL COMMENT '客服电话', + website VARCHAR(255) DEFAULT NULL COMMENT '官方网站', + tracking_url_template VARCHAR(500) DEFAULT NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '快递公司状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted), + KEY idx_wh_express_tenant_status (tenant_id, status, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java new file mode 100644 index 000000000..598783c3a --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -0,0 +1,61 @@ +package com.baiyue.warehouse.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformMasterDataControllerContractTest { + @Test + void 往来单位分页路径权限和接口说明完整() throws Exception { + assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners"); + assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list"); + assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create"); + assertMethod(PlatformPartnerController.class, "update", PutMapping.class, "platform:counterparty:edit"); + assertMethod(PlatformPartnerController.class, "changeStatus", PatchMapping.class, "platform:counterparty:status"); + } + + @Test + void 快递公司分页路径权限和接口说明完整() throws Exception { + assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies"); + assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list"); + assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create"); + assertMethod(PlatformExpressCompanyController.class, "update", PutMapping.class, "platform:express:edit"); + assertMethod(PlatformExpressCompanyController.class, "changeStatus", PatchMapping.class, "platform:express:status"); + } + + private static void assertContract(Class controller, String authority, String path) throws Exception { + RequestMapping mapping = controller.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).containsExactly(path); + + Method page = controller.getDeclaredMethod("page", + com.baiyue.common.security.AuthenticatedUser.class, + long.class, + controller == PlatformPartnerController.class + ? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class + : com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class); + assertThat(page.getAnnotation(GetMapping.class)).isNotNull(); + assertThat(page.getAnnotation(PreAuthorize.class).value()).contains(authority); + assertThat(page.getAnnotation(Operation.class).summary()).contains("分页查询"); + } + + private static void assertMethod(Class controller, String methodName, + Class mapping, + String authority) { + Method method = java.util.Arrays.stream(controller.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + assertThat(method.getAnnotation(mapping)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority); + assertThat(method.getAnnotation(Operation.class).summary()).isNotBlank(); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java new file mode 100644 index 000000000..e27391153 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java @@ -0,0 +1,43 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.mapper.ExpressCompanyMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ExpressCompanyRepositoryTest { + @BeforeAll + static void 初始化实体字段缓存() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class); + } + + @Test + void 平台分页强制租户并支持筛选() { + ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); + when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + new ExpressCompanyRepository(mapper).page(10001L, + new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectPage(any(Page.class), wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(10001L, "ACTIVE", "%顺丰%"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java new file mode 100644 index 000000000..ae8c31feb --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -0,0 +1,62 @@ +package com.baiyue.warehouse.repository; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseSchemaContractTest { + @Test + void 仓库三张表包含实体全部持久化字段() throws IOException { + String sql = read("warehouse.sql"); + assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code", + "timezone", "contact_name", "contact_phone", "contact_email", "status", "remark"); + assertColumns(sql, "wh_warehouse_address", "warehouse_id", "address_type", "country_code", "state_province", + "city", "district", "postal_code", "address_line1", "address_line2", "latitude", "longitude"); + assertColumns(sql, "wh_service_capability", "warehouse_id", "capability_code", "capability_name", "enabled", + "cutoff_time", "description"); + assertBaseColumns(sql); + } + + @Test + void 往来单位表包含实体全部持久化字段() throws IOException { + String sql = read("warehouse-partner.sql"); + assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name", + "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", + "postal_code", "status"); + assertBaseColumns(sql); + } + + @Test + void 快递公司表包含实体全部持久化字段() throws IOException { + String sql = read("warehouse-express-company.sql"); + assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website", + "tracking_url_template", "status", "remark"); + assertBaseColumns(sql); + } + + private static String read(String file) throws IOException { + return Files.readString(Path.of("src/main/resources/db", file)); + } + + private static void assertColumns(String sql, String table, String... columns) { + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS " + table); + for (String column : columns) { + assertThat(sql).contains(column + " "); + } + } + + private static void assertBaseColumns(String sql) { + assertColumns(sql, tableName(sql), "tenant_id", "created_by", "created_at", "updated_by", "updated_at", + "deleted", "version"); + } + + private static String tableName(String sql) { + int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ") + "CREATE TABLE IF NOT EXISTS ".length(); + int end = sql.indexOf(' ', start); + return sql.substring(start, end); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java new file mode 100644 index 000000000..6eadbc51b --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -0,0 +1,196 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformMasterDataManagementServiceTest { + @Test + void 往来单位详情强制租户隔离() { + PartnerRepository repository = mock(PartnerRepository.class); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class); + verify(repository).findOwned(10002L, 1L); + } + + @Test + void 往来单位编码在租户内必须唯一() { + PartnerRepository repository = mock(PartnerRepository.class); + when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + assertThatThrownBy(() -> service.create(10001L, partnerRequest())) + .isInstanceOf(BizException.class).hasMessageContaining("编码"); + verify(repository, never()).insert(any()); + } + + @Test + void 往来单位并发重复编码统一返回业务冲突() { + PartnerRepository repository = mock(PartnerRepository.class); + doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + assertThatThrownBy(() -> service.create(10001L, partnerRequest())) + .isInstanceOf(BizException.class).hasMessageContaining("编码"); + } + + @Test + void 往来单位新增写入租户和请求字段() { + PartnerRepository repository = mock(PartnerRepository.class); + doAnswer(invocation -> { + PartnerEntity entity = invocation.getArgument(0); + entity.setId(18L); + return null; + }).when(repository).insert(any()); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + assertThat(service.create(10001L, partnerRequest())).isEqualTo(18L); + + org.mockito.ArgumentCaptor entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getTenantId()).isEqualTo(10001L); + assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001"); + assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户"); + } + + @Test + void 往来单位编辑保留归属并更新字段() { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerEntity entity = new PartnerEntity(); + entity.setId(19L); + entity.setTenantId(10001L); + when(repository.findOwned(10001L, 19L)).thenReturn(entity); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + service.update(10001L, 19L, partnerRequest()); + + assertThat(entity.getTenantId()).isEqualTo(10001L); + assertThat(entity.getPartnerName()).isEqualTo("示例客户"); + verify(repository).save(entity); + } + + @Test + void 往来单位停用保留主数据() { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerEntity entity = new PartnerEntity(); + entity.setId(8L); + entity.setTenantId(10001L); + entity.setStatus("ACTIVE"); + when(repository.findOwned(10001L, 8L)).thenReturn(entity); + PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + + service.changeStatus(10001L, 8L, "INACTIVE"); + + assertThat(entity.getStatus()).isEqualTo("INACTIVE"); + verify(repository).save(entity); + } + + @Test + void 快递公司详情强制租户隔离() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class); + verify(repository).findOwned(10002L, 1L); + } + + @Test + void 快递公司编码在租户内必须唯一() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + when(repository.existsCode(10001L, "SF", null)).thenReturn(true); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + assertThatThrownBy(() -> service.create(10001L, expressRequest())) + .isInstanceOf(BizException.class).hasMessageContaining("编码"); + verify(repository, never()).insert(any()); + } + + @Test + void 快递公司并发重复编码统一返回业务冲突() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + assertThatThrownBy(() -> service.create(10001L, expressRequest())) + .isInstanceOf(BizException.class).hasMessageContaining("编码"); + } + + @Test + void 快递公司新增写入租户和请求字段() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + doAnswer(invocation -> { + ExpressCompanyEntity entity = invocation.getArgument(0); + entity.setId(28L); + return null; + }).when(repository).insert(any()); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + assertThat(service.create(10001L, expressRequest())).isEqualTo(28L); + + org.mockito.ArgumentCaptor entity = + org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getTenantId()).isEqualTo(10001L); + assertThat(entity.getValue().getCompanyCode()).isEqualTo("SF"); + assertThat(entity.getValue().getCompanyName()).isEqualTo("顺丰速运"); + } + + @Test + void 快递公司编辑保留归属并更新字段() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyEntity entity = new ExpressCompanyEntity(); + entity.setId(29L); + entity.setTenantId(10001L); + when(repository.findOwned(10001L, 29L)).thenReturn(entity); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + service.update(10001L, 29L, expressRequest()); + + assertThat(entity.getTenantId()).isEqualTo(10001L); + assertThat(entity.getCompanyName()).isEqualTo("顺丰速运"); + verify(repository).save(entity); + } + + @Test + void 快递公司停用保留主数据() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyEntity entity = new ExpressCompanyEntity(); + entity.setId(9L); + entity.setTenantId(10001L); + entity.setStatus("ACTIVE"); + when(repository.findOwned(10001L, 9L)).thenReturn(entity); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + service.changeStatus(10001L, 9L, "INACTIVE"); + + assertThat(entity.getStatus()).isEqualTo("INACTIVE"); + verify(repository).save(entity); + } + + private static PartnerSaveRequest partnerRequest() { + return new PartnerSaveRequest("CUS-001", "示例客户", "CUSTOMER", "张三", "13800138000", + "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE"); + } + + private static ExpressCompanySaveRequest expressRequest() { + return new ExpressCompanySaveRequest("SF", "顺丰速运", "95338", "https://www.sf-express.com", + "https://example.com/{trackingNo}", "ACTIVE", null); + } +}